From 744145be067b494dc850d06b6467eb1ce7964f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 14 Feb 2026 12:52:17 +0100 Subject: [PATCH] Refactored impa synchronization. --- .../Entities/Mail/MailItemFolder.cs | 2 + Wino.Core.Domain/Interfaces/IFolderService.cs | 7 + .../IImapSynchronizationStrategyProvider.cs | 11 - .../Interfaces/IImapSynchronizerStrategy.cs | 40 -- .../Models/Folders/IMailItemFolder.cs | 2 + .../Synchronizers/ImapClientPoolTests.cs | 83 +++ .../ImapSynchronizerIdleTests.cs | 79 +++ .../UnifiedImapSynchronizerTests.cs | 226 ++++++++ Wino.Core.Tests/Wino.Core.Tests.csproj | 1 + Wino.Core/CoreContainerSetup.cs | 4 - Wino.Core/Integration/ImapClientPool.cs | 455 ++++++++++------ Wino.Core/Integration/ImapServerQuirks.cs | 39 ++ Wino.Core/Properties/AssemblyInfo.cs | 3 + Wino.Core/Services/SynchronizerFactory.cs | 5 +- .../ImapSync/CondstoreSynchronizer.cs | 132 ----- .../ImapSynchronizationStrategyBase.cs | 193 ------- .../ImapSynchronizationStrategyProvider.cs | 30 - .../ImapSync/QResyncSynchronizer.cs | 124 ----- .../ImapSync/UidBasedSynchronizer.cs | 80 --- .../ImapSync/UnifiedImapSynchronizer.cs | 511 ++++++++++++------ Wino.Core/Synchronizers/ImapSynchronizer.cs | 458 ++++++++++------ Wino.Mail.ViewModels/MailListPageViewModel.cs | 152 +++--- Wino.Mail.WinUI/JS/editor.js | 2 +- Wino.Services/DatabaseService.cs | 23 +- .../Extensions/MailkitClientExtensions.cs | 70 ++- Wino.Services/FolderService.cs | 3 + 26 files changed, 1492 insertions(+), 1243 deletions(-) delete mode 100644 Wino.Core.Domain/Interfaces/IImapSynchronizationStrategyProvider.cs delete mode 100644 Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs create mode 100644 Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs create mode 100644 Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs create mode 100644 Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs create mode 100644 Wino.Core/Integration/ImapServerQuirks.cs create mode 100644 Wino.Core/Properties/AssemblyInfo.cs delete mode 100644 Wino.Core/Synchronizers/ImapSync/CondstoreSynchronizer.cs delete mode 100644 Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs delete mode 100644 Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyProvider.cs delete mode 100644 Wino.Core/Synchronizers/ImapSync/QResyncSynchronizer.cs delete mode 100644 Wino.Core/Synchronizers/ImapSync/UidBasedSynchronizer.cs diff --git a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs index bb1a2cae..4555799e 100644 --- a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs +++ b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs @@ -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; } /// /// Outlook shares delta changes per-folder. Gmail is for per-account. diff --git a/Wino.Core.Domain/Interfaces/IFolderService.cs b/Wino.Core.Domain/Interfaces/IFolderService.cs index 33cf1f35..754a67e2 100644 --- a/Wino.Core.Domain/Interfaces/IFolderService.cs +++ b/Wino.Core.Domain/Interfaces/IFolderService.cs @@ -79,6 +79,13 @@ public interface IFolderService /// Folder to update. Task UpdateFolderAsync(MailItemFolder folder); + /// + /// Updates only IMAP HighestModeSeq for the given folder. + /// + /// Folder id to update. + /// Latest known mod-seq value. + Task UpdateFolderHighestModeSeqAsync(Guid folderId, long highestModeSeq); + /// /// Returns the active folder menu items for the given account for UI. /// diff --git a/Wino.Core.Domain/Interfaces/IImapSynchronizationStrategyProvider.cs b/Wino.Core.Domain/Interfaces/IImapSynchronizationStrategyProvider.cs deleted file mode 100644 index 3fafc48e..00000000 --- a/Wino.Core.Domain/Interfaces/IImapSynchronizationStrategyProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -using MailKit.Net.Imap; - -namespace Wino.Core.Domain.Interfaces; - -/// -/// Provides a synchronization strategy for synchronizing IMAP folders based on the server capabilities. -/// -public interface IImapSynchronizationStrategyProvider -{ - IImapSynchronizerStrategy GetSynchronizationStrategy(IImapClient client); -} diff --git a/Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs b/Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs deleted file mode 100644 index 8b7769b6..00000000 --- a/Wino.Core.Domain/Interfaces/IImapSynchronizerStrategy.cs +++ /dev/null @@ -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 -{ - /// - /// Synchronizes given folder with the ImapClient client from the client pool. - /// - /// Client to perform sync with. I love Mira and Jasminka - /// Folder to synchronize. - /// Imap synchronizer that downloads messages. - /// Cancellation token. - /// List of new downloaded message ids that don't exist locally. - Task> HandleSynchronizationAsync(IImapClient client, - MailItemFolder folder, - IImapSynchronizer synchronizer, - CancellationToken cancellationToken = default); - - /// - /// Downloads given set of messages from the folder. - /// Folder is expected to be opened and synchronizer is connected. - /// - /// Synchronizer that performs the action. - /// Remote folder to download messages from. - /// Local folder to assign mails to. - /// Set of message uniqueids. - /// Cancellation token. - Task DownloadMessagesAsync(IImapSynchronizer synchronizer, - IMailFolder remoteFolder, - MailItemFolder localFolder, - UniqueIdSet uniqueIdSet, - CancellationToken cancellationToken = default); -} - diff --git a/Wino.Core.Domain/Models/Folders/IMailItemFolder.cs b/Wino.Core.Domain/Models/Folders/IMailItemFolder.cs index 866213f9..cd3c0a10 100644 --- a/Wino.Core.Domain/Models/Folders/IMailItemFolder.cs +++ b/Wino.Core.Domain/Models/Folders/IMailItemFolder.cs @@ -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; } diff --git a/Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs b/Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs new file mode 100644 index 00000000..5e92910c --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/ImapClientPoolTests.cs @@ -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(); + + 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); + } +} diff --git a/Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs b/Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs new file mode 100644 index 00000000..21b7ea98 --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/ImapSynchronizerIdleTests.cs @@ -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(); + 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(), + Mock.Of(), + Mock.Of()); + + return new ImapSynchronizer( + account, + Mock.Of(), + applicationConfiguration.Object, + unifiedSynchronizer, + Mock.Of()); + } +} diff --git a/Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs b/Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs new file mode 100644 index 00000000..87c00b9d --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/UnifiedImapSynchronizerTests.cs @@ -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(), + Mock.Of(), + Mock.Of()); + } + + [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(); + summaryMock.SetupGet(x => x.UniqueId).Returns(new UniqueId(42)); + summaryMock.SetupGet(x => x.Flags).Returns(MessageFlags.None); + + var mailServiceMock = new Mock(); + mailServiceMock + .Setup(x => x.GetExistingMailsAsync(localFolder.Id, It.IsAny>())) + .ReturnsAsync(new List()); + mailServiceMock + .Setup(x => x.CreateMailAsync(localFolder.MailAccountId, It.IsAny())) + .ReturnsAsync(true); + + var sut = new UnifiedImapSynchronizer( + Mock.Of(), + mailServiceMock.Object, + Mock.Of()); + + ImapMessageCreationPackage? capturedPackage = null; + + var imapSynchronizerMock = new Mock(); + imapSynchronizerMock + .Setup(x => x.CreateNewMailPackagesAsync(It.IsAny(), localFolder, It.IsAny())) + .Callback((package, _, _) => capturedPackage = package) + .ReturnsAsync(new List + { + new(new MailCopy { Id = "mail-id" }, null, localFolder.RemoteFolderId, Array.Empty()) + }); + + var processMethod = typeof(UnifiedImapSynchronizer).GetMethod("ProcessSummariesAsync", BindingFlags.Instance | BindingFlags.NonPublic); + processMethod.Should().NotBeNull(); + + var task = (Task>)processMethod!.Invoke( + sut, + [imapSynchronizerMock.Object, localFolder, new List { summaryMock.Object }, CancellationToken.None])!; + + var result = await task; + + result.Should().ContainSingle().Which.Should().Be("mail-id"); + capturedPackage.Should().NotBeNull(); + capturedPackage!.MimeMessage.Should().BeNull(); + } +} diff --git a/Wino.Core.Tests/Wino.Core.Tests.csproj b/Wino.Core.Tests/Wino.Core.Tests.csproj index 540f3825..485bec64 100644 --- a/Wino.Core.Tests/Wino.Core.Tests.csproj +++ b/Wino.Core.Tests/Wino.Core.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/Wino.Core/CoreContainerSetup.cs b/Wino.Core/CoreContainerSetup.cs index 631a0948..44c36cac 100644 --- a/Wino.Core/CoreContainerSetup.cs +++ b/Wino.Core/CoreContainerSetup.cs @@ -36,10 +36,6 @@ public static class CoreContainerSetup services.AddTransient(); services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); services.AddTransient(); // Register Outlook error handlers diff --git a/Wino.Core/Integration/ImapClientPool.cs b/Wino.Core/Integration/ImapClientPool.cs index 5114f2e2..2694b4aa 100644 --- a/Wino.Core/Integration/ImapClientPool.cs +++ b/Wino.Core/Integration/ImapClientPool.cs @@ -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 /// 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(); private readonly CustomServerInformation _customServerInformation; @@ -60,8 +52,14 @@ public class ImapClientPool : IDisposable private readonly ConcurrentDictionary _clientStates = new(); private readonly Channel _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(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); /// - /// Rents a client from the pool. Blocks until a client is available. + /// Rents a client from the pool with the default timeout. /// - public async Task RentAsync(CancellationToken cancellationToken = default) + public Task RentAsync(CancellationToken cancellationToken = default) + => RentAsync(TimeSpan.FromMilliseconds(DefaultAcquireTimeoutMs), cancellationToken); + + /// + /// Rents a client from the pool with explicit timeout and cancellation. + /// + public async Task 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}."); } /// /// Gets a client from the pool (legacy compatibility method). /// - public async Task GetClientAsync() => await RentAsync(CancellationToken.None).ConfigureAwait(false); + public Task GetClientAsync() + => GetClientAsync(CancellationToken.None, null); + + /// + /// Gets a client from the pool with explicit cancellation and timeout control. + /// + public async Task GetClientAsync(CancellationToken cancellationToken, TimeSpan? timeout = null) + => await RentAsync(timeout ?? TimeSpan.FromMilliseconds(DefaultAcquireTimeoutMs), cancellationToken).ConfigureAwait(false); /// /// Returns a client to the pool. /// 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); } /// @@ -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() diff --git a/Wino.Core/Integration/ImapServerQuirks.cs b/Wino.Core/Integration/ImapServerQuirks.cs new file mode 100644 index 00000000..559d14f8 --- /dev/null +++ b/Wino.Core/Integration/ImapServerQuirks.cs @@ -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 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; + } +} diff --git a/Wino.Core/Properties/AssemblyInfo.cs b/Wino.Core/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..8e625798 --- /dev/null +++ b/Wino.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Wino.Core.Tests")] diff --git a/Wino.Core/Services/SynchronizerFactory.cs b/Wino.Core/Services/SynchronizerFactory.cs index 4a80c5cc..ea082264 100644 --- a/Wino.Core/Services/SynchronizerFactory.cs +++ b/Wino.Core/Services/SynchronizerFactory.cs @@ -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; } diff --git a/Wino.Core/Synchronizers/ImapSync/CondstoreSynchronizer.cs b/Wino.Core/Synchronizers/ImapSync/CondstoreSynchronizer.cs deleted file mode 100644 index 5bc89f4f..00000000 --- a/Wino.Core/Synchronizers/ImapSync/CondstoreSynchronizer.cs +++ /dev/null @@ -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; - -/// -/// RFC 4551 CONDSTORE IMAP Synchronization strategy. -/// -internal class CondstoreSynchronizer : ImapSynchronizationStrategyBase -{ - public CondstoreSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService) - { - } - - public async override Task> 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(); - - 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> 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 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; - } -} diff --git a/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs b/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs deleted file mode 100644 index 282d6e26..00000000 --- a/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyBase.cs +++ /dev/null @@ -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> HandleSynchronizationAsync(IImapClient client, MailItemFolder folder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default); - internal abstract Task> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default); - - protected async Task> HandleChangedUIdsAsync(IImapSynchronizer synchronizer, - IMailFolder remoteFolder, - IList changedUids, - CancellationToken cancellationToken) - { - List 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 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); - } - } - } - } -} diff --git a/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyProvider.cs b/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyProvider.cs deleted file mode 100644 index 25c7d93e..00000000 --- a/Wino.Core/Synchronizers/ImapSync/ImapSynchronizationStrategyProvider.cs +++ /dev/null @@ -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; - } -} diff --git a/Wino.Core/Synchronizers/ImapSync/QResyncSynchronizer.cs b/Wino.Core/Synchronizers/ImapSync/QResyncSynchronizer.cs deleted file mode 100644 index 5316336e..00000000 --- a/Wino.Core/Synchronizers/ImapSync/QResyncSynchronizer.cs +++ /dev/null @@ -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; - -/// -/// RFC 5162 QRESYNC IMAP Synchronization strategy. -/// -internal class QResyncSynchronizer : ImapSynchronizationStrategyBase -{ - public QResyncSynchronizer(IFolderService folderService, IMailService mailService) : base(folderService, mailService) - { - } - - public override async Task> HandleSynchronizationAsync(IImapClient client, - MailItemFolder folder, - IImapSynchronizer synchronizer, - CancellationToken cancellationToken = default) - { - var downloadedMessageIds = new List(); - - 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> 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); - } -} diff --git a/Wino.Core/Synchronizers/ImapSync/UidBasedSynchronizer.cs b/Wino.Core/Synchronizers/ImapSync/UidBasedSynchronizer.cs deleted file mode 100644 index 6f67be3a..00000000 --- a/Wino.Core/Synchronizers/ImapSync/UidBasedSynchronizer.cs +++ /dev/null @@ -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; - -/// -/// Uid based IMAP Synchronization strategy. -/// -internal class UidBasedSynchronizer : ImapSynchronizationStrategyBase -{ - public UidBasedSynchronizer(IFolderService folderService, Domain.Interfaces.IMailService mailService) : base(folderService, mailService) - { - } - - public override async Task> 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(); - 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> GetChangedUidsAsync(IImapClient client, IMailFolder remoteFolder, IImapSynchronizer synchronizer, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } -} diff --git a/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs index e1d32b4c..cdaff26d 100644 --- a/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSync/UnifiedImapSynchronizer.cs @@ -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 /// public class UnifiedImapSynchronizer { + private static readonly TimeSpan UidReconcileInterval = TimeSpan.FromHours(12); + private readonly ILogger _logger = Log.ForContext(); 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 } /// - /// Determines the best synchronization strategy based on server capabilities. + /// Determines the best synchronization strategy based on server capabilities and known quirks. /// - 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 + /// + /// Metadata-only message download helper used by IMAP online search. + /// + public async Task> DownloadMessagesByUidsAsync( + IImapClient client, + IMailFolder remoteFolder, + MailItemFolder localFolder, + IList 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(); + + 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> 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(); - - if (client is not WinoImapClient winoClient) - throw new InvalidOperationException("QRESYNC requires WinoImapClient"); - IMailFolder remoteFolder = null; + var vanishedUids = new List(); + var changedFlags = new Dictionary(); + + 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> 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 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> SynchronizeWithUidBasedAsync( + private async Task> 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> 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> ProcessSummariesAsync( IImapSynchronizer synchronizer, - IMailFolder remoteFolder, MailItemFolder localFolder, - IList changedUids, + IList summaries, CancellationToken cancellationToken) { var downloadedMessageIds = new List(); - 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 uniqueIds) + private async Task ApplyDeletedUidsAsync(MailItemFolder folder, IList 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 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 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 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, /// - /// Basic UID-based synchronization - fallback for servers without advanced features. + /// UID-based delta synchronization fallback. /// UidBased } diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 5b0d5f0f..a7a824b4 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -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(); 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 ExtractContactsFromMessageSummary(IMessageSummary summary) + { + if (summary?.Envelope == null) return []; + + var contacts = new Dictionary(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 SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); @@ -413,30 +470,41 @@ public class ImapSynchronizer : WinoSynchronizer { - 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 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> OnlineSearchAsync(string queryText, List folders, CancellationToken cancellationToken = default) { IImapClient client = null; - IMailFolder activeFolder = null; try { @@ -838,6 +917,9 @@ public class ImapSynchronizer : WinoSynchronizer> 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(); - } - /// /// 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 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 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 _clientPool.PreWarmPoolAsync(); } + + diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index bd6c48d7..acc9136c 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -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) { diff --git a/Wino.Mail.WinUI/JS/editor.js b/Wino.Mail.WinUI/JS/editor.js index f2e55a7a..181bcdbe 100644 --- a/Wino.Mail.WinUI/JS/editor.js +++ b/Wino.Mail.WinUI/JS/editor.js @@ -97,7 +97,7 @@ function initializeJodit(fonts, defaultComposerFont, defaultComposerFontSize, de } function RenderHTML(htmlString) { - editor.s.insertHTML(htmlString); + editor.value = htmlString; editor.synchronizeValues(); } diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index 8bb36a79..d17528b5 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -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(), Connection.CreateTableAsync() ); + + 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); + } + } } diff --git a/Wino.Services/Extensions/MailkitClientExtensions.cs b/Wino.Services/Extensions/MailkitClientExtensions.cs index a0977beb..311c5e77 100644 --- a/Wino.Services/Extensions/MailkitClientExtensions.cs +++ b/Wino.Services/Extensions/MailkitClientExtensions.cs @@ -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; + } + /// /// Determines MailItemType based on MIME message content type. /// Calendar invitations have text/calendar content type with METHOD parameter. diff --git a/Wino.Services/FolderService.cs b/Wino.Services/FolderService.cs index 5f2f7e6c..2728377e 100644 --- a/Wino.Services/FolderService.cs +++ b/Wino.Services/FolderService.cs @@ -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)