Refactored all synchronizers to deal with some of the chronic issues.

This commit is contained in:
Burak Kaan Köse
2026-02-06 01:18:12 +01:00
parent d1425ca9ca
commit 071f1c9786
43 changed files with 2785 additions and 582 deletions
@@ -106,6 +106,12 @@ public class MailAccount
[Ignore]
public MailAccountPreferences Preferences { get; set; }
/// <summary>
/// Last time folder structure was synchronized.
/// Used for optimization - skip folder sync if synced recently.
/// </summary>
public DateTime? LastFolderStructureSyncDate { get; set; }
/// <summary>
/// Gets whether the account can perform ProfileInformation sync type.
/// </summary>
@@ -4,5 +4,6 @@ public enum SynchronizationCompletedState
{
Success, // All succeeded.
Canceled, // Canceled by user or HTTP call.
Failed // Exception.
Failed, // Exception.
PartiallyCompleted // Some folders succeeded, some failed.
}
@@ -0,0 +1,47 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Categorizes synchronization errors by their root cause for targeted handling.
/// </summary>
public enum SynchronizerErrorCategory
{
/// <summary>
/// Network-related issues: connection timeouts, DNS failures, socket errors.
/// </summary>
Network,
/// <summary>
/// Authentication failures: invalid credentials, expired tokens, revoked access.
/// </summary>
Authentication,
/// <summary>
/// Rate limiting: too many requests (HTTP 429), quota exceeded.
/// </summary>
RateLimit,
/// <summary>
/// Resource not found: folder or message deleted externally (HTTP 404).
/// </summary>
ResourceNotFound,
/// <summary>
/// Server errors: internal server errors (HTTP 5xx), service unavailable.
/// </summary>
ServerError,
/// <summary>
/// Protocol errors: IMAP/SMTP command failures, malformed responses.
/// </summary>
ProtocolError,
/// <summary>
/// Validation errors: invalid data, constraint violations.
/// </summary>
Validation,
/// <summary>
/// Unknown or unclassified error.
/// </summary>
Unknown
}
@@ -0,0 +1,31 @@
namespace Wino.Core.Domain.Enums;
/// <summary>
/// Classifies the severity of synchronization errors to determine retry behavior.
/// </summary>
public enum SynchronizerErrorSeverity
{
/// <summary>
/// Transient error that should be retried with exponential backoff.
/// Examples: network timeout, temporary server unavailability, rate limiting.
/// </summary>
Transient,
/// <summary>
/// Error that can be recovered from by skipping the affected item/folder and continuing sync.
/// Examples: folder deleted externally, message not found, permission denied on single item.
/// </summary>
Recoverable,
/// <summary>
/// Fatal error that requires stopping synchronization and user intervention.
/// Examples: account disabled, server permanently unavailable, critical configuration error.
/// </summary>
Fatal,
/// <summary>
/// Authentication error that requires the user to re-authenticate.
/// Examples: token expired, password changed, OAuth refresh failed.
/// </summary>
AuthRequired
}
@@ -171,4 +171,19 @@ public interface IAccountService
/// <returns>Whether the notifications should be created after sync or not.</returns>
Task<bool> IsNotificationsEnabled(Guid accountId);
Task UpdateAccountCustomServerInformationAsync(CustomServerInformation customServerInformation);
/// <summary>
/// Updates the last folder structure sync date for the given account.
/// Used for optimization to skip folder sync if it was done recently.
/// </summary>
/// <param name="accountId">Account id.</param>
Task UpdateLastFolderStructureSyncDateAsync(Guid accountId);
/// <summary>
/// Checks if folder structure should be synced based on the configured interval.
/// Returns true if LastFolderStructureSyncDate is null or older than the interval.
/// </summary>
/// <param name="accountId">Account id.</param>
/// <param name="syncInterval">Minimum interval between folder syncs.</param>
Task<bool> ShouldSyncFolderStructureAsync(Guid accountId, TimeSpan syncInterval);
}
@@ -162,4 +162,12 @@ public interface IMailService
/// <param name="onlineArchiveMailIds">Retrieved MailCopy ids from search result.</param>
/// <returns>Result model that contains added and removed mail copy ids.</returns>
Task<GmailArchiveComparisonResult> GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List<string> onlineArchiveMailIds);
/// <summary>
/// Gets the most recent mail IDs for a folder.
/// Used for notification purposes after sync completes.
/// </summary>
/// <param name="folderId">Folder ID.</param>
/// <param name="count">Number of recent mails to return.</param>
Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count);
}
@@ -0,0 +1,60 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Retry;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Domain.Interfaces;
/// <summary>
/// Executes operations with automatic retry and error handling support.
/// </summary>
public interface IRetryExecutor
{
/// <summary>
/// Executes an operation with automatic retry based on the specified policy.
/// </summary>
/// <typeparam name="T">The return type of the operation.</typeparam>
/// <param name="operation">The async operation to execute.</param>
/// <param name="policy">The retry policy to apply.</param>
/// <param name="errorContextFactory">Factory to create error context from exceptions.</param>
/// <param name="errorHandler">Optional error handler for custom error processing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the operation.</returns>
/// <exception cref="Exception">Thrown when all retries are exhausted or a fatal error occurs.</exception>
Task<T> ExecuteWithRetryAsync<T>(
Func<CancellationToken, Task<T>> operation,
RetryPolicy policy,
Func<Exception, SynchronizerErrorContext> errorContextFactory,
ISynchronizerErrorHandlerFactory errorHandler = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes an operation with automatic retry based on the specified policy (void return).
/// </summary>
/// <param name="operation">The async operation to execute.</param>
/// <param name="policy">The retry policy to apply.</param>
/// <param name="errorContextFactory">Factory to create error context from exceptions.</param>
/// <param name="errorHandler">Optional error handler for custom error processing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="Exception">Thrown when all retries are exhausted or a fatal error occurs.</exception>
Task ExecuteWithRetryAsync(
Func<CancellationToken, Task> operation,
RetryPolicy policy,
Func<Exception, SynchronizerErrorContext> errorContextFactory,
ISynchronizerErrorHandlerFactory errorHandler = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Executes an operation with default retry policy.
/// </summary>
/// <typeparam name="T">The return type of the operation.</typeparam>
/// <param name="operation">The async operation to execute.</param>
/// <param name="errorContextFactory">Factory to create error context from exceptions.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the operation.</returns>
Task<T> ExecuteWithRetryAsync<T>(
Func<CancellationToken, Task<T>> operation,
Func<Exception, SynchronizerErrorContext> errorContextFactory,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Domain.Interfaces;
public interface ISynchronizerErrorHandlerFactory
{
Task<bool> HandleErrorAsync(SynchronizerErrorContext error);
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
namespace Wino.Core.Domain.Models.Connectivity;
/// <summary>
/// Represents the health status of an IMAP connection pool.
/// </summary>
public class ConnectionPoolHealth
{
/// <summary>
/// Gets or sets the total number of connections in the pool (including IDLE).
/// </summary>
public int TotalConnections { get; set; }
/// <summary>
/// Gets or sets the number of connections available for use.
/// </summary>
public int AvailableConnections { get; set; }
/// <summary>
/// Gets or sets the number of connections currently in use.
/// </summary>
public int InUseConnections { get; set; }
/// <summary>
/// Gets or sets the number of connections that have failed and need reconnection.
/// </summary>
public int FailedConnections { get; set; }
/// <summary>
/// Gets or sets the number of connections currently reconnecting.
/// </summary>
public int ReconnectingConnections { get; set; }
/// <summary>
/// Gets or sets whether the dedicated IDLE connection is active and listening.
/// </summary>
public bool IdleConnectionActive { get; set; }
/// <summary>
/// Gets or sets the timestamp of the last health check.
/// </summary>
public DateTime LastHealthCheck { get; set; }
/// <summary>
/// Gets or sets recent issues encountered by the pool.
/// </summary>
public List<string> RecentIssues { get; set; } = [];
/// <summary>
/// Gets whether the pool is healthy (has minimum required connections).
/// </summary>
public bool IsHealthy => AvailableConnections >= 1 && FailedConnections == 0;
/// <summary>
/// Gets a summary of the pool health.
/// </summary>
public string Summary => $"Total: {TotalConnections}, Available: {AvailableConnections}, InUse: {InUseConnections}, Failed: {FailedConnections}, IDLE: {(IdleConnectionActive ? "Active" : "Inactive")}";
}
@@ -0,0 +1,105 @@
using System;
namespace Wino.Core.Domain.Models.Retry;
/// <summary>
/// Defines retry behavior for synchronization operations with exponential backoff.
/// </summary>
public class RetryPolicy
{
private static readonly Random _jitterRandom = new();
/// <summary>
/// Gets or sets the maximum number of retry attempts. Default is 3.
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// Gets or sets the initial delay before the first retry. Default is 1 second.
/// </summary>
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Gets or sets the multiplier for exponential backoff. Default is 2.0.
/// Each retry delay = previous delay * multiplier.
/// </summary>
public double BackoffMultiplier { get; set; } = 2.0;
/// <summary>
/// Gets or sets the maximum delay between retries. Default is 2 minutes.
/// </summary>
public TimeSpan MaxDelay { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Gets or sets whether to add random jitter to delays to prevent thundering herd.
/// Default is true.
/// </summary>
public bool UseJitter { get; set; } = true;
/// <summary>
/// Gets or sets the maximum jitter as a percentage of the delay (0.0 to 1.0).
/// Default is 0.25 (25%).
/// </summary>
public double JitterFactor { get; set; } = 0.25;
/// <summary>
/// Calculates the delay for the given retry attempt using exponential backoff.
/// </summary>
/// <param name="retryAttempt">The retry attempt number (1-based).</param>
/// <returns>The delay to wait before the retry.</returns>
public TimeSpan GetDelay(int retryAttempt)
{
if (retryAttempt <= 0)
return TimeSpan.Zero;
// Calculate base delay with exponential backoff
var baseDelayMs = InitialDelay.TotalMilliseconds * Math.Pow(BackoffMultiplier, retryAttempt - 1);
// Apply max delay cap
baseDelayMs = Math.Min(baseDelayMs, MaxDelay.TotalMilliseconds);
// Apply jitter if enabled
if (UseJitter)
{
var jitterRange = baseDelayMs * JitterFactor;
var jitter = (_jitterRandom.NextDouble() * 2 - 1) * jitterRange; // +/- jitter range
baseDelayMs = Math.Max(0, baseDelayMs + jitter);
}
return TimeSpan.FromMilliseconds(baseDelayMs);
}
/// <summary>
/// Creates a default retry policy suitable for most synchronization operations.
/// </summary>
public static RetryPolicy Default => new();
/// <summary>
/// Creates an aggressive retry policy with more attempts and shorter delays.
/// Suitable for transient network issues.
/// </summary>
public static RetryPolicy Aggressive => new()
{
MaxRetries = 5,
InitialDelay = TimeSpan.FromMilliseconds(500),
BackoffMultiplier = 1.5,
MaxDelay = TimeSpan.FromSeconds(30)
};
/// <summary>
/// Creates a conservative retry policy with longer delays.
/// Suitable for rate limiting scenarios.
/// </summary>
public static RetryPolicy RateLimited => new()
{
MaxRetries = 3,
InitialDelay = TimeSpan.FromSeconds(10),
BackoffMultiplier = 2.0,
MaxDelay = TimeSpan.FromMinutes(5)
};
/// <summary>
/// Creates a no-retry policy that doesn't retry on failure.
/// </summary>
public static RetryPolicy NoRetry => new() { MaxRetries = 0 };
}
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Synchronization;
/// <summary>
/// Result of synchronizing a single folder.
/// Used for partial failure tracking when one folder fails but others succeed.
/// </summary>
public class FolderSyncResult
{
/// <summary>
/// Gets or sets the folder ID.
/// </summary>
public Guid FolderId { get; set; }
/// <summary>
/// Gets or sets the folder name for display purposes.
/// </summary>
public string FolderName { get; set; }
/// <summary>
/// Gets or sets whether the folder sync was successful.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Gets or sets the number of messages downloaded/synchronized.
/// </summary>
public int DownloadedCount { get; set; }
/// <summary>
/// Gets or sets the number of messages deleted locally (removed from server).
/// </summary>
public int DeletedCount { get; set; }
/// <summary>
/// Gets or sets the number of messages whose flags were updated.
/// </summary>
public int UpdatedCount { get; set; }
/// <summary>
/// Gets or sets the error message if sync failed.
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// Gets or sets the error severity if sync failed.
/// </summary>
public SynchronizerErrorSeverity? ErrorSeverity { get; set; }
/// <summary>
/// Gets or sets the error category if sync failed.
/// </summary>
public SynchronizerErrorCategory? ErrorCategory { get; set; }
/// <summary>
/// Gets or sets whether this folder was skipped (e.g., due to configuration).
/// </summary>
public bool WasSkipped { get; set; }
/// <summary>
/// Gets or sets the reason the folder was skipped.
/// </summary>
public string SkipReason { get; set; }
/// <summary>
/// Creates a successful folder sync result.
/// </summary>
public static FolderSyncResult Successful(Guid folderId, string folderName, int downloaded = 0, int deleted = 0, int updated = 0)
=> new()
{
FolderId = folderId,
FolderName = folderName,
Success = true,
DownloadedCount = downloaded,
DeletedCount = deleted,
UpdatedCount = updated
};
/// <summary>
/// Creates a failed folder sync result.
/// </summary>
public static FolderSyncResult Failed(Guid folderId, string folderName, string errorMessage,
SynchronizerErrorSeverity severity = SynchronizerErrorSeverity.Fatal,
SynchronizerErrorCategory category = SynchronizerErrorCategory.Unknown)
=> new()
{
FolderId = folderId,
FolderName = folderName,
Success = false,
ErrorMessage = errorMessage,
ErrorSeverity = severity,
ErrorCategory = category
};
/// <summary>
/// Creates a failed folder sync result from an error context.
/// </summary>
public static FolderSyncResult Failed(Guid folderId, string folderName, SynchronizerErrorContext errorContext)
=> new()
{
FolderId = folderId,
FolderName = folderName,
Success = false,
ErrorMessage = errorContext?.ErrorMessage ?? "Unknown error",
ErrorSeverity = errorContext?.Severity ?? SynchronizerErrorSeverity.Fatal,
ErrorCategory = errorContext?.Category ?? SynchronizerErrorCategory.Unknown
};
/// <summary>
/// Creates a skipped folder sync result.
/// </summary>
public static FolderSyncResult Skipped(Guid folderId, string folderName, string reason)
=> new()
{
FolderId = folderId,
FolderName = folderName,
Success = true, // Skipping is not a failure
WasSkipped = true,
SkipReason = reason
};
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
@@ -25,6 +26,55 @@ public class MailSynchronizationResult
public Exception Exception { get; set; }
/// <summary>
/// Gets or sets the results for each folder that was synchronized.
/// Enables partial failure tracking - some folders may succeed while others fail.
/// </summary>
public List<FolderSyncResult> FolderResults { get; set; } = [];
/// <summary>
/// Gets whether the synchronization had any partial failures.
/// True if at least one folder failed but others succeeded.
/// </summary>
[JsonIgnore]
public bool HasPartialFailures => FolderResults.Any(f => !f.Success) && FolderResults.Any(f => f.Success);
/// <summary>
/// Gets the number of folders that were successfully synchronized.
/// </summary>
[JsonIgnore]
public int SuccessfulFolderCount => FolderResults.Count(f => f.Success);
/// <summary>
/// Gets the number of folders that failed to synchronize.
/// </summary>
[JsonIgnore]
public int FailedFolderCount => FolderResults.Count(f => !f.Success);
/// <summary>
/// Gets the total number of messages downloaded across all folders.
/// </summary>
[JsonIgnore]
public int TotalDownloadedCount => FolderResults.Sum(f => f.DownloadedCount);
/// <summary>
/// Gets the total number of messages deleted across all folders.
/// </summary>
[JsonIgnore]
public int TotalDeletedCount => FolderResults.Sum(f => f.DeletedCount);
/// <summary>
/// Gets the total number of messages updated across all folders.
/// </summary>
[JsonIgnore]
public int TotalUpdatedCount => FolderResults.Sum(f => f.UpdatedCount);
/// <summary>
/// Gets the folders that failed to sync for error reporting.
/// </summary>
[JsonIgnore]
public IEnumerable<FolderSyncResult> FailedFolders => FolderResults.Where(f => !f.Success);
public static MailSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success };
// Mail synchronization
@@ -43,6 +93,28 @@ public class MailSynchronizationResult
CompletedState = SynchronizationCompletedState.Success
};
/// <summary>
/// Creates a completed result with folder-level results.
/// </summary>
public static MailSynchronizationResult CompletedWithFolderResults(
IEnumerable<MailCopy> downloadedMessages,
List<FolderSyncResult> folderResults)
{
var hasAnyFailure = folderResults.Any(f => !f.Success);
var hasAnySuccess = folderResults.Any(f => f.Success);
return new()
{
DownloadedMessages = downloadedMessages,
FolderResults = folderResults,
CompletedState = hasAnyFailure && !hasAnySuccess
? SynchronizationCompletedState.Failed
: hasAnyFailure
? SynchronizationCompletedState.PartiallyCompleted
: SynchronizationCompletedState.Success
};
}
public static MailSynchronizationResult Canceled => new() { CompletedState = SynchronizationCompletedState.Canceled };
public static MailSynchronizationResult Failed(Exception exception) => new()
{
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Models.Synchronization;
/// <summary>
/// Contains context information about a synchronizer error
/// </summary>
public class SynchronizerErrorContext
{
/// <summary>
/// Account associated with the error
/// </summary>
public MailAccount Account { get; set; }
/// <summary>
/// Gets or sets the error code
/// </summary>
public int? ErrorCode { get; set; }
/// <summary>
/// Gets or sets the error message
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// Gets or sets the request bundle associated with the error
/// </summary>
public IRequestBundle RequestBundle { get; set; }
/// <summary>
/// Gets or sets additional data associated with the error
/// </summary>
public Dictionary<string, object> AdditionalData { get; set; } = new Dictionary<string, object>();
/// <summary>
/// Gets or sets the exception associated with the error
/// </summary>
public Exception Exception { get; set; }
/// <summary>
/// Gets or sets the severity of the error for retry decision making.
/// </summary>
public SynchronizerErrorSeverity Severity { get; set; } = SynchronizerErrorSeverity.Fatal;
/// <summary>
/// Gets or sets the category of the error for targeted handling.
/// </summary>
public SynchronizerErrorCategory Category { get; set; } = SynchronizerErrorCategory.Unknown;
/// <summary>
/// Gets or sets the current retry attempt count.
/// </summary>
public int RetryCount { get; set; }
/// <summary>
/// Gets or sets the maximum number of retries allowed.
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// Gets or sets the suggested delay before retrying.
/// </summary>
public TimeSpan? RetryDelay { get; set; }
/// <summary>
/// Gets or sets the folder ID associated with the error for partial failure tracking.
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets the folder name for display purposes.
/// </summary>
public string FolderName { get; set; }
/// <summary>
/// Gets or sets the type of operation that failed.
/// Examples: "FolderSync", "MailSync", "RequestExecution", "Idle"
/// </summary>
public string OperationType { get; set; }
/// <summary>
/// Gets whether this error should be retried based on severity and retry count.
/// </summary>
public bool ShouldRetry => Severity == SynchronizerErrorSeverity.Transient && RetryCount < MaxRetries;
/// <summary>
/// Gets whether synchronization can continue despite this error.
/// </summary>
public bool CanContinueSync => Severity == SynchronizerErrorSeverity.Recoverable ||
(Severity == SynchronizerErrorSeverity.Transient && RetryCount >= MaxRetries);
}
+20 -1
View File
@@ -4,6 +4,8 @@ using Wino.Authentication;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration.Processors;
using Wino.Core.Services;
using Wino.Core.Synchronizers.Errors.Gmail;
using Wino.Core.Synchronizers.Errors.Imap;
using Wino.Core.Synchronizers.Errors.Outlook;
using Wino.Core.Synchronizers.ImapSync;
@@ -37,12 +39,29 @@ public static class CoreContainerSetup
services.AddTransient<CondstoreSynchronizer>();
services.AddTransient<QResyncSynchronizer>();
services.AddTransient<UidBasedSynchronizer>();
services.AddTransient<UnifiedImapSynchronizer>();
// Register error factory handlers
// Register Outlook error handlers
services.AddTransient<ObjectCannotBeDeletedHandler>();
services.AddTransient<DeltaTokenExpiredHandler>();
// Register Gmail error handlers
services.AddTransient<GmailQuotaExceededHandler>();
services.AddTransient<GmailRateLimitHandler>();
services.AddTransient<GmailHistoryExpiredHandler>();
// Register IMAP error handlers
services.AddTransient<ImapConnectionLostHandler>();
services.AddTransient<ImapAuthenticationFailedHandler>();
services.AddTransient<ImapFolderNotFoundHandler>();
services.AddTransient<ImapProtocolErrorHandler>();
// Register error handler factories
services.AddTransient<IOutlookSynchronizerErrorHandlerFactory, OutlookSynchronizerErrorHandlingFactory>();
services.AddTransient<IGmailSynchronizerErrorHandlerFactory, GmailSynchronizerErrorHandlingFactory>();
services.AddTransient<IImapSynchronizerErrorHandlerFactory, ImapSynchronizerErrorHandlingFactory>();
// Register retry executor
services.AddTransient<IRetryExecutor, RetryExecutor>();
}
}
@@ -1,5 +1,5 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Domain.Interfaces;
@@ -23,10 +23,6 @@ public interface ISynchronizerErrorHandler
Task<bool> HandleAsync(SynchronizerErrorContext error);
}
public interface ISynchronizerErrorHandlerFactory
{
Task<bool> HandleErrorAsync(SynchronizerErrorContext error);
}
public interface IOutlookSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
public interface IGmailSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
public interface IImapSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
@@ -1,42 +0,0 @@
using System;
using System.Collections.Generic;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
namespace Wino.Core.Domain.Models.Errors;
/// <summary>
/// Contains context information about a synchronizer error
/// </summary>
public class SynchronizerErrorContext
{
/// <summary>
/// Account associated with the error
/// </summary>
public MailAccount Account { get; set; }
/// <summary>
/// Gets or sets the error code
/// </summary>
public int? ErrorCode { get; set; }
/// <summary>
/// Gets or sets the error message
/// </summary>
public string ErrorMessage { get; set; }
/// <summary>
/// Gets or sets the request bundle associated with the error
/// </summary>
public IRequestBundle RequestBundle { get; set; }
/// <summary>
/// Gets or sets additional data associated with the error
/// </summary>
public Dictionary<string, object> AdditionalData { get; set; } = new Dictionary<string, object>();
/// <summary>
/// Gets or sets the exception associated with the error
/// </summary>
public Exception Exception { get; set; }
}
+504 -307
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net;
@@ -6,13 +6,12 @@ using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Timers;
using MailKit.Net.Imap;
using MailKit.Net.Proxy;
using MailKit.Security;
using MimeKit.Cryptography;
using MoreLinq;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
@@ -20,19 +19,32 @@ using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Models.Connectivity;
namespace Wino.Core.Integration;
/// <summary>
/// Provides a pooling mechanism for ImapClient.
/// Makes sure that we don't have too many connections to the server.
/// Rents a connected & authenticated client from the pool all the time.
/// Connection state for tracking individual client health.
/// </summary>
public enum ImapClientState
{
Available,
InUse,
Idle,
Reconnecting,
Failed,
Disposed
}
/// <summary>
/// Provides an enhanced pooling mechanism for ImapClient with Channel-based async rental.
/// Maintains minimum active connections and a dedicated IDLE client.
/// </summary>
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
public class ImapClientPool : IDisposable
{
// Hardcoded implementation details for ID extension if the server supports.
// Some providers like Chinese 126 require Id to be sent before authentication.
// We don't expose any customer data here. Therefore it's safe for now.
// Later on maybe we can make it configurable and leave it to the user with passing
// real implementation details.
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",
@@ -42,180 +54,520 @@ public class ImapClientPool : IDisposable
Name = "Wino Mail User",
};
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
private readonly CustomServerInformation _customServerInformation;
private readonly Stream _protocolLogStream;
private readonly ConcurrentDictionary<WinoImapClient, ImapClientState> _clientStates = new();
private readonly Channel<WinoImapClient> _availableClients;
private readonly CancellationTokenSource _maintenanceCts = new();
private readonly object _idleClientLock = new();
private WinoImapClient _dedicatedIdleClient;
private bool _disposedValue;
private bool _initialized;
private Task _maintenanceTask;
public bool ThrowOnSSLHandshakeCallback { get; set; }
public ImapClientPoolOptions ImapClientPoolOptions { get; }
private bool _disposedValue;
private readonly int MinimumPoolSize = 5;
private readonly ConcurrentStack<IImapClient> _clients = new();
private readonly SemaphoreSlim _semaphore;
private readonly CustomServerInformation _customServerInformation;
private readonly Stream _protocolLogStream;
private readonly ILogger _logger = Log.ForContext<ImapClientPool>();
private readonly System.Timers.Timer _keepAliveTimer;
private readonly System.Timers.Timer _connectionMonitorTimer;
private const int KeepAliveInterval = 4 * 60 * 1000; // 4 minutes
private const int ConnectionMonitorInterval = 30 * 1000; // 30 seconds
/// <summary>
/// Gets the current health status of the connection pool.
/// </summary>
public ConnectionPoolHealth Health => GetHealthInternal();
public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions)
{
_customServerInformation = imapClientPoolOptions.ServerInformation;
_protocolLogStream = imapClientPoolOptions.ProtocolLog;
// Set the maximum pool size to 5 or the custom value if it's greater.
_semaphore = new(Math.Max(MinimumPoolSize, _customServerInformation.MaxConcurrentClients));
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
ImapClientPoolOptions = imapClientPoolOptions;
_keepAliveTimer = new System.Timers.Timer(KeepAliveInterval);
_connectionMonitorTimer = new System.Timers.Timer(ConnectionMonitorInterval);
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
_keepAliveTimer.Elapsed += KeepAliveTimerElapsed;
_connectionMonitorTimer.Elapsed += ConnectionMonitorTimerElapsed;
// Create unbounded channel for available clients
_availableClients = Channel.CreateUnbounded<WinoImapClient>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false,
AllowSynchronousContinuations = false
});
}
public async Task PreWarmPoolAsync()
/// <summary>
/// Initializes the pool by creating minimum connections and starting maintenance.
/// </summary>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
if (_initialized) return;
_logger.Information("Initializing IMAP client pool with {MinConnections} connections", MinActiveConnections);
try
{
for (int i = 0; i < MinimumPoolSize; i++)
// Create initial connections
for (int i = 0; i < MinActiveConnections; i++)
{
var client = CreateNewClient();
await EnsureCapabilitiesAsync(client, true);
_clients.Push(client);
cancellationToken.ThrowIfCancellationRequested();
var client = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
if (client != null)
{
_clientStates[client] = ImapClientState.Available;
await _availableClients.Writer.WriteAsync(client, cancellationToken).ConfigureAwait(false);
}
}
// Start monitoring timers after pool is warmed
_keepAliveTimer.Start();
_connectionMonitorTimer.Start();
// Create dedicated IDLE client
_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;
_logger.Information("IMAP client pool initialized. Health: {Health}", Health.Summary);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to pre-warm client pool");
}
}
private async void KeepAliveTimerElapsed(object sender, ElapsedEventArgs e)
{
foreach (var client in _clients)
{
try
{
if (client.IsConnected && !((WinoImapClient)client).IsBusy())
{
await SendNoOpAsync(client);
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to send NOOP to client");
}
}
}
private async void ConnectionMonitorTimerElapsed(object sender, ElapsedEventArgs e)
{
foreach (var client in _clients)
{
try
{
if (!client.IsConnected && !((WinoImapClient)client).IsBusy())
{
await EnsureCapabilitiesAsync(client, false);
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to reconnect client");
}
}
}
private async Task SendNoOpAsync(IImapClient client)
{
try
{
await client.NoOpAsync();
}
catch (Exception ex)
{
_logger.Warning(ex, "NOOP command failed");
_logger.Error(ex, "Failed to initialize IMAP client pool");
throw;
}
}
/// <summary>
/// Ensures all supported capabilities are enabled in this connection.
/// Reconnects and reauthenticates if necessary.
/// </summary>
/// <param name="isCreatedNew">Whether the client has been newly created.</param>
private async Task EnsureCapabilitiesAsync(IImapClient client, bool isCreatedNew)
/// Pre-warms the pool (legacy compatibility method).
/// </summary>
public Task PreWarmPoolAsync() => InitializeAsync(CancellationToken.None);
/// <summary>
/// Rents a client from the pool. Blocks until a client is available.
/// </summary>
public async Task<WinoImapClient> RentAsync(CancellationToken cancellationToken = default)
{
try
if (!_initialized)
await InitializeAsync(cancellationToken).ConfigureAwait(false);
while (!cancellationToken.IsCancellationRequested)
{
bool isReconnected = await EnsureConnectedAsync(client);
bool mustDoPostAuthIdentification = false;
if ((isCreatedNew || isReconnected) && client.IsConnected)
// Try to get an available client from the channel
if (_availableClients.Reader.TryRead(out var client))
{
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
await client.CompressAsync();
// Identify if the server supports ID extension.
// Some servers require it pre-authentication, some post-authentication.
// We'll observe the response here and do it after authentication if needed.
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
if (client != null && _clientStates.TryGetValue(client, out var state) && state == ImapClientState.Available)
{
try
{
await client.IdentifyAsync(_implementation);
// Ensure client is still connected
await EnsureClientReadyAsync(client, cancellationToken).ConfigureAwait(false);
_clientStates[client] = ImapClientState.InUse;
return client;
}
catch (ImapCommandException commandException) when (commandException.Response == ImapCommandResponse.No || commandException.Response == ImapCommandResponse.Bad)
catch (Exception ex)
{
mustDoPostAuthIdentification = true;
}
catch (Exception)
{
throw;
_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
}
}
}
await EnsureAuthenticatedAsync(client);
if ((isCreatedNew || isReconnected) && client.IsAuthenticated)
// No available client, try to create a new one
var newClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
if (newClient != null)
{
if (mustDoPostAuthIdentification) await client.IdentifyAsync(_implementation);
_clientStates[newClient] = ImapClientState.InUse;
return newClient;
}
// Activate post-auth capabilities.
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
// Wait a bit before retrying
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
throw new OperationCanceledException(cancellationToken);
}
/// <summary>
/// Gets a client from the pool (legacy compatibility method).
/// </summary>
public async Task<IImapClient> GetClientAsync() => await RentAsync(CancellationToken.None).ConfigureAwait(false);
/// <summary>
/// Returns a client to the pool.
/// </summary>
public void Return(WinoImapClient client, bool isFaulted = false)
{
if (client == null) return;
if (isFaulted || !client.IsConnected)
{
_clientStates[client] = ImapClientState.Failed;
DisposeClient(client);
return;
}
if (!_disposedValue)
{
_clientStates[client] = ImapClientState.Available;
_availableClients.Writer.TryWrite(client);
}
else
{
DisposeClient(client);
}
}
/// <summary>
/// Releases a client (legacy compatibility method).
/// </summary>
public void Release(IImapClient item, bool destroyClient = false)
{
if (item is WinoImapClient winoClient)
{
Return(winoClient, destroyClient);
}
else if (item != null)
{
DisposeClient(item);
}
}
/// <summary>
/// Gets the dedicated IDLE client. Creates one if not available.
/// </summary>
public async Task<WinoImapClient> GetIdleClientAsync(CancellationToken cancellationToken = default)
{
lock (_idleClientLock)
{
if (_dedicatedIdleClient != null && _dedicatedIdleClient.IsConnected)
{
return _dedicatedIdleClient;
}
}
// Need to create or reconnect IDLE client
var idleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
lock (_idleClientLock)
{
if (_dedicatedIdleClient != null)
{
DisposeClient(_dedicatedIdleClient);
}
_dedicatedIdleClient = idleClient;
if (idleClient != null)
{
_clientStates[idleClient] = ImapClientState.Idle;
}
}
return idleClient;
}
/// <summary>
/// Releases the IDLE client for reconnection.
/// </summary>
public void ReleaseIdleClient(bool isFaulted = false)
{
lock (_idleClientLock)
{
if (_dedicatedIdleClient != null)
{
if (isFaulted)
{
await client.EnableQuickResyncAsync().ConfigureAwait(false);
if (client is WinoImapClient winoImapClient) winoImapClient.IsQResyncEnabled = true;
_clientStates[_dedicatedIdleClient] = ImapClientState.Failed;
DisposeClient(_dedicatedIdleClient);
_dedicatedIdleClient = null;
}
else
{
_clientStates[_dedicatedIdleClient] = ImapClientState.Idle;
}
}
}
}
private ConnectionPoolHealth GetHealthInternal()
{
var health = new ConnectionPoolHealth
{
LastHealthCheck = DateTime.UtcNow,
IdleConnectionActive = _dedicatedIdleClient?.IsConnected ?? false
};
foreach (var kvp in _clientStates)
{
health.TotalConnections++;
switch (kvp.Value)
{
case ImapClientState.Available:
health.AvailableConnections++;
break;
case ImapClientState.InUse:
health.InUseConnections++;
break;
case ImapClientState.Failed:
health.FailedConnections++;
break;
case ImapClientState.Reconnecting:
health.ReconnectingConnections++;
break;
}
}
return health;
}
private async Task MaintenanceLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(MaintenanceIntervalMs, cancellationToken).ConfigureAwait(false);
// Send NOOP to keep connections alive
await SendNoOpToAvailableClientsAsync(cancellationToken).ConfigureAwait(false);
// Ensure minimum connections
await EnsureMinimumConnectionsAsync(cancellationToken).ConfigureAwait(false);
// Clean up failed connections
await CleanupFailedConnectionsAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.Warning(ex, "Error in pool maintenance loop");
}
}
}
private async Task SendNoOpToAvailableClientsAsync(CancellationToken cancellationToken)
{
foreach (var kvp in _clientStates)
{
if (kvp.Value == ImapClientState.Available && kvp.Key.IsConnected && !kvp.Key.IsBusy())
{
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;
}
}
}
}
private async Task EnsureMinimumConnectionsAsync(CancellationToken cancellationToken)
{
var health = Health;
var neededConnections = MinActiveConnections - health.AvailableConnections;
if (neededConnections > 0)
{
_logger.Debug("Creating {Count} connections to maintain minimum pool size", neededConnections);
for (int i = 0; i < neededConnections; i++)
{
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");
}
}
}
}
private Task CleanupFailedConnectionsAsync(CancellationToken cancellationToken)
{
foreach (var kvp in _clientStates)
{
if (kvp.Value == ImapClientState.Failed)
{
DisposeClient(kvp.Key);
_clientStates.TryRemove(kvp.Key, out _);
}
}
return Task.CompletedTask;
}
private async Task<WinoImapClient> CreateAndConnectClientAsync(CancellationToken cancellationToken)
{
var client = CreateNewClient();
try
{
await EnsureClientReadyAsync(client, cancellationToken).ConfigureAwait(false);
return client;
}
catch (Exception ex)
{
if (ex.InnerException is ImapTestSSLCertificateException imapTestSSLCertificateException)
throw imapTestSSLCertificateException;
_logger.Warning(ex, "Failed to create and connect new client");
DisposeClient(client);
return null;
}
}
throw new ImapClientPoolException(ex, GetProtocolLogContent());
}
finally
private async Task EnsureClientReadyAsync(WinoImapClient client, CancellationToken cancellationToken)
{
// Connect if needed
if (!client.IsConnected)
{
// Release it even if it fails.
_semaphore.Release();
client.ServerCertificateValidationCallback = MyServerCertificateValidationCallback;
await client.ConnectAsync(
_customServerInformation.IncomingServer,
int.Parse(_customServerInformation.IncomingServerPort),
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);
}
catch (ImapCommandException)
{
// Some servers require post-auth identification
}
}
}
// Authenticate if needed
if (!client.IsAuthenticated)
{
var cred = new NetworkCredential(
_customServerInformation.IncomingServerUsername,
_customServerInformation.IncomingServerPassword);
var authMethod = _customServerInformation.IncomingAuthenticationMethod;
if (authMethod != ImapAuthenticationMethod.Auto)
{
client.AuthenticationMechanisms.Clear();
var saslMechanism = GetSASLAuthenticationMethodName(authMethod);
client.AuthenticationMechanisms.Add(saslMechanism);
await client.AuthenticateAsync(SaslMechanism.Create(saslMechanism, cred), cancellationToken).ConfigureAwait(false);
}
else
{
await client.AuthenticateAsync(cred, cancellationToken).ConfigureAwait(false);
}
// Try post-auth ID if needed
if (client.Capabilities.HasFlag(ImapCapabilities.Id))
{
try
{
await client.IdentifyAsync(_implementation, cancellationToken).ConfigureAwait(false);
}
catch { /* Ignore */ }
}
// Enable QRESYNC if supported
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
{
await client.EnableQuickResyncAsync(cancellationToken).ConfigureAwait(false);
client.IsQResyncEnabled = true;
}
}
}
private WinoImapClient CreateNewClient()
{
var client = new WinoImapClient();
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
{
client.ProxyClient = new HttpProxyClient(
_customServerInformation.ProxyServer,
int.Parse(_customServerInformation.ProxyServerPort));
}
_logger.Debug("Created new ImapClient. Current pool size: {Count}", _clientStates.Count);
return client;
}
private void DisposeClient(IImapClient client)
{
try
{
if (client.IsConnected)
{
lock (client.SyncRoot)
{
client.Disconnect(quit: true);
}
}
client.Dispose();
}
catch (Exception ex)
{
_logger.Debug(ex, "Error disposing client");
}
}
private SecureSocketOptions GetSocketOptions(ImapConnectionSecurity connectionSecurity) => connectionSecurity switch
{
ImapConnectionSecurity.Auto => SecureSocketOptions.Auto,
ImapConnectionSecurity.None => SecureSocketOptions.None,
ImapConnectionSecurity.StartTls => SecureSocketOptions.StartTlsWhenAvailable,
ImapConnectionSecurity.SslTls => SecureSocketOptions.SslOnConnect,
_ => SecureSocketOptions.None
};
private string GetSASLAuthenticationMethodName(ImapAuthenticationMethod method) => method switch
{
ImapAuthenticationMethod.NormalPassword => "PLAIN",
ImapAuthenticationMethod.EncryptedPassword => "LOGIN",
ImapAuthenticationMethod.Ntlm => "NTLM",
ImapAuthenticationMethod.CramMd5 => "CRAM-MD5",
ImapAuthenticationMethod.DigestMd5 => "DIGEST-MD5",
_ => "PLAIN"
};
private bool MyServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
if (sslPolicyErrors == SslPolicyErrors.None) return true;
if (ThrowOnSSLHandshakeCallback)
{
throw new ImapTestSSLCertificateException(
certificate.Issuer,
certificate.GetExpirationDateString(),
certificate.GetEffectiveDateString());
}
return true;
}
public string GetProtocolLogContent()
{
if (_protocolLogStream == null) return default;
// Set the position to the beginning of the stream in case it is not already at the start
if (_protocolLogStream.CanSeek)
_protocolLogStream.Seek(0, SeekOrigin.Begin);
@@ -223,171 +575,12 @@ public class ImapClientPool : IDisposable
return reader.ReadToEnd();
}
public async Task<IImapClient> GetClientAsync()
{
await _semaphore.WaitAsync();
// Legacy compatibility methods
public Task<bool> EnsureConnectedAsync(IImapClient client) =>
Task.FromResult(client.IsConnected);
if (_clients.TryPop(out IImapClient item))
{
await EnsureCapabilitiesAsync(item, false);
return item;
}
var client = CreateNewClient();
await EnsureCapabilitiesAsync(client, true);
return client;
}
public void Release(IImapClient item, bool destroyClient = false)
{
if (item != null)
{
if (destroyClient)
{
if (item.IsConnected)
{
lock (item.SyncRoot)
{
item.Disconnect(quit: true);
}
}
item.Dispose();
}
else if (!_disposedValue)
{
_clients.Push(item);
}
_semaphore.Release();
}
}
private IImapClient CreateNewClient()
{
WinoImapClient client = null;
client = new WinoImapClient();
HttpProxyClient proxyClient = null;
// Add proxy client if exists.
if (!string.IsNullOrEmpty(_customServerInformation.ProxyServer))
{
proxyClient = new HttpProxyClient(_customServerInformation.ProxyServer, int.Parse(_customServerInformation.ProxyServerPort));
client.ProxyClient = proxyClient;
}
_logger.Debug("Creating new ImapClient. Current clients: {Count}", _clients.Count);
return client;
}
private SecureSocketOptions GetSocketOptions(ImapConnectionSecurity connectionSecurity)
=> connectionSecurity switch
{
ImapConnectionSecurity.Auto => SecureSocketOptions.Auto,
ImapConnectionSecurity.None => SecureSocketOptions.None,
ImapConnectionSecurity.StartTls => SecureSocketOptions.StartTlsWhenAvailable,
ImapConnectionSecurity.SslTls => SecureSocketOptions.SslOnConnect,
_ => SecureSocketOptions.None
};
/// <returns>True if the connection is newly established.</returns>
public async Task<bool> EnsureConnectedAsync(IImapClient client)
{
if (client.IsConnected) return false;
client.ServerCertificateValidationCallback = MyServerCertificateValidationCallback;
await client.ConnectAsync(_customServerInformation.IncomingServer,
int.Parse(_customServerInformation.IncomingServerPort),
GetSocketOptions(_customServerInformation.IncomingServerSocketOption));
// Print out useful information for testing.
if (client.IsConnected && ImapClientPoolOptions.IsTestPool)
{
// Print supported authentication methods for the client.
var supportedAuthMethods = client.AuthenticationMechanisms;
if (supportedAuthMethods == null || supportedAuthMethods.Count == 0)
{
WriteToProtocolLog("There are no supported authentication mechanisms...");
}
else
{
WriteToProtocolLog($"Supported authentication mechanisms: {string.Join(", ", supportedAuthMethods)}");
}
}
return true;
}
public async Task EnsureAuthenticatedAsync(IImapClient client)
{
if (client.IsAuthenticated) return;
var cred = new NetworkCredential(_customServerInformation.IncomingServerUsername, _customServerInformation.IncomingServerPassword);
var prefferedAuthenticationMethod = _customServerInformation.IncomingAuthenticationMethod;
if (prefferedAuthenticationMethod != ImapAuthenticationMethod.Auto)
{
// Anything beside Auto must be explicitly set for the client.
client.AuthenticationMechanisms.Clear();
var saslMechanism = GetSASLAuthenticationMethodName(prefferedAuthenticationMethod);
client.AuthenticationMechanisms.Add(saslMechanism);
await client.AuthenticateAsync(SaslMechanism.Create(saslMechanism, cred));
}
else
{
await client.AuthenticateAsync(cred);
}
}
private string GetSASLAuthenticationMethodName(ImapAuthenticationMethod method)
=> method switch
{
ImapAuthenticationMethod.NormalPassword => "PLAIN",
ImapAuthenticationMethod.EncryptedPassword => "LOGIN",
ImapAuthenticationMethod.Ntlm => "NTLM",
ImapAuthenticationMethod.CramMd5 => "CRAM-MD5",
ImapAuthenticationMethod.DigestMd5 => "DIGEST-MD5",
_ => "PLAIN"
};
private void WriteToProtocolLog(string message)
{
if (_protocolLogStream == null) return;
try
{
var messageBytes = Encoding.UTF8.GetBytes($"W: {message}\n");
_protocolLogStream.Write(messageBytes, 0, messageBytes.Length);
}
catch (ObjectDisposedException)
{
Log.Warning($"Protocol log stream is disposed. Cannot write to it.");
}
catch (Exception)
{
throw;
}
}
bool MyServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
// If there are no errors, then everything went smoothly.
if (sslPolicyErrors == SslPolicyErrors.None) return true;
// Imap connectivity test will throw to alert the user here.
if (ThrowOnSSLHandshakeCallback)
{
throw new ImapTestSSLCertificateException(certificate.Issuer, certificate.GetExpirationDateString(), certificate.GetEffectiveDateString());
}
return true;
}
public Task EnsureAuthenticatedAsync(IImapClient client) =>
Task.CompletedTask;
protected virtual void Dispose(bool disposing)
{
@@ -395,22 +588,26 @@ public class ImapClientPool : IDisposable
{
if (disposing)
{
_keepAliveTimer.Stop();
_connectionMonitorTimer.Stop();
_maintenanceCts.Cancel();
_maintenanceTask?.Wait(TimeSpan.FromSeconds(5));
_maintenanceCts.Dispose();
_keepAliveTimer.Dispose();
_connectionMonitorTimer.Dispose();
_availableClients.Writer.Complete();
_clients.ForEach(client =>
foreach (var kvp in _clientStates)
{
lock (client.SyncRoot)
{
client.Disconnect(true);
}
client.Dispose();
});
DisposeClient(kvp.Key);
}
_clientStates.Clear();
_clients.Clear();
lock (_idleClientLock)
{
if (_dedicatedIdleClient != null)
{
DisposeClient(_dedicatedIdleClient);
_dedicatedIdleClient = null;
}
}
}
_disposedValue = true;
@@ -107,6 +107,13 @@ public interface IImapChangeProcessor : IDefaultChangeProcessor
/// </summary>
/// <param name="folderId">Folder id to retrieve uIds for.</param>
Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId);
/// <summary>
/// Gets the most recent mail IDs for a folder (for notification purposes).
/// </summary>
/// <param name="folderId">Folder ID.</param>
/// <param name="count">Number of recent mails to return.</param>
Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count);
}
public class DefaultChangeProcessor(IDatabaseService databaseService,
@@ -18,4 +18,7 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor
}
public Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId);
public Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
=> MailService.GetRecentMailIdsForFolderAsync(folderId, count);
}
+1 -1
View File
@@ -9,7 +9,7 @@ namespace Wino.Core.Integration;
/// <summary>
/// Extended class for ImapClient that is used in Wino.
/// </summary>
internal class WinoImapClient : ImapClient
public class WinoImapClient : ImapClient
{
private int _busyCount;
@@ -1,11 +1,22 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Synchronizers.Errors.Gmail;
namespace Wino.Core.Services;
/// <summary>
/// Factory for handling Gmail synchronizer errors.
/// Registers and routes errors to appropriate handlers.
/// </summary>
public class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IGmailSynchronizerErrorHandlerFactory
{
public bool CanHandle(SynchronizerErrorContext error) => CanHandle(error);
public Task HandleAsync(SynchronizerErrorContext error) => HandleErrorAsync(error);
public GmailSynchronizerErrorHandlingFactory(
GmailQuotaExceededHandler quotaExceededHandler,
GmailRateLimitHandler rateLimitHandler,
GmailHistoryExpiredHandler historyExpiredHandler)
{
// Order matters - more specific handlers should be registered first
RegisterHandler(quotaExceededHandler);
RegisterHandler(historyExpiredHandler);
RegisterHandler(rateLimitHandler); // Most generic rate limit handler last
}
}
@@ -0,0 +1,24 @@
using Wino.Core.Domain.Interfaces;
using Wino.Core.Synchronizers.Errors.Imap;
namespace Wino.Core.Services;
/// <summary>
/// Factory for handling IMAP synchronizer errors.
/// Registers and routes errors to appropriate handlers.
/// </summary>
public class ImapSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IImapSynchronizerErrorHandlerFactory
{
public ImapSynchronizerErrorHandlingFactory(
ImapConnectionLostHandler connectionLostHandler,
ImapAuthenticationFailedHandler authFailedHandler,
ImapFolderNotFoundHandler folderNotFoundHandler,
ImapProtocolErrorHandler protocolErrorHandler)
{
// Order matters - more specific handlers should be registered first
RegisterHandler(authFailedHandler);
RegisterHandler(folderNotFoundHandler);
RegisterHandler(connectionLostHandler);
RegisterHandler(protocolErrorHandler); // Most generic, registered last
}
}
@@ -1,6 +1,6 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Synchronizers.Errors.Outlook;
namespace Wino.Core.Services;
+140
View File
@@ -0,0 +1,140 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Retry;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Services;
/// <summary>
/// Executes operations with automatic retry and error handling support.
/// Implements exponential backoff with jitter.
/// </summary>
public class RetryExecutor : IRetryExecutor
{
private readonly ILogger _logger = Log.ForContext<RetryExecutor>();
/// <inheritdoc/>
public async Task<T> ExecuteWithRetryAsync<T>(
Func<CancellationToken, Task<T>> operation,
RetryPolicy policy,
Func<Exception, SynchronizerErrorContext> errorContextFactory,
ISynchronizerErrorHandlerFactory errorHandler = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(operation);
ArgumentNullException.ThrowIfNull(policy);
ArgumentNullException.ThrowIfNull(errorContextFactory);
int attempt = 0;
Exception lastException = null;
while (attempt <= policy.MaxRetries)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return await operation(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw; // Don't retry on cancellation
}
catch (Exception ex)
{
lastException = ex;
attempt++;
var errorContext = errorContextFactory(ex);
errorContext.RetryCount = attempt;
errorContext.MaxRetries = policy.MaxRetries;
// Let the error handler process the error first
if (errorHandler != null)
{
try
{
var handled = await errorHandler.HandleErrorAsync(errorContext).ConfigureAwait(false);
if (handled)
{
_logger.Debug("Error handled by error handler, severity: {Severity}", errorContext.Severity);
}
}
catch (Exception handlerEx)
{
_logger.Warning(handlerEx, "Error handler threw an exception");
}
}
// Check if we should retry based on error severity
if (errorContext.Severity == SynchronizerErrorSeverity.Fatal ||
errorContext.Severity == SynchronizerErrorSeverity.AuthRequired)
{
_logger.Warning(ex, "Non-retryable error (severity: {Severity}), failing immediately", errorContext.Severity);
throw;
}
if (errorContext.Severity == SynchronizerErrorSeverity.Recoverable)
{
_logger.Debug(ex, "Recoverable error, not retrying but allowing continuation");
throw;
}
// Transient error - check if we have retries left
if (attempt > policy.MaxRetries)
{
_logger.Warning(ex, "All {MaxRetries} retries exhausted", policy.MaxRetries);
throw;
}
// Calculate delay and wait
var delay = errorContext.RetryDelay ?? policy.GetDelay(attempt);
_logger.Debug("Retry attempt {Attempt}/{MaxRetries} after {Delay}ms delay for error: {ErrorMessage}",
attempt, policy.MaxRetries, delay.TotalMilliseconds, ex.Message);
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
// Should not reach here, but just in case
throw lastException ?? new InvalidOperationException("Retry loop completed without result");
}
/// <inheritdoc/>
public async Task ExecuteWithRetryAsync(
Func<CancellationToken, Task> operation,
RetryPolicy policy,
Func<Exception, SynchronizerErrorContext> errorContextFactory,
ISynchronizerErrorHandlerFactory errorHandler = null,
CancellationToken cancellationToken = default)
{
await ExecuteWithRetryAsync(
async ct =>
{
await operation(ct).ConfigureAwait(false);
return true; // Dummy return value
},
policy,
errorContextFactory,
errorHandler,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task<T> ExecuteWithRetryAsync<T>(
Func<CancellationToken, Task<T>> operation,
Func<Exception, SynchronizerErrorContext> errorContextFactory,
CancellationToken cancellationToken = default)
{
return ExecuteWithRetryAsync(
operation,
RetryPolicy.Default,
errorContextFactory,
null,
cancellationToken);
}
}
@@ -2,7 +2,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Services;
+9 -2
View File
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Integration.Processors;
using Wino.Core.Synchronizers.ImapSync;
using Wino.Core.Synchronizers.Mail;
namespace Wino.Core.Services;
@@ -17,10 +18,12 @@ public class SynchronizerFactory : ISynchronizerFactory
private readonly IApplicationConfiguration _applicationConfiguration;
private readonly IOutlookSynchronizerErrorHandlerFactory _outlookSynchronizerErrorHandlerFactory;
private readonly IGmailSynchronizerErrorHandlerFactory _gmailSynchronizerErrorHandlerFactory;
private readonly IImapSynchronizerErrorHandlerFactory _imapSynchronizerErrorHandlerFactory;
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
private readonly IGmailChangeProcessor _gmailChangeProcessor;
private readonly IImapChangeProcessor _imapChangeProcessor;
private readonly IAuthenticationProvider _authenticationProvider;
private readonly UnifiedImapSynchronizer _unifiedImapSynchronizer;
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
@@ -32,7 +35,9 @@ public class SynchronizerFactory : ISynchronizerFactory
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
IApplicationConfiguration applicationConfiguration,
IOutlookSynchronizerErrorHandlerFactory outlookSynchronizerErrorHandlerFactory,
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory)
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory,
IImapSynchronizerErrorHandlerFactory imapSynchronizerErrorHandlerFactory,
UnifiedImapSynchronizer unifiedImapSynchronizer)
{
_outlookChangeProcessor = outlookChangeProcessor;
_gmailChangeProcessor = gmailChangeProcessor;
@@ -43,6 +48,8 @@ public class SynchronizerFactory : ISynchronizerFactory
_applicationConfiguration = applicationConfiguration;
_outlookSynchronizerErrorHandlerFactory = outlookSynchronizerErrorHandlerFactory;
_gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory;
_imapSynchronizerErrorHandlerFactory = imapSynchronizerErrorHandlerFactory;
_unifiedImapSynchronizer = unifiedImapSynchronizer;
}
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
@@ -78,7 +85,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);
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory);
default:
break;
}
@@ -0,0 +1,78 @@
using System;
using System.Threading.Tasks;
using Google;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Integration.Processors;
namespace Wino.Core.Synchronizers.Errors.Gmail;
/// <summary>
/// Handles Gmail history ID expiration errors.
/// When history is no longer available, resets the account's history ID to force a full resync.
/// </summary>
public class GmailHistoryExpiredHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<GmailHistoryExpiredHandler>();
private readonly IGmailChangeProcessor _gmailChangeProcessor;
public GmailHistoryExpiredHandler(IGmailChangeProcessor gmailChangeProcessor)
{
_gmailChangeProcessor = gmailChangeProcessor;
}
public bool CanHandle(SynchronizerErrorContext error)
{
// Gmail returns 404 when history ID is no longer valid
if (error.ErrorCode == 404)
{
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
return message.Contains("history") || message.Contains("notfound");
}
if (error.Exception is GoogleApiException googleEx)
{
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
{
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
return errorMessage.Contains("history") ||
errorMessage.Contains("not found") ||
errorMessage.Contains("starthistoryid");
}
}
return false;
}
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"Gmail history ID expired for account {AccountName} ({AccountId}). Resetting to force full sync.",
error.Account?.Name, error.Account?.Id);
error.Severity = SynchronizerErrorSeverity.Recoverable;
error.Category = SynchronizerErrorCategory.ResourceNotFound;
// Reset the account's synchronization identifier (history ID)
if (error.Account != null)
{
try
{
await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(
error.Account.Id, string.Empty).ConfigureAwait(false);
_logger.Information("Successfully reset Gmail history ID for account {AccountName}. Next sync will be full sync.",
error.Account.Name);
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to reset Gmail history ID for account {AccountName}",
error.Account.Name);
}
}
return true;
}
}
@@ -0,0 +1,57 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Google;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Gmail;
/// <summary>
/// Handles Gmail API quota exceeded errors (HTTP 403 with quota error).
/// This is a more severe rate limit that indicates daily quota exhaustion.
/// </summary>
public class GmailQuotaExceededHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<GmailQuotaExceededHandler>();
public bool CanHandle(SynchronizerErrorContext error)
{
if (error.Exception is GoogleApiException googleEx)
{
// Quota exceeded usually returns 403
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
{
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
var errorReason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty;
return errorMessage.Contains("quota") ||
errorMessage.Contains("limit exceeded") ||
errorReason.Contains("quota") ||
errorReason.Contains("ratelimitexceeded") ||
errorReason.Contains("userlimitexceeded");
}
}
return false;
}
public Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"Gmail API quota exceeded for account {AccountName} ({AccountId}). Sync will be paused.",
error.Account?.Name, error.Account?.Id);
// Quota exceeded is more severe - treat as fatal to prevent repeated failures
// The user will be notified and sync will resume after quota resets
error.Severity = SynchronizerErrorSeverity.Fatal;
error.Category = SynchronizerErrorCategory.RateLimit;
// Suggest a very long delay - quotas typically reset daily
error.RetryDelay = TimeSpan.FromHours(1);
return Task.FromResult(true);
}
}
@@ -0,0 +1,47 @@
using System;
using System.Threading.Tasks;
using Google;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Gmail;
/// <summary>
/// Handles Gmail API rate limiting errors (HTTP 429 Too Many Requests).
/// Marks the error as transient with appropriate backoff delay.
/// </summary>
public class GmailRateLimitHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<GmailRateLimitHandler>();
public bool CanHandle(SynchronizerErrorContext error)
{
if (error.ErrorCode == 429)
return true;
if (error.Exception is GoogleApiException googleEx)
{
return googleEx.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests ||
(googleEx.Error?.Code == 429);
}
return false;
}
public Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"Gmail API rate limit hit for account {AccountName} ({AccountId}). Operation: {Operation}. Will retry with backoff.",
error.Account?.Name, error.Account?.Id, error.OperationType ?? "N/A");
error.Severity = SynchronizerErrorSeverity.Transient;
error.Category = SynchronizerErrorCategory.RateLimit;
// Gmail rate limits are usually per-user, suggest a longer delay
error.RetryDelay = TimeSpan.FromSeconds(10);
return Task.FromResult(true);
}
}
@@ -0,0 +1,40 @@
using System.Threading.Tasks;
using MailKit.Security;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Imap;
/// <summary>
/// Handles IMAP authentication failures (AuthenticationException, SaslException).
/// Marks the error as requiring re-authentication.
/// </summary>
public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<ImapAuthenticationFailedHandler>();
public bool CanHandle(SynchronizerErrorContext error)
{
return error.Exception is AuthenticationException ||
error.Exception is SaslException ||
(error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false);
}
public Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.",
error.Account?.Name, error.Account?.Id);
// Mark as requiring authentication - this will stop sync and notify user
error.Severity = SynchronizerErrorSeverity.AuthRequired;
error.Category = SynchronizerErrorCategory.Authentication;
// No point in retrying auth failures - credentials need to be updated
error.RetryDelay = null;
return Task.FromResult(true);
}
}
@@ -0,0 +1,45 @@
using System;
using System.IO;
using System.Net.Sockets;
using System.Threading.Tasks;
using MailKit;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Imap;
/// <summary>
/// Handles IMAP connection loss errors (IOException, SocketException, ServiceNotConnectedException).
/// Marks the error as transient for retry with backoff.
/// </summary>
public class ImapConnectionLostHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<ImapConnectionLostHandler>();
public bool CanHandle(SynchronizerErrorContext error)
{
return error.Exception is IOException ||
error.Exception is SocketException ||
error.Exception is ServiceNotConnectedException ||
error.Exception?.InnerException is IOException ||
error.Exception?.InnerException is SocketException;
}
public Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"IMAP connection lost for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Will retry.",
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A");
// Mark as transient - the RetryExecutor will handle the retry logic
error.Severity = SynchronizerErrorSeverity.Transient;
error.Category = SynchronizerErrorCategory.Network;
// Suggest a reasonable retry delay for connection issues
error.RetryDelay = TimeSpan.FromSeconds(2);
return Task.FromResult(true);
}
}
@@ -0,0 +1,67 @@
using System.Threading.Tasks;
using MailKit;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Integration.Processors;
namespace Wino.Core.Synchronizers.Errors.Imap;
/// <summary>
/// Handles IMAP folder not found errors (FolderNotFoundException).
/// Deletes the folder locally and allows sync to continue with other folders.
/// </summary>
public class ImapFolderNotFoundHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<ImapFolderNotFoundHandler>();
private readonly IImapChangeProcessor _imapChangeProcessor;
public ImapFolderNotFoundHandler(IImapChangeProcessor imapChangeProcessor)
{
_imapChangeProcessor = imapChangeProcessor;
}
public bool CanHandle(SynchronizerErrorContext error)
{
return error.Exception is FolderNotFoundException ||
error.ErrorCode == 404 ||
(error.ErrorMessage?.Contains("folder not found", System.StringComparison.OrdinalIgnoreCase) ?? false) ||
(error.ErrorMessage?.Contains("mailbox not found", System.StringComparison.OrdinalIgnoreCase) ?? false);
}
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
{
_logger.Warning(error.Exception,
"IMAP folder not found for account {AccountName} ({AccountId}). Folder: {FolderName} ({FolderId}). Removing locally.",
error.Account?.Name, error.Account?.Id, error.FolderName, error.FolderId);
// Mark as recoverable - sync can continue with other folders
error.Severity = SynchronizerErrorSeverity.Recoverable;
error.Category = SynchronizerErrorCategory.ResourceNotFound;
// Try to delete the folder locally if we have the folder ID
if (error.FolderId.HasValue && error.Account != null)
{
try
{
// Get the folder's remote ID from the exception if available
var remoteId = error.Exception is FolderNotFoundException fnf ? fnf.FolderName : null;
if (!string.IsNullOrEmpty(remoteId))
{
await _imapChangeProcessor.DeleteFolderAsync(error.Account.Id, remoteId).ConfigureAwait(false);
_logger.Information("Successfully deleted local folder {FolderName} after server deletion.",
error.FolderName);
}
}
catch (System.Exception ex)
{
_logger.Warning(ex, "Failed to delete local folder {FolderName} ({FolderId})",
error.FolderName, error.FolderId);
}
}
return true;
}
}
@@ -0,0 +1,100 @@
using System;
using System.Threading.Tasks;
using MailKit.Net.Imap;
using Serilog;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Synchronization;
namespace Wino.Core.Synchronizers.Errors.Imap;
/// <summary>
/// Handles generic IMAP protocol errors (ImapProtocolException, ImapCommandException).
/// This is the catch-all handler for IMAP errors not handled by more specific handlers.
/// </summary>
public class ImapProtocolErrorHandler : ISynchronizerErrorHandler
{
private readonly ILogger _logger = Log.ForContext<ImapProtocolErrorHandler>();
public bool CanHandle(SynchronizerErrorContext error)
{
// This is a catch-all for IMAP-related exceptions
return error.Exception is ImapProtocolException ||
error.Exception is ImapCommandException;
}
public Task<bool> HandleAsync(SynchronizerErrorContext error)
{
var severity = ClassifyProtocolError(error);
var category = SynchronizerErrorCategory.ProtocolError;
_logger.Warning(error.Exception,
"IMAP protocol error for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Severity: {Severity}",
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A", severity);
error.Severity = severity;
error.Category = category;
// For transient protocol errors, suggest a retry delay
if (severity == SynchronizerErrorSeverity.Transient)
{
error.RetryDelay = TimeSpan.FromSeconds(5);
}
return Task.FromResult(true);
}
/// <summary>
/// Classifies the protocol error to determine if it's transient, recoverable, or fatal.
/// </summary>
private static SynchronizerErrorSeverity ClassifyProtocolError(SynchronizerErrorContext error)
{
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
var exMessage = error.Exception?.Message?.ToLowerInvariant() ?? string.Empty;
// Check for rate limiting / throttling
if (message.Contains("too many") || message.Contains("rate limit") ||
message.Contains("throttl") || exMessage.Contains("too many"))
{
return SynchronizerErrorSeverity.Transient;
}
// Check for temporary server issues
if (message.Contains("try again") || message.Contains("temporary") ||
message.Contains("busy") || exMessage.Contains("try again"))
{
return SynchronizerErrorSeverity.Transient;
}
// Check for command-specific errors that are usually transient
if (error.Exception is ImapCommandException cmdEx)
{
// NO response usually means the operation failed but can be retried
if (cmdEx.Response == ImapCommandResponse.No)
{
// Unless it's a permanent failure indication
if (message.Contains("permanent") || message.Contains("invalid"))
{
return SynchronizerErrorSeverity.Recoverable;
}
return SynchronizerErrorSeverity.Transient;
}
// BAD response usually indicates a protocol violation - don't retry
if (cmdEx.Response == ImapCommandResponse.Bad)
{
return SynchronizerErrorSeverity.Recoverable;
}
}
// Protocol exceptions that indicate connection issues
if (error.Exception is ImapProtocolException)
{
// Most protocol exceptions are connection-related and transient
return SynchronizerErrorSeverity.Transient;
}
// Default to recoverable for unknown protocol errors
return SynchronizerErrorSeverity.Recoverable;
}
}
@@ -3,7 +3,7 @@ using Microsoft.Graph.Models.ODataErrors;
using Microsoft.Kiota.Abstractions;
using Serilog;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Integration.Processors;
namespace Wino.Core.Synchronizers.Errors.Outlook;
@@ -1,8 +1,8 @@
using System.Threading.Tasks;
using Microsoft.Kiota.Abstractions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Requests;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Requests.Bundles;
namespace Wino.Core.Synchronizers.Errors.Outlook;
+297 -176
View File
@@ -8,7 +8,6 @@ using System.Threading.Tasks;
using System.Web;
using CommunityToolkit.Mvvm.Messaging;
using Google;
using Google.Apis.Calendar.v3;
using Google.Apis.Calendar.v3.Data;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
@@ -28,7 +27,6 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
@@ -51,19 +49,22 @@ namespace Wino.Core.Synchronizers.Mail;
public partial class GmailSynchronizerJsonContext : JsonSerializerContext;
/// <summary>
/// Gmail synchronizer implementation with per-folder history ID synchronization.
///
/// Gmail synchronizer implementation using Gmail History API for efficient incremental sync.
///
/// SYNCHRONIZATION STRATEGY:
/// - Uses Gmail History API for both initial and incremental sync
/// - Initial sync: Downloads top 1500 messages per folder with metadata only
/// - Incremental sync: Uses history ID to get only changes since last sync
/// - Messages are downloaded with metadata only (no MIME content during sync)
/// - MIME files are downloaded on-demand when user explicitly reads a message
///
/// - Initial sync: Downloads up to 1500 messages PER FOLDER with metadata only.
/// Uses a global HashSet to track downloaded message IDs, avoiding duplicate downloads
/// when messages have multiple labels. Each folder gets its full quota of messages.
/// - Incremental sync: Uses ONLY History API to get changes since last sync.
/// No per-folder downloads during incremental sync - this is the proper Gmail sync approach.
/// - Messages are downloaded with metadata only during initial sync (no MIME content)
/// - New messages during incremental sync are downloaded with full MIME content
/// - MIME files for initial sync messages are downloaded on-demand when user reads a message
///
/// Key implementation details:
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
/// - DownloadMessagesForFolderAsync: Downloads top 1500 messages for initial sync
/// - SynchronizeDeltaAsync: Processes incremental changes using history ID
/// - PerformInitialSyncAsync: Downloads messages per-folder with global deduplication
/// - SynchronizeDeltaAsync: Processes incremental changes using History API with pagination
/// - Handles 404/410 errors (history expired) by triggering full resync
/// - CreateMinimalMailCopyAsync: Extracts MailCopy fields from Gmail Metadata format
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
/// </summary>
@@ -153,15 +154,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
_logger.Information("Internal mail synchronization started for {Name}", Account.Name);
// Make sure that virtual archive folder exists before all.
if (!archiveFolderId.HasValue)
await InitializeArchiveFolderAsync().ConfigureAwait(false);
var downloadedMessageIds = new List<string>();
var folderResults = new List<FolderSyncResult>();
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
bool shouldSynchronizeFolders = true;
if (shouldSynchronizeFolders)
try
{
// Make sure that virtual archive folder exists before all.
if (!archiveFolderId.HasValue)
await InitializeArchiveFolderAsync().ConfigureAwait(false);
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
_logger.Information("Synchronizing folders for {Name}", Account.Name);
UpdateSyncProgress(0, 0, "Synchronizing folders...");
@@ -173,202 +175,291 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
throw new GmailServiceDisabledException();
}
catch (Exception)
{
throw;
}
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
UpdateSyncProgress(0, 0, "Folders synchronized");
// Stop synchronization at this point if type is only folder metadata sync.
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
cancellationToken.ThrowIfCancellationRequested();
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
if (isInitialSync)
{
// INITIAL SYNC: Download all messages globally (not per-folder) to avoid duplicates.
// Gmail messages can have multiple labels, so per-folder download would fetch same message multiple times.
downloadedMessageIds = await PerformInitialSyncAsync(cancellationToken).ConfigureAwait(false);
// Set the history ID to the latest value after initial sync
UpdateSyncProgress(0, 0, "Finalizing synchronization...");
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
if (profile.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(profile.HistoryId.Value).ConfigureAwait(false);
_logger.Information("Initial sync completed. Set history ID to {HistoryId}", profile.HistoryId.Value);
}
// Create successful folder results for all folders
var allFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
foreach (var folder in allFolders.Where(f => f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID))
{
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, 0));
}
}
else
{
// INCREMENTAL SYNC: Use ONLY History API - no per-folder downloads.
// This is the proper Gmail sync strategy as recommended by Google.
UpdateSyncProgress(0, 0, "Synchronizing changes...");
var deltaResult = await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(deltaResult.DownloadedMessageIds);
// If history sync was reset due to expired history ID, we need to do initial sync
if (deltaResult.RequiresFullResync)
{
_logger.Warning("History ID expired. Performing full resync for {Name}", Account.Name);
downloadedMessageIds = await PerformInitialSyncAsync(cancellationToken).ConfigureAwait(false);
// Update history ID after full resync
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
if (profile.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(profile.HistoryId.Value).ConfigureAwait(false);
_logger.Information("Full resync completed. Set history ID to {HistoryId}", profile.HistoryId.Value);
}
}
UpdateSyncProgress(0, 0, "Changes synchronized");
// Create folder results for incremental sync
var allFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
foreach (var folder in allFolders.Where(f => f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID))
{
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, 0));
}
}
}
// There is no specific folder synchronization in Gmail.
// Therefore we need to stop the synchronization at this point
// if type is only folder metadata sync.
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
cancellationToken.ThrowIfCancellationRequested();
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
var downloadedMessageIds = new List<string>();
// Get all folders to synchronize
var synchronizationFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
_logger.Information("Synchronizing {Count} folders for {Name}", synchronizationFolders.Count, Account.Name);
var totalFolders = synchronizationFolders.Count;
for (int i = 0; i < totalFolders; i++)
catch (OperationCanceledException)
{
var folder = synchronizationFolders[i];
// Update progress based on folder completion
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
return MailSynchronizationResult.Canceled;
}
// Process incremental changes using history API if we have a history ID
if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
catch (Exception ex)
{
UpdateSyncProgress(0, 0, "Synchronizing changes...");
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
UpdateSyncProgress(0, 0, "Changes synchronized");
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
return MailSynchronizationResult.Failed(ex);
}
// Get all unread new downloaded items for notifications
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
return MailSynchronizationResult.Completed(unreadNewItems);
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
}
/// <summary>
/// Synchronizes a single folder by downloading top 1500 messages with metadata only.
/// Result of delta synchronization using History API.
/// </summary>
private async Task<List<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
cancellationToken.ThrowIfCancellationRequested();
_logger.Debug("Synchronizing folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
try
{
// Download top 1500 messages for this folder
await DownloadMessagesForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
if (downloadedMessageIds.Any())
{
_logger.Information("Downloaded {Count} messages for folder {FolderName}", downloadedMessageIds.Count, folder.FolderName);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Error synchronizing folder {FolderName}", folder.FolderName);
throw;
}
return downloadedMessageIds;
}
private record DeltaSyncResult(List<string> DownloadedMessageIds, bool RequiresFullResync);
/// <summary>
/// Downloads top 1500 messages for a folder using Gmail API with metadata only.
/// Performs initial synchronization by downloading messages per-folder.
/// Each folder gets up to 1500 messages, but we track already downloaded message IDs globally
/// to avoid downloading the same message multiple times (Gmail messages can have multiple labels).
/// </summary>
private async Task DownloadMessagesForFolderAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
private async Task<List<string>> PerformInitialSyncAsync(CancellationToken cancellationToken)
{
_logger.Debug("Downloading messages for folder {FolderName}", folder.FolderName);
// Track all downloaded message IDs globally to avoid duplicate downloads
var downloadedMessageIds = new HashSet<string>();
_logger.Information("Performing initial sync for {Name} - downloading messages per folder", Account.Name);
try
{
var totalDownloaded = 0;
string pageToken = null;
// Get all folders to sync (exclude virtual ARCHIVE folder)
var folders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
var syncableFolders = folders
.Where(f => f.IsSynchronizationEnabled && f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID)
.ToList();
// Gmail API returns messages newest first by default
// We'll download up to 1500 messages per folder
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
var totalFolders = syncableFolders.Count;
var totalMessagesDownloaded = 0;
do
for (int i = 0; i < totalFolders; i++)
{
var folder = syncableFolders[i];
cancellationToken.ThrowIfCancellationRequested();
var request = _gmailService.Users.Messages.List("me");
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
request.PageToken = pageToken;
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
var response = await request.ExecuteAsync(cancellationToken);
_logger.Debug("Downloading messages for folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
if (response.Messages != null && response.Messages.Count > 0)
var folderDownloaded = 0;
string pageToken = null;
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
do
{
var messageIds = response.Messages.Select(m => m.Id).ToList();
cancellationToken.ThrowIfCancellationRequested();
// Download metadata in batches
await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
var request = _gmailService.Users.Messages.List("me");
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
request.PageToken = pageToken;
downloadedMessageIds.AddRange(messageIds);
totalDownloaded += messageIds.Count;
remainingToDownload -= messageIds.Count;
var response = await request.ExecuteAsync(cancellationToken);
_logger.Debug("Downloaded {Count} messages for folder {FolderName} (total: {Total})", messageIds.Count, folder.FolderName, totalDownloaded);
if (response.Messages != null && response.Messages.Count > 0)
{
// Filter out already downloaded messages to avoid duplicates
var newMessageIds = response.Messages
.Select(m => m.Id)
.Where(id => !downloadedMessageIds.Contains(id))
.ToList();
// Update progress
UpdateSyncProgress(0, 0, $"Downloaded {totalDownloaded} messages from {folder.FolderName}");
}
if (newMessageIds.Count > 0)
{
// Download metadata in batches (no raw MIME during initial sync)
await DownloadMessagesInBatchAsync(newMessageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
pageToken = response.NextPageToken;
foreach (var id in newMessageIds)
{
downloadedMessageIds.Add(id);
}
// Stop if we've downloaded enough messages or no more pages
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
break;
folderDownloaded += newMessageIds.Count;
totalMessagesDownloaded += newMessageIds.Count;
}
} while (!string.IsNullOrEmpty(pageToken));
// Count all messages (including duplicates) toward the folder limit
remainingToDownload -= response.Messages.Count;
// Store history ID for future incremental syncs
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
Account.SynchronizationDeltaIdentifier = profile.HistoryId.ToString();
await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
_logger.Debug("Folder {FolderName}: Downloaded {New} new messages ({Total} total in folder)",
folder.FolderName, newMessageIds.Count, folderDownloaded);
}
_logger.Information("Completed downloading {Count} messages for folder {FolderName}", totalDownloaded, folder.FolderName);
pageToken = response.NextPageToken;
// Stop if we've processed enough messages for this folder or no more pages
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
break;
} while (!string.IsNullOrEmpty(pageToken));
_logger.Information("Folder {FolderName}: Downloaded {Count} messages", folder.FolderName, folderDownloaded);
UpdateSyncProgress(0, 0, $"Downloaded {totalMessagesDownloaded} messages");
}
_logger.Information("Initial sync completed. Downloaded {Count} unique messages for {Name}", downloadedMessageIds.Count, Account.Name);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_logger.Warning("Rate limit exceeded while downloading messages for folder {FolderName}. Retrying after delay.", folder.FolderName);
_logger.Warning("Rate limit exceeded during initial sync. Retrying after delay.");
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
throw;
}
catch (Exception ex)
{
_logger.Error(ex, "Error downloading messages for folder {FolderName}", folder.FolderName);
_logger.Error(ex, "Error during initial sync for {Name}", Account.Name);
throw;
}
return downloadedMessageIds.ToList();
}
private async Task SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
/// <summary>
/// Performs incremental synchronization using Gmail History API.
/// This is the recommended approach for Gmail sync after initial sync is complete.
/// Returns a result indicating downloaded messages and whether a full resync is needed.
/// </summary>
private async Task<DeltaSyncResult> SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
try
{
var historyRequest = _gmailService.Users.History.List("me");
historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
string pageToken = null;
var historyResponse = await historyRequest.ExecuteAsync();
if (historyResponse.History != null)
do
{
var addedMessageIds = new List<string>();
cancellationToken.ThrowIfCancellationRequested();
// Collect all added messages first
foreach (var historyRecord in historyResponse.History)
var historyRequest = _gmailService.Users.History.List("me");
historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
if (!string.IsNullOrEmpty(pageToken))
historyRequest.PageToken = pageToken;
var historyResponse = await historyRequest.ExecuteAsync(cancellationToken);
if (historyResponse.History != null)
{
if (historyRecord.MessagesAdded != null)
var addedMessageIds = new List<string>();
// Collect all added messages first
foreach (var historyRecord in historyResponse.History)
{
addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
if (historyRecord.MessagesAdded != null)
{
addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
}
}
}
// Process added messages in batches if any
// During delta sync, download with Raw format to get MIME content
if (addedMessageIds.Count != 0)
{
await DownloadMessagesInBatchAsync(addedMessageIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
}
// Process added messages in batches if any
// During delta sync, download with Raw format to get MIME content for new messages
if (addedMessageIds.Count != 0)
{
// Deduplicate message IDs
var uniqueAddedIds = addedMessageIds.Distinct().ToList();
await DownloadMessagesInBatchAsync(uniqueAddedIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(uniqueAddedIds);
}
// Process other history changes
foreach (var historyRecord in historyResponse.History)
{
// Process other history changes (label changes, deletions)
await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
}
}
}
catch (Exception)
{
// CRITICAL: Update the history ID to the latest one after processing all changes
// History IDs are always incremental, so the response contains the latest history ID
if (historyResponse.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(historyResponse.HistoryId.Value).ConfigureAwait(false);
_logger.Debug("Updated history ID to {HistoryId} after delta sync", historyResponse.HistoryId.Value);
}
pageToken = historyResponse.NextPageToken;
} while (!string.IsNullOrEmpty(pageToken));
_logger.Information("Delta sync completed. Downloaded {Count} new messages for {Name}", downloadedMessageIds.Count, Account.Name);
return new DeltaSyncResult(downloadedMessageIds, RequiresFullResync: false);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound ||
(int)ex.HttpStatusCode == 410) // Gone - history expired
{
// History ID is no longer valid (expired or not found)
// This happens when:
// 1. The history ID is too old (Gmail keeps history for ~30 days)
// 2. The account was reset or history was cleared
// Reset the sync identifier and signal that a full resync is needed
_logger.Warning("History ID {HistoryId} expired or not found for {Name}. Full resync required. Error: {Error}",
Account.SynchronizationDeltaIdentifier, Account.Name, ex.Message);
// Clear the sync identifier to trigger initial sync
Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor
.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, null)
.ConfigureAwait(false);
return new DeltaSyncResult(downloadedMessageIds, RequiresFullResync: true);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_logger.Warning("Rate limit exceeded during delta sync for {Name}. Retrying after delay.", Account.Name);
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
throw;
}
}
@@ -802,6 +893,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
foreach (var labelId in addedLabel.LabelIds)
{
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleArchiveAssignmentAsync(messageId).ConfigureAwait(false);
continue;
}
// When UNREAD label is added mark the message as un-read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
@@ -822,6 +920,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
foreach (var labelId in removedLabel.LabelIds)
{
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleUnarchiveAssignmentAsync(messageId).ConfigureAwait(false);
continue;
}
// When UNREAD label is removed mark the message as read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
@@ -1160,15 +1265,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (packages != null)
{
// For Gmail, multiple packages can share the same message (different labels/folders)
// They should all share the same FileId so MIME is stored only once
Guid sharedFileId = Guid.NewGuid();
// For Gmail, multiple packages share the same message (different labels/folders)
// They already share the same FileId (set in CreateNewMailPackagesAsync) so MIME is stored only once
foreach (var package in packages)
{
// Set the same FileId for all copies
package.Copy.FileId = sharedFileId;
// Create the mail copy with the MIME (if downloaded)
var packageWithMime = downloadRawMime && mimeMessage != null
? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
@@ -1256,7 +1357,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Gmail calendar attachments are stored in Google Drive
// RemoteAttachmentId contains either FileId or FileUrl
// For simplicity, we'll try to download from the FileId/FileUrl
if (string.IsNullOrEmpty(attachment.RemoteAttachmentId))
{
_logger.Error("RemoteAttachmentId is empty for attachment {AttachmentId}", attachment.Id);
@@ -1267,7 +1368,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// The attachment.RemoteAttachmentId is either a FileId or FileUrl
// Since we can't directly download from Calendar API, this would require Drive API access
// For now, throw NotSupportedException as Gmail attachments require additional Drive API setup
_logger.Warning("Gmail calendar attachment download requires Google Drive API access. FileId/URL: {RemoteId}", attachment.RemoteAttachmentId);
throw new NotSupportedException("Gmail calendar attachments are stored in Google Drive and require additional API configuration to download.");
}
@@ -1615,7 +1716,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Check Content-Type header for text/calendar
var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value;
if (!string.IsNullOrEmpty(contentTypeHeader))
{
// Check if it's a calendar message (text/calendar or multipart with calendar)
@@ -1623,11 +1724,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
// Check the METHOD parameter to determine invitation type
var methodMatch = System.Text.RegularExpressions.Regex.Match(contentTypeHeader, @"method=([^;\s]+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (methodMatch.Success)
{
var method = methodMatch.Groups[1].Value.Trim('"').ToUpperInvariant();
return method switch
{
"REQUEST" => MailItemType.CalendarInvitation,
@@ -1636,7 +1737,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_ => MailItemType.Mail
};
}
// If no method specified, assume it's an invitation
return MailItemType.CalendarInvitation;
}
@@ -1683,11 +1784,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
var packageList = new List<NewMailItemPackage>();
// Create MailCopy from metadata only - NO MIME download
var mailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
// Create base MailCopy from metadata only - NO MIME download
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
// Check for local draft mapping using X-Wino-Draft-Id header from metadata
if (mailCopy.IsDraft)
if (baseMailCopy.IsDraft)
{
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
@@ -1696,7 +1797,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one.
bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, baseMailCopy.Id, baseMailCopy.DraftId, baseMailCopy.ThreadId);
if (isMappingSuccesfull) return null;
@@ -1704,12 +1805,32 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
}
// For Gmail, a single mail can have multiple labels (folders).
// Each label requires a separate MailCopy entry in the database with:
// - Same Id, UniqueId, FileId (shared across all copies)
// - Different FolderId (one per label)
// ARCHIVE label is excluded here as it's virtual and handled by MapArchivedMailsAsync
if (message.LabelIds is not null)
{
// Generate shared identifiers that will be the same for all copies of this mail
var sharedId = baseMailCopy.Id;
var sharedFileId = baseMailCopy.FileId;
foreach (var labelId in message.LabelIds)
{
// Skip ARCHIVE label - it's virtual and handled separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
continue;
// Create a new MailCopy instance for each label to avoid shared reference issues
var mailCopyForLabel = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
// Ensure all copies share the same Id and FileId
mailCopyForLabel.Id = sharedId;
mailCopyForLabel.FileId = sharedFileId;
// Pass null for MimeMessage - it will be downloaded later when user reads the mail
packageList.Add(new NewMailItemPackage(mailCopy, null, labelId));
packageList.Add(new NewMailItemPackage(mailCopyForLabel, null, labelId));
}
}
@@ -1807,7 +1928,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create a patch event to update only the attendee response
var patchEvent = new Event();
// We need to get the event first to update the specific attendee
// However, for efficiency, we'll use the patch method with sendUpdates parameter
var patchRequest = _calendarService.Events.Patch(new Event
@@ -1824,8 +1945,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send updates to other attendees if there's a message
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
@@ -1861,8 +1982,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
@@ -1898,8 +2019,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
@@ -1979,8 +2100,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var updateRequest = _calendarService.Events.Update(googleEvent, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send notifications to attendees if the event has attendees
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(updateRequest, request)];
@@ -0,0 +1,473 @@
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 MoreLinq;
using Serilog;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Integration;
using Wino.Services.Extensions;
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
namespace Wino.Core.Synchronizers.ImapSync;
/// <summary>
/// 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.
/// </summary>
public class UnifiedImapSynchronizer
{
private readonly ILogger _logger = Log.ForContext<UnifiedImapSynchronizer>();
private readonly IFolderService _folderService;
private readonly IMailService _mailService;
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
// Minimum summary items to Fetch for mail synchronization from IMAP.
private readonly MessageSummaryItems MailSynchronizationFlags =
MessageSummaryItems.Flags |
MessageSummaryItems.UniqueId |
MessageSummaryItems.ThreadId |
MessageSummaryItems.EmailId |
MessageSummaryItems.Headers |
MessageSummaryItems.PreviewText |
MessageSummaryItems.GMailThreadId |
MessageSummaryItems.References |
MessageSummaryItems.ModSeq;
public UnifiedImapSynchronizer(
IFolderService folderService,
IMailService mailService,
IImapSynchronizerErrorHandlerFactory errorHandlerFactory)
{
_folderService = folderService;
_mailService = mailService;
_errorHandlerFactory = errorHandlerFactory;
}
/// <summary>
/// Determines the best synchronization strategy based on server capabilities.
/// </summary>
public ImapSyncStrategy DetermineSyncStrategy(IImapClient client)
{
if (client is WinoImapClient winoClient &&
client.Capabilities.HasFlag(ImapCapabilities.QuickResync) &&
winoClient.IsQResyncEnabled)
{
return ImapSyncStrategy.QResync;
}
if (client.Capabilities.HasFlag(ImapCapabilities.CondStore))
{
return ImapSyncStrategy.Condstore;
}
return ImapSyncStrategy.UidBased;
}
/// <summary>
/// Main synchronization entry point. Automatically selects the best strategy.
/// </summary>
public async Task<FolderSyncResult> SynchronizeFolderAsync(
IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken = default)
{
var strategy = DetermineSyncStrategy(client);
_logger.Debug("Using {Strategy} sync strategy for folder {FolderName}", strategy, folder.FolderName);
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)
};
return FolderSyncResult.Successful(folder.Id, folder.FolderName, downloadedIds.Count);
}
catch (FolderNotFoundException)
{
_logger.Warning("Folder {FolderName} not found on server, deleting locally", folder.FolderName);
await _folderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
return FolderSyncResult.Skipped(folder.Id, folder.FolderName, "Folder not found on server");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
var errorContext = new SynchronizerErrorContext
{
ErrorMessage = ex.Message,
Exception = ex,
FolderId = folder.Id,
FolderName = folder.FolderName,
OperationType = "ImapFolderSync"
};
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
if (errorContext.CanContinueSync)
{
_logger.Warning(ex, "Folder {FolderName} sync failed with recoverable error", folder.FolderName);
return FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext);
}
_logger.Error(ex, "Folder {FolderName} sync failed with fatal error", folder.FolderName);
throw;
}
}
#region QRESYNC Strategy
private async Task<List<string>> SynchronizeWithQResyncAsync(
IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
if (client is not WinoImapClient winoClient)
throw new InvalidOperationException("QRESYNC requires WinoImapClient");
IMailFolder remoteFolder = null;
try
{
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
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);
// Open with QRESYNC parameters
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, allUniqueIds, cancellationToken).ConfigureAwait(false);
// Get changed UIDs
var changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, changedUids, 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 _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
}
finally
{
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
{
await remoteFolder.CloseAsync().ConfigureAwait(false);
}
}
return downloadedMessageIds;
}
#endregion
#region CONDSTORE Strategy
private async Task<List<string>> SynchronizeWithCondstoreAsync(
IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
IMailFolder remoteFolder = null;
try
{
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;
if (remoteFolder.HighestModSeq > localHighestModSeq || isInitialSync)
{
IList<UniqueId> changedUids;
// Use SORT if available for better ordering
if (client.Capabilities.HasFlag(ImapCapabilities.Sort))
{
changedUids = await remoteFolder.SortAsync(
SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)),
[OrderBy.ReverseDate],
cancellationToken).ConfigureAwait(false);
}
else
{
changedUids = await remoteFolder.SearchAsync(
SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)),
cancellationToken).ConfigureAwait(false);
}
// For initial sync, limit the number of messages
if (isInitialSync)
{
changedUids = changedUids
.OrderByDescending(a => a.Id)
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
.ToList();
}
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, changedUids, cancellationToken).ConfigureAwait(false);
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
}
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
}
finally
{
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
{
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
return downloadedMessageIds;
}
#endregion
#region UID-Based Strategy (Fallback)
private async Task<List<string>> SynchronizeWithUidBasedAsync(
IImapClient client,
MailItemFolder folder,
IImapSynchronizer synchronizer,
CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
IMailFolder remoteFolder = null;
try
{
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();
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, limitedUids, cancellationToken).ConfigureAwait(false);
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
}
finally
{
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
{
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
return downloadedMessageIds;
}
#endregion
#region Shared Processing Methods
private async Task<List<string>> ProcessChangedUidsAsync(
IImapSynchronizer synchronizer,
IMailFolder remoteFolder,
MailItemFolder localFolder,
IList<UniqueId> changedUids,
CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
if (changedUids == null || changedUids.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 newMessageUids = changedUids.Except(existingMailUids).ToList();
// 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);
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);
if (mailPackages != null)
{
foreach (var package in mailPackages)
{
if (package != null)
{
await _mailService.CreateMailAsync(localFolder.MailAccountId, package).ConfigureAwait(false);
}
}
}
}
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);
}
}
private async Task UpdateMailFlagsAsync(MailCopy mailCopy, MessageFlags flags)
{
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);
}
}
private void HandleMessagesVanished(MailItemFolder folder, IList<UniqueId> uniqueIds)
{
// Fire and forget - these are event handlers
_ = Task.Run(async () =>
{
foreach (var uniqueId in uniqueIds)
{
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)
{
if (uniqueId == null) return;
_ = Task.Run(async () =>
{
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);
});
}
#endregion
}
/// <summary>
/// IMAP synchronization strategy enumeration.
/// </summary>
public enum ImapSyncStrategy
{
/// <summary>
/// RFC 5162 Quick Resync - supports vanished messages and efficient delta sync.
/// </summary>
QResync,
/// <summary>
/// RFC 4551 Conditional Store - supports mod-seq based change tracking.
/// </summary>
Condstore,
/// <summary>
/// Basic UID-based synchronization - fallback for servers without advanced features.
/// </summary>
UidBased
}
+111 -27
View File
@@ -26,6 +26,7 @@ using Wino.Core.Integration.Processors;
using Wino.Core.Requests.Bundles;
using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
using Wino.Core.Synchronizers.ImapSync;
using Wino.Messaging.Server;
using Wino.Messaging.UI;
using Wino.Services.Extensions;
@@ -52,16 +53,22 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
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) : base(account, WeakReferenceMessenger.Default)
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;
var protocolLogStream = CreateAccountProtocolLogFileStream();
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
@@ -303,53 +310,130 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
var folderResults = new List<FolderSyncResult>();
_logger.Information("Internal synchronization started for {Name}", Account.Name);
_logger.Information("Options: {Options}", options);
// Set indeterminate progress initially
UpdateSyncProgress(0, 0, "Synchronizing...");
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
if (shouldDoFolderSync)
try
{
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
}
// Set indeterminate progress initially
UpdateSyncProgress(0, 0, "Synchronizing...");
if (options.Type != MailSynchronizationType.FoldersOnly)
{
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
var totalFolders = synchronizationFolders.Count;
for (int i = 0; i < totalFolders; i++)
if (shouldDoFolderSync)
{
var folder = synchronizationFolders[i];
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
}
// Update progress based on folder completion
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
if (options.Type != MailSynchronizationType.FoldersOnly)
{
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false);
var totalFolders = synchronizationFolders.Count;
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
if (folderDownloadedMessageIds != null)
for (int i = 0; i < totalFolders; i++)
{
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
var folder = synchronizationFolders[i];
// Update progress based on folder completion
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
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);
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);
}
}
finally
{
if (client != null)
{
_clientPool.Release(client);
}
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
var errorContext = new SynchronizerErrorContext
{
Account = Account,
ErrorMessage = ex.Message,
Exception = ex,
FolderId = folder.Id,
FolderName = folder.FolderName,
OperationType = "ImapFolderSync"
};
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
if (errorContext.CanContinueSync)
{
_logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName);
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
}
else
{
_logger.Error(ex, "Folder {FolderName} sync failed with fatal error", folder.FolderName);
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
throw;
}
}
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
}
}
}
// Reset progress
ResetSyncProgress();
catch (OperationCanceledException)
{
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
return MailSynchronizationResult.Canceled;
}
catch (Exception ex)
{
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
return MailSynchronizationResult.Failed(ex);
}
finally
{
// Reset progress
ResetSyncProgress();
}
// Get all unread new downloaded items and return in the result.
// This is primarily used in notifications.
var unreadNewItems = await _imapChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
return MailSynchronizationResult.Completed(unreadNewItems);
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
}
/// <summary>
/// Gets the most recent downloaded message IDs for a folder.
/// Used for notification purposes after sync completes.
/// </summary>
private async Task<List<string>> GetDownloadedIdsForFolderAsync(MailItemFolder folder, int count)
{
// Get the most recent mail IDs from the folder
var recentMails = await _imapChangeProcessor.GetRecentMailIdsForFolderAsync(folder.Id, count).ConfigureAwait(false);
return recentMails?.ToList() ?? new List<string>();
}
public override async Task ExecuteNativeRequestsAsync(List<IRequestBundle<ImapRequest>> batchedRequests, CancellationToken cancellationToken = default)
+68 -8
View File
@@ -30,7 +30,6 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
@@ -141,6 +140,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
var folderResults = new List<FolderSyncResult>();
_logger.Information("Internal synchronization started for {Name}", Account.Name);
_logger.Information("Options: {Options}", options);
@@ -169,17 +169,77 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
var statusMessage = string.Format(Translator.Sync_SynchronizingFolder, folder.FolderName, progressPercentage);
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), statusMessage);
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
try
{
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, folderDownloadedMessageIds.Count()));
}
catch (OperationCanceledException)
{
// Cancellation should stop the entire sync
throw;
}
catch (ODataError odataError)
{
// Handle OData errors - determine if we should continue or stop
var errorContext = new SynchronizerErrorContext
{
Account = Account,
ErrorCode = (int?)odataError.ResponseStatusCode,
ErrorMessage = odataError.Error?.Message ?? odataError.Message,
Exception = odataError,
FolderId = folder.Id,
FolderName = folder.FolderName,
OperationType = "FolderSync"
};
var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
if (errorContext.CanContinueSync)
{
_logger.Warning("Folder {FolderName} sync failed with recoverable error, continuing with other folders. Error: {Error}",
folder.FolderName, odataError.Error?.Message);
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
}
else
{
_logger.Error(odataError, "Folder {FolderName} sync failed with fatal error, stopping sync", folder.FolderName);
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
throw;
}
}
catch (Exception ex)
{
// For unexpected exceptions, try to classify and decide if we should continue
var errorContext = new SynchronizerErrorContext
{
Account = Account,
ErrorMessage = ex.Message,
Exception = ex,
FolderId = folder.Id,
FolderName = folder.FolderName,
OperationType = "FolderSync",
Severity = SynchronizerErrorSeverity.Recoverable, // Default to recoverable for individual folders
Category = SynchronizerErrorCategory.Unknown
};
_logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName);
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
}
}
}
}
catch (OperationCanceledException)
{
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
return MailSynchronizationResult.Canceled;
}
catch (Exception ex)
{
_logger.Error(ex, "Synchronizing folders for {Name}", Account.Name);
Debugger.Break();
throw;
return MailSynchronizationResult.Failed(ex);
}
finally
{
@@ -187,12 +247,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
ResetSyncProgress();
}
// Get all unred new downloaded items and return in the result.
// Get all unread new downloaded items and return in the result.
// This is primarily used in notifications.
var unreadNewItems = await _outlookChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
return MailSynchronizationResult.Completed(unreadNewItems);
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
}
public async Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
+4
View File
@@ -39,4 +39,8 @@
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Domain\Models\Errors\" />
</ItemGroup>
</Project>
@@ -670,7 +670,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
await MailCollection.UpdateMailCopy(updatedMail, source);
await ExecuteUIThread(() => { SetupTopBarActions(); });
// await ExecuteUIThread(() => { SetupTopBarActions(); });
}
protected override async void OnMailRemoved(MailCopy removedMail)
+20
View File
@@ -599,4 +599,24 @@ public class AccountService : BaseDatabaseService, IAccountService
return account?.Preferences?.IsNotificationsEnabled ?? false;
}
public async Task UpdateLastFolderStructureSyncDateAsync(Guid accountId)
{
var account = await GetAccountAsync(accountId);
if (account == null) return;
account.LastFolderStructureSyncDate = DateTime.UtcNow;
await Connection.UpdateAsync(account, typeof(MailAccount)).ConfigureAwait(false);
}
public async Task<bool> ShouldSyncFolderStructureAsync(Guid accountId, TimeSpan syncInterval)
{
var account = await GetAccountAsync(accountId);
if (account == null) return true;
if (!account.LastFolderStructureSyncDate.HasValue)
return true;
return DateTime.UtcNow - account.LastFolderStructureSyncDate.Value > syncInterval;
}
}
+12
View File
@@ -1124,6 +1124,18 @@ public class MailService : BaseDatabaseService, IMailService
return new GmailArchiveComparisonResult(addedMails, removedMails);
}
public async Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
{
var recentMails = await Connection.Table<MailCopy>()
.Where(a => a.FolderId == folderId)
.OrderByDescending(a => a.CreationDate)
.Take(count)
.ToListAsync()
.ConfigureAwait(false);
return recentMails.Select(m => m.Id);
}
public async Task<List<MailCopy>> GetMailItemsAsync(IEnumerable<string> mailCopyIds)
{
if (!mailCopyIds.Any()) return [];