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

This commit is contained in:
Burak Kaan Köse
2026-02-06 01:18:12 +01:00
parent d1425ca9ca
commit 071f1c9786
43 changed files with 2785 additions and 582 deletions
+297 -176
View File
@@ -8,7 +8,6 @@ using System.Threading.Tasks;
using System.Web;
using CommunityToolkit.Mvvm.Messaging;
using Google;
using Google.Apis.Calendar.v3;
using Google.Apis.Calendar.v3.Data;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
@@ -28,7 +27,6 @@ using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Errors;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
@@ -51,19 +49,22 @@ namespace Wino.Core.Synchronizers.Mail;
public partial class GmailSynchronizerJsonContext : JsonSerializerContext;
/// <summary>
/// Gmail synchronizer implementation with per-folder history ID synchronization.
///
/// Gmail synchronizer implementation using Gmail History API for efficient incremental sync.
///
/// SYNCHRONIZATION STRATEGY:
/// - Uses Gmail History API for both initial and incremental sync
/// - Initial sync: Downloads top 1500 messages per folder with metadata only
/// - Incremental sync: Uses history ID to get only changes since last sync
/// - Messages are downloaded with metadata only (no MIME content during sync)
/// - MIME files are downloaded on-demand when user explicitly reads a message
///
/// - Initial sync: Downloads up to 1500 messages PER FOLDER with metadata only.
/// Uses a global HashSet to track downloaded message IDs, avoiding duplicate downloads
/// when messages have multiple labels. Each folder gets its full quota of messages.
/// - Incremental sync: Uses ONLY History API to get changes since last sync.
/// No per-folder downloads during incremental sync - this is the proper Gmail sync approach.
/// - Messages are downloaded with metadata only during initial sync (no MIME content)
/// - New messages during incremental sync are downloaded with full MIME content
/// - MIME files for initial sync messages are downloaded on-demand when user reads a message
///
/// Key implementation details:
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
/// - DownloadMessagesForFolderAsync: Downloads top 1500 messages for initial sync
/// - SynchronizeDeltaAsync: Processes incremental changes using history ID
/// - PerformInitialSyncAsync: Downloads messages per-folder with global deduplication
/// - SynchronizeDeltaAsync: Processes incremental changes using History API with pagination
/// - Handles 404/410 errors (history expired) by triggering full resync
/// - CreateMinimalMailCopyAsync: Extracts MailCopy fields from Gmail Metadata format
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
/// </summary>
@@ -153,15 +154,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
_logger.Information("Internal mail synchronization started for {Name}", Account.Name);
// Make sure that virtual archive folder exists before all.
if (!archiveFolderId.HasValue)
await InitializeArchiveFolderAsync().ConfigureAwait(false);
var downloadedMessageIds = new List<string>();
var folderResults = new List<FolderSyncResult>();
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
bool shouldSynchronizeFolders = true;
if (shouldSynchronizeFolders)
try
{
// Make sure that virtual archive folder exists before all.
if (!archiveFolderId.HasValue)
await InitializeArchiveFolderAsync().ConfigureAwait(false);
// Gmail must always synchronize folders before because it doesn't have a per-folder sync.
_logger.Information("Synchronizing folders for {Name}", Account.Name);
UpdateSyncProgress(0, 0, "Synchronizing folders...");
@@ -173,202 +175,291 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
throw new GmailServiceDisabledException();
}
catch (Exception)
{
throw;
}
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
UpdateSyncProgress(0, 0, "Folders synchronized");
// Stop synchronization at this point if type is only folder metadata sync.
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
cancellationToken.ThrowIfCancellationRequested();
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
if (isInitialSync)
{
// INITIAL SYNC: Download all messages globally (not per-folder) to avoid duplicates.
// Gmail messages can have multiple labels, so per-folder download would fetch same message multiple times.
downloadedMessageIds = await PerformInitialSyncAsync(cancellationToken).ConfigureAwait(false);
// Set the history ID to the latest value after initial sync
UpdateSyncProgress(0, 0, "Finalizing synchronization...");
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
if (profile.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(profile.HistoryId.Value).ConfigureAwait(false);
_logger.Information("Initial sync completed. Set history ID to {HistoryId}", profile.HistoryId.Value);
}
// Create successful folder results for all folders
var allFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
foreach (var folder in allFolders.Where(f => f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID))
{
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, 0));
}
}
else
{
// INCREMENTAL SYNC: Use ONLY History API - no per-folder downloads.
// This is the proper Gmail sync strategy as recommended by Google.
UpdateSyncProgress(0, 0, "Synchronizing changes...");
var deltaResult = await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(deltaResult.DownloadedMessageIds);
// If history sync was reset due to expired history ID, we need to do initial sync
if (deltaResult.RequiresFullResync)
{
_logger.Warning("History ID expired. Performing full resync for {Name}", Account.Name);
downloadedMessageIds = await PerformInitialSyncAsync(cancellationToken).ConfigureAwait(false);
// Update history ID after full resync
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
if (profile.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(profile.HistoryId.Value).ConfigureAwait(false);
_logger.Information("Full resync completed. Set history ID to {HistoryId}", profile.HistoryId.Value);
}
}
UpdateSyncProgress(0, 0, "Changes synchronized");
// Create folder results for incremental sync
var allFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
foreach (var folder in allFolders.Where(f => f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID))
{
folderResults.Add(FolderSyncResult.Successful(folder.Id, folder.FolderName, 0));
}
}
}
// There is no specific folder synchronization in Gmail.
// Therefore we need to stop the synchronization at this point
// if type is only folder metadata sync.
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
cancellationToken.ThrowIfCancellationRequested();
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
var downloadedMessageIds = new List<string>();
// Get all folders to synchronize
var synchronizationFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
_logger.Information("Synchronizing {Count} folders for {Name}", synchronizationFolders.Count, Account.Name);
var totalFolders = synchronizationFolders.Count;
for (int i = 0; i < totalFolders; i++)
catch (OperationCanceledException)
{
var folder = synchronizationFolders[i];
// Update progress based on folder completion
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(folderDownloadedMessageIds);
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
return MailSynchronizationResult.Canceled;
}
// Process incremental changes using history API if we have a history ID
if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
catch (Exception ex)
{
UpdateSyncProgress(0, 0, "Synchronizing changes...");
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
UpdateSyncProgress(0, 0, "Changes synchronized");
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
return MailSynchronizationResult.Failed(ex);
}
// Get all unread new downloaded items for notifications
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
return MailSynchronizationResult.Completed(unreadNewItems);
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
}
/// <summary>
/// Synchronizes a single folder by downloading top 1500 messages with metadata only.
/// Result of delta synchronization using History API.
/// </summary>
private async Task<List<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
cancellationToken.ThrowIfCancellationRequested();
_logger.Debug("Synchronizing folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
try
{
// Download top 1500 messages for this folder
await DownloadMessagesForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
if (downloadedMessageIds.Any())
{
_logger.Information("Downloaded {Count} messages for folder {FolderName}", downloadedMessageIds.Count, folder.FolderName);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Error synchronizing folder {FolderName}", folder.FolderName);
throw;
}
return downloadedMessageIds;
}
private record DeltaSyncResult(List<string> DownloadedMessageIds, bool RequiresFullResync);
/// <summary>
/// Downloads top 1500 messages for a folder using Gmail API with metadata only.
/// Performs initial synchronization by downloading messages per-folder.
/// Each folder gets up to 1500 messages, but we track already downloaded message IDs globally
/// to avoid downloading the same message multiple times (Gmail messages can have multiple labels).
/// </summary>
private async Task DownloadMessagesForFolderAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
private async Task<List<string>> PerformInitialSyncAsync(CancellationToken cancellationToken)
{
_logger.Debug("Downloading messages for folder {FolderName}", folder.FolderName);
// Track all downloaded message IDs globally to avoid duplicate downloads
var downloadedMessageIds = new HashSet<string>();
_logger.Information("Performing initial sync for {Name} - downloading messages per folder", Account.Name);
try
{
var totalDownloaded = 0;
string pageToken = null;
// Get all folders to sync (exclude virtual ARCHIVE folder)
var folders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
var syncableFolders = folders
.Where(f => f.IsSynchronizationEnabled && f.RemoteFolderId != ServiceConstants.ARCHIVE_LABEL_ID)
.ToList();
// Gmail API returns messages newest first by default
// We'll download up to 1500 messages per folder
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
var totalFolders = syncableFolders.Count;
var totalMessagesDownloaded = 0;
do
for (int i = 0; i < totalFolders; i++)
{
var folder = syncableFolders[i];
cancellationToken.ThrowIfCancellationRequested();
var request = _gmailService.Users.Messages.List("me");
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
request.PageToken = pageToken;
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
var response = await request.ExecuteAsync(cancellationToken);
_logger.Debug("Downloading messages for folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
if (response.Messages != null && response.Messages.Count > 0)
var folderDownloaded = 0;
string pageToken = null;
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
do
{
var messageIds = response.Messages.Select(m => m.Id).ToList();
cancellationToken.ThrowIfCancellationRequested();
// Download metadata in batches
await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
var request = _gmailService.Users.Messages.List("me");
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
request.PageToken = pageToken;
downloadedMessageIds.AddRange(messageIds);
totalDownloaded += messageIds.Count;
remainingToDownload -= messageIds.Count;
var response = await request.ExecuteAsync(cancellationToken);
_logger.Debug("Downloaded {Count} messages for folder {FolderName} (total: {Total})", messageIds.Count, folder.FolderName, totalDownloaded);
if (response.Messages != null && response.Messages.Count > 0)
{
// Filter out already downloaded messages to avoid duplicates
var newMessageIds = response.Messages
.Select(m => m.Id)
.Where(id => !downloadedMessageIds.Contains(id))
.ToList();
// Update progress
UpdateSyncProgress(0, 0, $"Downloaded {totalDownloaded} messages from {folder.FolderName}");
}
if (newMessageIds.Count > 0)
{
// Download metadata in batches (no raw MIME during initial sync)
await DownloadMessagesInBatchAsync(newMessageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
pageToken = response.NextPageToken;
foreach (var id in newMessageIds)
{
downloadedMessageIds.Add(id);
}
// Stop if we've downloaded enough messages or no more pages
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
break;
folderDownloaded += newMessageIds.Count;
totalMessagesDownloaded += newMessageIds.Count;
}
} while (!string.IsNullOrEmpty(pageToken));
// Count all messages (including duplicates) toward the folder limit
remainingToDownload -= response.Messages.Count;
// Store history ID for future incremental syncs
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
Account.SynchronizationDeltaIdentifier = profile.HistoryId.ToString();
await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
_logger.Debug("Folder {FolderName}: Downloaded {New} new messages ({Total} total in folder)",
folder.FolderName, newMessageIds.Count, folderDownloaded);
}
_logger.Information("Completed downloading {Count} messages for folder {FolderName}", totalDownloaded, folder.FolderName);
pageToken = response.NextPageToken;
// Stop if we've processed enough messages for this folder or no more pages
if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
break;
} while (!string.IsNullOrEmpty(pageToken));
_logger.Information("Folder {FolderName}: Downloaded {Count} messages", folder.FolderName, folderDownloaded);
UpdateSyncProgress(0, 0, $"Downloaded {totalMessagesDownloaded} messages");
}
_logger.Information("Initial sync completed. Downloaded {Count} unique messages for {Name}", downloadedMessageIds.Count, Account.Name);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_logger.Warning("Rate limit exceeded while downloading messages for folder {FolderName}. Retrying after delay.", folder.FolderName);
_logger.Warning("Rate limit exceeded during initial sync. Retrying after delay.");
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
throw;
}
catch (Exception ex)
{
_logger.Error(ex, "Error downloading messages for folder {FolderName}", folder.FolderName);
_logger.Error(ex, "Error during initial sync for {Name}", Account.Name);
throw;
}
return downloadedMessageIds.ToList();
}
private async Task SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
/// <summary>
/// Performs incremental synchronization using Gmail History API.
/// This is the recommended approach for Gmail sync after initial sync is complete.
/// Returns a result indicating downloaded messages and whether a full resync is needed.
/// </summary>
private async Task<DeltaSyncResult> SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
try
{
var historyRequest = _gmailService.Users.History.List("me");
historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
string pageToken = null;
var historyResponse = await historyRequest.ExecuteAsync();
if (historyResponse.History != null)
do
{
var addedMessageIds = new List<string>();
cancellationToken.ThrowIfCancellationRequested();
// Collect all added messages first
foreach (var historyRecord in historyResponse.History)
var historyRequest = _gmailService.Users.History.List("me");
historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
if (!string.IsNullOrEmpty(pageToken))
historyRequest.PageToken = pageToken;
var historyResponse = await historyRequest.ExecuteAsync(cancellationToken);
if (historyResponse.History != null)
{
if (historyRecord.MessagesAdded != null)
var addedMessageIds = new List<string>();
// Collect all added messages first
foreach (var historyRecord in historyResponse.History)
{
addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
if (historyRecord.MessagesAdded != null)
{
addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
}
}
}
// Process added messages in batches if any
// During delta sync, download with Raw format to get MIME content
if (addedMessageIds.Count != 0)
{
await DownloadMessagesInBatchAsync(addedMessageIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
}
// Process added messages in batches if any
// During delta sync, download with Raw format to get MIME content for new messages
if (addedMessageIds.Count != 0)
{
// Deduplicate message IDs
var uniqueAddedIds = addedMessageIds.Distinct().ToList();
await DownloadMessagesInBatchAsync(uniqueAddedIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
downloadedMessageIds.AddRange(uniqueAddedIds);
}
// Process other history changes
foreach (var historyRecord in historyResponse.History)
{
// Process other history changes (label changes, deletions)
await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
}
}
}
catch (Exception)
{
// CRITICAL: Update the history ID to the latest one after processing all changes
// History IDs are always incremental, so the response contains the latest history ID
if (historyResponse.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(historyResponse.HistoryId.Value).ConfigureAwait(false);
_logger.Debug("Updated history ID to {HistoryId} after delta sync", historyResponse.HistoryId.Value);
}
pageToken = historyResponse.NextPageToken;
} while (!string.IsNullOrEmpty(pageToken));
_logger.Information("Delta sync completed. Downloaded {Count} new messages for {Name}", downloadedMessageIds.Count, Account.Name);
return new DeltaSyncResult(downloadedMessageIds, RequiresFullResync: false);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound ||
(int)ex.HttpStatusCode == 410) // Gone - history expired
{
// History ID is no longer valid (expired or not found)
// This happens when:
// 1. The history ID is too old (Gmail keeps history for ~30 days)
// 2. The account was reset or history was cleared
// Reset the sync identifier and signal that a full resync is needed
_logger.Warning("History ID {HistoryId} expired or not found for {Name}. Full resync required. Error: {Error}",
Account.SynchronizationDeltaIdentifier, Account.Name, ex.Message);
// Clear the sync identifier to trigger initial sync
Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor
.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, null)
.ConfigureAwait(false);
return new DeltaSyncResult(downloadedMessageIds, RequiresFullResync: true);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
_logger.Warning("Rate limit exceeded during delta sync for {Name}. Retrying after delay.", Account.Name);
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
throw;
}
}
@@ -802,6 +893,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
foreach (var labelId in addedLabel.LabelIds)
{
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleArchiveAssignmentAsync(messageId).ConfigureAwait(false);
continue;
}
// When UNREAD label is added mark the message as un-read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
@@ -822,6 +920,13 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
foreach (var labelId in removedLabel.LabelIds)
{
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleUnarchiveAssignmentAsync(messageId).ConfigureAwait(false);
continue;
}
// When UNREAD label is removed mark the message as read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
@@ -1160,15 +1265,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (packages != null)
{
// For Gmail, multiple packages can share the same message (different labels/folders)
// They should all share the same FileId so MIME is stored only once
Guid sharedFileId = Guid.NewGuid();
// For Gmail, multiple packages share the same message (different labels/folders)
// They already share the same FileId (set in CreateNewMailPackagesAsync) so MIME is stored only once
foreach (var package in packages)
{
// Set the same FileId for all copies
package.Copy.FileId = sharedFileId;
// Create the mail copy with the MIME (if downloaded)
var packageWithMime = downloadRawMime && mimeMessage != null
? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
@@ -1256,7 +1357,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Gmail calendar attachments are stored in Google Drive
// RemoteAttachmentId contains either FileId or FileUrl
// For simplicity, we'll try to download from the FileId/FileUrl
if (string.IsNullOrEmpty(attachment.RemoteAttachmentId))
{
_logger.Error("RemoteAttachmentId is empty for attachment {AttachmentId}", attachment.Id);
@@ -1267,7 +1368,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// The attachment.RemoteAttachmentId is either a FileId or FileUrl
// Since we can't directly download from Calendar API, this would require Drive API access
// For now, throw NotSupportedException as Gmail attachments require additional Drive API setup
_logger.Warning("Gmail calendar attachment download requires Google Drive API access. FileId/URL: {RemoteId}", attachment.RemoteAttachmentId);
throw new NotSupportedException("Gmail calendar attachments are stored in Google Drive and require additional API configuration to download.");
}
@@ -1615,7 +1716,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Check Content-Type header for text/calendar
var contentTypeHeader = headers.FirstOrDefault(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))?.Value;
if (!string.IsNullOrEmpty(contentTypeHeader))
{
// Check if it's a calendar message (text/calendar or multipart with calendar)
@@ -1623,11 +1724,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
// Check the METHOD parameter to determine invitation type
var methodMatch = System.Text.RegularExpressions.Regex.Match(contentTypeHeader, @"method=([^;\s]+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (methodMatch.Success)
{
var method = methodMatch.Groups[1].Value.Trim('"').ToUpperInvariant();
return method switch
{
"REQUEST" => MailItemType.CalendarInvitation,
@@ -1636,7 +1737,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_ => MailItemType.Mail
};
}
// If no method specified, assume it's an invitation
return MailItemType.CalendarInvitation;
}
@@ -1683,11 +1784,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
var packageList = new List<NewMailItemPackage>();
// Create MailCopy from metadata only - NO MIME download
var mailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
// Create base MailCopy from metadata only - NO MIME download
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
// Check for local draft mapping using X-Wino-Draft-Id header from metadata
if (mailCopy.IsDraft)
if (baseMailCopy.IsDraft)
{
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
@@ -1696,7 +1797,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// This message belongs to existing local draft copy.
// We don't need to create a new mail copy for this message, just update the existing one.
bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, baseMailCopy.Id, baseMailCopy.DraftId, baseMailCopy.ThreadId);
if (isMappingSuccesfull) return null;
@@ -1704,12 +1805,32 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
}
// For Gmail, a single mail can have multiple labels (folders).
// Each label requires a separate MailCopy entry in the database with:
// - Same Id, UniqueId, FileId (shared across all copies)
// - Different FolderId (one per label)
// ARCHIVE label is excluded here as it's virtual and handled by MapArchivedMailsAsync
if (message.LabelIds is not null)
{
// Generate shared identifiers that will be the same for all copies of this mail
var sharedId = baseMailCopy.Id;
var sharedFileId = baseMailCopy.FileId;
foreach (var labelId in message.LabelIds)
{
// Skip ARCHIVE label - it's virtual and handled separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
continue;
// Create a new MailCopy instance for each label to avoid shared reference issues
var mailCopyForLabel = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
// Ensure all copies share the same Id and FileId
mailCopyForLabel.Id = sharedId;
mailCopyForLabel.FileId = sharedFileId;
// Pass null for MimeMessage - it will be downloaded later when user reads the mail
packageList.Add(new NewMailItemPackage(mailCopy, null, labelId));
packageList.Add(new NewMailItemPackage(mailCopyForLabel, null, labelId));
}
}
@@ -1807,7 +1928,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create a patch event to update only the attendee response
var patchEvent = new Event();
// We need to get the event first to update the specific attendee
// However, for efficiency, we'll use the patch method with sendUpdates parameter
var patchRequest = _calendarService.Events.Patch(new Event
@@ -1824,8 +1945,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send updates to other attendees if there's a message
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
@@ -1861,8 +1982,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
@@ -1898,8 +2019,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
@@ -1979,8 +2100,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var updateRequest = _calendarService.Events.Update(googleEvent, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send notifications to attendees if the event has attendees
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All
updateRequest.SendUpdates = (attendees != null && attendees.Count > 0)
? Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.All
: Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(updateRequest, request)];