Refactored all synchronizers to deal with some of the chronic issues.
This commit is contained in:
@@ -106,6 +106,12 @@ public class MailAccount
|
|||||||
[Ignore]
|
[Ignore]
|
||||||
public MailAccountPreferences Preferences { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Gets whether the account can perform ProfileInformation sync type.
|
/// Gets whether the account can perform ProfileInformation sync type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ public enum SynchronizationCompletedState
|
|||||||
{
|
{
|
||||||
Success, // All succeeded.
|
Success, // All succeeded.
|
||||||
Canceled, // Canceled by user or HTTP call.
|
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>
|
/// <returns>Whether the notifications should be created after sync or not.</returns>
|
||||||
Task<bool> IsNotificationsEnabled(Guid accountId);
|
Task<bool> IsNotificationsEnabled(Guid accountId);
|
||||||
Task UpdateAccountCustomServerInformationAsync(CustomServerInformation customServerInformation);
|
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>
|
/// <param name="onlineArchiveMailIds">Retrieved MailCopy ids from search result.</param>
|
||||||
/// <returns>Result model that contains added and removed mail copy ids.</returns>
|
/// <returns>Result model that contains added and removed mail copy ids.</returns>
|
||||||
Task<GmailArchiveComparisonResult> GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List<string> onlineArchiveMailIds);
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Wino.Core.Domain.Entities.Mail;
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
@@ -25,6 +26,55 @@ public class MailSynchronizationResult
|
|||||||
|
|
||||||
public Exception Exception { get; set; }
|
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 };
|
public static MailSynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success };
|
||||||
|
|
||||||
// Mail synchronization
|
// Mail synchronization
|
||||||
@@ -43,6 +93,28 @@ public class MailSynchronizationResult
|
|||||||
CompletedState = SynchronizationCompletedState.Success
|
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 Canceled => new() { CompletedState = SynchronizationCompletedState.Canceled };
|
||||||
public static MailSynchronizationResult Failed(Exception exception) => new()
|
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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ using Wino.Authentication;
|
|||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Integration.Processors;
|
using Wino.Core.Integration.Processors;
|
||||||
using Wino.Core.Services;
|
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.Errors.Outlook;
|
||||||
using Wino.Core.Synchronizers.ImapSync;
|
using Wino.Core.Synchronizers.ImapSync;
|
||||||
|
|
||||||
@@ -37,12 +39,29 @@ public static class CoreContainerSetup
|
|||||||
services.AddTransient<CondstoreSynchronizer>();
|
services.AddTransient<CondstoreSynchronizer>();
|
||||||
services.AddTransient<QResyncSynchronizer>();
|
services.AddTransient<QResyncSynchronizer>();
|
||||||
services.AddTransient<UidBasedSynchronizer>();
|
services.AddTransient<UidBasedSynchronizer>();
|
||||||
|
services.AddTransient<UnifiedImapSynchronizer>();
|
||||||
|
|
||||||
// Register error factory handlers
|
// Register Outlook error handlers
|
||||||
services.AddTransient<ObjectCannotBeDeletedHandler>();
|
services.AddTransient<ObjectCannotBeDeletedHandler>();
|
||||||
services.AddTransient<DeltaTokenExpiredHandler>();
|
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<IOutlookSynchronizerErrorHandlerFactory, OutlookSynchronizerErrorHandlingFactory>();
|
||||||
services.AddTransient<IGmailSynchronizerErrorHandlerFactory, GmailSynchronizerErrorHandlingFactory>();
|
services.AddTransient<IGmailSynchronizerErrorHandlerFactory, GmailSynchronizerErrorHandlingFactory>();
|
||||||
|
services.AddTransient<IImapSynchronizerErrorHandlerFactory, ImapSynchronizerErrorHandlingFactory>();
|
||||||
|
|
||||||
|
// Register retry executor
|
||||||
|
services.AddTransient<IRetryExecutor, RetryExecutor>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
|
|
||||||
namespace Wino.Core.Domain.Interfaces;
|
namespace Wino.Core.Domain.Interfaces;
|
||||||
|
|
||||||
@@ -23,10 +23,6 @@ public interface ISynchronizerErrorHandler
|
|||||||
Task<bool> HandleAsync(SynchronizerErrorContext error);
|
Task<bool> HandleAsync(SynchronizerErrorContext error);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ISynchronizerErrorHandlerFactory
|
|
||||||
{
|
|
||||||
Task<bool> HandleErrorAsync(SynchronizerErrorContext error);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IOutlookSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
|
public interface IOutlookSynchronizerErrorHandlerFactory : ISynchronizerErrorHandlerFactory;
|
||||||
public interface IGmailSynchronizerErrorHandlerFactory : 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; }
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
@@ -6,13 +6,12 @@ using System.Net.Security;
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Timers;
|
|
||||||
using MailKit.Net.Imap;
|
using MailKit.Net.Imap;
|
||||||
using MailKit.Net.Proxy;
|
using MailKit.Net.Proxy;
|
||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
using MimeKit.Cryptography;
|
using MimeKit.Cryptography;
|
||||||
using MoreLinq;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
@@ -20,19 +19,32 @@ using Wino.Core.Domain.Exceptions;
|
|||||||
using Wino.Core.Domain.Models.Connectivity;
|
using Wino.Core.Domain.Models.Connectivity;
|
||||||
|
|
||||||
namespace Wino.Core.Integration;
|
namespace Wino.Core.Integration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides a pooling mechanism for ImapClient.
|
/// Connection state for tracking individual client health.
|
||||||
/// Makes sure that we don't have too many connections to the server.
|
/// </summary>
|
||||||
/// Rents a connected & authenticated client from the pool all the time.
|
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>
|
/// </summary>
|
||||||
/// <param name="customServerInformation">Connection/Authentication info to be used to configure ImapClient.</param>
|
|
||||||
public class ImapClientPool : IDisposable
|
public class ImapClientPool : IDisposable
|
||||||
{
|
{
|
||||||
// Hardcoded implementation details for ID extension if the server supports.
|
private const int MinActiveConnections = 3;
|
||||||
// Some providers like Chinese 126 require Id to be sent before authentication.
|
private const int IdleConnectionReserved = 1;
|
||||||
// We don't expose any customer data here. Therefore it's safe for now.
|
private const int KeepAliveIntervalMs = 4 * 60 * 1000; // 4 minutes
|
||||||
// Later on maybe we can make it configurable and leave it to the user with passing
|
private const int ConnectionMonitorIntervalMs = 30 * 1000; // 30 seconds
|
||||||
// real implementation details.
|
private const int MaintenanceIntervalMs = 60 * 1000; // 1 minute
|
||||||
|
|
||||||
private readonly ImapImplementation _implementation = new()
|
private readonly ImapImplementation _implementation = new()
|
||||||
{
|
{
|
||||||
Version = "1.8.0",
|
Version = "1.8.0",
|
||||||
@@ -42,180 +54,520 @@ public class ImapClientPool : IDisposable
|
|||||||
Name = "Wino Mail User",
|
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 bool ThrowOnSSLHandshakeCallback { get; set; }
|
||||||
public ImapClientPoolOptions ImapClientPoolOptions { get; }
|
public ImapClientPoolOptions ImapClientPoolOptions { get; }
|
||||||
|
|
||||||
private bool _disposedValue;
|
/// <summary>
|
||||||
private readonly int MinimumPoolSize = 5;
|
/// Gets the current health status of the connection pool.
|
||||||
private readonly ConcurrentStack<IImapClient> _clients = new();
|
/// </summary>
|
||||||
private readonly SemaphoreSlim _semaphore;
|
public ConnectionPoolHealth Health => GetHealthInternal();
|
||||||
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
|
|
||||||
|
|
||||||
public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions)
|
public ImapClientPool(ImapClientPoolOptions imapClientPoolOptions)
|
||||||
{
|
{
|
||||||
_customServerInformation = imapClientPoolOptions.ServerInformation;
|
_customServerInformation = imapClientPoolOptions.ServerInformation;
|
||||||
_protocolLogStream = imapClientPoolOptions.ProtocolLog;
|
_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;
|
ImapClientPoolOptions = imapClientPoolOptions;
|
||||||
|
|
||||||
_keepAliveTimer = new System.Timers.Timer(KeepAliveInterval);
|
CryptographyContext.Register(typeof(WindowsSecureMimeContext));
|
||||||
_connectionMonitorTimer = new System.Timers.Timer(ConnectionMonitorInterval);
|
|
||||||
|
|
||||||
_keepAliveTimer.Elapsed += KeepAliveTimerElapsed;
|
// Create unbounded channel for available clients
|
||||||
_connectionMonitorTimer.Elapsed += ConnectionMonitorTimerElapsed;
|
_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
|
try
|
||||||
{
|
{
|
||||||
for (int i = 0; i < MinimumPoolSize; i++)
|
// Create initial connections
|
||||||
|
for (int i = 0; i < MinActiveConnections; i++)
|
||||||
{
|
{
|
||||||
var client = CreateNewClient();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
await EnsureCapabilitiesAsync(client, true);
|
|
||||||
_clients.Push(client);
|
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
|
// Create dedicated IDLE client
|
||||||
_keepAliveTimer.Start();
|
_dedicatedIdleClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_connectionMonitorTimer.Start();
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.Error(ex, "Failed to pre-warm client pool");
|
_logger.Error(ex, "Failed to initialize IMAP client pool");
|
||||||
}
|
throw;
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensures all supported capabilities are enabled in this connection.
|
/// Pre-warms the pool (legacy compatibility method).
|
||||||
/// Reconnects and reauthenticates if necessary.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="isCreatedNew">Whether the client has been newly created.</param>
|
public Task PreWarmPoolAsync() => InitializeAsync(CancellationToken.None);
|
||||||
private async Task EnsureCapabilitiesAsync(IImapClient client, bool isCreatedNew)
|
|
||||||
|
/// <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);
|
// Try to get an available client from the channel
|
||||||
bool mustDoPostAuthIdentification = false;
|
if (_availableClients.Reader.TryRead(out var client))
|
||||||
|
|
||||||
if ((isCreatedNew || isReconnected) && client.IsConnected)
|
|
||||||
{
|
{
|
||||||
if (client.Capabilities.HasFlag(ImapCapabilities.Compress))
|
if (client != null && _clientStates.TryGetValue(client, out var state) && state == ImapClientState.Available)
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
try
|
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;
|
_logger.Warning(ex, "Client from pool was not ready, marking as failed");
|
||||||
}
|
_clientStates[client] = ImapClientState.Failed;
|
||||||
catch (Exception)
|
// Continue to try next client or create new one
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await EnsureAuthenticatedAsync(client);
|
// No available client, try to create a new one
|
||||||
|
var newClient = await CreateAndConnectClientAsync(cancellationToken).ConfigureAwait(false);
|
||||||
if ((isCreatedNew || isReconnected) && client.IsAuthenticated)
|
if (newClient != null)
|
||||||
{
|
{
|
||||||
if (mustDoPostAuthIdentification) await client.IdentifyAsync(_implementation);
|
_clientStates[newClient] = ImapClientState.InUse;
|
||||||
|
return newClient;
|
||||||
|
}
|
||||||
|
|
||||||
// Activate post-auth capabilities.
|
// Wait a bit before retrying
|
||||||
if (client.Capabilities.HasFlag(ImapCapabilities.QuickResync))
|
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);
|
_clientStates[_dedicatedIdleClient] = ImapClientState.Failed;
|
||||||
if (client is WinoImapClient winoImapClient) winoImapClient.IsQResyncEnabled = true;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (ex.InnerException is ImapTestSSLCertificateException imapTestSSLCertificateException)
|
_logger.Warning(ex, "Failed to create and connect new client");
|
||||||
throw imapTestSSLCertificateException;
|
DisposeClient(client);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw new ImapClientPoolException(ex, GetProtocolLogContent());
|
private async Task EnsureClientReadyAsync(WinoImapClient client, CancellationToken cancellationToken)
|
||||||
}
|
{
|
||||||
finally
|
// Connect if needed
|
||||||
|
if (!client.IsConnected)
|
||||||
{
|
{
|
||||||
// Release it even if it fails.
|
client.ServerCertificateValidationCallback = MyServerCertificateValidationCallback;
|
||||||
_semaphore.Release();
|
|
||||||
|
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()
|
public string GetProtocolLogContent()
|
||||||
{
|
{
|
||||||
if (_protocolLogStream == null) return default;
|
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)
|
if (_protocolLogStream.CanSeek)
|
||||||
_protocolLogStream.Seek(0, SeekOrigin.Begin);
|
_protocolLogStream.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
@@ -223,171 +575,12 @@ public class ImapClientPool : IDisposable
|
|||||||
return reader.ReadToEnd();
|
return reader.ReadToEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IImapClient> GetClientAsync()
|
// Legacy compatibility methods
|
||||||
{
|
public Task<bool> EnsureConnectedAsync(IImapClient client) =>
|
||||||
await _semaphore.WaitAsync();
|
Task.FromResult(client.IsConnected);
|
||||||
|
|
||||||
if (_clients.TryPop(out IImapClient item))
|
public Task EnsureAuthenticatedAsync(IImapClient client) =>
|
||||||
{
|
Task.CompletedTask;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual void Dispose(bool disposing)
|
protected virtual void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
@@ -395,22 +588,26 @@ public class ImapClientPool : IDisposable
|
|||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
{
|
{
|
||||||
_keepAliveTimer.Stop();
|
_maintenanceCts.Cancel();
|
||||||
_connectionMonitorTimer.Stop();
|
_maintenanceTask?.Wait(TimeSpan.FromSeconds(5));
|
||||||
|
_maintenanceCts.Dispose();
|
||||||
|
|
||||||
_keepAliveTimer.Dispose();
|
_availableClients.Writer.Complete();
|
||||||
_connectionMonitorTimer.Dispose();
|
|
||||||
|
|
||||||
_clients.ForEach(client =>
|
foreach (var kvp in _clientStates)
|
||||||
{
|
{
|
||||||
lock (client.SyncRoot)
|
DisposeClient(kvp.Key);
|
||||||
{
|
}
|
||||||
client.Disconnect(true);
|
_clientStates.Clear();
|
||||||
}
|
|
||||||
client.Dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
_clients.Clear();
|
lock (_idleClientLock)
|
||||||
|
{
|
||||||
|
if (_dedicatedIdleClient != null)
|
||||||
|
{
|
||||||
|
DisposeClient(_dedicatedIdleClient);
|
||||||
|
_dedicatedIdleClient = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposedValue = true;
|
_disposedValue = true;
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ public interface IImapChangeProcessor : IDefaultChangeProcessor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="folderId">Folder id to retrieve uIds for.</param>
|
/// <param name="folderId">Folder id to retrieve uIds for.</param>
|
||||||
Task<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId);
|
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,
|
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<IList<uint>> GetKnownUidsForFolderAsync(Guid folderId) => FolderService.GetKnownUidsForFolderAsync(folderId);
|
||||||
|
|
||||||
|
public Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
|
||||||
|
=> MailService.GetRecentMailIdsForFolderAsync(folderId, count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Wino.Core.Integration;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extended class for ImapClient that is used in Wino.
|
/// Extended class for ImapClient that is used in Wino.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class WinoImapClient : ImapClient
|
public class WinoImapClient : ImapClient
|
||||||
{
|
{
|
||||||
private int _busyCount;
|
private int _busyCount;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
using System.Threading.Tasks;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Synchronizers.Errors.Gmail;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
|
||||||
|
|
||||||
namespace Wino.Core.Services;
|
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 class GmailSynchronizerErrorHandlingFactory : SynchronizerErrorHandlingFactory, IGmailSynchronizerErrorHandlerFactory
|
||||||
{
|
{
|
||||||
public bool CanHandle(SynchronizerErrorContext error) => CanHandle(error);
|
public GmailSynchronizerErrorHandlingFactory(
|
||||||
|
GmailQuotaExceededHandler quotaExceededHandler,
|
||||||
public Task HandleAsync(SynchronizerErrorContext error) => HandleErrorAsync(error);
|
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 System.Threading.Tasks;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
using Wino.Core.Synchronizers.Errors.Outlook;
|
using Wino.Core.Synchronizers.Errors.Outlook;
|
||||||
|
|
||||||
namespace Wino.Core.Services;
|
namespace Wino.Core.Services;
|
||||||
|
|||||||
@@ -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 System.Threading.Tasks;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
|
|
||||||
namespace Wino.Core.Services;
|
namespace Wino.Core.Services;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using Wino.Core.Domain.Entities.Shared;
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Integration.Processors;
|
using Wino.Core.Integration.Processors;
|
||||||
|
using Wino.Core.Synchronizers.ImapSync;
|
||||||
using Wino.Core.Synchronizers.Mail;
|
using Wino.Core.Synchronizers.Mail;
|
||||||
|
|
||||||
namespace Wino.Core.Services;
|
namespace Wino.Core.Services;
|
||||||
@@ -17,10 +18,12 @@ public class SynchronizerFactory : ISynchronizerFactory
|
|||||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||||
private readonly IOutlookSynchronizerErrorHandlerFactory _outlookSynchronizerErrorHandlerFactory;
|
private readonly IOutlookSynchronizerErrorHandlerFactory _outlookSynchronizerErrorHandlerFactory;
|
||||||
private readonly IGmailSynchronizerErrorHandlerFactory _gmailSynchronizerErrorHandlerFactory;
|
private readonly IGmailSynchronizerErrorHandlerFactory _gmailSynchronizerErrorHandlerFactory;
|
||||||
|
private readonly IImapSynchronizerErrorHandlerFactory _imapSynchronizerErrorHandlerFactory;
|
||||||
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
|
private readonly IOutlookChangeProcessor _outlookChangeProcessor;
|
||||||
private readonly IGmailChangeProcessor _gmailChangeProcessor;
|
private readonly IGmailChangeProcessor _gmailChangeProcessor;
|
||||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||||
private readonly IAuthenticationProvider _authenticationProvider;
|
private readonly IAuthenticationProvider _authenticationProvider;
|
||||||
|
private readonly UnifiedImapSynchronizer _unifiedImapSynchronizer;
|
||||||
|
|
||||||
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
|
private readonly List<IWinoSynchronizerBase> synchronizerCache = new();
|
||||||
|
|
||||||
@@ -32,7 +35,9 @@ public class SynchronizerFactory : ISynchronizerFactory
|
|||||||
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
||||||
IApplicationConfiguration applicationConfiguration,
|
IApplicationConfiguration applicationConfiguration,
|
||||||
IOutlookSynchronizerErrorHandlerFactory outlookSynchronizerErrorHandlerFactory,
|
IOutlookSynchronizerErrorHandlerFactory outlookSynchronizerErrorHandlerFactory,
|
||||||
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory)
|
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory,
|
||||||
|
IImapSynchronizerErrorHandlerFactory imapSynchronizerErrorHandlerFactory,
|
||||||
|
UnifiedImapSynchronizer unifiedImapSynchronizer)
|
||||||
{
|
{
|
||||||
_outlookChangeProcessor = outlookChangeProcessor;
|
_outlookChangeProcessor = outlookChangeProcessor;
|
||||||
_gmailChangeProcessor = gmailChangeProcessor;
|
_gmailChangeProcessor = gmailChangeProcessor;
|
||||||
@@ -43,6 +48,8 @@ public class SynchronizerFactory : ISynchronizerFactory
|
|||||||
_applicationConfiguration = applicationConfiguration;
|
_applicationConfiguration = applicationConfiguration;
|
||||||
_outlookSynchronizerErrorHandlerFactory = outlookSynchronizerErrorHandlerFactory;
|
_outlookSynchronizerErrorHandlerFactory = outlookSynchronizerErrorHandlerFactory;
|
||||||
_gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory;
|
_gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory;
|
||||||
|
_imapSynchronizerErrorHandlerFactory = imapSynchronizerErrorHandlerFactory;
|
||||||
|
_unifiedImapSynchronizer = unifiedImapSynchronizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IWinoSynchronizerBase> GetAccountSynchronizerAsync(Guid accountId)
|
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;
|
var gmailAuthenticator = _authenticationProvider.GetAuthenticator(Domain.Enums.MailProviderType.Gmail) as IGmailAuthenticator;
|
||||||
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
return new GmailSynchronizer(mailAccount, gmailAuthenticator, _gmailChangeProcessor, _gmailSynchronizerErrorHandlerFactory);
|
||||||
case Domain.Enums.MailProviderType.IMAP4:
|
case Domain.Enums.MailProviderType.IMAP4:
|
||||||
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration);
|
return new ImapSynchronizer(mailAccount, _imapChangeProcessor, _imapSynchronizationStrategyProvider, _applicationConfiguration, _unifiedImapSynchronizer, _imapSynchronizerErrorHandlerFactory);
|
||||||
default:
|
default:
|
||||||
break;
|
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 Microsoft.Kiota.Abstractions;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
using Wino.Core.Integration.Processors;
|
using Wino.Core.Integration.Processors;
|
||||||
|
|
||||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Kiota.Abstractions;
|
using Microsoft.Kiota.Abstractions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
|
||||||
using Wino.Core.Domain.Models.Requests;
|
using Wino.Core.Domain.Models.Requests;
|
||||||
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
using Wino.Core.Requests.Bundles;
|
using Wino.Core.Requests.Bundles;
|
||||||
|
|
||||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ using System.Threading.Tasks;
|
|||||||
using System.Web;
|
using System.Web;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Google;
|
using Google;
|
||||||
using Google.Apis.Calendar.v3;
|
|
||||||
using Google.Apis.Calendar.v3.Data;
|
using Google.Apis.Calendar.v3.Data;
|
||||||
using Google.Apis.Gmail.v1;
|
using Google.Apis.Gmail.v1;
|
||||||
using Google.Apis.Gmail.v1.Data;
|
using Google.Apis.Gmail.v1.Data;
|
||||||
@@ -28,7 +27,6 @@ using Wino.Core.Domain.Enums;
|
|||||||
using Wino.Core.Domain.Exceptions;
|
using Wino.Core.Domain.Exceptions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Accounts;
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
|
||||||
using Wino.Core.Domain.Models.Folders;
|
using Wino.Core.Domain.Models.Folders;
|
||||||
using Wino.Core.Domain.Models.MailItem;
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
@@ -51,19 +49,22 @@ namespace Wino.Core.Synchronizers.Mail;
|
|||||||
public partial class GmailSynchronizerJsonContext : JsonSerializerContext;
|
public partial class GmailSynchronizerJsonContext : JsonSerializerContext;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gmail synchronizer implementation with per-folder history ID synchronization.
|
/// Gmail synchronizer implementation using Gmail History API for efficient incremental sync.
|
||||||
///
|
///
|
||||||
/// SYNCHRONIZATION STRATEGY:
|
/// SYNCHRONIZATION STRATEGY:
|
||||||
/// - Uses Gmail History API for both initial and incremental sync
|
/// - Initial sync: Downloads up to 1500 messages PER FOLDER with metadata only.
|
||||||
/// - Initial sync: Downloads top 1500 messages per folder with metadata only
|
/// Uses a global HashSet to track downloaded message IDs, avoiding duplicate downloads
|
||||||
/// - Incremental sync: Uses history ID to get only changes since last sync
|
/// when messages have multiple labels. Each folder gets its full quota of messages.
|
||||||
/// - Messages are downloaded with metadata only (no MIME content during sync)
|
/// - Incremental sync: Uses ONLY History API to get changes since last sync.
|
||||||
/// - MIME files are downloaded on-demand when user explicitly reads a message
|
/// 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:
|
/// Key implementation details:
|
||||||
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
|
/// - PerformInitialSyncAsync: Downloads messages per-folder with global deduplication
|
||||||
/// - DownloadMessagesForFolderAsync: Downloads top 1500 messages for initial sync
|
/// - SynchronizeDeltaAsync: Processes incremental changes using History API with pagination
|
||||||
/// - SynchronizeDeltaAsync: Processes incremental changes using history ID
|
/// - Handles 404/410 errors (history expired) by triggering full resync
|
||||||
/// - CreateMinimalMailCopyAsync: Extracts MailCopy fields from Gmail Metadata format
|
/// - CreateMinimalMailCopyAsync: Extracts MailCopy fields from Gmail Metadata format
|
||||||
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
|
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -153,15 +154,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
{
|
{
|
||||||
_logger.Information("Internal mail synchronization started for {Name}", Account.Name);
|
_logger.Information("Internal mail synchronization started for {Name}", Account.Name);
|
||||||
|
|
||||||
// Make sure that virtual archive folder exists before all.
|
var downloadedMessageIds = new List<string>();
|
||||||
if (!archiveFolderId.HasValue)
|
var folderResults = new List<FolderSyncResult>();
|
||||||
await InitializeArchiveFolderAsync().ConfigureAwait(false);
|
|
||||||
|
|
||||||
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
|
try
|
||||||
bool shouldSynchronizeFolders = true;
|
|
||||||
|
|
||||||
if (shouldSynchronizeFolders)
|
|
||||||
{
|
{
|
||||||
|
// 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);
|
_logger.Information("Synchronizing folders for {Name}", Account.Name);
|
||||||
UpdateSyncProgress(0, 0, "Synchronizing folders...");
|
UpdateSyncProgress(0, 0, "Synchronizing folders...");
|
||||||
|
|
||||||
@@ -173,202 +175,291 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
{
|
{
|
||||||
throw new GmailServiceDisabledException();
|
throw new GmailServiceDisabledException();
|
||||||
}
|
}
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
|
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
|
||||||
UpdateSyncProgress(0, 0, "Folders synchronized");
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
// 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++)
|
|
||||||
{
|
{
|
||||||
var folder = synchronizationFolders[i];
|
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
|
||||||
|
return MailSynchronizationResult.Canceled;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
// Process incremental changes using history API if we have a history ID
|
|
||||||
if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
|
|
||||||
{
|
{
|
||||||
UpdateSyncProgress(0, 0, "Synchronizing changes...");
|
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
|
||||||
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
|
return MailSynchronizationResult.Failed(ex);
|
||||||
UpdateSyncProgress(0, 0, "Changes synchronized");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all unread new downloaded items for notifications
|
// Get all unread new downloaded items for notifications
|
||||||
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
||||||
|
|
||||||
return MailSynchronizationResult.Completed(unreadNewItems);
|
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Synchronizes a single folder by downloading top 1500 messages with metadata only.
|
/// Result of delta synchronization using History API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<List<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
|
private record DeltaSyncResult(List<string> DownloadedMessageIds, bool RequiresFullResync);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
var totalDownloaded = 0;
|
// Get all folders to sync (exclude virtual ARCHIVE folder)
|
||||||
string pageToken = null;
|
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
|
var totalFolders = syncableFolders.Count;
|
||||||
// We'll download up to 1500 messages per folder
|
var totalMessagesDownloaded = 0;
|
||||||
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
|
|
||||||
|
|
||||||
do
|
for (int i = 0; i < totalFolders; i++)
|
||||||
{
|
{
|
||||||
|
var folder = syncableFolders[i];
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var request = _gmailService.Users.Messages.List("me");
|
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||||
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
|
|
||||||
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
|
|
||||||
request.PageToken = pageToken;
|
|
||||||
|
|
||||||
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
|
var request = _gmailService.Users.Messages.List("me");
|
||||||
await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
|
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);
|
var response = await request.ExecuteAsync(cancellationToken);
|
||||||
totalDownloaded += messageIds.Count;
|
|
||||||
remainingToDownload -= messageIds.Count;
|
|
||||||
|
|
||||||
_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
|
if (newMessageIds.Count > 0)
|
||||||
UpdateSyncProgress(0, 0, $"Downloaded {totalDownloaded} messages from {folder.FolderName}");
|
{
|
||||||
}
|
// 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
|
folderDownloaded += newMessageIds.Count;
|
||||||
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
|
totalMessagesDownloaded += newMessageIds.Count;
|
||||||
break;
|
}
|
||||||
|
|
||||||
} while (!string.IsNullOrEmpty(pageToken));
|
// Count all messages (including duplicates) toward the folder limit
|
||||||
|
remainingToDownload -= response.Messages.Count;
|
||||||
|
|
||||||
// Store history ID for future incremental syncs
|
_logger.Debug("Folder {FolderName}: Downloaded {New} new messages ({Total} total in folder)",
|
||||||
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
|
folder.FolderName, newMessageIds.Count, folderDownloaded);
|
||||||
Account.SynchronizationDeltaIdentifier = profile.HistoryId.ToString();
|
}
|
||||||
await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
|
|
||||||
|
|
||||||
_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)
|
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);
|
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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;
|
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
|
try
|
||||||
{
|
{
|
||||||
var historyRequest = _gmailService.Users.History.List("me");
|
string pageToken = null;
|
||||||
historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
|
|
||||||
|
|
||||||
var historyResponse = await historyRequest.ExecuteAsync();
|
do
|
||||||
|
|
||||||
if (historyResponse.History != null)
|
|
||||||
{
|
{
|
||||||
var addedMessageIds = new List<string>();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
// Collect all added messages first
|
var historyRequest = _gmailService.Users.History.List("me");
|
||||||
foreach (var historyRecord in historyResponse.History)
|
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
|
// Process added messages in batches if any
|
||||||
// During delta sync, download with Raw format to get MIME content
|
// During delta sync, download with Raw format to get MIME content for new messages
|
||||||
if (addedMessageIds.Count != 0)
|
if (addedMessageIds.Count != 0)
|
||||||
{
|
{
|
||||||
await DownloadMessagesInBatchAsync(addedMessageIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
|
// Deduplicate message IDs
|
||||||
}
|
var uniqueAddedIds = addedMessageIds.Distinct().ToList();
|
||||||
|
await DownloadMessagesInBatchAsync(uniqueAddedIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
|
||||||
|
downloadedMessageIds.AddRange(uniqueAddedIds);
|
||||||
|
}
|
||||||
|
|
||||||
// Process other history changes
|
// Process other history changes (label changes, deletions)
|
||||||
foreach (var historyRecord in historyResponse.History)
|
|
||||||
{
|
|
||||||
await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
|
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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -802,6 +893,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
|
|
||||||
foreach (var labelId in addedLabel.LabelIds)
|
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.
|
// When UNREAD label is added mark the message as un-read.
|
||||||
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
||||||
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
|
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
|
||||||
@@ -822,6 +920,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
|
|
||||||
foreach (var labelId in removedLabel.LabelIds)
|
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.
|
// When UNREAD label is removed mark the message as read.
|
||||||
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
||||||
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
|
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
|
||||||
@@ -1160,15 +1265,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
|
|
||||||
if (packages != null)
|
if (packages != null)
|
||||||
{
|
{
|
||||||
// For Gmail, multiple packages can share the same message (different labels/folders)
|
// For Gmail, multiple packages share the same message (different labels/folders)
|
||||||
// They should all share the same FileId so MIME is stored only once
|
// They already share the same FileId (set in CreateNewMailPackagesAsync) so MIME is stored only once
|
||||||
Guid sharedFileId = Guid.NewGuid();
|
|
||||||
|
|
||||||
foreach (var package in packages)
|
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)
|
// Create the mail copy with the MIME (if downloaded)
|
||||||
var packageWithMime = downloadRawMime && mimeMessage != null
|
var packageWithMime = downloadRawMime && mimeMessage != null
|
||||||
? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
|
? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
|
||||||
@@ -1683,11 +1784,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
{
|
{
|
||||||
var packageList = new List<NewMailItemPackage>();
|
var packageList = new List<NewMailItemPackage>();
|
||||||
|
|
||||||
// Create MailCopy from metadata only - NO MIME download
|
// Create base MailCopy from metadata only - NO MIME download
|
||||||
var mailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||||
|
|
||||||
// Check for local draft mapping using X-Wino-Draft-Id header from metadata
|
// 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;
|
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.
|
// 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.
|
// 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;
|
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)
|
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)
|
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
|
// 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ using Wino.Core.Integration.Processors;
|
|||||||
using Wino.Core.Requests.Bundles;
|
using Wino.Core.Requests.Bundles;
|
||||||
using Wino.Core.Requests.Folder;
|
using Wino.Core.Requests.Folder;
|
||||||
using Wino.Core.Requests.Mail;
|
using Wino.Core.Requests.Mail;
|
||||||
|
using Wino.Core.Synchronizers.ImapSync;
|
||||||
using Wino.Messaging.Server;
|
using Wino.Messaging.Server;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
using Wino.Services.Extensions;
|
using Wino.Services.Extensions;
|
||||||
@@ -52,16 +53,22 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
|||||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||||
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
|
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
|
||||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||||
|
private readonly UnifiedImapSynchronizer _unifiedSynchronizer;
|
||||||
|
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
||||||
|
|
||||||
public ImapSynchronizer(MailAccount account,
|
public ImapSynchronizer(MailAccount account,
|
||||||
IImapChangeProcessor imapChangeProcessor,
|
IImapChangeProcessor imapChangeProcessor,
|
||||||
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
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.
|
// Create client pool with account protocol log.
|
||||||
_imapChangeProcessor = imapChangeProcessor;
|
_imapChangeProcessor = imapChangeProcessor;
|
||||||
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
|
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
|
||||||
_applicationConfiguration = applicationConfiguration;
|
_applicationConfiguration = applicationConfiguration;
|
||||||
|
_unifiedSynchronizer = unifiedSynchronizer;
|
||||||
|
_errorHandlerFactory = errorHandlerFactory;
|
||||||
|
|
||||||
var protocolLogStream = CreateAccountProtocolLogFileStream();
|
var protocolLogStream = CreateAccountProtocolLogFileStream();
|
||||||
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
|
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)
|
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var downloadedMessageIds = new List<string>();
|
var downloadedMessageIds = new List<string>();
|
||||||
|
var folderResults = new List<FolderSyncResult>();
|
||||||
|
|
||||||
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
||||||
_logger.Information("Options: {Options}", options);
|
_logger.Information("Options: {Options}", options);
|
||||||
|
|
||||||
// Set indeterminate progress initially
|
try
|
||||||
UpdateSyncProgress(0, 0, "Synchronizing...");
|
|
||||||
|
|
||||||
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
|
|
||||||
|
|
||||||
if (shouldDoFolderSync)
|
|
||||||
{
|
{
|
||||||
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
|
// Set indeterminate progress initially
|
||||||
}
|
UpdateSyncProgress(0, 0, "Synchronizing...");
|
||||||
|
|
||||||
if (options.Type != MailSynchronizationType.FoldersOnly)
|
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
|
||||||
{
|
|
||||||
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var totalFolders = synchronizationFolders.Count;
|
if (shouldDoFolderSync)
|
||||||
|
|
||||||
for (int i = 0; i < totalFolders; i++)
|
|
||||||
{
|
{
|
||||||
var folder = synchronizationFolders[i];
|
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Update progress based on folder completion
|
if (options.Type != MailSynchronizationType.FoldersOnly)
|
||||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
{
|
||||||
|
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;
|
for (int i = 0; i < totalFolders; i++)
|
||||||
|
|
||||||
if (folderDownloadedMessageIds != null)
|
|
||||||
{
|
{
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
// Reset progress
|
{
|
||||||
ResetSyncProgress();
|
_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.
|
// Get all unread new downloaded items and return in the result.
|
||||||
// This is primarily used in notifications.
|
// This is primarily used in notifications.
|
||||||
|
|
||||||
var unreadNewItems = await _imapChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
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)
|
public override async Task ExecuteNativeRequestsAsync(List<IRequestBundle<ImapRequest>> batchedRequests, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ using Wino.Core.Domain.Enums;
|
|||||||
using Wino.Core.Domain.Exceptions;
|
using Wino.Core.Domain.Exceptions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Core.Domain.Models.Accounts;
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
using Wino.Core.Domain.Models.Errors;
|
|
||||||
using Wino.Core.Domain.Models.Folders;
|
using Wino.Core.Domain.Models.Folders;
|
||||||
using Wino.Core.Domain.Models.MailItem;
|
using Wino.Core.Domain.Models.MailItem;
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
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)
|
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var downloadedMessageIds = new List<string>();
|
var downloadedMessageIds = new List<string>();
|
||||||
|
var folderResults = new List<FolderSyncResult>();
|
||||||
|
|
||||||
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
||||||
_logger.Information("Options: {Options}", options);
|
_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);
|
var statusMessage = string.Format(Translator.Sync_SynchronizingFolder, folder.FolderName, progressPercentage);
|
||||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), statusMessage);
|
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), statusMessage);
|
||||||
|
|
||||||
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
try
|
||||||
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
|
{
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.Error(ex, "Synchronizing folders for {Name}", Account.Name);
|
_logger.Error(ex, "Synchronizing folders for {Name}", Account.Name);
|
||||||
Debugger.Break();
|
return MailSynchronizationResult.Failed(ex);
|
||||||
|
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -187,12 +247,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
ResetSyncProgress();
|
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.
|
// This is primarily used in notifications.
|
||||||
|
|
||||||
var unreadNewItems = await _outlookChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
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)
|
public async Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -39,4 +39,8 @@
|
|||||||
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
|
<ProjectReference Include="..\Wino.Messages\Wino.Messaging.csproj" />
|
||||||
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
|
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Domain\Models\Errors\" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -670,7 +670,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
|
|||||||
|
|
||||||
await MailCollection.UpdateMailCopy(updatedMail, source);
|
await MailCollection.UpdateMailCopy(updatedMail, source);
|
||||||
|
|
||||||
await ExecuteUIThread(() => { SetupTopBarActions(); });
|
// await ExecuteUIThread(() => { SetupTopBarActions(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async void OnMailRemoved(MailCopy removedMail)
|
protected override async void OnMailRemoved(MailCopy removedMail)
|
||||||
|
|||||||
@@ -599,4 +599,24 @@ public class AccountService : BaseDatabaseService, IAccountService
|
|||||||
|
|
||||||
return account?.Preferences?.IsNotificationsEnabled ?? false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1124,6 +1124,18 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
return new GmailArchiveComparisonResult(addedMails, removedMails);
|
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)
|
public async Task<List<MailCopy>> GetMailItemsAsync(IEnumerable<string> mailCopyIds)
|
||||||
{
|
{
|
||||||
if (!mailCopyIds.Any()) return [];
|
if (!mailCopyIds.Any()) return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user