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)