Refactored impa synchronization.
This commit is contained in:
@@ -29,6 +29,8 @@ public class MailItemFolder : IMailItemFolder
|
||||
// For IMAP
|
||||
public uint UidValidity { get; set; }
|
||||
public long HighestModeSeq { get; set; }
|
||||
public uint HighestKnownUid { get; set; }
|
||||
public DateTime? LastUidReconcileUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outlook shares delta changes per-folder. Gmail is for per-account.
|
||||
|
||||
@@ -79,6 +79,13 @@ public interface IFolderService
|
||||
/// <param name="folder">Folder to update.</param>
|
||||
Task UpdateFolderAsync(MailItemFolder folder);
|
||||
|
||||
/// <summary>
|
||||
/// Updates only IMAP HighestModeSeq for the given folder.
|
||||
/// </summary>
|
||||
/// <param name="folderId">Folder id to update.</param>
|
||||
/// <param name="highestModeSeq">Latest known mod-seq value.</param>
|
||||
Task UpdateFolderHighestModeSeqAsync(Guid folderId, long highestModeSeq);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active folder menu items for the given account for UI.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
using MailKit.Net.Imap;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a synchronization strategy for synchronizing IMAP folders based on the server capabilities.
|
||||
/// </summary>
|
||||
public interface IImapSynchronizationStrategyProvider
|
||||
{
|
||||
IImapSynchronizerStrategy GetSynchronizationStrategy(IImapClient client);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
public interface IImapSynchronizerStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Synchronizes given folder with the ImapClient client from the client pool.
|
||||
/// </summary>
|
||||
/// <param name="client">Client to perform sync with. I love Mira and Jasminka</param>
|
||||
/// <param name="folder">Folder to synchronize.</param>
|
||||
/// <param name="synchronizer">Imap synchronizer that downloads messages.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of new downloaded message ids that don't exist locally.</returns>
|
||||
Task<List<string>> HandleSynchronizationAsync(IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads given set of messages from the folder.
|
||||
/// Folder is expected to be opened and synchronizer is connected.
|
||||
/// </summary>
|
||||
/// <param name="synchronizer">Synchronizer that performs the action.</param>
|
||||
/// <param name="remoteFolder">Remote folder to download messages from.</param>
|
||||
/// <param name="localFolder">Local folder to assign mails to.</param>
|
||||
/// <param name="uniqueIdSet">Set of message uniqueids.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task DownloadMessagesAsync(IImapSynchronizer synchronizer,
|
||||
IMailFolder remoteFolder,
|
||||
MailItemFolder localFolder,
|
||||
UniqueIdSet uniqueIdSet,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ public interface IMailItemFolder
|
||||
string DeltaToken { get; set; }
|
||||
string FolderName { get; set; }
|
||||
long HighestModeSeq { get; set; }
|
||||
uint HighestKnownUid { get; set; }
|
||||
Guid Id { get; set; }
|
||||
bool IsHidden { get; set; }
|
||||
bool IsSticky { get; set; }
|
||||
bool IsSynchronizationEnabled { get; set; }
|
||||
bool IsSystemFolder { get; set; }
|
||||
DateTime? LastSynchronizedDate { get; set; }
|
||||
DateTime? LastUidReconcileUtc { get; set; }
|
||||
Guid MailAccountId { get; set; }
|
||||
string ParentRemoteFolderId { get; set; }
|
||||
string RemoteFolderId { get; set; }
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using FluentAssertions;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Models.Connectivity;
|
||||
using Wino.Core.Integration;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public class ImapClientPoolTests
|
||||
{
|
||||
[Fact]
|
||||
public void CalculateMaxConnections_ShouldUseDefault_WhenConfiguredValueIsNonPositive()
|
||||
{
|
||||
ImapClientPool.CalculateMaxConnections(0).Should().Be(5);
|
||||
ImapClientPool.CalculateMaxConnections(-4).Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateMaxConnections_ShouldClampToTen_WhenConfiguredValueIsTooHigh()
|
||||
{
|
||||
ImapClientPool.CalculateMaxConnections(40).Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTargetMinimumConnections_ShouldRespectConservativeMode()
|
||||
{
|
||||
ImapClientPool.CalculateTargetMinimumConnections(maxConnections: 5, useConservativeConnections: true).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTargetMinimumConnections_ShouldBeTwo_WhenNotConservativeAndCapacityAllows()
|
||||
{
|
||||
ImapClientPool.CalculateTargetMinimumConnections(maxConnections: 5, useConservativeConnections: false).Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RentAsync_ShouldThrowImapClientPoolException_WhenAcquireTimesOut()
|
||||
{
|
||||
var serverInformation = new CustomServerInformation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IncomingServer = "127.0.0.1",
|
||||
IncomingServerPort = "1",
|
||||
IncomingServerUsername = "user",
|
||||
IncomingServerPassword = "password",
|
||||
IncomingServerSocketOption = ImapConnectionSecurity.None,
|
||||
IncomingAuthenticationMethod = ImapAuthenticationMethod.Auto,
|
||||
MaxConcurrentClients = 2
|
||||
};
|
||||
|
||||
using var pool = new ImapClientPool(ImapClientPoolOptions.CreateTestPool(serverInformation, protocolLog: null));
|
||||
|
||||
var act = async () => await pool.RentAsync(TimeSpan.FromMilliseconds(400));
|
||||
var exception = await act.Should().ThrowAsync<ImapClientPoolException>();
|
||||
|
||||
exception.Which.CustomServerInformation.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_ShouldBeSafe_WhenCalledConcurrently()
|
||||
{
|
||||
var serverInformation = new CustomServerInformation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IncomingServer = "127.0.0.1",
|
||||
IncomingServerPort = "1",
|
||||
IncomingServerUsername = "user",
|
||||
IncomingServerPassword = "password",
|
||||
IncomingServerSocketOption = ImapConnectionSecurity.None,
|
||||
IncomingAuthenticationMethod = ImapAuthenticationMethod.Auto,
|
||||
MaxConcurrentClients = 2
|
||||
};
|
||||
|
||||
using var pool = new ImapClientPool(ImapClientPoolOptions.CreateTestPool(serverInformation, protocolLog: null));
|
||||
|
||||
var init1 = pool.InitializeAsync();
|
||||
var init2 = pool.InitializeAsync();
|
||||
|
||||
await Task.WhenAll(init1, init2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
using Wino.Core.Synchronizers.Mail;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public class ImapSynchronizerIdleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ShouldTriggerIdleSynchronization_ShouldDebounceBurstSignals()
|
||||
{
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "wino-imap-idle-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
|
||||
var synchronizer = CreateSynchronizer(tempDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
synchronizer.ShouldTriggerIdleSynchronization(baseTime).Should().BeTrue();
|
||||
synchronizer.ShouldTriggerIdleSynchronization(baseTime.AddSeconds(5)).Should().BeFalse();
|
||||
synchronizer.ShouldTriggerIdleSynchronization(baseTime.AddSeconds(16)).Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await synchronizer.KillSynchronizerAsync();
|
||||
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ImapSynchronizer CreateSynchronizer(string appDataFolder)
|
||||
{
|
||||
var account = new MailAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "IMAP Test",
|
||||
Address = "test@example.com",
|
||||
ProviderType = MailProviderType.IMAP4,
|
||||
ServerInformation = new CustomServerInformation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IncomingServer = "imap.example.com",
|
||||
IncomingServerPort = "993",
|
||||
IncomingServerUsername = "user",
|
||||
IncomingServerPassword = "password",
|
||||
MaxConcurrentClients = 5
|
||||
}
|
||||
};
|
||||
|
||||
var applicationConfiguration = new Mock<IApplicationConfiguration>();
|
||||
applicationConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, appDataFolder);
|
||||
applicationConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, appDataFolder);
|
||||
applicationConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, appDataFolder);
|
||||
applicationConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty);
|
||||
|
||||
var unifiedSynchronizer = new UnifiedImapSynchronizer(
|
||||
Mock.Of<IFolderService>(),
|
||||
Mock.Of<IMailService>(),
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
|
||||
return new ImapSynchronizer(
|
||||
account,
|
||||
Mock.Of<IImapChangeProcessor>(),
|
||||
applicationConfiguration.Object,
|
||||
unifiedSynchronizer,
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using FluentAssertions;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using Moq;
|
||||
using System.Reflection;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
using Xunit;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public class UnifiedImapSynchronizerTests
|
||||
{
|
||||
private static UnifiedImapSynchronizer CreateSut()
|
||||
{
|
||||
return new UnifiedImapSynchronizer(
|
||||
Mock.Of<IFolderService>(),
|
||||
Mock.Of<IMailService>(),
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldPrioritizeQResync_WhenEnabledAndSupported()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync | ImapCapabilities.CondStore,
|
||||
isQResyncEnabled: true,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.QResync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldFallbackToCondstore_WhenQResyncNotEnabled()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync | ImapCapabilities.CondStore,
|
||||
isQResyncEnabled: false,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.Condstore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldUseUidFallback_WhenNoAdvancedCapability()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.None,
|
||||
isQResyncEnabled: false,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.UidBased);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldRespectQuirkOverride_ForStrictProviders()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync | ImapCapabilities.CondStore,
|
||||
isQResyncEnabled: true,
|
||||
serverHost: "imap.qq.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.Condstore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldFallbackToUid_WhenCondstoreIsUnavailable()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync,
|
||||
isQResyncEnabled: false,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.UidBased);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldFallbackToUid_WhenQuirkDisablesQresyncAndNoCondstore()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync,
|
||||
isQResyncEnabled: true,
|
||||
serverHost: "imap.163.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.UidBased);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateHighestKnownUid_ShouldUseMaxOfCurrentObservedAndUidNext()
|
||||
{
|
||||
var result = UnifiedImapSynchronizer.CalculateHighestKnownUid(
|
||||
currentHighestKnownUid: 100,
|
||||
uidNext: new MailKit.UniqueId(151),
|
||||
observedUids: new uint[] { 120, 140, 130 });
|
||||
|
||||
result.Should().Be(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateHighestKnownUid_ShouldNotRegress_WhenObservedUidsAreLower()
|
||||
{
|
||||
var result = UnifiedImapSynchronizer.CalculateHighestKnownUid(
|
||||
currentHighestKnownUid: 500,
|
||||
uidNext: null,
|
||||
observedUids: new uint[] { 110, 120, 130 });
|
||||
|
||||
result.Should().Be(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateHighestKnownUid_ShouldUseUidNextMinusOne_WhenNoObservedUids()
|
||||
{
|
||||
var result = UnifiedImapSynchronizer.CalculateHighestKnownUid(
|
||||
currentHighestKnownUid: 0,
|
||||
uidNext: new MailKit.UniqueId(901),
|
||||
observedUids: null);
|
||||
|
||||
result.Should().Be(900);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRunUidReconcile_ShouldReturnTrue_WhenNeverReconciled()
|
||||
{
|
||||
var shouldRun = UnifiedImapSynchronizer.ShouldRunUidReconcile(
|
||||
lastUidReconcileUtc: null,
|
||||
utcNow: DateTime.UtcNow,
|
||||
reconcileInterval: TimeSpan.FromHours(12));
|
||||
|
||||
shouldRun.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRunUidReconcile_ShouldReturnFalse_WhenWithinInterval()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var shouldRun = UnifiedImapSynchronizer.ShouldRunUidReconcile(
|
||||
lastUidReconcileUtc: now.AddHours(-1),
|
||||
utcNow: now,
|
||||
reconcileInterval: TimeSpan.FromHours(12));
|
||||
|
||||
shouldRun.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRunUidReconcile_ShouldReturnTrue_WhenIntervalElapsed()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var shouldRun = UnifiedImapSynchronizer.ShouldRunUidReconcile(
|
||||
lastUidReconcileUtc: now.AddHours(-13),
|
||||
utcNow: now,
|
||||
reconcileInterval: TimeSpan.FromHours(12));
|
||||
|
||||
shouldRun.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSummariesAsync_ShouldUseMetadataOnlyPackage()
|
||||
{
|
||||
var localFolder = new MailItemFolder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MailAccountId = Guid.NewGuid(),
|
||||
FolderName = "Inbox",
|
||||
RemoteFolderId = "INBOX"
|
||||
};
|
||||
|
||||
var summaryMock = new Mock<IMessageSummary>();
|
||||
summaryMock.SetupGet(x => x.UniqueId).Returns(new UniqueId(42));
|
||||
summaryMock.SetupGet(x => x.Flags).Returns(MessageFlags.None);
|
||||
|
||||
var mailServiceMock = new Mock<IMailService>();
|
||||
mailServiceMock
|
||||
.Setup(x => x.GetExistingMailsAsync(localFolder.Id, It.IsAny<IEnumerable<UniqueId>>()))
|
||||
.ReturnsAsync(new List<MailCopy>());
|
||||
mailServiceMock
|
||||
.Setup(x => x.CreateMailAsync(localFolder.MailAccountId, It.IsAny<NewMailItemPackage>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var sut = new UnifiedImapSynchronizer(
|
||||
Mock.Of<IFolderService>(),
|
||||
mailServiceMock.Object,
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
|
||||
ImapMessageCreationPackage? capturedPackage = null;
|
||||
|
||||
var imapSynchronizerMock = new Mock<IImapSynchronizer>();
|
||||
imapSynchronizerMock
|
||||
.Setup(x => x.CreateNewMailPackagesAsync(It.IsAny<ImapMessageCreationPackage>(), localFolder, It.IsAny<CancellationToken>()))
|
||||
.Callback<ImapMessageCreationPackage, MailItemFolder, CancellationToken>((package, _, _) => capturedPackage = package)
|
||||
.ReturnsAsync(new List<NewMailItemPackage>
|
||||
{
|
||||
new(new MailCopy { Id = "mail-id" }, null, localFolder.RemoteFolderId, Array.Empty<AccountContact>())
|
||||
});
|
||||
|
||||
var processMethod = typeof(UnifiedImapSynchronizer).GetMethod("ProcessSummariesAsync", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
processMethod.Should().NotBeNull();
|
||||
|
||||
var task = (Task<List<string>>)processMethod!.Invoke(
|
||||
sut,
|
||||
[imapSynchronizerMock.Object, localFolder, new List<IMessageSummary> { summaryMock.Object }, CancellationToken.None])!;
|
||||
|
||||
var result = await task;
|
||||
|
||||
result.Should().ContainSingle().Which.Should().Be("mail-id");
|
||||
capturedPackage.Should().NotBeNull();
|
||||
capturedPackage!.MimeMessage.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wino.Core\Wino.Core.csproj" />
|
||||
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
|
||||
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -36,10 +36,6 @@ public static class CoreContainerSetup
|
||||
services.AddTransient<IOutlookAuthenticator, OutlookAuthenticator>();
|
||||
services.AddTransient<IGmailAuthenticator, GmailAuthenticator>();
|
||||
|
||||
services.AddTransient<IImapSynchronizationStrategyProvider, ImapSynchronizationStrategyProvider>();
|
||||
services.AddTransient<CondstoreSynchronizer>();
|
||||
services.AddTransient<QResyncSynchronizer>();
|
||||
services.AddTransient<UidBasedSynchronizer>();
|
||||
services.AddTransient<UnifiedImapSynchronizer>();
|
||||
|
||||
// Register Outlook error handlers
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Net.Proxy;
|
||||
using MailKit.Security;
|
||||
@@ -39,20 +42,9 @@ public enum ImapClientState
|
||||
/// </summary>
|
||||
public class ImapClientPool : IDisposable
|
||||
{
|
||||
private const int MinActiveConnections = 3;
|
||||
private const int IdleConnectionReserved = 1;
|
||||
private const int KeepAliveIntervalMs = 4 * 60 * 1000; // 4 minutes
|
||||
private const int ConnectionMonitorIntervalMs = 30 * 1000; // 30 seconds
|
||||
private const int MaintenanceIntervalMs = 60 * 1000; // 1 minute
|
||||
|
||||
private readonly ImapImplementation _implementation = new()
|
||||
{
|
||||
Version = "1.8.0",
|
||||
OS = "Windows",
|
||||
Vendor = "Wino",
|
||||
SupportUrl = "https://www.winomail.app",
|
||||
Name = "Wino Mail User",
|
||||
};
|
||||
private const int DefaultAcquireTimeoutMs = 45_000;
|
||||
private const int KeepAliveIntervalMs = 4 * 60 * 1000;
|
||||
private const int MaintenanceIntervalMs = 60 * 1000;
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
|
||||
private readonly CustomServerInformation _customServerInformation;
|
||||
@@ -60,8 +52,14 @@ public class ImapClientPool : IDisposable
|
||||
private readonly ConcurrentDictionary<WinoImapClient, ImapClientState> _clientStates = new();
|
||||
private readonly Channel<WinoImapClient> _availableClients;
|
||||
private readonly CancellationTokenSource _maintenanceCts = new();
|
||||
private readonly SemaphoreSlim _initializeSemaphore = new(1, 1);
|
||||
private readonly object _idleClientLock = new();
|
||||
private readonly ImapServerQuirkProfile _quirks;
|
||||
private readonly ImapImplementation _implementation;
|
||||
private readonly int _maxConnections;
|
||||
private readonly int _targetMinimumConnections;
|
||||
|
||||
private DateTime _lastKeepAliveSentUtc = DateTime.MinValue;
|
||||
private WinoImapClient _dedicatedIdleClient;
|
||||
private bool _disposedValue;
|
||||
private bool _initialized;
|
||||
@@ -81,9 +79,16 @@ public class ImapClientPool : IDisposable
|
||||
_protocolLogStream = imapClientPoolOptions.ProtocolLog;
|
||||
ImapClientPoolOptions = imapClientPoolOptions;
|
||||
|
||||
_quirks = ImapServerQuirks.Resolve(_customServerInformation.IncomingServer);
|
||||
|
||||
// Keep connection counts conservative by default and always cap by provider limits.
|
||||
_maxConnections = CalculateMaxConnections(_customServerInformation.MaxConcurrentClients);
|
||||
_targetMinimumConnections = CalculateTargetMinimumConnections(_maxConnections, _quirks.UseConservativeConnections);
|
||||
|
||||
_implementation = CreateImplementation();
|
||||
|
||||
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
|
||||
|
||||
// Create unbounded channel for available clients
|
||||
_availableClients = Channel.CreateUnbounded<WinoImapClient>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
@@ -99,12 +104,15 @@ public class ImapClientPool : IDisposable
|
||||
{
|
||||
if (_initialized) return;
|
||||
|
||||
_logger.Information("Initializing IMAP client pool with {MinConnections} connections", MinActiveConnections);
|
||||
await _initializeSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Create initial connections
|
||||
for (int i = 0; i < MinActiveConnections; i++)
|
||||
if (_initialized) return;
|
||||
|
||||
_logger.Information("Initializing IMAP client pool with {MinimumConnections} minimum active connections (max: {MaxConnections})", _targetMinimumConnections, _maxConnections);
|
||||
|
||||
for (int i = 0; i < _targetMinimumConnections; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -116,14 +124,15 @@ public class ImapClientPool : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Create dedicated IDLE client
|
||||
_dedicatedIdleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_dedicatedIdleClient != null)
|
||||
if (CanCreateAdditionalConnection())
|
||||
{
|
||||
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
|
||||
_dedicatedIdleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_dedicatedIdleClient != null)
|
||||
{
|
||||
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
|
||||
}
|
||||
}
|
||||
|
||||
// Start maintenance task
|
||||
_maintenanceTask = Task.Run(() => MaintenanceLoopAsync(_maintenanceCts.Token), _maintenanceCts.Token);
|
||||
|
||||
_initialized = true;
|
||||
@@ -132,7 +141,11 @@ public class ImapClientPool : IDisposable
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to initialize IMAP client pool");
|
||||
throw;
|
||||
throw CreatePoolException("IMAP client pool initialization failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initializeSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,79 +155,104 @@ public class ImapClientPool : IDisposable
|
||||
public Task PreWarmPoolAsync() => InitializeAsync(CancellationToken.None);
|
||||
|
||||
/// <summary>
|
||||
/// Rents a client from the pool. Blocks until a client is available.
|
||||
/// Rents a client from the pool with the default timeout.
|
||||
/// </summary>
|
||||
public async Task<WinoImapClient> RentAsync(CancellationToken cancellationToken = default)
|
||||
public Task<WinoImapClient> RentAsync(CancellationToken cancellationToken = default)
|
||||
=> RentAsync(TimeSpan.FromMilliseconds(DefaultAcquireTimeoutMs), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Rents a client from the pool with explicit timeout and cancellation.
|
||||
/// </summary>
|
||||
public async Task<WinoImapClient> RentAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_initialized)
|
||||
await InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
linkedCts.CancelAfter(timeout);
|
||||
var token = linkedCts.Token;
|
||||
|
||||
int createFailures = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Try to get an available client from the channel
|
||||
if (_availableClients.Reader.TryRead(out var client))
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
if (client != null && _clientStates.TryGetValue(client, out var state) && state == ImapClientState.Available)
|
||||
if (_availableClients.Reader.TryRead(out var pooledClient))
|
||||
{
|
||||
try
|
||||
if (pooledClient != null && _clientStates.TryGetValue(pooledClient, out var state) && state == ImapClientState.Available)
|
||||
{
|
||||
// Ensure client is still connected
|
||||
await EnsureClientReadyAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
_clientStates[client] = ImapClientState.InUse;
|
||||
return client;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Client from pool was not ready, marking as failed");
|
||||
_clientStates[client] = ImapClientState.Failed;
|
||||
// Continue to try next client or create new one
|
||||
try
|
||||
{
|
||||
await EnsureClientReadyAsync(pooledClient, token).ConfigureAwait(false);
|
||||
_clientStates[pooledClient] = ImapClientState.InUse;
|
||||
return pooledClient;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Pooled IMAP client was not ready. Marking as failed.");
|
||||
MarkClientAsFailed(pooledClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No available client, try to create a new one
|
||||
var newClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (newClient != null)
|
||||
{
|
||||
_clientStates[newClient] = ImapClientState.InUse;
|
||||
return newClient;
|
||||
}
|
||||
if (CanCreateAdditionalConnection())
|
||||
{
|
||||
var newClient = await CreateAndConnectClientAsync(token).ConfigureAwait(false);
|
||||
if (newClient != null)
|
||||
{
|
||||
_clientStates[newClient] = ImapClientState.InUse;
|
||||
return newClient;
|
||||
}
|
||||
|
||||
// Wait a bit before retrying
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
createFailures++;
|
||||
}
|
||||
|
||||
await Task.Delay(150, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw CreatePoolException($"Timed out while acquiring an IMAP client after {timeout.TotalSeconds:F1} seconds. Failures: {createFailures}.");
|
||||
}
|
||||
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
throw cancellationToken.IsCancellationRequested
|
||||
? new OperationCanceledException(cancellationToken)
|
||||
: CreatePoolException($"Failed to acquire IMAP client within {timeout.TotalSeconds:F1} seconds. Failures: {createFailures}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a client from the pool (legacy compatibility method).
|
||||
/// </summary>
|
||||
public async Task<IImapClient> GetClientAsync() => await RentAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
public Task<IImapClient> GetClientAsync()
|
||||
=> GetClientAsync(CancellationToken.None, null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a client from the pool with explicit cancellation and timeout control.
|
||||
/// </summary>
|
||||
public async Task<IImapClient> GetClientAsync(CancellationToken cancellationToken, TimeSpan? timeout = null)
|
||||
=> await RentAsync(timeout ?? TimeSpan.FromMilliseconds(DefaultAcquireTimeoutMs), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a client to the pool.
|
||||
/// </summary>
|
||||
public void Return(WinoImapClient client, bool isFaulted = false)
|
||||
{
|
||||
if (client == null) return;
|
||||
|
||||
if (isFaulted || !client.IsConnected)
|
||||
if (client == null || _disposedValue)
|
||||
{
|
||||
_clientStates[client] = ImapClientState.Failed;
|
||||
DisposeClient(client);
|
||||
if (client != null)
|
||||
DisposeClient(client);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_disposedValue)
|
||||
if (isFaulted || !client.IsConnected)
|
||||
{
|
||||
_clientStates[client] = ImapClientState.Available;
|
||||
_availableClients.Writer.TryWrite(client);
|
||||
}
|
||||
else
|
||||
{
|
||||
DisposeClient(client);
|
||||
MarkClientAsFailed(client);
|
||||
return;
|
||||
}
|
||||
|
||||
_clientStates[client] = ImapClientState.Available;
|
||||
_availableClients.Writer.TryWrite(client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -245,20 +283,25 @@ public class ImapClientPool : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Need to create or reconnect IDLE client
|
||||
if (!CanCreateAdditionalConnection())
|
||||
{
|
||||
_logger.Warning("Unable to allocate a dedicated IDLE client because pool is at max capacity ({MaxConnections}).", _maxConnections);
|
||||
return null;
|
||||
}
|
||||
|
||||
var idleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (idleClient == null)
|
||||
return null;
|
||||
|
||||
lock (_idleClientLock)
|
||||
{
|
||||
if (_dedicatedIdleClient != null)
|
||||
{
|
||||
DisposeClient(_dedicatedIdleClient);
|
||||
MarkClientAsFailed(_dedicatedIdleClient);
|
||||
}
|
||||
|
||||
_dedicatedIdleClient = idleClient;
|
||||
if (idleClient != null)
|
||||
{
|
||||
_clientStates[idleClient] = ImapClientState.Idle;
|
||||
}
|
||||
_clientStates[idleClient] = ImapClientState.Idle;
|
||||
}
|
||||
|
||||
return idleClient;
|
||||
@@ -271,19 +314,17 @@ public class ImapClientPool : IDisposable
|
||||
{
|
||||
lock (_idleClientLock)
|
||||
{
|
||||
if (_dedicatedIdleClient != null)
|
||||
if (_dedicatedIdleClient == null)
|
||||
return;
|
||||
|
||||
if (isFaulted || !_dedicatedIdleClient.IsConnected)
|
||||
{
|
||||
if (isFaulted)
|
||||
{
|
||||
_clientStates[_dedicatedIdleClient] = ImapClientState.Failed;
|
||||
DisposeClient(_dedicatedIdleClient);
|
||||
_dedicatedIdleClient = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
|
||||
}
|
||||
MarkClientAsFailed(_dedicatedIdleClient);
|
||||
_dedicatedIdleClient = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,14 +367,15 @@ public class ImapClientPool : IDisposable
|
||||
{
|
||||
await Task.Delay(MaintenanceIntervalMs, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Send NOOP to keep connections alive
|
||||
await SendNoOpToAvailableClientsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var keepAliveElapsedMs = (DateTime.UtcNow - _lastKeepAliveSentUtc).TotalMilliseconds;
|
||||
if (keepAliveElapsedMs >= KeepAliveIntervalMs)
|
||||
{
|
||||
await SendNoOpToAvailableClientsAsync(cancellationToken).ConfigureAwait(false);
|
||||
_lastKeepAliveSentUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Ensure minimum connections
|
||||
await EnsureMinimumConnectionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clean up failed connections
|
||||
await CleanupFailedConnectionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await CleanupFailedConnectionsAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -350,59 +392,65 @@ public class ImapClientPool : IDisposable
|
||||
{
|
||||
foreach (var kvp in _clientStates)
|
||||
{
|
||||
if (kvp.Value == ImapClientState.Available && kvp.Key.IsConnected && !kvp.Key.IsBusy())
|
||||
if (kvp.Value != ImapClientState.Available)
|
||||
continue;
|
||||
|
||||
if (!kvp.Key.IsConnected || kvp.Key.IsBusy())
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
await kvp.Key.NoOpAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "NOOP failed for client, marking as failed");
|
||||
_clientStates[kvp.Key] = ImapClientState.Failed;
|
||||
}
|
||||
await kvp.Key.NoOpAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "NOOP failed for pooled client. Marking as failed.");
|
||||
MarkClientAsFailed(kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureMinimumConnectionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var health = Health;
|
||||
var neededConnections = MinActiveConnections - health.AvailableConnections;
|
||||
var availableConnections = _clientStates.Count(kvp => kvp.Value == ImapClientState.Available);
|
||||
var neededConnections = _targetMinimumConnections - availableConnections;
|
||||
|
||||
if (neededConnections > 0)
|
||||
if (neededConnections <= 0)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < neededConnections; i++)
|
||||
{
|
||||
_logger.Debug("Creating {Count} connections to maintain minimum pool size", neededConnections);
|
||||
if (!CanCreateAdditionalConnection())
|
||||
break;
|
||||
|
||||
for (int i = 0; i < neededConnections; i++)
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (client != null)
|
||||
{
|
||||
_clientStates[client] = ImapClientState.Available;
|
||||
await _availableClients.Writer.WriteAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to create new connection during maintenance");
|
||||
}
|
||||
var client = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (client == null)
|
||||
continue;
|
||||
|
||||
_clientStates[client] = ImapClientState.Available;
|
||||
await _availableClients.Writer.WriteAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to create minimum pool connection during maintenance.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task CleanupFailedConnectionsAsync(CancellationToken cancellationToken)
|
||||
private Task CleanupFailedConnectionsAsync()
|
||||
{
|
||||
foreach (var kvp in _clientStates)
|
||||
{
|
||||
if (kvp.Value == ImapClientState.Failed)
|
||||
{
|
||||
DisposeClient(kvp.Key);
|
||||
_clientStates.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
if (kvp.Value != ImapClientState.Failed && kvp.Value != ImapClientState.Disposed)
|
||||
continue;
|
||||
|
||||
DisposeClient(kvp.Key);
|
||||
_clientStates.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -417,7 +465,7 @@ public class ImapClientPool : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to create and connect new client");
|
||||
_logger.Warning(ex, "Failed to create and connect IMAP client.");
|
||||
DisposeClient(client);
|
||||
return null;
|
||||
}
|
||||
@@ -425,7 +473,6 @@ public class ImapClientPool : IDisposable
|
||||
|
||||
private async Task EnsureClientReadyAsync(WinoImapClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
// Connect if needed
|
||||
if (!client.IsConnected)
|
||||
{
|
||||
client.ServerCertificateValidationCallback = MyServerCertificateValidationCallback;
|
||||
@@ -436,27 +483,21 @@ public class ImapClientPool : IDisposable
|
||||
GetSocketOptions(_customServerInformation.IncomingServerSocketOption),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Enable compression if supported
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
|
||||
{
|
||||
await client.CompressAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Handle ID extension
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.IdentifyAsync(_implementation, cancellationToken).ConfigureAwait(false);
|
||||
await client.CompressAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ImapCommandException)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Some servers require post-auth identification
|
||||
_logger.Debug(ex, "Failed to enable IMAP compression. Continuing without compression.");
|
||||
}
|
||||
}
|
||||
|
||||
await TryIdentifyAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Authenticate if needed
|
||||
if (!client.IsAuthenticated)
|
||||
{
|
||||
var cred = new NetworkCredential(
|
||||
@@ -477,28 +518,53 @@ public class ImapClientPool : IDisposable
|
||||
await client.AuthenticateAsync(cred, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Try post-auth ID if needed
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
|
||||
await TryIdentifyAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
client.IsQResyncEnabled = false;
|
||||
if (!_quirks.DisableQResync && client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.IdentifyAsync(_implementation, cancellationToken).ConfigureAwait(false);
|
||||
await client.EnableQuickResyncAsync(cancellationToken).ConfigureAwait(false);
|
||||
client.IsQResyncEnabled = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "Failed to enable QRESYNC for {Server}. Falling back to non-QRESYNC synchronization.", _customServerInformation.IncomingServer);
|
||||
}
|
||||
catch { /* Ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable QRESYNC if supported
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
||||
{
|
||||
await client.EnableQuickResyncAsync(cancellationToken).ConfigureAwait(false);
|
||||
client.IsQResyncEnabled = true;
|
||||
}
|
||||
private async Task TryIdentifyAsync(WinoImapClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!client.Capabilities.HasFlag(ImapCapabilities.Id))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await client.IdentifyAsync(_implementation, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ImapCommandException)
|
||||
{
|
||||
// Some servers refuse ID even if advertised. Ignore and continue.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "Failed to send IMAP ID payload. Continuing without Identify().");
|
||||
}
|
||||
}
|
||||
|
||||
private WinoImapClient CreateNewClient()
|
||||
{
|
||||
var client = new WinoImapClient();
|
||||
IProtocolLogger protocolLogger = null;
|
||||
|
||||
if (_protocolLogStream != null)
|
||||
{
|
||||
protocolLogger = new ProtocolLogger(_protocolLogStream, leaveOpen: true);
|
||||
}
|
||||
|
||||
var client = protocolLogger != null ? new WinoImapClient(protocolLogger) : new WinoImapClient();
|
||||
|
||||
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
|
||||
{
|
||||
@@ -507,12 +573,15 @@ public class ImapClientPool : IDisposable
|
||||
int.Parse(_customServerInformation.ProxyServerPort));
|
||||
}
|
||||
|
||||
_logger.Debug("Created new ImapClient. Current pool size: {Count}", _clientStates.Count);
|
||||
_logger.Debug("Created new IMAP client. Current tracked pool size: {Count}", _clientStates.Count);
|
||||
return client;
|
||||
}
|
||||
|
||||
private void DisposeClient(IImapClient client)
|
||||
{
|
||||
if (client == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (client.IsConnected)
|
||||
@@ -522,14 +591,58 @@ public class ImapClientPool : IDisposable
|
||||
client.Disconnect(quit: true);
|
||||
}
|
||||
}
|
||||
|
||||
client.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug(ex, "Error disposing client");
|
||||
_logger.Debug(ex, "Error disposing IMAP client.");
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkClientAsFailed(WinoImapClient client)
|
||||
{
|
||||
if (client == null)
|
||||
return;
|
||||
|
||||
_clientStates[client] = ImapClientState.Failed;
|
||||
}
|
||||
|
||||
private bool CanCreateAdditionalConnection()
|
||||
{
|
||||
var activeCount = _clientStates.Count(kvp => kvp.Value != ImapClientState.Failed && kvp.Value != ImapClientState.Disposed);
|
||||
return activeCount < _maxConnections;
|
||||
}
|
||||
|
||||
private ImapClientPoolException CreatePoolException(string message, Exception innerException = null)
|
||||
{
|
||||
var protocolLog = GetProtocolLogContent() ?? string.Empty;
|
||||
|
||||
return innerException == null
|
||||
? new ImapClientPoolException(message, _customServerInformation, protocolLog)
|
||||
: new ImapClientPoolException(innerException, protocolLog);
|
||||
}
|
||||
|
||||
private static ImapImplementation CreateImplementation()
|
||||
{
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
return new ImapImplementation
|
||||
{
|
||||
Name = "Wino Mail",
|
||||
Version = version,
|
||||
Vendor = "Wino",
|
||||
OS = Environment.OSVersion.VersionString,
|
||||
SupportUrl = "https://www.winomail.app"
|
||||
};
|
||||
}
|
||||
|
||||
public static int CalculateMaxConnections(int configuredMaxConcurrentClients)
|
||||
=> Math.Clamp(configuredMaxConcurrentClients <= 0 ? 5 : configuredMaxConcurrentClients, 1, 10);
|
||||
|
||||
public static int CalculateTargetMinimumConnections(int maxConnections, bool useConservativeConnections)
|
||||
=> useConservativeConnections ? 1 : Math.Min(2, Math.Max(1, maxConnections));
|
||||
|
||||
private SecureSocketOptions GetSocketOptions(ImapConnectionSecurity connectionSecurity) => connectionSecurity switch
|
||||
{
|
||||
ImapConnectionSecurity.Auto => SecureSocketOptions.Auto,
|
||||
@@ -584,34 +697,34 @@ public class ImapClientPool : IDisposable
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposedValue)
|
||||
if (_disposedValue)
|
||||
return;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
if (disposing)
|
||||
_maintenanceCts.Cancel();
|
||||
_maintenanceTask?.Wait(TimeSpan.FromSeconds(5));
|
||||
_maintenanceCts.Dispose();
|
||||
_initializeSemaphore.Dispose();
|
||||
|
||||
_availableClients.Writer.Complete();
|
||||
|
||||
foreach (var kvp in _clientStates)
|
||||
{
|
||||
_maintenanceCts.Cancel();
|
||||
_maintenanceTask?.Wait(TimeSpan.FromSeconds(5));
|
||||
_maintenanceCts.Dispose();
|
||||
|
||||
_availableClients.Writer.Complete();
|
||||
|
||||
foreach (var kvp in _clientStates)
|
||||
{
|
||||
DisposeClient(kvp.Key);
|
||||
}
|
||||
_clientStates.Clear();
|
||||
|
||||
lock (_idleClientLock)
|
||||
{
|
||||
if (_dedicatedIdleClient != null)
|
||||
{
|
||||
DisposeClient(_dedicatedIdleClient);
|
||||
_dedicatedIdleClient = null;
|
||||
}
|
||||
}
|
||||
DisposeClient(kvp.Key);
|
||||
}
|
||||
|
||||
_disposedValue = true;
|
||||
_clientStates.Clear();
|
||||
|
||||
lock (_idleClientLock)
|
||||
{
|
||||
_dedicatedIdleClient = null;
|
||||
}
|
||||
|
||||
_protocolLogStream?.Dispose();
|
||||
}
|
||||
|
||||
_disposedValue = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Wino.Core.Integration;
|
||||
|
||||
internal sealed class ImapServerQuirkProfile
|
||||
{
|
||||
public static readonly ImapServerQuirkProfile Default = new();
|
||||
|
||||
public bool DisableQResync { get; init; }
|
||||
public bool DisableCondstore { get; init; }
|
||||
public bool UseConservativeConnections { get; init; }
|
||||
}
|
||||
|
||||
internal static class ImapServerQuirks
|
||||
{
|
||||
private static readonly Dictionary<string, ImapServerQuirkProfile> Quirks = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Some strict providers are more stable with conservative behavior.
|
||||
["qq.com"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true },
|
||||
["163.com"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true },
|
||||
["126.com"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true },
|
||||
["yeah.net"] = new ImapServerQuirkProfile { DisableQResync = true, UseConservativeConnections = true }
|
||||
};
|
||||
|
||||
public static ImapServerQuirkProfile Resolve(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
return ImapServerQuirkProfile.Default;
|
||||
|
||||
foreach (var (key, profile) in Quirks)
|
||||
{
|
||||
if (host.Contains(key, StringComparison.OrdinalIgnoreCase))
|
||||
return profile;
|
||||
}
|
||||
|
||||
return ImapServerQuirkProfile.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Wino.Core.Tests")]
|
||||
@@ -14,7 +14,6 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
private bool isInitialized = false;
|
||||
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
|
||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||
private readonly IOutlookSynchronizerErrorHandlerFactory _outlookSynchronizerErrorHandlerFactory;
|
||||
private readonly IGmailSynchronizerErrorHandlerFactory _gmailSynchronizerErrorHandlerFactory;
|
||||
@@ -32,7 +31,6 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
IImapChangeProcessor imapChangeProcessor,
|
||||
IAuthenticationProvider authenticationProvider,
|
||||
IAccountService accountService,
|
||||
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
||||
IApplicationConfiguration applicationConfiguration,
|
||||
IOutlookSynchronizerErrorHandlerFactory outlookSynchronizerErrorHandlerFactory,
|
||||
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory,
|
||||
@@ -44,7 +42,6 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
_authenticationProvider = authenticationProvider;
|
||||
_accountService = accountService;
|
||||
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
|
||||
_applicationConfiguration = applicationConfiguration;
|
||||
_outlookSynchronizerErrorHandlerFactory = outlookSynchronizerErrorHandlerFactory;
|
||||
_gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory;
|
||||
@@ -85,7 +82,7 @@ public class SynchronizerFactory : ISynchronizerFactory
|
||||
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
|
||||
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
||||
case Domain.Enums.MailProviderType.IMAP4:
|
||||
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory);
|
||||
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4551 CONDSTORE IMAP Synchronization strategy.
|
||||
/// </summary>
|
||||
internal class CondstoreSynchronizer : ImapSynchronizationStrategyBase
|
||||
{
|
||||
public CondstoreSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
|
||||
{
|
||||
}
|
||||
|
||||
public async override Task<List<string>> HandleSynchronizationAsync(IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
|
||||
|
||||
if (!client.Capabilities.HasFlag(ImapCapabilities.CondStore))
|
||||
throw new ImapSynchronizerStrategyException("Server does not support CONDSTORE.");
|
||||
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
Folder = folder;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var localHighestModSeq = (ulong)folder.HighestModeSeq;
|
||||
|
||||
bool isInitialSynchronization = localHighestModSeq == 0;
|
||||
|
||||
// There are some changes on new messages or flag changes.
|
||||
// Deletions are tracked separately because some servers do not increase
|
||||
// the MODSEQ value for deleted messages.
|
||||
if (remoteFolder.HighestModSeq > localHighestModSeq)
|
||||
{
|
||||
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get locally exists mails for the returned UIDs.
|
||||
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
|
||||
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
if (remoteFolder.IsOpen)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient winoClient, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
|
||||
var remoteHighestModSeq = remoteFolder.HighestModSeq;
|
||||
|
||||
// Search for emails with a MODSEQ greater than the last known value.
|
||||
// Use SORT extension if server supports.
|
||||
|
||||
IList<UniqueId> changedUids = null;
|
||||
|
||||
if (winoClient.Capabilities.HasFlag(ImapCapabilities.Sort))
|
||||
{
|
||||
// Highest mod seq must be greater than 0 for SORT.
|
||||
changedUids = await remoteFolder.SortAsync(SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)), [OrderBy.ReverseDate], cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// For initial synchronizations, take the first allowed number of items.
|
||||
// For consequtive synchronizations, take all the items. We don't want to miss any changes.
|
||||
// Smaller uid means newer message. For initial sync, we need start taking items from the top.
|
||||
|
||||
bool isInitialSynchronization = localHighestModSeq == 0;
|
||||
|
||||
if (isInitialSynchronization)
|
||||
{
|
||||
changedUids = changedUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
|
||||
}
|
||||
|
||||
return changedUids;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Services.Extensions;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
public abstract class ImapSynchronizationStrategyBase : IImapSynchronizerStrategy
|
||||
{
|
||||
// Minimum summary items to Fetch for mail synchronization from IMAP.
|
||||
protected readonly MessageSummaryItems MailSynchronizationFlags =
|
||||
MessageSummaryItems.Flags |
|
||||
MessageSummaryItems.UniqueId |
|
||||
MessageSummaryItems.ThreadId |
|
||||
MessageSummaryItems.EmailId |
|
||||
MessageSummaryItems.Headers |
|
||||
MessageSummaryItems.PreviewText |
|
||||
MessageSummaryItems.GMailThreadId |
|
||||
MessageSummaryItems.References |
|
||||
MessageSummaryItems.ModSeq;
|
||||
|
||||
protected IFolderService FolderService { get; }
|
||||
protected IMailService MailService { get; }
|
||||
protected MailItemFolder Folder { get; set; }
|
||||
|
||||
protected ImapSynchronizationStrategyBase(IFolderService folderService, IMailService mailService)
|
||||
{
|
||||
FolderService = folderService;
|
||||
MailService = mailService;
|
||||
}
|
||||
|
||||
public abstract Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
|
||||
internal abstract Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default);
|
||||
|
||||
protected async Task<List<string>> HandleChangedUIdsAsync(IImapSynchronizer synchronizer,
|
||||
IMailFolder remoteFolder,
|
||||
IList<UniqueId> changedUids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<string> downloadedMessageIds = new();
|
||||
|
||||
var existingMails = await MailService.GetExistingMailsAsync(Folder.Id, changedUids).ConfigureAwait(false);
|
||||
var existingMailUids = existingMails.Select(m => MailkitClientExtensions.ResolveUidStruct(m.Id)).ToArray();
|
||||
|
||||
// These are the non-existing mails. They will be downloaded + processed.
|
||||
var newMessageIds = changedUids.Except(existingMailUids).ToList();
|
||||
var deletedMessageIds = existingMailUids.Except(changedUids).ToList();
|
||||
|
||||
// Fetch minimum data for the existing mails in one query.
|
||||
var existingFlagData = await remoteFolder.FetchAsync(existingMailUids, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId).ConfigureAwait(false);
|
||||
|
||||
foreach (var update in existingFlagData)
|
||||
{
|
||||
if (update.UniqueId == UniqueId.Invalid)
|
||||
{
|
||||
Log.Warning($"Couldn't fetch UniqueId for the mail. FetchAsync failed.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (update.Flags == null)
|
||||
{
|
||||
Log.Warning($"Couldn't fetch flags for the mail with UID {update.UniqueId.Id}. FetchAsync failed.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingMail = existingMails.FirstOrDefault(m => MailkitClientExtensions.ResolveUidStruct(m.Id).Id == update.UniqueId.Id);
|
||||
|
||||
if (existingMail == null)
|
||||
{
|
||||
Log.Warning($"Couldn't find the mail with UID {update.UniqueId.Id} in the local database. Flag update is ignored.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await HandleMessageFlagsChangeAsync(existingMail, update.Flags.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Fetch the new mails in batch.
|
||||
|
||||
var batchedMessageIds = newMessageIds.Batch(50).ToList();
|
||||
// Create tasks for each batch.
|
||||
foreach (var group in batchedMessageIds)
|
||||
{
|
||||
downloadedMessageIds.AddRange(group.Select(a => MailkitClientExtensions.CreateUid(Folder.Id, a.Id)));
|
||||
|
||||
await DownloadMessagesAsync(synchronizer, remoteFolder, Folder, new UniqueIdSet(group, SortOrder.Ascending), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
protected async Task HandleMessageFlagsChangeAsync(UniqueId? uniqueId, MessageFlags flags)
|
||||
{
|
||||
if (Folder == null) return;
|
||||
if (uniqueId == null) return;
|
||||
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Value.Id);
|
||||
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
|
||||
await MailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false);
|
||||
await MailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async Task HandleMessageFlagsChangeAsync(MailCopy mailCopy, MessageFlags flags)
|
||||
{
|
||||
if (mailCopy == null) return;
|
||||
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
|
||||
if (isFlagged != mailCopy.IsFlagged)
|
||||
{
|
||||
await MailService.ChangeFlagStatusAsync(mailCopy.Id, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (isRead != mailCopy.IsRead)
|
||||
{
|
||||
await MailService.ChangeReadStatusAsync(mailCopy.Id, isRead).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task HandleMessageDeletedAsync(IList<UniqueId> uniqueIds)
|
||||
{
|
||||
if (Folder == null) return;
|
||||
if (uniqueIds == null || uniqueIds.Count == 0) return;
|
||||
|
||||
foreach (var uniqueId in uniqueIds)
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(Folder.Id, uniqueId.Id);
|
||||
|
||||
await MailService.DeleteMailAsync(Folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected void OnMessagesVanished(object sender, MessagesVanishedEventArgs args)
|
||||
=> HandleMessageDeletedAsync(args.UniqueIds).ConfigureAwait(false);
|
||||
|
||||
protected void OnMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs args)
|
||||
=> HandleMessageFlagsChangeAsync(args.UniqueId, args.Flags).ConfigureAwait(false);
|
||||
|
||||
protected async Task ManageUUIdBasedDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allUids = (await FolderService.GetKnownUidsForFolderAsync(localFolder.Id)).Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
if (allUids.Count > 0)
|
||||
{
|
||||
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken);
|
||||
var deletedUids = allUids.Except(remoteAllUids).ToList();
|
||||
|
||||
await HandleMessageDeletedAsync(deletedUids).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DownloadMessagesAsync(IImapSynchronizer synchronizer,
|
||||
IMailFolder folder,
|
||||
MailItemFolder localFolder,
|
||||
UniqueIdSet uniqueIdSet,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var summaries = await folder.FetchAsync(uniqueIdSet, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
var mimeMessage = await folder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage);
|
||||
|
||||
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, localFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mailPackages != null)
|
||||
{
|
||||
foreach (var package in mailPackages)
|
||||
{
|
||||
// Local draft is mapped. We don't need to create a new mail copy.
|
||||
if (package == null) continue;
|
||||
|
||||
await MailService.CreateMailAsync(localFolder.MailAccountId, package).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using MailKit.Net.Imap;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
internal class ImapSynchronizationStrategyProvider : IImapSynchronizationStrategyProvider
|
||||
{
|
||||
private readonly QResyncSynchronizer _qResyncSynchronizer;
|
||||
private readonly CondstoreSynchronizer _condstoreSynchronizer;
|
||||
private readonly UidBasedSynchronizer _uidBasedSynchronizer;
|
||||
|
||||
public ImapSynchronizationStrategyProvider(QResyncSynchronizer qResyncSynchronizer, CondstoreSynchronizer condstoreSynchronizer, UidBasedSynchronizer uidBasedSynchronizer)
|
||||
{
|
||||
_qResyncSynchronizer = qResyncSynchronizer;
|
||||
_condstoreSynchronizer = condstoreSynchronizer;
|
||||
_uidBasedSynchronizer = uidBasedSynchronizer;
|
||||
}
|
||||
|
||||
public IImapSynchronizerStrategy GetSynchronizationStrategy(IImapClient client)
|
||||
{
|
||||
if (client is not WinoImapClient winoImapClient)
|
||||
throw new System.ArgumentException("Client must be of type WinoImapClient.", nameof(client));
|
||||
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync) && winoImapClient.IsQResyncEnabled) return _qResyncSynchronizer;
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.CondStore)) return _condstoreSynchronizer;
|
||||
|
||||
return _uidBasedSynchronizer;
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 5162 QRESYNC IMAP Synchronization strategy.
|
||||
/// </summary>
|
||||
internal class QResyncSynchronizer : ImapSynchronizationStrategyBase
|
||||
{
|
||||
public QResyncSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new ImapSynchronizerStrategyException("Client must be of type WinoImapClient.");
|
||||
|
||||
if (!client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
||||
throw new ImapSynchronizerStrategyException("Server does not support QRESYNC.");
|
||||
|
||||
if (!winoClient.IsQResyncEnabled)
|
||||
throw new ImapSynchronizerStrategyException("QRESYNC is not enabled for WinoImapClient.");
|
||||
|
||||
// Ready to implement QRESYNC synchronization.
|
||||
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
Folder = folder;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check the Uid validity first.
|
||||
// If they don't match, clear all the local data and perform full-resync.
|
||||
|
||||
bool isCacheValid = remoteFolder.UidValidity == folder.UidValidity;
|
||||
|
||||
if (!isCacheValid)
|
||||
{
|
||||
// TODO: Remove all local data.
|
||||
}
|
||||
|
||||
// Perform QRESYNC synchronization.
|
||||
var localHighestModSeq = (ulong)folder.HighestModeSeq;
|
||||
// HIGHESTMODSEQ must be a positive integer, 0 is illegal.
|
||||
// It's harmless to set it to 1, as RFC-compliant server without mod-seq would ignore this parameter.
|
||||
if (localHighestModSeq == 0) localHighestModSeq = 1;
|
||||
|
||||
remoteFolder.MessagesVanished += OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged += OnMessageFlagsChanged;
|
||||
|
||||
var allUids = await FolderService.GetKnownUidsForFolderAsync(folder.Id);
|
||||
var allUniqueIds = allUids.Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, allUniqueIds).ConfigureAwait(false);
|
||||
|
||||
var changedUids = await GetChangedUidsAsync(client, remoteFolder, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await HandleChangedUIdsAsync(synchronizer, remoteFolder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update the local folder with the new highest mod-seq and validity.
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
folder.UidValidity = remoteFolder.UidValidity;
|
||||
|
||||
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await FolderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
remoteFolder.MessagesVanished -= OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged -= OnMessageFlagsChanged;
|
||||
|
||||
if (remoteFolder.IsOpen)
|
||||
{
|
||||
await remoteFolder.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
internal override async Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var localHighestModSeq = (ulong)Folder.HighestModeSeq;
|
||||
if (localHighestModSeq == 0) localHighestModSeq = 1;
|
||||
return await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// Uid based IMAP Synchronization strategy.
|
||||
/// </summary>
|
||||
internal class UidBasedSynchronizer : ImapSynchronizationStrategyBase
|
||||
{
|
||||
public UidBasedSynchronizer(IFolderService folderService, Domain.Interfaces.IMailService mailService) : base(folderService, mailService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<List<string>> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new ArgumentException("Client must be of type WinoImapClient.", nameof(client));
|
||||
|
||||
Folder = folder;
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await winoClient.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Fetch UIDs from the remote folder
|
||||
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
remoteUids = remoteUids.OrderByDescending(a => a.Id).Take((int)synchronizer.InitialMessageDownloadCountPerFolder).ToList();
|
||||
|
||||
await HandleChangedUIdsAsync(synchronizer, remoteFolder, remoteUids, cancellationToken).ConfigureAwait(false);
|
||||
await ManageUUIdBasedDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
await FolderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
if (remoteFolder.IsOpen)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
internal override Task<IList<UniqueId>> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -23,29 +23,29 @@ namespace Wino.Core.Synchronizers.ImapSync;
|
||||
/// Unified IMAP synchronization strategy that automatically selects the best available method:
|
||||
/// 1. QRESYNC (RFC 5162) - Best: supports quick resync with vanished messages
|
||||
/// 2. CONDSTORE (RFC 4551) - Good: supports mod-seq based change tracking
|
||||
/// 3. UID-based - Fallback: basic UID comparison
|
||||
///
|
||||
/// This consolidates the previous QResyncSynchronizer, CondstoreSynchronizer, and UidBasedSynchronizer
|
||||
/// into a single, enterprise-grade implementation with proper error handling and partial failure support.
|
||||
/// 3. UID-based delta - Fallback: tracks UIDNEXT/high-water UID without sequence-number persistence
|
||||
/// </summary>
|
||||
public class UnifiedImapSynchronizer
|
||||
{
|
||||
private static readonly TimeSpan UidReconcileInterval = TimeSpan.FromHours(12);
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<UnifiedImapSynchronizer>();
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
||||
|
||||
// Minimum summary items to Fetch for mail synchronization from IMAP.
|
||||
private readonly MessageSummaryItems MailSynchronizationFlags =
|
||||
// Metadata-first synchronization flags: no full MIME body download.
|
||||
private readonly MessageSummaryItems _mailSynchronizationFlags =
|
||||
MessageSummaryItems.Flags |
|
||||
MessageSummaryItems.UniqueId |
|
||||
MessageSummaryItems.ThreadId |
|
||||
MessageSummaryItems.EmailId |
|
||||
MessageSummaryItems.InternalDate |
|
||||
MessageSummaryItems.Envelope |
|
||||
MessageSummaryItems.Headers |
|
||||
MessageSummaryItems.PreviewText |
|
||||
MessageSummaryItems.GMailThreadId |
|
||||
MessageSummaryItems.References |
|
||||
MessageSummaryItems.ModSeq;
|
||||
MessageSummaryItems.ModSeq |
|
||||
MessageSummaryItems.BodyStructure;
|
||||
|
||||
public UnifiedImapSynchronizer(
|
||||
IFolderService folderService,
|
||||
@@ -58,21 +58,25 @@ public class UnifiedImapSynchronizer
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the best synchronization strategy based on server capabilities.
|
||||
/// Determines the best synchronization strategy based on server capabilities and known quirks.
|
||||
/// </summary>
|
||||
public ImapSyncStrategy DetermineSyncStrategy(IImapClient client)
|
||||
public ImapSyncStrategy DetermineSyncStrategy(IImapClient client, string serverHost)
|
||||
{
|
||||
if (client is WinoImapClient winoClient &&
|
||||
client.Capabilities.HasFlag(ImapCapabilities.QuickResync) &&
|
||||
winoClient.IsQResyncEnabled)
|
||||
{
|
||||
return ImapSyncStrategy.QResync;
|
||||
}
|
||||
var capabilities = client.Capabilities;
|
||||
var isQResyncEnabled = client is WinoImapClient winoClient && winoClient.IsQResyncEnabled;
|
||||
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.CondStore))
|
||||
{
|
||||
return DetermineSyncStrategy(capabilities, isQResyncEnabled, serverHost);
|
||||
}
|
||||
|
||||
public ImapSyncStrategy DetermineSyncStrategy(ImapCapabilities capabilities, bool isQResyncEnabled, string serverHost = null)
|
||||
{
|
||||
var quirks = ImapServerQuirks.Resolve(serverHost);
|
||||
|
||||
if (!quirks.DisableQResync && capabilities.HasFlag(ImapCapabilities.QuickResync) && isQResyncEnabled)
|
||||
return ImapSyncStrategy.QResync;
|
||||
|
||||
if (!quirks.DisableCondstore && capabilities.HasFlag(ImapCapabilities.CondStore))
|
||||
return ImapSyncStrategy.Condstore;
|
||||
}
|
||||
|
||||
return ImapSyncStrategy.UidBased;
|
||||
}
|
||||
@@ -84,20 +88,48 @@ public class UnifiedImapSynchronizer
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
string serverHost,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var strategy = DetermineSyncStrategy(client);
|
||||
var strategy = DetermineSyncStrategy(client, serverHost);
|
||||
_logger.Debug("Using {Strategy} sync strategy for folder {FolderName}", strategy, folder.FolderName);
|
||||
|
||||
var originalHighestModeSeq = folder.HighestModeSeq;
|
||||
var originalUidValidity = folder.UidValidity;
|
||||
var originalHighestKnownUid = folder.HighestKnownUid;
|
||||
var originalLastUidReconcileUtc = folder.LastUidReconcileUtc;
|
||||
|
||||
try
|
||||
{
|
||||
var downloadedIds = strategy switch
|
||||
{
|
||||
ImapSyncStrategy.QResync => await SynchronizeWithQResyncAsync(client, folder, synchronizer, cancellationToken),
|
||||
ImapSyncStrategy.Condstore => await SynchronizeWithCondstoreAsync(client, folder, synchronizer, cancellationToken),
|
||||
_ => await SynchronizeWithUidBasedAsync(client, folder, synchronizer, cancellationToken)
|
||||
ImapSyncStrategy.QResync => await SynchronizeWithQResyncAsync(client, folder, synchronizer, cancellationToken).ConfigureAwait(false),
|
||||
ImapSyncStrategy.Condstore => await SynchronizeWithCondstoreAsync(client, folder, synchronizer, cancellationToken).ConfigureAwait(false),
|
||||
_ => await SynchronizeWithUidDeltaAsync(client, folder, synchronizer, cancellationToken).ConfigureAwait(false)
|
||||
};
|
||||
|
||||
if (strategy == ImapSyncStrategy.QResync)
|
||||
{
|
||||
if (folder.HighestModeSeq != originalHighestModeSeq)
|
||||
{
|
||||
await _folderService.UpdateFolderHighestModeSeqAsync(folder.Id, folder.HighestModeSeq).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bool requiresFullFolderUpdate =
|
||||
folder.UidValidity != originalUidValidity
|
||||
|| folder.HighestKnownUid != originalHighestKnownUid
|
||||
|| folder.LastUidReconcileUtc != originalLastUidReconcileUtc;
|
||||
|
||||
if (requiresFullFolderUpdate)
|
||||
{
|
||||
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return FolderSyncResult.Successful(folder.Id, folder.FolderName, downloadedIds.Count);
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
@@ -122,7 +154,7 @@ public class UnifiedImapSynchronizer
|
||||
OperationType = "ImapFolderSync"
|
||||
};
|
||||
|
||||
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
_ = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
|
||||
if (errorContext.CanContinueSync)
|
||||
{
|
||||
@@ -135,7 +167,41 @@ public class UnifiedImapSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
#region QRESYNC Strategy
|
||||
/// <summary>
|
||||
/// Metadata-only message download helper used by IMAP online search.
|
||||
/// </summary>
|
||||
public async Task<List<string>> DownloadMessagesByUidsAsync(
|
||||
IImapClient client,
|
||||
IMailFolder remoteFolder,
|
||||
MailItemFolder localFolder,
|
||||
IList<UniqueId> uids,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (uids == null || uids.Count == 0)
|
||||
return [];
|
||||
|
||||
if (!remoteFolder.IsOpen)
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
foreach (var batch in uids.Distinct().OrderBy(a => a.Id).Batch(50))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var summaryBatch = await remoteFolder
|
||||
.FetchAsync(new UniqueIdSet(batch.ToList(), SortOrder.Ascending), _mailSynchronizationFlags, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds.AddRange(await ProcessSummariesAsync(synchronizer, localFolder, summaryBatch, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
UpdateHighestKnownUid(localFolder, remoteFolder, uids.Select(a => a.Id));
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#region Strategy Implementations
|
||||
|
||||
private async Task<List<string>> SynchronizeWithQResyncAsync(
|
||||
IImapClient client,
|
||||
@@ -143,57 +209,87 @@ public class UnifiedImapSynchronizer
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (client is not WinoImapClient)
|
||||
throw new InvalidOperationException("QRESYNC requires WinoImapClient.");
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new InvalidOperationException("QRESYNC requires WinoImapClient");
|
||||
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
var vanishedUids = new List<UniqueId>();
|
||||
var changedFlags = new Dictionary<uint, MessageFlags>();
|
||||
|
||||
void OnMessagesVanished(object sender, MessagesVanishedEventArgs args)
|
||||
{
|
||||
lock (vanishedUids)
|
||||
{
|
||||
vanishedUids.AddRange(args.UniqueIds);
|
||||
}
|
||||
}
|
||||
|
||||
void OnMessageFlagsChanged(object sender, MessageFlagsChangedEventArgs args)
|
||||
{
|
||||
if (args.UniqueId is not UniqueId uniqueId)
|
||||
return;
|
||||
|
||||
lock (changedFlags)
|
||||
{
|
||||
changedFlags[uniqueId.Id] = args.Flags;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Open once to validate UIDVALIDITY and reset local state if needed.
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureUidValidityStateAsync(folder, remoteFolder).ConfigureAwait(false);
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var knownUids = await _folderService.GetKnownUidsForFolderAsync(folder.Id).ConfigureAwait(false);
|
||||
var knownUidStructs = knownUids.Select(a => new UniqueId(a)).ToList();
|
||||
var localHighestModSeq = (ulong)Math.Max(folder.HighestModeSeq, 1);
|
||||
var allUids = await _folderService.GetKnownUidsForFolderAsync(folder.Id);
|
||||
var allUniqueIds = allUids.Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
// Subscribe to events before opening
|
||||
remoteFolder.MessagesVanished += (s, e) => HandleMessagesVanished(folder, e.UniqueIds);
|
||||
remoteFolder.MessageFlagsChanged += (s, e) => HandleMessageFlagsChanged(folder, e.UniqueId, e.Flags);
|
||||
remoteFolder.MessagesVanished += OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged += OnMessageFlagsChanged;
|
||||
|
||||
// Open with QRESYNC parameters
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, allUniqueIds, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder
|
||||
.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, knownUidStructs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Get changed UIDs
|
||||
var changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
var changedUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update folder tracking
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
folder.UidValidity = remoteFolder.UidValidity;
|
||||
|
||||
// Handle deletions
|
||||
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
await ApplyFlagChangesAsync(folder, changedFlags).ConfigureAwait(false);
|
||||
await ApplyDeletedUidsAsync(folder, vanishedUids).ConfigureAwait(false);
|
||||
|
||||
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
if (ShouldRunUidReconcile(folder))
|
||||
{
|
||||
await ReconcileDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
|
||||
if (remoteFolder != null)
|
||||
{
|
||||
await remoteFolder.CloseAsync().ConfigureAwait(false);
|
||||
remoteFolder.MessagesVanished -= OnMessagesVanished;
|
||||
remoteFolder.MessageFlagsChanged -= OnMessageFlagsChanged;
|
||||
|
||||
if (remoteFolder.IsOpen && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CONDSTORE Strategy
|
||||
|
||||
private async Task<List<string>> SynchronizeWithCondstoreAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
@@ -208,29 +304,28 @@ public class UnifiedImapSynchronizer
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var localHighestModSeq = (ulong)folder.HighestModeSeq;
|
||||
bool isInitialSync = localHighestModSeq == 0;
|
||||
await EnsureUidValidityStateAsync(folder, remoteFolder).ConfigureAwait(false);
|
||||
|
||||
var localHighestModSeq = (ulong)Math.Max(folder.HighestModeSeq, 1);
|
||||
bool isInitialSync = folder.HighestModeSeq == 0;
|
||||
|
||||
if (remoteFolder.HighestModSeq > localHighestModSeq || isInitialSync)
|
||||
{
|
||||
IList<UniqueId> changedUids;
|
||||
|
||||
// Use SORT if available for better ordering
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Sort))
|
||||
{
|
||||
changedUids = await remoteFolder.SortAsync(
|
||||
SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)),
|
||||
[OrderBy.ReverseDate],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
changedUids = await remoteFolder
|
||||
.SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
changedUids = await remoteFolder.SearchAsync(
|
||||
SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
changedUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// For initial sync, limit the number of messages
|
||||
if (isInitialSync)
|
||||
{
|
||||
changedUids = changedUids
|
||||
@@ -239,13 +334,14 @@ public class UnifiedImapSynchronizer
|
||||
.ToList();
|
||||
}
|
||||
|
||||
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
if (ShouldRunUidReconcile(folder))
|
||||
{
|
||||
await ReconcileDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -258,11 +354,7 @@ public class UnifiedImapSynchronizer
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UID-Based Strategy (Fallback)
|
||||
|
||||
private async Task<List<string>> SynchronizeWithUidBasedAsync(
|
||||
private async Task<List<string>> SynchronizeWithUidDeltaAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
@@ -276,16 +368,35 @@ public class UnifiedImapSynchronizer
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get all remote UIDs and take the most recent ones
|
||||
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
var limitedUids = remoteUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
await EnsureUidValidityStateAsync(folder, remoteFolder).ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, limitedUids, cancellationToken).ConfigureAwait(false);
|
||||
if (folder.HighestKnownUid == 0)
|
||||
{
|
||||
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
var initialUids = remoteUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, initialUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
UpdateHighestKnownUid(folder, remoteFolder, remoteUids.Select(a => a.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
var minUid = new UniqueId(folder.HighestKnownUid + 1);
|
||||
var deltaUids = await remoteFolder
|
||||
.SearchAsync(SearchQuery.Uids(new UniqueIdRange(minUid, UniqueId.MaxValue)), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, deltaUids, synchronizer, cancellationToken).ConfigureAwait(false);
|
||||
UpdateHighestKnownUid(folder, remoteFolder, deltaUids.Select(a => a.Id));
|
||||
}
|
||||
|
||||
if (ShouldRunUidReconcile(folder))
|
||||
{
|
||||
await ReconcileDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -300,108 +411,89 @@ public class UnifiedImapSynchronizer
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Processing Methods
|
||||
#region Shared Helpers
|
||||
|
||||
private async Task<List<string>> ProcessChangedUidsAsync(
|
||||
private async Task EnsureUidValidityStateAsync(MailItemFolder folder, IMailFolder remoteFolder)
|
||||
{
|
||||
if (folder.UidValidity != 0 && remoteFolder.UidValidity != folder.UidValidity)
|
||||
{
|
||||
_logger.Warning("UIDVALIDITY changed for folder {FolderName}. Resetting local folder state.", folder.FolderName);
|
||||
|
||||
var existingMails = await _mailService.GetMailsByFolderIdAsync(folder.Id).ConfigureAwait(false);
|
||||
foreach (var mail in existingMails)
|
||||
{
|
||||
await _mailService.DeleteMailAsync(folder.MailAccountId, mail.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
folder.HighestKnownUid = 0;
|
||||
folder.HighestModeSeq = 0;
|
||||
folder.LastUidReconcileUtc = null;
|
||||
}
|
||||
|
||||
folder.UidValidity = remoteFolder.UidValidity;
|
||||
}
|
||||
|
||||
private async Task<List<string>> ProcessSummariesAsync(
|
||||
IImapSynchronizer synchronizer,
|
||||
IMailFolder remoteFolder,
|
||||
MailItemFolder localFolder,
|
||||
IList<UniqueId> changedUids,
|
||||
IList<IMessageSummary> summaries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
if (changedUids == null || changedUids.Count == 0)
|
||||
if (summaries == null || summaries.Count == 0)
|
||||
return downloadedMessageIds;
|
||||
|
||||
// Get existing mails to determine what's new vs. updated
|
||||
var existingMails = await _mailService.GetExistingMailsAsync(localFolder.Id, changedUids).ConfigureAwait(false);
|
||||
var existingMailUids = existingMails.Select(m => MailkitClientExtensions.ResolveUidStruct(m.Id)).ToArray();
|
||||
var uniqueIds = summaries
|
||||
.Where(s => s.UniqueId != UniqueId.Invalid)
|
||||
.Select(s => s.UniqueId)
|
||||
.ToList();
|
||||
|
||||
var newMessageUids = changedUids.Except(existingMailUids).ToList();
|
||||
if (uniqueIds.Count == 0)
|
||||
return downloadedMessageIds;
|
||||
|
||||
// Update flags for existing mails
|
||||
if (existingMailUids.Any())
|
||||
{
|
||||
var existingFlagData = await remoteFolder.FetchAsync(existingMailUids, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var update in existingFlagData)
|
||||
{
|
||||
if (update.UniqueId == UniqueId.Invalid || update.Flags == null) continue;
|
||||
|
||||
var existingMail = existingMails.FirstOrDefault(m => MailkitClientExtensions.ResolveUidStruct(m.Id).Id == update.UniqueId.Id);
|
||||
if (existingMail != null)
|
||||
{
|
||||
await UpdateMailFlagsAsync(existingMail, update.Flags.Value).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download new messages in batches
|
||||
var batches = newMessageUids.Batch(50);
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batchList = batch.ToList();
|
||||
downloadedMessageIds.AddRange(batchList.Select(uid => MailkitClientExtensions.CreateUid(localFolder.Id, uid.Id)));
|
||||
|
||||
await DownloadMessagesAsync(synchronizer, remoteFolder, localFolder, new UniqueIdSet(batchList, SortOrder.Ascending), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
private async Task DownloadMessagesAsync(
|
||||
IImapSynchronizer synchronizer,
|
||||
IMailFolder folder,
|
||||
MailItemFolder localFolder,
|
||||
UniqueIdSet uniqueIdSet,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var summaries = await folder.FetchAsync(uniqueIdSet, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
|
||||
var existingMails = await _mailService.GetExistingMailsAsync(localFolder.Id, uniqueIds).ConfigureAwait(false);
|
||||
var existingByUid = existingMails
|
||||
.Select(m => (Uid: MailkitClientExtensions.ResolveUidStruct(m.Id), Mail: m))
|
||||
.ToDictionary(a => a.Uid.Id, a => a.Mail);
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mimeMessage = await folder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
|
||||
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage);
|
||||
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, localFolder, cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (mailPackages != null)
|
||||
if (summary.UniqueId == UniqueId.Invalid)
|
||||
continue;
|
||||
|
||||
if (existingByUid.TryGetValue(summary.UniqueId.Id, out var existingMail))
|
||||
{
|
||||
if (summary.Flags != null)
|
||||
{
|
||||
foreach (var package in mailPackages)
|
||||
{
|
||||
if (package != null)
|
||||
{
|
||||
await _mailService.CreateMailAsync(localFolder.MailAccountId, package).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
await UpdateMailFlagsAsync(existingMail, summary.Flags.Value).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage: null);
|
||||
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, localFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mailPackages == null)
|
||||
continue;
|
||||
|
||||
foreach (var package in mailPackages)
|
||||
{
|
||||
if (package == null)
|
||||
continue;
|
||||
|
||||
var inserted = await _mailService.CreateMailAsync(localFolder.MailAccountId, package).ConfigureAwait(false);
|
||||
if (inserted)
|
||||
{
|
||||
downloadedMessageIds.Add(package.Copy.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to download message {UniqueId} in folder {FolderName}", summary.UniqueId, localFolder.FolderName);
|
||||
// Continue with other messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
var allLocalUids = (await _folderService.GetKnownUidsForFolderAsync(localFolder.Id)).Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
if (allLocalUids.Count == 0) return;
|
||||
|
||||
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
var deletedUids = allLocalUids.Except(remoteAllUids).ToList();
|
||||
|
||||
foreach (var deletedUid in deletedUids)
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(localFolder.Id, deletedUid.Id);
|
||||
await _mailService.DeleteMailAsync(localFolder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
private async Task UpdateMailFlagsAsync(MailCopy mailCopy, MessageFlags flags)
|
||||
@@ -420,32 +512,95 @@ public class UnifiedImapSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMessagesVanished(MailItemFolder folder, IList<UniqueId> uniqueIds)
|
||||
private async Task ApplyDeletedUidsAsync(MailItemFolder folder, IList<UniqueId> uniqueIds)
|
||||
{
|
||||
// Fire and forget - these are event handlers
|
||||
_ = Task.Run(async () =>
|
||||
if (uniqueIds == null || uniqueIds.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var uniqueId in uniqueIds.Distinct())
|
||||
{
|
||||
foreach (var uniqueId in uniqueIds)
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Id);
|
||||
await _mailService.DeleteMailAsync(folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Id);
|
||||
await _mailService.DeleteMailAsync(folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMessageFlagsChanged(MailItemFolder folder, UniqueId? uniqueId, MessageFlags flags)
|
||||
private async Task ApplyFlagChangesAsync(MailItemFolder folder, IDictionary<uint, MessageFlags> changedFlags)
|
||||
{
|
||||
if (uniqueId == null) return;
|
||||
if (changedFlags == null || changedFlags.Count == 0)
|
||||
return;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
foreach (var changed in changedFlags)
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Value.Id);
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, changed.Key);
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(changed.Value);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(changed.Value);
|
||||
|
||||
await _mailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false);
|
||||
await _mailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRunUidReconcile(MailItemFolder folder)
|
||||
{
|
||||
return ShouldRunUidReconcile(folder.LastUidReconcileUtc, DateTime.UtcNow, UidReconcileInterval);
|
||||
}
|
||||
|
||||
private async Task ReconcileDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
var allLocalUids = (await _folderService.GetKnownUidsForFolderAsync(localFolder.Id).ConfigureAwait(false))
|
||||
.Select(a => new UniqueId(a))
|
||||
.ToList();
|
||||
|
||||
if (allLocalUids.Count == 0)
|
||||
{
|
||||
localFolder.LastUidReconcileUtc = DateTime.UtcNow;
|
||||
return;
|
||||
}
|
||||
|
||||
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
var deletedUids = allLocalUids.Except(remoteAllUids).ToList();
|
||||
|
||||
await ApplyDeletedUidsAsync(localFolder, deletedUids).ConfigureAwait(false);
|
||||
localFolder.LastUidReconcileUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static void UpdateHighestKnownUid(MailItemFolder folder, IMailFolder remoteFolder, IEnumerable<uint> observedUids)
|
||||
{
|
||||
folder.HighestKnownUid = CalculateHighestKnownUid(folder.HighestKnownUid, remoteFolder?.UidNext, observedUids);
|
||||
}
|
||||
|
||||
public static bool ShouldRunUidReconcile(DateTime? lastUidReconcileUtc, DateTime utcNow, TimeSpan reconcileInterval)
|
||||
{
|
||||
if (!lastUidReconcileUtc.HasValue)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return utcNow - lastUidReconcileUtc.Value >= reconcileInterval;
|
||||
}
|
||||
|
||||
public static uint CalculateHighestKnownUid(uint currentHighestKnownUid, UniqueId? uidNext, IEnumerable<uint> observedUids)
|
||||
{
|
||||
uint observedMax = 0;
|
||||
|
||||
if (observedUids != null)
|
||||
{
|
||||
foreach (var uid in observedUids)
|
||||
{
|
||||
if (uid > observedMax)
|
||||
{
|
||||
observedMax = uid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint uidNextBased = 0;
|
||||
if (uidNext.HasValue)
|
||||
{
|
||||
uidNextBased = uidNext.Value.Id > 0 ? uidNext.Value.Id - 1 : 0;
|
||||
}
|
||||
|
||||
return Math.Max(currentHighestKnownUid, Math.Max(observedMax, uidNextBased));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -467,7 +622,7 @@ public enum ImapSyncStrategy
|
||||
Condstore,
|
||||
|
||||
/// <summary>
|
||||
/// Basic UID-based synchronization - fallback for servers without advanced features.
|
||||
/// UID-based delta synchronization fallback.
|
||||
/// </summary>
|
||||
UidBased
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -10,7 +9,6 @@ using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MimeKit;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
@@ -44,29 +42,31 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
|
||||
#region Idle Implementation
|
||||
|
||||
private CancellationTokenSource idleCancellationTokenSource;
|
||||
private CancellationTokenSource idleDoneTokenSource;
|
||||
private static readonly Random IdleReconnectJitter = new();
|
||||
private readonly object _idleDebounceLock = new();
|
||||
private CancellationTokenSource _idleLoopCancellationTokenSource;
|
||||
private Task _idleLoopTask;
|
||||
private int _lastIdleInboxCount = -1;
|
||||
private DateTime _lastIdleSyncRequestUtc = DateTime.MinValue;
|
||||
private readonly TimeSpan _idleSyncDebounceWindow = TimeSpan.FromSeconds(15);
|
||||
|
||||
#endregion
|
||||
|
||||
private readonly ILogger _logger = Log.ForContext<ImapSynchronizer>();
|
||||
private readonly ImapClientPool _clientPool;
|
||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
|
||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||
private readonly UnifiedImapSynchronizer _unifiedSynchronizer;
|
||||
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
||||
|
||||
public ImapSynchronizer(MailAccount account,
|
||||
IImapChangeProcessor imapChangeProcessor,
|
||||
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
||||
IApplicationConfiguration applicationConfiguration,
|
||||
UnifiedImapSynchronizer unifiedSynchronizer,
|
||||
IImapSynchronizerErrorHandlerFactory errorHandlerFactory) : base(account, WeakReferenceMessenger.Default)
|
||||
{
|
||||
// Create client pool with account protocol log.
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
|
||||
_applicationConfiguration = applicationConfiguration;
|
||||
_unifiedSynchronizer = unifiedSynchronizer;
|
||||
_errorHandlerFactory = errorHandlerFactory;
|
||||
@@ -321,21 +321,33 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
// Check draft mapping.
|
||||
// This is the same implementation as in the OutlookSynchronizer.
|
||||
|
||||
if (message.MimeMessage != null &&
|
||||
message.MimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader) &&
|
||||
Guid.TryParse(message.MimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
|
||||
string draftHeaderValue = null;
|
||||
|
||||
if (message.MimeMessage?.Headers?.Contains(Domain.Constants.WinoLocalDraftHeader) == true)
|
||||
{
|
||||
draftHeaderValue = message.MimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader];
|
||||
}
|
||||
else if (message.MessageSummary?.Headers?.Contains(Domain.Constants.WinoLocalDraftHeader) == true)
|
||||
{
|
||||
draftHeaderValue = message.MessageSummary.Headers[Domain.Constants.WinoLocalDraftHeader];
|
||||
}
|
||||
|
||||
if (Guid.TryParse(draftHeaderValue, out Guid localDraftCopyUniqueId))
|
||||
{
|
||||
// This message belongs to existing local draft copy.
|
||||
// We don't need to create a new mail copy for this message, just update the existing one.
|
||||
|
||||
bool isMappingSuccessful = await _imapChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
|
||||
bool isMappingSuccessful = await _imapChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, draftHeaderValue, mailCopy.ThreadId);
|
||||
|
||||
if (isMappingSuccessful) return null;
|
||||
|
||||
// Local copy doesn't exists. Continue execution to insert mail copy.
|
||||
}
|
||||
|
||||
var contacts = ExtractContactsFromMimeMessage(message.MimeMessage);
|
||||
var contacts = message.MimeMessage != null
|
||||
? ExtractContactsFromMimeMessage(message.MimeMessage)
|
||||
: ExtractContactsFromMessageSummary(message.MessageSummary);
|
||||
|
||||
var package = new NewMailItemPackage(mailCopy, message.MimeMessage, assignedFolder.RemoteFolderId, contacts);
|
||||
|
||||
return
|
||||
@@ -388,6 +400,51 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AccountContact> ExtractContactsFromMessageSummary(IMessageSummary summary)
|
||||
{
|
||||
if (summary?.Envelope == null) return [];
|
||||
|
||||
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AddFromInternetAddressList(summary.Envelope.From);
|
||||
AddFromInternetAddressList(summary.Envelope.To);
|
||||
AddFromInternetAddressList(summary.Envelope.Cc);
|
||||
AddFromInternetAddressList(summary.Envelope.Bcc);
|
||||
AddFromInternetAddressList(summary.Envelope.ReplyTo);
|
||||
|
||||
var senderMailbox = summary.Envelope.Sender?.Mailboxes?.FirstOrDefault();
|
||||
if (senderMailbox != null)
|
||||
{
|
||||
AddContact(senderMailbox.Address, senderMailbox.Name);
|
||||
}
|
||||
|
||||
return contacts.Values.ToList();
|
||||
|
||||
void AddFromInternetAddressList(InternetAddressList addresses)
|
||||
{
|
||||
if (addresses == null) return;
|
||||
|
||||
foreach (var mailbox in addresses.Mailboxes)
|
||||
{
|
||||
AddContact(mailbox.Address, mailbox.Name);
|
||||
}
|
||||
}
|
||||
|
||||
void AddContact(string address, string name)
|
||||
{
|
||||
var trimmedAddress = address?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedAddress)) return;
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(name) ? trimmedAddress : name.Trim();
|
||||
|
||||
contacts[trimmedAddress] = new AccountContact
|
||||
{
|
||||
Address = trimmedAddress,
|
||||
Name = displayName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
@@ -413,30 +470,41 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
||||
|
||||
var totalFolders = synchronizationFolders.Count;
|
||||
const int maxParallelFolderSyncClients = 3;
|
||||
var folderSyncSemaphore = new SemaphoreSlim(maxParallelFolderSyncClients, maxParallelFolderSyncClients);
|
||||
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var linkedToken = linkedCancellationTokenSource.Token;
|
||||
var resultLock = new object();
|
||||
int completedFolders = 0;
|
||||
|
||||
for (int i = 0; i < totalFolders; i++)
|
||||
var syncTasks = synchronizationFolders.Select(async folder =>
|
||||
{
|
||||
var folder = synchronizationFolders[i];
|
||||
|
||||
// Update progress based on folder completion
|
||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||
await folderSyncSemaphore.WaitAsync(linkedToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Use the unified synchronizer for folder sync
|
||||
IImapClient client = null;
|
||||
|
||||
try
|
||||
{
|
||||
client = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
||||
var folderResult = await _unifiedSynchronizer.SynchronizeFolderAsync(client, folder, this, cancellationToken).ConfigureAwait(false);
|
||||
folderResults.Add(folderResult);
|
||||
client = await _clientPool.GetClientAsync(linkedToken).ConfigureAwait(false);
|
||||
var folderResult = await _unifiedSynchronizer
|
||||
.SynchronizeFolderAsync(client, folder, this, Account.ServerInformation?.IncomingServer, linkedToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
List<string> folderDownloadedIds = null;
|
||||
if (folderResult.Success && folderResult.DownloadedCount > 0)
|
||||
{
|
||||
// Get the downloaded message IDs for this folder
|
||||
var folderDownloadedIds = await GetDownloadedIdsForFolderAsync(folder, folderResult.DownloadedCount).ConfigureAwait(false);
|
||||
downloadedMessageIds.AddRange(folderDownloadedIds);
|
||||
folderDownloadedIds = await GetDownloadedIdsForFolderAsync(folder, folderResult.DownloadedCount).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lock (resultLock)
|
||||
{
|
||||
folderResults.Add(folderResult);
|
||||
if (folderDownloadedIds != null && folderDownloadedIds.Count > 0)
|
||||
{
|
||||
downloadedMessageIds.AddRange(folderDownloadedIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -463,23 +531,35 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
OperationType = "ImapFolderSync"
|
||||
};
|
||||
|
||||
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
_ = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
var failedResult = FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext);
|
||||
|
||||
if (errorContext.CanContinueSync)
|
||||
lock (resultLock)
|
||||
{
|
||||
_logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName);
|
||||
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
|
||||
folderResults.Add(failedResult);
|
||||
}
|
||||
else
|
||||
|
||||
if (!errorContext.CanContinueSync)
|
||||
{
|
||||
_logger.Error(ex, "Folder {FolderName} sync failed with fatal error", folder.FolderName);
|
||||
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
|
||||
linkedCancellationTokenSource.Cancel();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
_logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
folderSyncSemaphore.Release();
|
||||
|
||||
var completed = Interlocked.Increment(ref completedFolders);
|
||||
UpdateSyncProgress(totalFolders, totalFolders - completed, $"Syncing {folder.FolderName}...");
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
await Task.WhenAll(syncTasks).ConfigureAwait(false);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -827,7 +907,6 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
public override async Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IImapClient client = null;
|
||||
IMailFolder activeFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -838,6 +917,9 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
if (folder is not MailItemFolder localFolder)
|
||||
continue;
|
||||
|
||||
var remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -866,9 +948,9 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
|
||||
if (nonExistingUniqueIds.Count != 0)
|
||||
{
|
||||
var syncStrategy = _imapSynchronizationStrategyProvider.GetSynchronizationStrategy(client);
|
||||
|
||||
await syncStrategy.DownloadMessagesAsync(this, remoteFolder, folder as MailItemFolder, new UniqueIdSet(nonExistingUniqueIds, SortOrder.Ascending), cancellationToken).ConfigureAwait(false);
|
||||
await _unifiedSynchronizer
|
||||
.DownloadMessagesByUidsAsync(client, remoteFolder, localFolder, nonExistingUniqueIds, this, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
@@ -883,52 +965,10 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (activeFolder?.IsOpen ?? false)
|
||||
{
|
||||
await activeFolder.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_clientPool.Release(client);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<string>> SynchronizeFolderInternalAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!folder.IsSynchronizationEnabled) return default;
|
||||
|
||||
IImapClient availableClient = null;
|
||||
|
||||
retry:
|
||||
try
|
||||
{
|
||||
|
||||
availableClient = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
||||
|
||||
var strategy = _imapSynchronizationStrategyProvider.GetSynchronizationStrategy(availableClient);
|
||||
return await strategy.HandleSynchronizationAsync(availableClient, folder, this, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
_clientPool.Release(availableClient, false);
|
||||
|
||||
goto retry;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignore cancellations.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Synchronization failed for folder {FolderName}", folder.FolderName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_clientPool.Release(availableClient, false);
|
||||
}
|
||||
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the local folder should be updated with the remote folder.
|
||||
/// IMAP only compares folder name for now.
|
||||
@@ -941,111 +981,143 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
protected override Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public async Task StartIdleClientAsync()
|
||||
public Task StartIdleClientAsync()
|
||||
{
|
||||
IImapClient idleClient = null;
|
||||
IMailFolder inboxFolder = null;
|
||||
if (IsDisposing)
|
||||
return Task.CompletedTask;
|
||||
|
||||
bool? reconnect = null;
|
||||
if (_idleLoopTask != null && !_idleLoopTask.IsCompleted)
|
||||
return Task.CompletedTask;
|
||||
|
||||
try
|
||||
_idleLoopCancellationTokenSource = new CancellationTokenSource();
|
||||
_idleLoopTask = RunIdleLoopAsync(_idleLoopCancellationTokenSource.Token);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task RunIdleLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
int reconnectAttempt = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested && !IsDisposing)
|
||||
{
|
||||
var client = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
||||
IImapClient idleClient = null;
|
||||
IMailFolder inboxFolder = null;
|
||||
bool shouldReconnect = false;
|
||||
|
||||
if (!client.Capabilities.HasFlag(ImapCapabilities.Idle))
|
||||
try
|
||||
{
|
||||
Log.Debug($"{Account.Name} does not support Idle command. Ignored.");
|
||||
return;
|
||||
idleClient = await _clientPool.GetIdleClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (idleClient == null)
|
||||
{
|
||||
_logger.Warning("Dedicated IDLE client could not be allocated for {AccountName}.", Account.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idleClient.Capabilities.HasFlag(ImapCapabilities.Idle))
|
||||
{
|
||||
_logger.Information("{AccountName} does not support IMAP IDLE. Automatic updates rely on global sync interval.", Account.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (idleClient.Inbox == null)
|
||||
{
|
||||
_logger.Warning("{AccountName} does not expose Inbox for IDLE listening.", Account.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
inboxFolder = idleClient.Inbox;
|
||||
|
||||
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_lastIdleInboxCount = inboxFolder.Count;
|
||||
inboxFolder.CountChanged += IdleInboxCountChanged;
|
||||
|
||||
reconnectAttempt = 0;
|
||||
_logger.Debug("Started dedicated IDLE loop for {AccountName}.", Account.Name);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested && !IsDisposing && idleClient.IsConnected)
|
||||
{
|
||||
using var idleDoneTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(9));
|
||||
await idleClient.IdleAsync(idleDoneTokenSource.Token, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (ImapProtocolException protocolException)
|
||||
{
|
||||
_logger.Information(protocolException, "Idle client received protocol exception for {AccountName}.", Account.Name);
|
||||
shouldReconnect = true;
|
||||
}
|
||||
catch (IOException ioException)
|
||||
{
|
||||
_logger.Information(ioException, "Idle client received IO exception for {AccountName}.", Account.Name);
|
||||
shouldReconnect = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested || IsDisposing)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
shouldReconnect = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Idle client loop failed for {AccountName}.", Account.Name);
|
||||
shouldReconnect = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (inboxFolder != null)
|
||||
{
|
||||
inboxFolder.CountChanged -= IdleInboxCountChanged;
|
||||
|
||||
if (inboxFolder.IsOpen && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await inboxFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_clientPool.ReleaseIdleClient(isFaulted: shouldReconnect);
|
||||
}
|
||||
|
||||
if (client.Inbox == null)
|
||||
if (!shouldReconnect)
|
||||
{
|
||||
Log.Warning($"{Account.Name} does not have an Inbox folder for idle client to track. Ignored.");
|
||||
return;
|
||||
break;
|
||||
}
|
||||
|
||||
// Setup idle client.
|
||||
idleClient = client;
|
||||
reconnectAttempt++;
|
||||
var reconnectDelay = GetIdleReconnectDelay(reconnectAttempt);
|
||||
_logger.Information("Reconnecting IDLE client for {AccountName} in {Delay}.", Account.Name, reconnectDelay);
|
||||
|
||||
idleDoneTokenSource ??= new CancellationTokenSource();
|
||||
idleCancellationTokenSource ??= new CancellationTokenSource();
|
||||
|
||||
inboxFolder = client.Inbox;
|
||||
|
||||
await inboxFolder.OpenAsync(FolderAccess.ReadOnly, idleCancellationTokenSource.Token);
|
||||
|
||||
inboxFolder.CountChanged += IdleNotificationTriggered;
|
||||
inboxFolder.MessageFlagsChanged += IdleNotificationTriggered;
|
||||
inboxFolder.MessageExpunged += IdleNotificationTriggered;
|
||||
inboxFolder.MessagesVanished += IdleNotificationTriggered;
|
||||
|
||||
Log.Debug("Starting an idle client for {Name}", Account.Name);
|
||||
|
||||
await client.IdleAsync(idleDoneTokenSource.Token, idleCancellationTokenSource.Token);
|
||||
}
|
||||
catch (ImapProtocolException protocolException)
|
||||
{
|
||||
Log.Information(protocolException, "Idle client received protocol exception.");
|
||||
reconnect = true;
|
||||
}
|
||||
catch (IOException ioException)
|
||||
{
|
||||
Log.Information(ioException, "Idle client received IO exception.");
|
||||
reconnect = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
reconnect = !IsDisposing;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Idle client failed to start.");
|
||||
reconnect = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (inboxFolder != null)
|
||||
try
|
||||
{
|
||||
inboxFolder.CountChanged -= IdleNotificationTriggered;
|
||||
inboxFolder.MessageFlagsChanged -= IdleNotificationTriggered;
|
||||
inboxFolder.MessageExpunged -= IdleNotificationTriggered;
|
||||
inboxFolder.MessagesVanished -= IdleNotificationTriggered;
|
||||
await Task.Delay(reconnectDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (idleDoneTokenSource != null)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
idleDoneTokenSource.Dispose();
|
||||
idleDoneTokenSource = null;
|
||||
}
|
||||
|
||||
if (idleClient != null)
|
||||
{
|
||||
// Killing the client is not necessary. We can re-use it later.
|
||||
_clientPool.Release(idleClient, destroyClient: false);
|
||||
|
||||
idleClient = null;
|
||||
}
|
||||
|
||||
if (reconnect == true)
|
||||
{
|
||||
Log.Information("Idle client is reconnecting.");
|
||||
|
||||
_ = StartIdleClientAsync();
|
||||
}
|
||||
else if (reconnect == false)
|
||||
{
|
||||
Log.Information("Finalized idle client.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan GetIdleReconnectDelay(int attempt)
|
||||
{
|
||||
var backoffSeconds = Math.Min(60, Math.Pow(2, Math.Min(attempt, 6)));
|
||||
int jitterMs;
|
||||
|
||||
lock (IdleReconnectJitter)
|
||||
{
|
||||
jitterMs = IdleReconnectJitter.Next(250, 1250);
|
||||
}
|
||||
|
||||
return TimeSpan.FromSeconds(backoffSeconds) + TimeSpan.FromMilliseconds(jitterMs);
|
||||
}
|
||||
|
||||
private void RequestIdleChangeSynchronization()
|
||||
{
|
||||
Debug.WriteLine("Detected idle change.");
|
||||
|
||||
// We don't really need to act on the count change in detail.
|
||||
// Our synchronization should be enough to handle the changes with on-demand sync.
|
||||
// We can just trigger a sync here IMAPIdle type.
|
||||
if (!ShouldTriggerIdleSynchronization(DateTime.UtcNow))
|
||||
return;
|
||||
|
||||
var options = new MailSynchronizationOptions()
|
||||
{
|
||||
@@ -1056,15 +1128,57 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options));
|
||||
}
|
||||
|
||||
private void IdleNotificationTriggered(object sender, EventArgs e)
|
||||
=> RequestIdleChangeSynchronization();
|
||||
|
||||
public Task StopIdleClientAsync()
|
||||
internal bool ShouldTriggerIdleSynchronization(DateTime nowUtc)
|
||||
{
|
||||
idleDoneTokenSource?.Cancel();
|
||||
idleCancellationTokenSource?.Cancel();
|
||||
lock (_idleDebounceLock)
|
||||
{
|
||||
if (nowUtc - _lastIdleSyncRequestUtc < _idleSyncDebounceWindow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
_lastIdleSyncRequestUtc = nowUtc;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void IdleInboxCountChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (sender is not IMailFolder inboxFolder)
|
||||
return;
|
||||
|
||||
var currentCount = inboxFolder.Count;
|
||||
var previousCount = _lastIdleInboxCount;
|
||||
_lastIdleInboxCount = currentCount;
|
||||
|
||||
if (currentCount > previousCount)
|
||||
{
|
||||
RequestIdleChangeSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopIdleClientAsync()
|
||||
{
|
||||
if (_idleLoopCancellationTokenSource != null)
|
||||
{
|
||||
_idleLoopCancellationTokenSource.Cancel();
|
||||
}
|
||||
|
||||
if (_idleLoopTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _idleLoopTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
_idleLoopCancellationTokenSource?.Dispose();
|
||||
_idleLoopCancellationTokenSource = null;
|
||||
_idleLoopTask = null;
|
||||
}
|
||||
|
||||
public override async Task KillSynchronizerAsync()
|
||||
@@ -1078,3 +1192,5 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
|
||||
public Task PreWarmClientPoolAsync() => _clientPool.PreWarmPoolAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -624,6 +624,37 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
private static bool IsDraftOrSentFolder(MailCopy mailItem)
|
||||
=> mailItem?.AssignedFolder?.SpecialFolderType is SpecialFolderType.Draft or SpecialFolderType.Sent;
|
||||
|
||||
private bool IsActiveDraftFolder()
|
||||
=> ActiveFolder?.SpecialFolderType == SpecialFolderType.Draft;
|
||||
|
||||
private bool BelongsToActiveFolder(MailCopy mailItem)
|
||||
=> mailItem?.AssignedFolder != null && ActiveFolder?.HandlingFolders?.Any(a => a.Id == mailItem.AssignedFolder.Id) == true;
|
||||
|
||||
private bool ShouldIncludeByThread(MailCopy mailItem)
|
||||
=> PreferencesService.IsThreadingEnabled
|
||||
&& !string.IsNullOrEmpty(mailItem?.ThreadId)
|
||||
&& ThreadIdExistsInCollection(mailItem);
|
||||
|
||||
private bool ShouldIncludeAddedMailInCurrentList(MailCopy addedMail)
|
||||
{
|
||||
if (addedMail == null || ActiveFolder == null || addedMail.AssignedFolder == null)
|
||||
return false;
|
||||
|
||||
// 1) If threading is enabled and we already have the same conversation in view, include it.
|
||||
if (ShouldIncludeByThread(addedMail))
|
||||
return true;
|
||||
|
||||
// 2) Include items that belong to the active folder.
|
||||
if (BelongsToActiveFolder(addedMail))
|
||||
return true;
|
||||
|
||||
// 3) Draft-specific visibility: include drafts while viewing Drafts.
|
||||
if (addedMail.IsDraft && IsActiveDraftFolder())
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsMailMatchingLocalSearch(MailCopy mailItem)
|
||||
{
|
||||
if (!IsInSearchMode) return true;
|
||||
@@ -639,37 +670,10 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
|
||||
private bool ShouldRemoveUpdatedMailFromCurrentList(MailCopy updatedMail)
|
||||
{
|
||||
if (ActiveFolder == null || updatedMail?.AssignedFolder == null) return true;
|
||||
|
||||
bool isFromDraftOrSentFolder = IsDraftOrSentFolder(updatedMail);
|
||||
|
||||
if (!isFromDraftOrSentFolder && !ActiveFolder.HandlingFolders.Any(a => a.Id == updatedMail.AssignedFolder.Id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isFromDraftOrSentFolder && !ThreadIdExistsInCollection(updatedMail))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ShouldPreventItemAdd(updatedMail))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SelectedFolderPivot?.IsFocused is bool isFocused && updatedMail.IsFocused != isFocused)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Online search results are a server-provided snapshot. Keep current items stable.
|
||||
if (IsInSearchMode && (IsOnlineSearchEnabled || AreSearchResultsOnline))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !IsMailMatchingLocalSearch(updatedMail);
|
||||
// Update flow already checks if this item is currently listed.
|
||||
// Keep the item in the list and update in-place.
|
||||
_ = updatedMail;
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -704,72 +708,58 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
||||
// At least one of the accounts we are listing must match with the account of the added mail.
|
||||
if (!ActiveFolder.HandlingFolders.Any(a => a.MailAccountId == addedMail.AssignedAccount.Id)) return;
|
||||
|
||||
// Messages coming to sent or draft folder should only be inserted if their ThreadId exists in the collection.
|
||||
bool isFromDraftOrSentFolder = IsDraftOrSentFolder(addedMail);
|
||||
|
||||
if (isFromDraftOrSentFolder)
|
||||
// Fix for draft duplication: When a draft is created for reply/forward, it's first added as local draft.
|
||||
// Then the server sync fetches it back. We should skip adding remote drafts if a local draft already exists
|
||||
// with the same ThreadId. The mapping system (DraftMapped) will handle updating the existing local draft.
|
||||
if (addedMail.IsDraft && !addedMail.IsLocalDraft && !string.IsNullOrEmpty(addedMail.ThreadId))
|
||||
{
|
||||
// Fix for draft duplication: When a draft is created for reply/forward, it's first added as local draft.
|
||||
// Then the server sync fetches it back. We should skip adding remote drafts if a local draft already exists
|
||||
// with the same ThreadId. The mapping system (DraftMapped) will handle updating the existing local draft.
|
||||
if (addedMail.IsDraft && !addedMail.IsLocalDraft && !string.IsNullOrEmpty(addedMail.ThreadId))
|
||||
// Check if collection already has a local draft with the same ThreadId in the same folder
|
||||
bool hasLocalDraftInSameThread = false;
|
||||
|
||||
foreach (var group in MailCollection.MailItems)
|
||||
{
|
||||
// Check if collection already has a local draft with the same ThreadId in the same folder
|
||||
bool hasLocalDraftInSameThread = false;
|
||||
|
||||
foreach (var group in MailCollection.MailItems)
|
||||
foreach (var item in group)
|
||||
{
|
||||
foreach (var item in group)
|
||||
if (item is MailItemViewModel mailItem)
|
||||
{
|
||||
if (item is MailItemViewModel mailItem)
|
||||
if (mailItem.IsDraft &&
|
||||
mailItem.MailCopy.IsLocalDraft &&
|
||||
mailItem.MailCopy.ThreadId == addedMail.ThreadId &&
|
||||
mailItem.MailCopy.FolderId == addedMail.FolderId)
|
||||
{
|
||||
if (mailItem.IsDraft &&
|
||||
mailItem.MailCopy.IsLocalDraft &&
|
||||
mailItem.MailCopy.ThreadId == addedMail.ThreadId &&
|
||||
mailItem.MailCopy.FolderId == addedMail.FolderId)
|
||||
hasLocalDraftInSameThread = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (item is ThreadMailItemViewModel threadItem)
|
||||
{
|
||||
foreach (var threadEmail in threadItem.ThreadEmails)
|
||||
{
|
||||
if (threadEmail.IsDraft &&
|
||||
threadEmail.MailCopy.IsLocalDraft &&
|
||||
threadEmail.MailCopy.ThreadId == addedMail.ThreadId &&
|
||||
threadEmail.MailCopy.FolderId == addedMail.FolderId)
|
||||
{
|
||||
hasLocalDraftInSameThread = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (item is ThreadMailItemViewModel threadItem)
|
||||
{
|
||||
foreach (var threadEmail in threadItem.ThreadEmails)
|
||||
{
|
||||
if (threadEmail.IsDraft &&
|
||||
threadEmail.MailCopy.IsLocalDraft &&
|
||||
threadEmail.MailCopy.ThreadId == addedMail.ThreadId &&
|
||||
threadEmail.MailCopy.FolderId == addedMail.FolderId)
|
||||
{
|
||||
hasLocalDraftInSameThread = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasLocalDraftInSameThread) break;
|
||||
}
|
||||
if (hasLocalDraftInSameThread) break;
|
||||
}
|
||||
if (hasLocalDraftInSameThread) break;
|
||||
}
|
||||
|
||||
if (hasLocalDraftInSameThread)
|
||||
{
|
||||
// Local draft exists in the same thread - skip adding remote duplicate
|
||||
// The mapping system will update the local draft with remote IDs when DraftMapped message is received
|
||||
return;
|
||||
}
|
||||
if (hasLocalDraftInSameThread) break;
|
||||
}
|
||||
|
||||
// Only add if the ThreadId exists in the collection (can be threaded with existing items)
|
||||
if (!ThreadIdExistsInCollection(addedMail)) return;
|
||||
if (hasLocalDraftInSameThread)
|
||||
{
|
||||
// Local draft exists in the same thread - skip adding remote duplicate
|
||||
// The mapping system will update the local draft with remote IDs when DraftMapped message is received
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Item does not belong to this folder.
|
||||
if (!ActiveFolder.HandlingFolders.Any(a => a.Id == addedMail.AssignedFolder.Id)) return;
|
||||
|
||||
// Item should be prevented from being added to the list due to filter.
|
||||
if (ShouldPreventItemAdd(addedMail)) return;
|
||||
}
|
||||
if (!ShouldIncludeAddedMailInCurrentList(addedMail)) return;
|
||||
if (ShouldPreventItemAdd(addedMail)) return;
|
||||
|
||||
if (SelectedFolderPivot?.IsFocused is bool isFocused && addedMail.IsFocused != isFocused)
|
||||
{
|
||||
|
||||
@@ -97,7 +97,7 @@ function initializeJodit(fonts, defaultComposerFont, defaultComposerFontSize, de
|
||||
}
|
||||
|
||||
function RenderHTML(htmlString) {
|
||||
editor.s.insertHTML(htmlString);
|
||||
editor.value = htmlString;
|
||||
editor.synchronizeValues();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
@@ -63,6 +64,26 @@ public class DatabaseService : IDatabaseService
|
||||
Connection.CreateTableAsync<Reminder>(),
|
||||
Connection.CreateTableAsync<MailInvitationCalendarMapping>()
|
||||
);
|
||||
|
||||
await EnsureSchemaUpgradesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureSchemaUpgradesAsync()
|
||||
{
|
||||
var folderColumns = await Connection.GetTableInfoAsync(nameof(MailItemFolder)).ConfigureAwait(false);
|
||||
|
||||
if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.HighestKnownUid)))
|
||||
{
|
||||
await Connection
|
||||
.ExecuteAsync($"ALTER TABLE {nameof(MailItemFolder)} ADD COLUMN {nameof(MailItemFolder.HighestKnownUid)} INTEGER NOT NULL DEFAULT 0")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.LastUidReconcileUtc)))
|
||||
{
|
||||
await Connection
|
||||
.ExecuteAsync($"ALTER TABLE {nameof(MailItemFolder)} ADD COLUMN {nameof(MailItemFolder.LastUidReconcileUtc)} TEXT NULL")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,44 +93,47 @@ public static class MailkitClientExtensions
|
||||
return HtmlAgilityPackExtensions.GetPreviewText(message.HtmlBody);
|
||||
}
|
||||
|
||||
public static MailCopy GetMailDetails(this IMessageSummary messageSummary, MailItemFolder folder, MimeMessage mime)
|
||||
public static MailCopy GetMailDetails(this IMessageSummary messageSummary, MailItemFolder folder, MimeMessage mime = null)
|
||||
{
|
||||
// MessageSummary will only have UniqueId, Flags, ThreadId.
|
||||
// Other properties are extracted directly from the MimeMessage.
|
||||
// IMAP UIDs are unique only within a folder.
|
||||
// MailCopy.Id maps to {FolderId}_{UID} for deterministic folder-local identity.
|
||||
|
||||
// IMAP doesn't have unique id for mails.
|
||||
// All mails are mapped to specific folders with incremental Id.
|
||||
// Uid 1 may belong to different messages in different folders, but can never be
|
||||
// same for different messages in same folders.
|
||||
// Here we create arbitrary Id that maps the Id of the message with Folder UniqueId.
|
||||
// When folder becomes invalid, we'll clear out these MailCopies as well.
|
||||
var envelope = messageSummary.Envelope;
|
||||
|
||||
var messageUid = CreateUid(folder.Id, messageSummary.UniqueId.Id);
|
||||
var previewText = mime.GetPreviewText();
|
||||
var subject = mime?.Subject ?? envelope?.Subject ?? string.Empty;
|
||||
var previewText = mime != null ? mime.GetPreviewText() : GetPreviewText(messageSummary, subject);
|
||||
|
||||
// Use InternalDate (server received date) if available, otherwise fall back to Date header (sent date)
|
||||
var creationDate = messageSummary.InternalDate?.UtcDateTime ?? mime.Date.UtcDateTime;
|
||||
// Prefer InternalDate (server received time). Fall back to envelope date and finally UTC now.
|
||||
var creationDate = messageSummary.InternalDate?.UtcDateTime
|
||||
?? envelope?.Date?.UtcDateTime
|
||||
?? DateTime.UtcNow;
|
||||
|
||||
// Detect calendar invitation based on MIME content type
|
||||
var itemType = GetMailItemTypeFromMime(mime);
|
||||
var messageId = mime?.GetMessageId() ?? envelope?.MessageId ?? string.Empty;
|
||||
var fromName = mime != null ? GetActualSenderName(mime) : GetEnvelopeSenderName(envelope);
|
||||
var fromAddress = mime != null ? GetActualSenderAddress(mime) : GetEnvelopeSenderAddress(envelope);
|
||||
var references = mime?.References?.GetReferences() ?? messageSummary.References?.GetReferences();
|
||||
var inReplyTo = mime != null ? mime.GetInReplyTo() : envelope?.InReplyTo ?? string.Empty;
|
||||
var hasAttachments = mime != null ? mime.Attachments.Any() : false;
|
||||
var itemType = mime != null ? GetMailItemTypeFromMime(mime) : MailItemType.Mail;
|
||||
|
||||
var copy = new MailCopy()
|
||||
{
|
||||
Id = messageUid,
|
||||
CreationDate = creationDate,
|
||||
ThreadId = messageSummary.GetThreadId(),
|
||||
MessageId = mime.GetMessageId(),
|
||||
Subject = mime.Subject,
|
||||
MessageId = messageId,
|
||||
Subject = subject,
|
||||
IsRead = messageSummary.Flags.GetIsRead(),
|
||||
IsFlagged = messageSummary.Flags.GetIsFlagged(),
|
||||
PreviewText = previewText,
|
||||
FromAddress = GetActualSenderAddress(mime),
|
||||
FromName = GetActualSenderName(mime),
|
||||
FromAddress = fromAddress,
|
||||
FromName = fromName,
|
||||
IsFocused = false,
|
||||
Importance = mime.GetImportance(),
|
||||
References = mime.References?.GetReferences(),
|
||||
InReplyTo = mime.GetInReplyTo(),
|
||||
HasAttachments = mime.Attachments.Any(),
|
||||
Importance = mime != null ? mime.GetImportance() : MailImportance.Normal,
|
||||
References = references,
|
||||
InReplyTo = inReplyTo,
|
||||
HasAttachments = hasAttachments,
|
||||
FileId = Guid.NewGuid(),
|
||||
ItemType = itemType
|
||||
};
|
||||
@@ -138,6 +141,29 @@ public static class MailkitClientExtensions
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static string GetPreviewText(IMessageSummary messageSummary, string subjectFallback)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(messageSummary.PreviewText))
|
||||
return messageSummary.PreviewText;
|
||||
|
||||
return subjectFallback ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string GetEnvelopeSenderName(Envelope envelope)
|
||||
{
|
||||
var mailbox = envelope?.From?.Mailboxes?.FirstOrDefault() ?? envelope?.Sender?.Mailboxes?.FirstOrDefault();
|
||||
if (mailbox == null)
|
||||
return Translator.UnknownSender;
|
||||
|
||||
return string.IsNullOrWhiteSpace(mailbox.Name) ? mailbox.Address : mailbox.Name;
|
||||
}
|
||||
|
||||
private static string GetEnvelopeSenderAddress(Envelope envelope)
|
||||
{
|
||||
var mailbox = envelope?.From?.Mailboxes?.FirstOrDefault() ?? envelope?.Sender?.Mailboxes?.FirstOrDefault();
|
||||
return mailbox?.Address ?? Translator.UnknownSender;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines MailItemType based on MIME message content type.
|
||||
/// Calendar invitations have text/calendar content type with METHOD parameter.
|
||||
|
||||
@@ -490,6 +490,9 @@ public class FolderService : BaseDatabaseService, IFolderService
|
||||
await Connection.UpdateAsync(folder, typeof(MailItemFolder)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task UpdateFolderHighestModeSeqAsync(Guid folderId, long highestModeSeq)
|
||||
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET HighestModeSeq = ? WHERE Id = ?", highestModeSeq, folderId);
|
||||
|
||||
private async Task DeleteFolderAsync(MailItemFolder folder)
|
||||
{
|
||||
if (folder == null)
|
||||
|
||||
Reference in New Issue
Block a user