Refactored all synchronizers to deal with some of the chronic issues.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail history ID expiration errors.
|
||||
/// When history is no longer available, resets the account's history ID to force a full resync.
|
||||
/// </summary>
|
||||
public class GmailHistoryExpiredHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailHistoryExpiredHandler>();
|
||||
private readonly IGmailChangeProcessor _gmailChangeProcessor;
|
||||
|
||||
public GmailHistoryExpiredHandler(IGmailChangeProcessor gmailChangeProcessor)
|
||||
{
|
||||
_gmailChangeProcessor = gmailChangeProcessor;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
// Gmail returns 404 when history ID is no longer valid
|
||||
if (error.ErrorCode == 404)
|
||||
{
|
||||
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
|
||||
return message.Contains("history") || message.Contains("notfound");
|
||||
}
|
||||
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
return errorMessage.Contains("history") ||
|
||||
errorMessage.Contains("not found") ||
|
||||
errorMessage.Contains("starthistoryid");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail history ID expired for account {AccountName} ({AccountId}). Resetting to force full sync.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.Recoverable;
|
||||
error.Category = SynchronizerErrorCategory.ResourceNotFound;
|
||||
|
||||
// Reset the account's synchronization identifier (history ID)
|
||||
if (error.Account != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(
|
||||
error.Account.Id, string.Empty).ConfigureAwait(false);
|
||||
|
||||
_logger.Information("Successfully reset Gmail history ID for account {AccountName}. Next sync will be full sync.",
|
||||
error.Account.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Failed to reset Gmail history ID for account {AccountName}",
|
||||
error.Account.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail API quota exceeded errors (HTTP 403 with quota error).
|
||||
/// This is a more severe rate limit that indicates daily quota exhaustion.
|
||||
/// </summary>
|
||||
public class GmailQuotaExceededHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailQuotaExceededHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
// Quota exceeded usually returns 403
|
||||
if (googleEx.HttpStatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
var errorMessage = googleEx.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
var errorReason = googleEx.Error?.Errors?.FirstOrDefault()?.Reason?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
return errorMessage.Contains("quota") ||
|
||||
errorMessage.Contains("limit exceeded") ||
|
||||
errorReason.Contains("quota") ||
|
||||
errorReason.Contains("ratelimitexceeded") ||
|
||||
errorReason.Contains("userlimitexceeded");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail API quota exceeded for account {AccountName} ({AccountId}). Sync will be paused.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
// Quota exceeded is more severe - treat as fatal to prevent repeated failures
|
||||
// The user will be notified and sync will resume after quota resets
|
||||
error.Severity = SynchronizerErrorSeverity.Fatal;
|
||||
error.Category = SynchronizerErrorCategory.RateLimit;
|
||||
|
||||
// Suggest a very long delay - quotas typically reset daily
|
||||
error.RetryDelay = TimeSpan.FromHours(1);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Google;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles Gmail API rate limiting errors (HTTP 429 Too Many Requests).
|
||||
/// Marks the error as transient with appropriate backoff delay.
|
||||
/// </summary>
|
||||
public class GmailRateLimitHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<GmailRateLimitHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
if (error.ErrorCode == 429)
|
||||
return true;
|
||||
|
||||
if (error.Exception is GoogleApiException googleEx)
|
||||
{
|
||||
return googleEx.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests ||
|
||||
(googleEx.Error?.Code == 429);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"Gmail API rate limit hit for account {AccountName} ({AccountId}). Operation: {Operation}. Will retry with backoff.",
|
||||
error.Account?.Name, error.Account?.Id, error.OperationType ?? "N/A");
|
||||
|
||||
error.Severity = SynchronizerErrorSeverity.Transient;
|
||||
error.Category = SynchronizerErrorCategory.RateLimit;
|
||||
|
||||
// Gmail rate limits are usually per-user, suggest a longer delay
|
||||
error.RetryDelay = TimeSpan.FromSeconds(10);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Security;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP authentication failures (AuthenticationException, SaslException).
|
||||
/// Marks the error as requiring re-authentication.
|
||||
/// </summary>
|
||||
public class ImapAuthenticationFailedHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapAuthenticationFailedHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is AuthenticationException ||
|
||||
error.Exception is SaslException ||
|
||||
(error.ErrorMessage?.Contains("authentication", System.StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP authentication failed for account {AccountName} ({AccountId}). User needs to re-authenticate.",
|
||||
error.Account?.Name, error.Account?.Id);
|
||||
|
||||
// Mark as requiring authentication - this will stop sync and notify user
|
||||
error.Severity = SynchronizerErrorSeverity.AuthRequired;
|
||||
error.Category = SynchronizerErrorCategory.Authentication;
|
||||
|
||||
// No point in retrying auth failures - credentials need to be updated
|
||||
error.RetryDelay = null;
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP connection loss errors (IOException, SocketException, ServiceNotConnectedException).
|
||||
/// Marks the error as transient for retry with backoff.
|
||||
/// </summary>
|
||||
public class ImapConnectionLostHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapConnectionLostHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is IOException ||
|
||||
error.Exception is SocketException ||
|
||||
error.Exception is ServiceNotConnectedException ||
|
||||
error.Exception?.InnerException is IOException ||
|
||||
error.Exception?.InnerException is SocketException;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP connection lost for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Will retry.",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A");
|
||||
|
||||
// Mark as transient - the RetryExecutor will handle the retry logic
|
||||
error.Severity = SynchronizerErrorSeverity.Transient;
|
||||
error.Category = SynchronizerErrorCategory.Network;
|
||||
|
||||
// Suggest a reasonable retry delay for connection issues
|
||||
error.RetryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles IMAP folder not found errors (FolderNotFoundException).
|
||||
/// Deletes the folder locally and allows sync to continue with other folders.
|
||||
/// </summary>
|
||||
public class ImapFolderNotFoundHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapFolderNotFoundHandler>();
|
||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||
|
||||
public ImapFolderNotFoundHandler(IImapChangeProcessor imapChangeProcessor)
|
||||
{
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
}
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
return error.Exception is FolderNotFoundException ||
|
||||
error.ErrorCode == 404 ||
|
||||
(error.ErrorMessage?.Contains("folder not found", System.StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(error.ErrorMessage?.Contains("mailbox not found", System.StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
public async Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP folder not found for account {AccountName} ({AccountId}). Folder: {FolderName} ({FolderId}). Removing locally.",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName, error.FolderId);
|
||||
|
||||
// Mark as recoverable - sync can continue with other folders
|
||||
error.Severity = SynchronizerErrorSeverity.Recoverable;
|
||||
error.Category = SynchronizerErrorCategory.ResourceNotFound;
|
||||
|
||||
// Try to delete the folder locally if we have the folder ID
|
||||
if (error.FolderId.HasValue && error.Account != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the folder's remote ID from the exception if available
|
||||
var remoteId = error.Exception is FolderNotFoundException fnf ? fnf.FolderName : null;
|
||||
|
||||
if (!string.IsNullOrEmpty(remoteId))
|
||||
{
|
||||
await _imapChangeProcessor.DeleteFolderAsync(error.Account.Id, remoteId).ConfigureAwait(false);
|
||||
_logger.Information("Successfully deleted local folder {FolderName} after server deletion.",
|
||||
error.FolderName);
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to delete local folder {FolderName} ({FolderId})",
|
||||
error.FolderName, error.FolderId);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit.Net.Imap;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Imap;
|
||||
|
||||
/// <summary>
|
||||
/// Handles generic IMAP protocol errors (ImapProtocolException, ImapCommandException).
|
||||
/// This is the catch-all handler for IMAP errors not handled by more specific handlers.
|
||||
/// </summary>
|
||||
public class ImapProtocolErrorHandler : ISynchronizerErrorHandler
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<ImapProtocolErrorHandler>();
|
||||
|
||||
public bool CanHandle(SynchronizerErrorContext error)
|
||||
{
|
||||
// This is a catch-all for IMAP-related exceptions
|
||||
return error.Exception is ImapProtocolException ||
|
||||
error.Exception is ImapCommandException;
|
||||
}
|
||||
|
||||
public Task<bool> HandleAsync(SynchronizerErrorContext error)
|
||||
{
|
||||
var severity = ClassifyProtocolError(error);
|
||||
var category = SynchronizerErrorCategory.ProtocolError;
|
||||
|
||||
_logger.Warning(error.Exception,
|
||||
"IMAP protocol error for account {AccountName} ({AccountId}). Folder: {FolderName}. Operation: {Operation}. Severity: {Severity}",
|
||||
error.Account?.Name, error.Account?.Id, error.FolderName ?? "N/A", error.OperationType ?? "N/A", severity);
|
||||
|
||||
error.Severity = severity;
|
||||
error.Category = category;
|
||||
|
||||
// For transient protocol errors, suggest a retry delay
|
||||
if (severity == SynchronizerErrorSeverity.Transient)
|
||||
{
|
||||
error.RetryDelay = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies the protocol error to determine if it's transient, recoverable, or fatal.
|
||||
/// </summary>
|
||||
private static SynchronizerErrorSeverity ClassifyProtocolError(SynchronizerErrorContext error)
|
||||
{
|
||||
var message = error.ErrorMessage?.ToLowerInvariant() ?? string.Empty;
|
||||
var exMessage = error.Exception?.Message?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// Check for rate limiting / throttling
|
||||
if (message.Contains("too many") || message.Contains("rate limit") ||
|
||||
message.Contains("throttl") || exMessage.Contains("too many"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Check for temporary server issues
|
||||
if (message.Contains("try again") || message.Contains("temporary") ||
|
||||
message.Contains("busy") || exMessage.Contains("try again"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Check for command-specific errors that are usually transient
|
||||
if (error.Exception is ImapCommandException cmdEx)
|
||||
{
|
||||
// NO response usually means the operation failed but can be retried
|
||||
if (cmdEx.Response == ImapCommandResponse.No)
|
||||
{
|
||||
// Unless it's a permanent failure indication
|
||||
if (message.Contains("permanent") || message.Contains("invalid"))
|
||||
{
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// BAD response usually indicates a protocol violation - don't retry
|
||||
if (cmdEx.Response == ImapCommandResponse.Bad)
|
||||
{
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol exceptions that indicate connection issues
|
||||
if (error.Exception is ImapProtocolException)
|
||||
{
|
||||
// Most protocol exceptions are connection-related and transient
|
||||
return SynchronizerErrorSeverity.Transient;
|
||||
}
|
||||
|
||||
// Default to recoverable for unknown protocol errors
|
||||
return SynchronizerErrorSeverity.Recoverable;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Graph.Models.ODataErrors;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration.Processors;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
|
||||
namespace Wino.Core.Synchronizers.Errors.Outlook;
|
||||
|
||||
@@ -8,7 +8,6 @@ using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Google;
|
||||
using Google.Apis.Calendar.v3;
|
||||
using Google.Apis.Calendar.v3.Data;
|
||||
using Google.Apis.Gmail.v1;
|
||||
using Google.Apis.Gmail.v1.Data;
|
||||
@@ -28,7 +27,6 @@ using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
@@ -51,19 +49,22 @@ namespace Wino.Core.Synchronizers.Mail;
|
||||
public partial class GmailSynchronizerJsonContext : JsonSerializerContext;
|
||||
|
||||
/// <summary>
|
||||
/// Gmail synchronizer implementation with per-folder history ID synchronization.
|
||||
///
|
||||
/// Gmail synchronizer implementation using Gmail History API for efficient incremental sync.
|
||||
///
|
||||
/// SYNCHRONIZATION STRATEGY:
|
||||
/// - Uses Gmail History API for both initial and incremental sync
|
||||
/// - Initial sync: Downloads top 1500 messages per folder with metadata only
|
||||
/// - Incremental sync: Uses history ID to get only changes since last sync
|
||||
/// - Messages are downloaded with metadata only (no MIME content during sync)
|
||||
/// - MIME files are downloaded on-demand when user explicitly reads a message
|
||||
///
|
||||
/// - Initial sync: Downloads up to 1500 messages PER FOLDER with metadata only.
|
||||
/// Uses a global HashSet to track downloaded message IDs, avoiding duplicate downloads
|
||||
/// when messages have multiple labels. Each folder gets its full quota of messages.
|
||||
/// - Incremental sync: Uses ONLY History API to get changes since last sync.
|
||||
/// No per-folder downloads during incremental sync - this is the proper Gmail sync approach.
|
||||
/// - Messages are downloaded with metadata only during initial sync (no MIME content)
|
||||
/// - New messages during incremental sync are downloaded with full MIME content
|
||||
/// - MIME files for initial sync messages are downloaded on-demand when user reads a message
|
||||
///
|
||||
/// Key implementation details:
|
||||
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
|
||||
/// - DownloadMessagesForFolderAsync: Downloads top 1500 messages for initial sync
|
||||
/// - SynchronizeDeltaAsync: Processes incremental changes using history ID
|
||||
/// - PerformInitialSyncAsync: Downloads messages per-folder with global deduplication
|
||||
/// - SynchronizeDeltaAsync: Processes incremental changes using History API with pagination
|
||||
/// - Handles 404/410 errors (history expired) by triggering full resync
|
||||
/// - CreateMinimalMailCopyAsync: Extracts MailCopy fields from Gmail Metadata format
|
||||
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
|
||||
/// </summary>
|
||||
@@ -153,15 +154,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
{
|
||||
_logger.Information("Internal mail synchronization started for {Name}", Account.Name);
|
||||
|
||||
// Make sure that virtual archive folder exists before all.
|
||||
if (!archiveFolderId.HasValue)
|
||||
await InitializeArchiveFolderAsync().ConfigureAwait(false);
|
||||
var downloadedMessageIds = new List<string>();
|
||||
var folderResults = new List<FolderSyncResult>();
|
||||
|
||||
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
|
||||
bool shouldSynchronizeFolders = true;
|
||||
|
||||
if (shouldSynchronizeFolders)
|
||||
try
|
||||
{
|
||||
// Make sure that virtual archive folder exists before all.
|
||||
if (!archiveFolderId.HasValue)
|
||||
await InitializeArchiveFolderAsync().ConfigureAwait(false);
|
||||
|
||||
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
|
||||
_logger.Information("Synchronizing folders for {Name}", Account.Name);
|
||||
UpdateSyncProgress(0, 0, "Synchronizing folders...");
|
||||
|
||||
@@ -173,202 +175,291 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
{
|
||||
throw new GmailServiceDisabledException();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
|
||||
UpdateSyncProgress(0, 0, "Folders synchronized");
|
||||
|
||||
// Stop synchronization at this point if type is only folder metadata sync.
|
||||
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
|
||||
|
||||
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
|
||||
|
||||
if (isInitialSync)
|
||||
{
|
||||
// INITIAL SYNC: Download all messages globally (not per-folder) to avoid duplicates.
|
||||
// Gmail messages can have multiple labels, so per-folder download would fetch same message multiple times.
|
||||
downloadedMessageIds = await PerformInitialSyncAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Set the history ID to the latest value after initial sync
|
||||
UpdateSyncProgress(0, 0, "Finalizing synchronization...");
|
||||
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
|
||||
if (profile.HistoryId.HasValue)
|
||||
{
|
||||
await UpdateAccountSyncIdentifierAsync(profile.HistoryId.Value).ConfigureAwait(false);
|
||||
_logger.Information("Initial sync completed. Set history ID to {HistoryId}", profile.HistoryId.Value);
|
||||
}
|
||||
|
||||
// Create successful folder results for all folders
|
||||
var allFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
||||
foreach (var folder in allFolders.Where(f => f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID))
|
||||
{
|
||||
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, 0));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// INCREMENTAL SYNC: Use ONLY History API - no per-folder downloads.
|
||||
// This is the proper Gmail sync strategy as recommended by Google.
|
||||
UpdateSyncProgress(0, 0, "Synchronizing changes...");
|
||||
var deltaResult = await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
downloadedMessageIds.AddRange(deltaResult.DownloadedMessageIds);
|
||||
|
||||
// If history sync was reset due to expired history ID, we need to do initial sync
|
||||
if (deltaResult.RequiresFullResync)
|
||||
{
|
||||
_logger.Warning("History ID expired. Performing full resync for {Name}", Account.Name);
|
||||
downloadedMessageIds = await PerformInitialSyncAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update history ID after full resync
|
||||
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
|
||||
if (profile.HistoryId.HasValue)
|
||||
{
|
||||
await UpdateAccountSyncIdentifierAsync(profile.HistoryId.Value).ConfigureAwait(false);
|
||||
_logger.Information("Full resync completed. Set history ID to {HistoryId}", profile.HistoryId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSyncProgress(0, 0, "Changes synchronized");
|
||||
|
||||
// Create folder results for incremental sync
|
||||
var allFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
||||
foreach (var folder in allFolders.Where(f => f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID))
|
||||
{
|
||||
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There is no specific folder synchronization in Gmail.
|
||||
// Therefore we need to stop the synchronization at this point
|
||||
// if type is only folder metadata sync.
|
||||
|
||||
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
|
||||
|
||||
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
|
||||
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
// Get all folders to synchronize
|
||||
var synchronizationFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
||||
|
||||
_logger.Information("Synchronizing {Count} folders for {Name}", synchronizationFolders.Count, Account.Name);
|
||||
|
||||
var totalFolders = synchronizationFolders.Count;
|
||||
|
||||
for (int i = 0; i < totalFolders; i++)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
var folder = synchronizationFolders[i];
|
||||
|
||||
// Update progress based on folder completion
|
||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||
|
||||
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
|
||||
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
|
||||
return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
|
||||
// Process incremental changes using history API if we have a history ID
|
||||
if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
|
||||
catch (Exception ex)
|
||||
{
|
||||
UpdateSyncProgress(0, 0, "Synchronizing changes...");
|
||||
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
UpdateSyncProgress(0, 0, "Changes synchronized");
|
||||
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
|
||||
return MailSynchronizationResult.Failed(ex);
|
||||
}
|
||||
|
||||
// Get all unread new downloaded items for notifications
|
||||
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
||||
|
||||
return MailSynchronizationResult.Completed(unreadNewItems);
|
||||
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes a single folder by downloading top 1500 messages with metadata only.
|
||||
/// Result of delta synchronization using History API.
|
||||
/// </summary>
|
||||
private async Task<List<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.Debug("Synchronizing folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
|
||||
|
||||
try
|
||||
{
|
||||
// Download top 1500 messages for this folder
|
||||
await DownloadMessagesForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (downloadedMessageIds.Any())
|
||||
{
|
||||
_logger.Information("Downloaded {Count} messages for folder {FolderName}", downloadedMessageIds.Count, folder.FolderName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Error synchronizing folder {FolderName}", folder.FolderName);
|
||||
throw;
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
private record DeltaSyncResult(List<string> DownloadedMessageIds, bool RequiresFullResync);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads top 1500 messages for a folder using Gmail API with metadata only.
|
||||
/// Performs initial synchronization by downloading messages per-folder.
|
||||
/// Each folder gets up to 1500 messages, but we track already downloaded message IDs globally
|
||||
/// to avoid downloading the same message multiple times (Gmail messages can have multiple labels).
|
||||
/// </summary>
|
||||
private async Task DownloadMessagesForFolderAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
|
||||
private async Task<List<string>> PerformInitialSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Debug("Downloading messages for folder {FolderName}", folder.FolderName);
|
||||
// Track all downloaded message IDs globally to avoid duplicate downloads
|
||||
var downloadedMessageIds = new HashSet<string>();
|
||||
|
||||
_logger.Information("Performing initial sync for {Name} - downloading messages per folder", Account.Name);
|
||||
|
||||
try
|
||||
{
|
||||
var totalDownloaded = 0;
|
||||
string pageToken = null;
|
||||
// Get all folders to sync (exclude virtual ARCHIVE folder)
|
||||
var folders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
|
||||
var syncableFolders = folders
|
||||
.Where(f => f.IsSynchronizationEnabled && f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID)
|
||||
.ToList();
|
||||
|
||||
// Gmail API returns messages newest first by default
|
||||
// We'll download up to 1500 messages per folder
|
||||
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
|
||||
var totalFolders = syncableFolders.Count;
|
||||
var totalMessagesDownloaded = 0;
|
||||
|
||||
do
|
||||
for (int i = 0; i < totalFolders; i++)
|
||||
{
|
||||
var folder = syncableFolders[i];
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var request = _gmailService.Users.Messages.List("me");
|
||||
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
|
||||
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
|
||||
request.PageToken = pageToken;
|
||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||
|
||||
var response = await request.ExecuteAsync(cancellationToken);
|
||||
_logger.Debug("Downloading messages for folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
|
||||
|
||||
if (response.Messages != null && response.Messages.Count > 0)
|
||||
var folderDownloaded = 0;
|
||||
string pageToken = null;
|
||||
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
|
||||
|
||||
do
|
||||
{
|
||||
var messageIds = response.Messages.Select(m => m.Id).ToList();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Download metadata in batches
|
||||
await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
|
||||
var request = _gmailService.Users.Messages.List("me");
|
||||
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
|
||||
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
|
||||
request.PageToken = pageToken;
|
||||
|
||||
downloadedMessageIds.AddRange(messageIds);
|
||||
totalDownloaded += messageIds.Count;
|
||||
remainingToDownload -= messageIds.Count;
|
||||
var response = await request.ExecuteAsync(cancellationToken);
|
||||
|
||||
_logger.Debug("Downloaded {Count} messages for folder {FolderName} (total: {Total})", messageIds.Count, folder.FolderName, totalDownloaded);
|
||||
if (response.Messages != null && response.Messages.Count > 0)
|
||||
{
|
||||
// Filter out already downloaded messages to avoid duplicates
|
||||
var newMessageIds = response.Messages
|
||||
.Select(m => m.Id)
|
||||
.Where(id => !downloadedMessageIds.Contains(id))
|
||||
.ToList();
|
||||
|
||||
// Update progress
|
||||
UpdateSyncProgress(0, 0, $"Downloaded {totalDownloaded} messages from {folder.FolderName}");
|
||||
}
|
||||
if (newMessageIds.Count > 0)
|
||||
{
|
||||
// Download metadata in batches (no raw MIME during initial sync)
|
||||
await DownloadMessagesInBatchAsync(newMessageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
pageToken = response.NextPageToken;
|
||||
foreach (var id in newMessageIds)
|
||||
{
|
||||
downloadedMessageIds.Add(id);
|
||||
}
|
||||
|
||||
// Stop if we've downloaded enough messages or no more pages
|
||||
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
|
||||
break;
|
||||
folderDownloaded += newMessageIds.Count;
|
||||
totalMessagesDownloaded += newMessageIds.Count;
|
||||
}
|
||||
|
||||
} while (!string.IsNullOrEmpty(pageToken));
|
||||
// Count all messages (including duplicates) toward the folder limit
|
||||
remainingToDownload -= response.Messages.Count;
|
||||
|
||||
// Store history ID for future incremental syncs
|
||||
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
|
||||
Account.SynchronizationDeltaIdentifier = profile.HistoryId.ToString();
|
||||
await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
|
||||
_logger.Debug("Folder {FolderName}: Downloaded {New} new messages ({Total} total in folder)",
|
||||
folder.FolderName, newMessageIds.Count, folderDownloaded);
|
||||
}
|
||||
|
||||
_logger.Information("Completed downloading {Count} messages for folder {FolderName}", totalDownloaded, folder.FolderName);
|
||||
pageToken = response.NextPageToken;
|
||||
|
||||
// Stop if we've processed enough messages for this folder or no more pages
|
||||
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
|
||||
break;
|
||||
|
||||
} while (!string.IsNullOrEmpty(pageToken));
|
||||
|
||||
_logger.Information("Folder {FolderName}: Downloaded {Count} messages", folder.FolderName, folderDownloaded);
|
||||
UpdateSyncProgress(0, 0, $"Downloaded {totalMessagesDownloaded} messages");
|
||||
}
|
||||
|
||||
_logger.Information("Initial sync completed. Downloaded {Count} unique messages for {Name}", downloadedMessageIds.Count, Account.Name);
|
||||
}
|
||||
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
_logger.Warning("Rate limit exceeded while downloading messages for folder {FolderName}. Retrying after delay.", folder.FolderName);
|
||||
_logger.Warning("Rate limit exceeded during initial sync. Retrying after delay.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Error downloading messages for folder {FolderName}", folder.FolderName);
|
||||
_logger.Error(ex, "Error during initial sync for {Name}", Account.Name);
|
||||
throw;
|
||||
}
|
||||
|
||||
return downloadedMessageIds.ToList();
|
||||
}
|
||||
|
||||
private async Task SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
/// <summary>
|
||||
/// Performs incremental synchronization using Gmail History API.
|
||||
/// This is the recommended approach for Gmail sync after initial sync is complete.
|
||||
/// Returns a result indicating downloaded messages and whether a full resync is needed.
|
||||
/// </summary>
|
||||
private async Task<DeltaSyncResult> SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var historyRequest = _gmailService.Users.History.List("me");
|
||||
historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
|
||||
string pageToken = null;
|
||||
|
||||
var historyResponse = await historyRequest.ExecuteAsync();
|
||||
|
||||
if (historyResponse.History != null)
|
||||
do
|
||||
{
|
||||
var addedMessageIds = new List<string>();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Collect all added messages first
|
||||
foreach (var historyRecord in historyResponse.History)
|
||||
var historyRequest = _gmailService.Users.History.List("me");
|
||||
historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
|
||||
|
||||
if (!string.IsNullOrEmpty(pageToken))
|
||||
historyRequest.PageToken = pageToken;
|
||||
|
||||
var historyResponse = await historyRequest.ExecuteAsync(cancellationToken);
|
||||
|
||||
if (historyResponse.History != null)
|
||||
{
|
||||
if (historyRecord.MessagesAdded != null)
|
||||
var addedMessageIds = new List<string>();
|
||||
|
||||
// Collect all added messages first
|
||||
foreach (var historyRecord in historyResponse.History)
|
||||
{
|
||||
addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
|
||||
if (historyRecord.MessagesAdded != null)
|
||||
{
|
||||
addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process added messages in batches if any
|
||||
// During delta sync, download with Raw format to get MIME content
|
||||
if (addedMessageIds.Count != 0)
|
||||
{
|
||||
await DownloadMessagesInBatchAsync(addedMessageIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
// Process added messages in batches if any
|
||||
// During delta sync, download with Raw format to get MIME content for new messages
|
||||
if (addedMessageIds.Count != 0)
|
||||
{
|
||||
// Deduplicate message IDs
|
||||
var uniqueAddedIds = addedMessageIds.Distinct().ToList();
|
||||
await DownloadMessagesInBatchAsync(uniqueAddedIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
|
||||
downloadedMessageIds.AddRange(uniqueAddedIds);
|
||||
}
|
||||
|
||||
// Process other history changes
|
||||
foreach (var historyRecord in historyResponse.History)
|
||||
{
|
||||
// Process other history changes (label changes, deletions)
|
||||
await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
// CRITICAL: Update the history ID to the latest one after processing all changes
|
||||
// History IDs are always incremental, so the response contains the latest history ID
|
||||
if (historyResponse.HistoryId.HasValue)
|
||||
{
|
||||
await UpdateAccountSyncIdentifierAsync(historyResponse.HistoryId.Value).ConfigureAwait(false);
|
||||
_logger.Debug("Updated history ID to {HistoryId} after delta sync", historyResponse.HistoryId.Value);
|
||||
}
|
||||
|
||||
pageToken = historyResponse.NextPageToken;
|
||||
|
||||
} while (!string.IsNullOrEmpty(pageToken));
|
||||
|
||||
_logger.Information("Delta sync completed. Downloaded {Count} new messages for {Name}", downloadedMessageIds.Count, Account.Name);
|
||||
|
||||
return new DeltaSyncResult(downloadedMessageIds, RequiresFullResync: false);
|
||||
}
|
||||
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound ||
|
||||
(int)ex.HttpStatusCode == 410) // Gone - history expired
|
||||
{
|
||||
// History ID is no longer valid (expired or not found)
|
||||
// This happens when:
|
||||
// 1. The history ID is too old (Gmail keeps history for ~30 days)
|
||||
// 2. The account was reset or history was cleared
|
||||
// Reset the sync identifier and signal that a full resync is needed
|
||||
_logger.Warning("History ID {HistoryId} expired or not found for {Name}. Full resync required. Error: {Error}",
|
||||
Account.SynchronizationDeltaIdentifier, Account.Name, ex.Message);
|
||||
|
||||
// Clear the sync identifier to trigger initial sync
|
||||
Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor
|
||||
.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, null)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new DeltaSyncResult(downloadedMessageIds, RequiresFullResync: true);
|
||||
}
|
||||
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
_logger.Warning("Rate limit exceeded during delta sync for {Name}. Retrying after delay.", Account.Name);
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -802,6 +893,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
foreach (var labelId in addedLabel.LabelIds)
|
||||
{
|
||||
// ARCHIVE is a virtual folder - handle it separately
|
||||
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
|
||||
{
|
||||
await HandleArchiveAssignmentAsync(messageId).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// When UNREAD label is added mark the message as un-read.
|
||||
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
||||
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
|
||||
@@ -822,6 +920,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
foreach (var labelId in removedLabel.LabelIds)
|
||||
{
|
||||
// ARCHIVE is a virtual folder - handle it separately
|
||||
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
|
||||
{
|
||||
await HandleUnarchiveAssignmentAsync(messageId).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// When UNREAD label is removed mark the message as read.
|
||||
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
||||
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
|
||||
@@ -1160,15 +1265,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
if (packages != null)
|
||||
{
|
||||
// For Gmail, multiple packages can share the same message (different labels/folders)
|
||||
// They should all share the same FileId so MIME is stored only once
|
||||
Guid sharedFileId = Guid.NewGuid();
|
||||
// For Gmail, multiple packages share the same message (different labels/folders)
|
||||
// They already share the same FileId (set in CreateNewMailPackagesAsync) so MIME is stored only once
|
||||
|
||||
foreach (var package in packages)
|
||||
{
|
||||
// Set the same FileId for all copies
|
||||
package.Copy.FileId = sharedFileId;
|
||||
|
||||
// Create the mail copy with the MIME (if downloaded)
|
||||
var packageWithMime = downloadRawMime && mimeMessage != null
|
||||
? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
|
||||
@@ -1256,7 +1357,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// Gmail calendar attachments are stored in Google Drive
|
||||
// RemoteAttachmentId contains either FileId or FileUrl
|
||||
// For simplicity, we'll try to download from the FileId/FileUrl
|
||||
|
||||
|
||||
if (string.IsNullOrEmpty(attachment.RemoteAttachmentId))
|
||||
{
|
||||
_logger.Error("RemoteAttachmentId is empty for attachment {AttachmentId}", attachment.Id);
|
||||
@@ -1267,7 +1368,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// The attachment.RemoteAttachmentId is either a FileId or FileUrl
|
||||
// Since we can't directly download from Calendar API, this would require Drive API access
|
||||
// For now, throw NotSupportedException as Gmail attachments require additional Drive API setup
|
||||
|
||||
|
||||
_logger.Warning("Gmail calendar attachment download requires Google Drive API access. FileId/URL: {RemoteId}", attachment.RemoteAttachmentId);
|
||||
throw new NotSupportedException("Gmail calendar attachments are stored in Google Drive and require additional API configuration to download.");
|
||||
}
|
||||
@@ -1615,7 +1716,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
// Check Content-Type header for text/calendar
|
||||
var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(contentTypeHeader))
|
||||
{
|
||||
// Check if it's a calendar message (text/calendar or multipart with calendar)
|
||||
@@ -1623,11 +1724,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
{
|
||||
// Check the METHOD parameter to determine invitation type
|
||||
var methodMatch = System.Text.RegularExpressions.Regex.Match(contentTypeHeader, @"method=([^;\s]+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
|
||||
if (methodMatch.Success)
|
||||
{
|
||||
var method = methodMatch.Groups[1].Value.Trim('"').ToUpperInvariant();
|
||||
|
||||
|
||||
return method switch
|
||||
{
|
||||
"REQUEST" => MailItemType.CalendarInvitation,
|
||||
@@ -1636,7 +1737,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
_ => MailItemType.Mail
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// If no method specified, assume it's an invitation
|
||||
return MailItemType.CalendarInvitation;
|
||||
}
|
||||
@@ -1683,11 +1784,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
{
|
||||
var packageList = new List<NewMailItemPackage>();
|
||||
|
||||
// Create MailCopy from metadata only - NO MIME download
|
||||
var mailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||
// Create base MailCopy from metadata only - NO MIME download
|
||||
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||
|
||||
// Check for local draft mapping using X-Wino-Draft-Id header from metadata
|
||||
if (mailCopy.IsDraft)
|
||||
if (baseMailCopy.IsDraft)
|
||||
{
|
||||
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
|
||||
@@ -1696,7 +1797,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// This message belongs to existing local draft copy.
|
||||
// We don't need to create a new mail copy for this message, just update the existing one.
|
||||
|
||||
bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
|
||||
bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, baseMailCopy.Id, baseMailCopy.DraftId, baseMailCopy.ThreadId);
|
||||
|
||||
if (isMappingSuccesfull) return null;
|
||||
|
||||
@@ -1704,12 +1805,32 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
}
|
||||
}
|
||||
|
||||
// For Gmail, a single mail can have multiple labels (folders).
|
||||
// Each label requires a separate MailCopy entry in the database with:
|
||||
// - Same Id, UniqueId, FileId (shared across all copies)
|
||||
// - Different FolderId (one per label)
|
||||
// ARCHIVE label is excluded here as it's virtual and handled by MapArchivedMailsAsync
|
||||
if (message.LabelIds is not null)
|
||||
{
|
||||
// Generate shared identifiers that will be the same for all copies of this mail
|
||||
var sharedId = baseMailCopy.Id;
|
||||
var sharedFileId = baseMailCopy.FileId;
|
||||
|
||||
foreach (var labelId in message.LabelIds)
|
||||
{
|
||||
// Skip ARCHIVE label - it's virtual and handled separately
|
||||
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
|
||||
continue;
|
||||
|
||||
// Create a new MailCopy instance for each label to avoid shared reference issues
|
||||
var mailCopyForLabel = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||
|
||||
// Ensure all copies share the same Id and FileId
|
||||
mailCopyForLabel.Id = sharedId;
|
||||
mailCopyForLabel.FileId = sharedFileId;
|
||||
|
||||
// Pass null for MimeMessage - it will be downloaded later when user reads the mail
|
||||
packageList.Add(new NewMailItemPackage(mailCopy, null, labelId));
|
||||
packageList.Add(new NewMailItemPackage(mailCopyForLabel, null, labelId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1807,7 +1928,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
|
||||
// Create a patch event to update only the attendee response
|
||||
var patchEvent = new Event();
|
||||
|
||||
|
||||
// We need to get the event first to update the specific attendee
|
||||
// However, for efficiency, we'll use the patch method with sendUpdates parameter
|
||||
var patchRequest = _calendarService.Events.Patch(new Event
|
||||
@@ -1824,8 +1945,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
|
||||
// Send updates to other attendees if there's a message
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
|
||||
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
|
||||
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
|
||||
@@ -1861,8 +1982,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
}
|
||||
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
|
||||
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
|
||||
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
|
||||
@@ -1898,8 +2019,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
}
|
||||
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
|
||||
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
|
||||
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
|
||||
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
|
||||
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
|
||||
@@ -1979,8 +2100,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
var updateRequest = _calendarService.Events.Update(googleEvent, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
|
||||
// Send notifications to attendees if the event has attendees
|
||||
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
|
||||
? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All
|
||||
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
|
||||
? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All
|
||||
: Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.None;
|
||||
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(updateRequest, request)];
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MoreLinq;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Integration;
|
||||
using Wino.Services.Extensions;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Synchronizers.ImapSync;
|
||||
|
||||
/// <summary>
|
||||
/// Unified IMAP synchronization strategy that automatically selects the best available method:
|
||||
/// 1. QRESYNC (RFC 5162) - Best: supports quick resync with vanished messages
|
||||
/// 2. CONDSTORE (RFC 4551) - Good: supports mod-seq based change tracking
|
||||
/// 3. UID-based - Fallback: basic UID comparison
|
||||
///
|
||||
/// This consolidates the previous QResyncSynchronizer, CondstoreSynchronizer, and UidBasedSynchronizer
|
||||
/// into a single, enterprise-grade implementation with proper error handling and partial failure support.
|
||||
/// </summary>
|
||||
public class UnifiedImapSynchronizer
|
||||
{
|
||||
private readonly ILogger _logger = Log.ForContext<UnifiedImapSynchronizer>();
|
||||
private readonly IFolderService _folderService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
||||
|
||||
// Minimum summary items to Fetch for mail synchronization from IMAP.
|
||||
private readonly MessageSummaryItems MailSynchronizationFlags =
|
||||
MessageSummaryItems.Flags |
|
||||
MessageSummaryItems.UniqueId |
|
||||
MessageSummaryItems.ThreadId |
|
||||
MessageSummaryItems.EmailId |
|
||||
MessageSummaryItems.Headers |
|
||||
MessageSummaryItems.PreviewText |
|
||||
MessageSummaryItems.GMailThreadId |
|
||||
MessageSummaryItems.References |
|
||||
MessageSummaryItems.ModSeq;
|
||||
|
||||
public UnifiedImapSynchronizer(
|
||||
IFolderService folderService,
|
||||
IMailService mailService,
|
||||
IImapSynchronizerErrorHandlerFactory errorHandlerFactory)
|
||||
{
|
||||
_folderService = folderService;
|
||||
_mailService = mailService;
|
||||
_errorHandlerFactory = errorHandlerFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the best synchronization strategy based on server capabilities.
|
||||
/// </summary>
|
||||
public ImapSyncStrategy DetermineSyncStrategy(IImapClient client)
|
||||
{
|
||||
if (client is WinoImapClient winoClient &&
|
||||
client.Capabilities.HasFlag(ImapCapabilities.QuickResync) &&
|
||||
winoClient.IsQResyncEnabled)
|
||||
{
|
||||
return ImapSyncStrategy.QResync;
|
||||
}
|
||||
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.CondStore))
|
||||
{
|
||||
return ImapSyncStrategy.Condstore;
|
||||
}
|
||||
|
||||
return ImapSyncStrategy.UidBased;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main synchronization entry point. Automatically selects the best strategy.
|
||||
/// </summary>
|
||||
public async Task<FolderSyncResult> SynchronizeFolderAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var strategy = DetermineSyncStrategy(client);
|
||||
_logger.Debug("Using {Strategy} sync strategy for folder {FolderName}", strategy, folder.FolderName);
|
||||
|
||||
try
|
||||
{
|
||||
var downloadedIds = strategy switch
|
||||
{
|
||||
ImapSyncStrategy.QResync => await SynchronizeWithQResyncAsync(client, folder, synchronizer, cancellationToken),
|
||||
ImapSyncStrategy.Condstore => await SynchronizeWithCondstoreAsync(client, folder, synchronizer, cancellationToken),
|
||||
_ => await SynchronizeWithUidBasedAsync(client, folder, synchronizer, cancellationToken)
|
||||
};
|
||||
|
||||
return FolderSyncResult.Successful(folder.Id, folder.FolderName, downloadedIds.Count);
|
||||
}
|
||||
catch (FolderNotFoundException)
|
||||
{
|
||||
_logger.Warning("Folder {FolderName} not found on server, deleting locally", folder.FolderName);
|
||||
await _folderService.DeleteFolderAsync(folder.MailAccountId, folder.RemoteFolderId).ConfigureAwait(false);
|
||||
|
||||
return FolderSyncResult.Skipped(folder.Id, folder.FolderName, "Folder not found on server");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorContext = new SynchronizerErrorContext
|
||||
{
|
||||
ErrorMessage = ex.Message,
|
||||
Exception = ex,
|
||||
FolderId = folder.Id,
|
||||
FolderName = folder.FolderName,
|
||||
OperationType = "ImapFolderSync"
|
||||
};
|
||||
|
||||
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
|
||||
if (errorContext.CanContinueSync)
|
||||
{
|
||||
_logger.Warning(ex, "Folder {FolderName} sync failed with recoverable error", folder.FolderName);
|
||||
return FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext);
|
||||
}
|
||||
|
||||
_logger.Error(ex, "Folder {FolderName} sync failed with fatal error", folder.FolderName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
#region QRESYNC Strategy
|
||||
|
||||
private async Task<List<string>> SynchronizeWithQResyncAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
if (client is not WinoImapClient winoClient)
|
||||
throw new InvalidOperationException("QRESYNC requires WinoImapClient");
|
||||
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var localHighestModSeq = (ulong)Math.Max(folder.HighestModeSeq, 1);
|
||||
var allUids = await _folderService.GetKnownUidsForFolderAsync(folder.Id);
|
||||
var allUniqueIds = allUids.Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
// Subscribe to events before opening
|
||||
remoteFolder.MessagesVanished += (s, e) => HandleMessagesVanished(folder, e.UniqueIds);
|
||||
remoteFolder.MessageFlagsChanged += (s, e) => HandleMessageFlagsChanged(folder, e.UniqueId, e.Flags);
|
||||
|
||||
// Open with QRESYNC parameters
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, folder.UidValidity, localHighestModSeq, allUniqueIds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get changed UIDs
|
||||
var changedUids = await remoteFolder.SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update folder tracking
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
folder.UidValidity = remoteFolder.UidValidity;
|
||||
|
||||
// Handle deletions
|
||||
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await remoteFolder.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CONDSTORE Strategy
|
||||
|
||||
private async Task<List<string>> SynchronizeWithCondstoreAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var localHighestModSeq = (ulong)folder.HighestModeSeq;
|
||||
bool isInitialSync = localHighestModSeq == 0;
|
||||
|
||||
if (remoteFolder.HighestModSeq > localHighestModSeq || isInitialSync)
|
||||
{
|
||||
IList<UniqueId> changedUids;
|
||||
|
||||
// Use SORT if available for better ordering
|
||||
if (client.Capabilities.HasFlag(ImapCapabilities.Sort))
|
||||
{
|
||||
changedUids = await remoteFolder.SortAsync(
|
||||
SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)),
|
||||
[OrderBy.ReverseDate],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
changedUids = await remoteFolder.SearchAsync(
|
||||
SearchQuery.ChangedSince(Math.Max(localHighestModSeq, 1)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// For initial sync, limit the number of messages
|
||||
if (isInitialSync)
|
||||
{
|
||||
changedUids = changedUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, changedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
folder.HighestModeSeq = unchecked((long)remoteFolder.HighestModSeq);
|
||||
await _folderService.UpdateFolderAsync(folder).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UID-Based Strategy (Fallback)
|
||||
|
||||
private async Task<List<string>> SynchronizeWithUidBasedAsync(
|
||||
IImapClient client,
|
||||
MailItemFolder folder,
|
||||
IImapSynchronizer synchronizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
IMailFolder remoteFolder = null;
|
||||
|
||||
try
|
||||
{
|
||||
remoteFolder = await client.GetFolderAsync(folder.RemoteFolderId, cancellationToken).ConfigureAwait(false);
|
||||
await remoteFolder.OpenAsync(FolderAccess.ReadOnly, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get all remote UIDs and take the most recent ones
|
||||
var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
var limitedUids = remoteUids
|
||||
.OrderByDescending(a => a.Id)
|
||||
.Take((int)synchronizer.InitialMessageDownloadCountPerFolder)
|
||||
.ToList();
|
||||
|
||||
downloadedMessageIds = await ProcessChangedUidsAsync(synchronizer, remoteFolder, folder, limitedUids, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await HandleDeletedMessagesAsync(folder, remoteFolder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (remoteFolder?.IsOpen == true && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await remoteFolder.CloseAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared Processing Methods
|
||||
|
||||
private async Task<List<string>> ProcessChangedUidsAsync(
|
||||
IImapSynchronizer synchronizer,
|
||||
IMailFolder remoteFolder,
|
||||
MailItemFolder localFolder,
|
||||
IList<UniqueId> changedUids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
|
||||
if (changedUids == null || changedUids.Count == 0)
|
||||
return downloadedMessageIds;
|
||||
|
||||
// Get existing mails to determine what's new vs. updated
|
||||
var existingMails = await _mailService.GetExistingMailsAsync(localFolder.Id, changedUids).ConfigureAwait(false);
|
||||
var existingMailUids = existingMails.Select(m => MailkitClientExtensions.ResolveUidStruct(m.Id)).ToArray();
|
||||
|
||||
var newMessageUids = changedUids.Except(existingMailUids).ToList();
|
||||
|
||||
// Update flags for existing mails
|
||||
if (existingMailUids.Any())
|
||||
{
|
||||
var existingFlagData = await remoteFolder.FetchAsync(existingMailUids, MessageSummaryItems.Flags | MessageSummaryItems.UniqueId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var update in existingFlagData)
|
||||
{
|
||||
if (update.UniqueId == UniqueId.Invalid || update.Flags == null) continue;
|
||||
|
||||
var existingMail = existingMails.FirstOrDefault(m => MailkitClientExtensions.ResolveUidStruct(m.Id).Id == update.UniqueId.Id);
|
||||
if (existingMail != null)
|
||||
{
|
||||
await UpdateMailFlagsAsync(existingMail, update.Flags.Value).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download new messages in batches
|
||||
var batches = newMessageUids.Batch(50);
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batchList = batch.ToList();
|
||||
downloadedMessageIds.AddRange(batchList.Select(uid => MailkitClientExtensions.CreateUid(localFolder.Id, uid.Id)));
|
||||
|
||||
await DownloadMessagesAsync(synchronizer, remoteFolder, localFolder, new UniqueIdSet(batchList, SortOrder.Ascending), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return downloadedMessageIds;
|
||||
}
|
||||
|
||||
private async Task DownloadMessagesAsync(
|
||||
IImapSynchronizer synchronizer,
|
||||
IMailFolder folder,
|
||||
MailItemFolder localFolder,
|
||||
UniqueIdSet uniqueIdSet,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var summaries = await folder.FetchAsync(uniqueIdSet, MailSynchronizationFlags, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mimeMessage = await folder.GetMessageAsync(summary.UniqueId, cancellationToken).ConfigureAwait(false);
|
||||
var creationPackage = new ImapMessageCreationPackage(summary, mimeMessage);
|
||||
var mailPackages = await synchronizer.CreateNewMailPackagesAsync(creationPackage, localFolder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (mailPackages != null)
|
||||
{
|
||||
foreach (var package in mailPackages)
|
||||
{
|
||||
if (package != null)
|
||||
{
|
||||
await _mailService.CreateMailAsync(localFolder.MailAccountId, package).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to download message {UniqueId} in folder {FolderName}", summary.UniqueId, localFolder.FolderName);
|
||||
// Continue with other messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleDeletedMessagesAsync(MailItemFolder localFolder, IMailFolder remoteFolder, CancellationToken cancellationToken)
|
||||
{
|
||||
var allLocalUids = (await _folderService.GetKnownUidsForFolderAsync(localFolder.Id)).Select(a => new UniqueId(a)).ToList();
|
||||
|
||||
if (allLocalUids.Count == 0) return;
|
||||
|
||||
var remoteAllUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false);
|
||||
var deletedUids = allLocalUids.Except(remoteAllUids).ToList();
|
||||
|
||||
foreach (var deletedUid in deletedUids)
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(localFolder.Id, deletedUid.Id);
|
||||
await _mailService.DeleteMailAsync(localFolder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateMailFlagsAsync(MailCopy mailCopy, MessageFlags flags)
|
||||
{
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
|
||||
if (isFlagged != mailCopy.IsFlagged)
|
||||
{
|
||||
await _mailService.ChangeFlagStatusAsync(mailCopy.Id, isFlagged).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (isRead != mailCopy.IsRead)
|
||||
{
|
||||
await _mailService.ChangeReadStatusAsync(mailCopy.Id, isRead).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMessagesVanished(MailItemFolder folder, IList<UniqueId> uniqueIds)
|
||||
{
|
||||
// Fire and forget - these are event handlers
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
foreach (var uniqueId in uniqueIds)
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Id);
|
||||
await _mailService.DeleteMailAsync(folder.MailAccountId, localMailCopyId).ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleMessageFlagsChanged(MailItemFolder folder, UniqueId? uniqueId, MessageFlags flags)
|
||||
{
|
||||
if (uniqueId == null) return;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var localMailCopyId = MailkitClientExtensions.CreateUid(folder.Id, uniqueId.Value.Id);
|
||||
var isFlagged = MailkitClientExtensions.GetIsFlagged(flags);
|
||||
var isRead = MailkitClientExtensions.GetIsRead(flags);
|
||||
|
||||
await _mailService.ChangeReadStatusAsync(localMailCopyId, isRead).ConfigureAwait(false);
|
||||
await _mailService.ChangeFlagStatusAsync(localMailCopyId, isFlagged).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IMAP synchronization strategy enumeration.
|
||||
/// </summary>
|
||||
public enum ImapSyncStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// RFC 5162 Quick Resync - supports vanished messages and efficient delta sync.
|
||||
/// </summary>
|
||||
QResync,
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4551 Conditional Store - supports mod-seq based change tracking.
|
||||
/// </summary>
|
||||
Condstore,
|
||||
|
||||
/// <summary>
|
||||
/// Basic UID-based synchronization - fallback for servers without advanced features.
|
||||
/// </summary>
|
||||
UidBased
|
||||
}
|
||||
@@ -26,6 +26,7 @@ using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
using Wino.Messaging.Server;
|
||||
using Wino.Messaging.UI;
|
||||
using Wino.Services.Extensions;
|
||||
@@ -52,16 +53,22 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
private readonly IImapChangeProcessor _imapChangeProcessor;
|
||||
private readonly IImapSynchronizationStrategyProvider _imapSynchronizationStrategyProvider;
|
||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||
private readonly UnifiedImapSynchronizer _unifiedSynchronizer;
|
||||
private readonly IImapSynchronizerErrorHandlerFactory _errorHandlerFactory;
|
||||
|
||||
public ImapSynchronizer(MailAccount account,
|
||||
IImapChangeProcessor imapChangeProcessor,
|
||||
IImapSynchronizationStrategyProvider imapSynchronizationStrategyProvider,
|
||||
IApplicationConfiguration applicationConfiguration) : base(account, WeakReferenceMessenger.Default)
|
||||
IApplicationConfiguration applicationConfiguration,
|
||||
UnifiedImapSynchronizer unifiedSynchronizer,
|
||||
IImapSynchronizerErrorHandlerFactory errorHandlerFactory) : base(account, WeakReferenceMessenger.Default)
|
||||
{
|
||||
// Create client pool with account protocol log.
|
||||
_imapChangeProcessor = imapChangeProcessor;
|
||||
_imapSynchronizationStrategyProvider = imapSynchronizationStrategyProvider;
|
||||
_applicationConfiguration = applicationConfiguration;
|
||||
_unifiedSynchronizer = unifiedSynchronizer;
|
||||
_errorHandlerFactory = errorHandlerFactory;
|
||||
|
||||
var protocolLogStream = CreateAccountProtocolLogFileStream();
|
||||
var poolOptions = ImapClientPoolOptions.CreateDefault(Account.ServerInformation, protocolLogStream);
|
||||
@@ -303,53 +310,130 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
var folderResults = new List<FolderSyncResult>();
|
||||
|
||||
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
||||
_logger.Information("Options: {Options}", options);
|
||||
|
||||
// Set indeterminate progress initially
|
||||
UpdateSyncProgress(0, 0, "Synchronizing...");
|
||||
|
||||
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
|
||||
|
||||
if (shouldDoFolderSync)
|
||||
try
|
||||
{
|
||||
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
// Set indeterminate progress initially
|
||||
UpdateSyncProgress(0, 0, "Synchronizing...");
|
||||
|
||||
if (options.Type != MailSynchronizationType.FoldersOnly)
|
||||
{
|
||||
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
||||
bool shouldDoFolderSync = options.Type == MailSynchronizationType.FullFolders || options.Type == MailSynchronizationType.FoldersOnly;
|
||||
|
||||
var totalFolders = synchronizationFolders.Count;
|
||||
|
||||
for (int i = 0; i < totalFolders; i++)
|
||||
if (shouldDoFolderSync)
|
||||
{
|
||||
var folder = synchronizationFolders[i];
|
||||
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Update progress based on folder completion
|
||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||
if (options.Type != MailSynchronizationType.FoldersOnly)
|
||||
{
|
||||
var synchronizationFolders = await _imapChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
|
||||
|
||||
var folderDownloadedMessageIds = await SynchronizeFolderInternalAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||
var totalFolders = synchronizationFolders.Count;
|
||||
|
||||
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
|
||||
|
||||
if (folderDownloadedMessageIds != null)
|
||||
for (int i = 0; i < totalFolders; i++)
|
||||
{
|
||||
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
|
||||
var folder = synchronizationFolders[i];
|
||||
|
||||
// Update progress based on folder completion
|
||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
|
||||
|
||||
try
|
||||
{
|
||||
// Use the unified synchronizer for folder sync
|
||||
IImapClient client = null;
|
||||
|
||||
try
|
||||
{
|
||||
client = await _clientPool.GetClientAsync().ConfigureAwait(false);
|
||||
var folderResult = await _unifiedSynchronizer.SynchronizeFolderAsync(client, folder, this, cancellationToken).ConfigureAwait(false);
|
||||
folderResults.Add(folderResult);
|
||||
|
||||
if (folderResult.Success && folderResult.DownloadedCount > 0)
|
||||
{
|
||||
// Get the downloaded message IDs for this folder
|
||||
var folderDownloadedIds = await GetDownloadedIdsForFolderAsync(folder, folderResult.DownloadedCount).ConfigureAwait(false);
|
||||
downloadedMessageIds.AddRange(folderDownloadedIds);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client != null)
|
||||
{
|
||||
_clientPool.Release(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorContext = new SynchronizerErrorContext
|
||||
{
|
||||
Account = Account,
|
||||
ErrorMessage = ex.Message,
|
||||
Exception = ex,
|
||||
FolderId = folder.Id,
|
||||
FolderName = folder.FolderName,
|
||||
OperationType = "ImapFolderSync"
|
||||
};
|
||||
|
||||
var handled = await _errorHandlerFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
|
||||
if (errorContext.CanContinueSync)
|
||||
{
|
||||
_logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName);
|
||||
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error(ex, "Folder {FolderName} sync failed with fatal error", folder.FolderName);
|
||||
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested) return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset progress
|
||||
ResetSyncProgress();
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
|
||||
return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
|
||||
return MailSynchronizationResult.Failed(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Reset progress
|
||||
ResetSyncProgress();
|
||||
}
|
||||
|
||||
// Get all unread new downloaded items and return in the result.
|
||||
// This is primarily used in notifications.
|
||||
|
||||
var unreadNewItems = await _imapChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
||||
|
||||
return MailSynchronizationResult.Completed(unreadNewItems);
|
||||
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent downloaded message IDs for a folder.
|
||||
/// Used for notification purposes after sync completes.
|
||||
/// </summary>
|
||||
private async Task<List<string>> GetDownloadedIdsForFolderAsync(MailItemFolder folder, int count)
|
||||
{
|
||||
// Get the most recent mail IDs from the folder
|
||||
var recentMails = await _imapChangeProcessor.GetRecentMailIdsForFolderAsync(folder.Id, count).ConfigureAwait(false);
|
||||
return recentMails?.ToList() ?? new List<string>();
|
||||
}
|
||||
|
||||
public override async Task ExecuteNativeRequestsAsync(List<IRequestBundle<ImapRequest>> batchedRequests, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -30,7 +30,6 @@ using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Accounts;
|
||||
using Wino.Core.Domain.Models.Errors;
|
||||
using Wino.Core.Domain.Models.Folders;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
@@ -141,6 +140,7 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadedMessageIds = new List<string>();
|
||||
var folderResults = new List<FolderSyncResult>();
|
||||
|
||||
_logger.Information("Internal synchronization started for {Name}", Account.Name);
|
||||
_logger.Information("Options: {Options}", options);
|
||||
@@ -169,17 +169,77 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
var statusMessage = string.Format(Translator.Sync_SynchronizingFolder, folder.FolderName, progressPercentage);
|
||||
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), statusMessage);
|
||||
|
||||
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
|
||||
try
|
||||
{
|
||||
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
||||
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
|
||||
|
||||
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, folderDownloadedMessageIds.Count()));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancellation should stop the entire sync
|
||||
throw;
|
||||
}
|
||||
catch (ODataError odataError)
|
||||
{
|
||||
// Handle OData errors - determine if we should continue or stop
|
||||
var errorContext = new SynchronizerErrorContext
|
||||
{
|
||||
Account = Account,
|
||||
ErrorCode = (int?)odataError.ResponseStatusCode,
|
||||
ErrorMessage = odataError.Error?.Message ?? odataError.Message,
|
||||
Exception = odataError,
|
||||
FolderId = folder.Id,
|
||||
FolderName = folder.FolderName,
|
||||
OperationType = "FolderSync"
|
||||
};
|
||||
|
||||
var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
|
||||
|
||||
if (errorContext.CanContinueSync)
|
||||
{
|
||||
_logger.Warning("Folder {FolderName} sync failed with recoverable error, continuing with other folders. Error: {Error}",
|
||||
folder.FolderName, odataError.Error?.Message);
|
||||
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error(odataError, "Folder {FolderName} sync failed with fatal error, stopping sync", folder.FolderName);
|
||||
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// For unexpected exceptions, try to classify and decide if we should continue
|
||||
var errorContext = new SynchronizerErrorContext
|
||||
{
|
||||
Account = Account,
|
||||
ErrorMessage = ex.Message,
|
||||
Exception = ex,
|
||||
FolderId = folder.Id,
|
||||
FolderName = folder.FolderName,
|
||||
OperationType = "FolderSync",
|
||||
Severity = SynchronizerErrorSeverity.Recoverable, // Default to recoverable for individual folders
|
||||
Category = SynchronizerErrorCategory.Unknown
|
||||
};
|
||||
|
||||
_logger.Warning(ex, "Folder {FolderName} sync failed, continuing with other folders", folder.FolderName);
|
||||
folderResults.Add(FolderSyncResult.Failed(folder.Id, folder.FolderName, errorContext));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
|
||||
return MailSynchronizationResult.Canceled;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Synchronizing folders for {Name}", Account.Name);
|
||||
Debugger.Break();
|
||||
|
||||
throw;
|
||||
return MailSynchronizationResult.Failed(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -187,12 +247,12 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
ResetSyncProgress();
|
||||
}
|
||||
|
||||
// Get all unred new downloaded items and return in the result.
|
||||
// Get all unread new downloaded items and return in the result.
|
||||
// This is primarily used in notifications.
|
||||
|
||||
var unreadNewItems = await _outlookChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
||||
|
||||
return MailSynchronizationResult.Completed(unreadNewItems);
|
||||
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
|
||||
}
|
||||
|
||||
public async Task DownloadSearchResultMessageAsync(string messageId, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||
|
||||
Reference in New Issue
Block a user