Files
Wino-Mail/Wino.Core/Synchronizers/GmailSynchronizer.cs
T

2550 lines
109 KiB
C#
Raw Normal View History

2024-04-18 01:44:37 +02:00
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
2025-11-14 14:42:05 +01:00
using System.Text.Json.Serialization;
2024-04-18 01:44:37 +02:00
using System.Threading;
using System.Threading.Tasks;
2025-10-20 18:27:02 +02:00
using System.Web;
using CommunityToolkit.Mvvm.Messaging;
using Google;
2024-11-30 12:47:24 +01:00
using Google.Apis.Calendar.v3.Data;
2024-04-18 01:44:37 +02:00
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
using Google.Apis.Http;
using Google.Apis.PeopleService.v1;
2024-04-18 01:44:37 +02:00
using Google.Apis.Requests;
using Google.Apis.Services;
using MailKit;
using Microsoft.IdentityModel.Tokens;
using MimeKit;
using MoreLinq;
using Serilog;
using Wino.Core.Domain.Entities.Calendar;
2024-11-10 23:28:25 +01:00
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
2026-02-06 20:13:44 +01:00
using Wino.Core.Domain.Extensions;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
2025-02-22 00:22:00 +01:00
using Wino.Core.Domain.Models.Folders;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Extensions;
using Wino.Core.Http;
using Wino.Core.Integration.Processors;
2024-11-10 23:28:25 +01:00
using Wino.Core.Requests.Bundles;
2025-12-30 11:59:54 +01:00
using Wino.Core.Requests.Calendar;
2024-11-26 20:03:10 +01:00
using Wino.Core.Requests.Folder;
using Wino.Core.Requests.Mail;
using Wino.Messaging.UI;
using Wino.Services;
using CalendarService = Google.Apis.Calendar.v3.CalendarService;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
namespace Wino.Core.Synchronizers.Mail;
2025-11-14 14:42:05 +01:00
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(Label))]
[JsonSerializable(typeof(Draft))]
public partial class GmailSynchronizerJsonContext : JsonSerializerContext;
2025-10-30 17:15:05 +01:00
/// <summary>
/// Gmail synchronizer implementation using Gmail History API for efficient incremental sync.
///
/// SYNCHRONIZATION STRATEGY:
/// - 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
///
2025-10-30 17:15:05 +01:00
/// Key implementation details:
/// - 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
2025-10-30 17:15:05 +01:00
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
/// </summary>
2025-02-16 11:54:23 +01:00
public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message, Event>, IHttpClientFactory
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
public override uint BatchModificationSize => 1000;
2025-10-30 17:15:05 +01:00
/// <summary>
2025-10-29 18:44:15 +01:00
/// Maximum messages to fetch per folder during initial sync (1500).
2025-10-30 17:15:05 +01:00
/// All messages are downloaded with METADATA ONLY - no raw MIME content.
/// Uses Gmail API's Metadata format which includes headers, labels, and snippet but NOT full message body.
/// </summary>
public override uint InitialMessageDownloadCountPerFolder => 1500;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// It's actually 100. But Gmail SDK has internal bug for Out of Memory exception.
// https://github.com/googleapis/google-api-dotnet-client/issues/2603
private const uint MaximumAllowedBatchRequestSize = 10;
2025-10-29 18:44:15 +01:00
2025-02-16 11:54:23 +01:00
private readonly ConfigurableHttpClient _googleHttpClient;
private readonly GmailService _gmailService;
private readonly CalendarService _calendarService;
private readonly PeopleServiceService _peopleService;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private readonly IGmailChangeProcessor _gmailChangeProcessor;
private readonly IGmailSynchronizerErrorHandlerFactory _gmailSynchronizerErrorHandlerFactory;
2025-02-16 11:54:23 +01:00
private readonly ILogger _logger = Log.ForContext<GmailSynchronizer>();
2025-02-23 17:05:46 +01:00
// Keeping a reference for quick access to the virtual archive folder.
private Guid? archiveFolderId;
2025-10-29 18:44:15 +01:00
2025-02-16 11:54:23 +01:00
public GmailSynchronizer(MailAccount account,
IGmailAuthenticator authenticator,
IGmailChangeProcessor gmailChangeProcessor,
2025-10-31 00:51:27 +01:00
IGmailSynchronizerErrorHandlerFactory gmailSynchronizerErrorHandlerFactory) : base(account, WeakReferenceMessenger.Default)
2025-02-16 11:54:23 +01:00
{
var messageHandler = new GmailClientMessageHandler(authenticator, account);
2025-02-16 11:54:23 +01:00
var initializer = new BaseClientService.Initializer()
{
HttpClientFactory = this
};
2025-02-16 11:54:23 +01:00
_googleHttpClient = new ConfigurableHttpClient(messageHandler);
_gmailService = new GmailService(initializer);
_peopleService = new PeopleServiceService(initializer);
_calendarService = new CalendarService(initializer);
2025-02-16 11:54:23 +01:00
_gmailChangeProcessor = gmailChangeProcessor;
_gmailSynchronizerErrorHandlerFactory = gmailSynchronizerErrorHandlerFactory;
2025-02-16 11:54:23 +01:00
}
2025-02-16 11:54:23 +01:00
public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _googleHttpClient;
2025-02-16 11:54:23 +01:00
public override async Task<ProfileInformation> GetProfileInformationAsync()
{
var profileRequest = _peopleService.People.Get("people/me");
profileRequest.PersonFields = "names,photos,emailAddresses";
2025-02-16 11:54:23 +01:00
string senderName = string.Empty, base64ProfilePicture = string.Empty, address = string.Empty;
2025-02-16 11:54:23 +01:00
var userProfile = await profileRequest.ExecuteAsync();
2025-02-16 11:54:23 +01:00
senderName = userProfile.Names?.FirstOrDefault()?.DisplayName ?? Account.SenderName;
2025-02-16 11:54:23 +01:00
var profilePicture = userProfile.Photos?.FirstOrDefault()?.Url ?? string.Empty;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (!string.IsNullOrEmpty(profilePicture))
{
base64ProfilePicture = await GetProfilePictureBase64EncodedAsync(profilePicture).ConfigureAwait(false);
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
address = userProfile.EmailAddresses.FirstOrDefault(a => a.Metadata.Primary == true).Value;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return new ProfileInformation(senderName, base64ProfilePicture, address);
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
protected override async Task SynchronizeAliasesAsync()
{
var sendAsListRequest = _gmailService.Users.Settings.SendAs.List("me");
var sendAsListResponse = await sendAsListRequest.ExecuteAsync();
var remoteAliases = sendAsListResponse.GetRemoteAliases();
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.UpdateRemoteAliasInformationAsync(Account, remoteAliases).ConfigureAwait(false);
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
protected override async Task<MailSynchronizationResult> SynchronizeMailsInternalAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal mail synchronization started for {Name}", Account.Name);
2024-04-18 01:44:37 +02:00
var downloadedMessageIds = new List<string>();
var folderResults = new List<FolderSyncResult>();
2024-04-18 01:44:37 +02:00
try
2025-02-16 11:54:23 +01:00
{
// 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.
2025-02-16 11:54:23 +01:00
_logger.Information("Synchronizing folders for {Name}", Account.Name);
2025-10-31 01:41:51 +01:00
UpdateSyncProgress(0, 0, "Synchronizing folders...");
2024-04-18 01:44:37 +02:00
try
{
await SynchronizeFoldersAsync(cancellationToken).ConfigureAwait(false);
}
catch (GoogleApiException googleException) when (googleException.Message.Contains("Mail service not enabled"))
{
throw new GmailServiceDisabledException();
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Information("Synchronizing folders for {Name} is completed", Account.Name);
2025-10-31 01:41:51 +01:00
UpdateSyncProgress(0, 0, "Folders synchronized");
2024-04-18 01:44:37 +02:00
// Stop synchronization at this point if type is only folder metadata sync.
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
2024-04-18 01:44:37 +02:00
cancellationToken.ThrowIfCancellationRequested();
2024-04-18 01:44:37 +02:00
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
2024-04-18 01:44:37 +02:00
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
2024-04-18 01:44:37 +02:00
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);
2025-11-14 14:42:05 +01:00
// 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));
}
}
2026-02-06 21:46:30 +01:00
// Map Gmail Draft resource IDs for all drafts.
// Gmail's Messages API doesn't expose Draft IDs, so we query the Drafts API separately.
// This ensures DraftId is correctly set for both Wino-created and externally-created drafts.
await MapDraftIdsAsync(cancellationToken).ConfigureAwait(false);
2026-02-12 18:57:55 +01:00
// Keep virtual Archive folder assignments in sync with Gmail "in:archive" query.
try
{
await MapArchivedMailsAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to map Gmail archive folder for {Name}", Account.Name);
}
}
catch (OperationCanceledException)
2025-02-16 11:54:23 +01:00
{
_logger.Information("Synchronization was canceled for {Name}", Account.Name);
return MailSynchronizationResult.Canceled;
2025-10-30 17:15:05 +01:00
}
catch (Exception ex)
2025-10-30 17:15:05 +01:00
{
_logger.Error(ex, "Synchronization failed for {Name}", Account.Name);
return MailSynchronizationResult.Failed(ex);
2025-10-30 17:15:05 +01:00
}
2024-04-18 01:44:37 +02:00
// Get all unread new downloaded items for notifications
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
return MailSynchronizationResult.CompletedWithFolderResults(unreadNewItems, folderResults);
}
/// <summary>
/// Result of delta synchronization using History API.
/// </summary>
private record DeltaSyncResult(List<string> DownloadedMessageIds, bool RequiresFullResync);
2024-04-18 01:44:37 +02:00
/// <summary>
/// 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<List<string>> PerformInitialSyncAsync(CancellationToken cancellationToken)
2025-10-30 17:15:05 +01:00
{
// 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);
2025-10-30 17:15:05 +01:00
try
2025-02-16 11:54:23 +01:00
{
// 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)
.OrderByDescending(f => f.SpecialFolderType == SpecialFolderType.Draft || f.RemoteFolderId == ServiceConstants.DRAFT_LABEL_ID)
.ToList();
var totalFolders = syncableFolders.Count;
var totalMessagesDownloaded = 0;
2024-04-18 01:44:37 +02:00
for (int i = 0; i < totalFolders; i++)
2025-02-16 11:54:23 +01:00
{
var folder = syncableFolders[i];
cancellationToken.ThrowIfCancellationRequested();
UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
_logger.Debug("Downloading messages for folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
2024-04-18 01:44:37 +02:00
var folderDownloaded = 0;
string pageToken = null;
var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
do
2025-10-30 17:15:05 +01:00
{
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;
var response = await request.ExecuteAsync(cancellationToken);
2024-04-18 01:44:37 +02:00
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();
if (newMessageIds.Count > 0)
{
// Draft folder needs MIME during initial sync so compose can open immediately.
bool shouldDownloadRawMime = folder.SpecialFolderType == SpecialFolderType.Draft || folder.RemoteFolderId == ServiceConstants.DRAFT_LABEL_ID;
await DownloadMessagesInBatchAsync(newMessageIds, downloadRawMime: shouldDownloadRawMime, cancellationToken).ConfigureAwait(false);
foreach (var id in newMessageIds)
{
downloadedMessageIds.Add(id);
}
folderDownloaded += newMessageIds.Count;
totalMessagesDownloaded += newMessageIds.Count;
}
// Count all messages (including duplicates) toward the folder limit
remainingToDownload -= response.Messages.Count;
_logger.Debug("Folder {FolderName}: Downloaded {New} new messages ({Total} total in folder)",
folder.FolderName, newMessageIds.Count, folderDownloaded);
}
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);
2025-10-30 17:15:05 +01:00
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
2025-10-30 17:15:05 +01:00
{
_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 during initial sync for {Name}", Account.Name);
2025-10-30 17:15:05 +01:00
throw;
}
return downloadedMessageIds.ToList();
2025-10-30 17:15:05 +01:00
}
/// <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)
2025-10-30 17:15:05 +01:00
{
var downloadedMessageIds = new List<string>();
2025-10-30 17:15:05 +01:00
try
{
string pageToken = null;
2025-02-16 11:43:30 +01:00
do
2025-10-30 17:15:05 +01:00
{
cancellationToken.ThrowIfCancellationRequested();
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);
2024-04-18 01:44:37 +02:00
if (historyResponse.History != null)
2025-10-30 17:15:05 +01:00
{
var addedMessageIds = new List<string>();
// Collect all added messages first
foreach (var historyRecord in historyResponse.History)
2025-10-30 17:15:05 +01:00
{
if (historyRecord.MessagesAdded != null)
{
addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
}
2025-10-30 17:15:05 +01:00
}
2024-04-18 01:44:37 +02:00
// 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 (label changes, deletions)
await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
2025-10-30 17:15:05 +01:00
}
2024-04-18 01:44:37 +02:00
// 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)
2025-10-30 17:15:05 +01:00
{
await UpdateAccountSyncIdentifierAsync(historyResponse.HistoryId.Value).ConfigureAwait(false);
_logger.Debug("Updated history ID to {HistoryId} after delta sync", historyResponse.HistoryId.Value);
2025-10-30 17:15:05 +01:00
}
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
2025-02-16 11:54:23 +01:00
{
// 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);
2024-12-24 18:30:25 +01:00
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);
2025-10-30 17:15:05 +01:00
throw;
2025-02-16 11:54:23 +01:00
}
2025-10-30 17:15:05 +01:00
}
2024-12-24 18:30:25 +01:00
2025-02-16 11:54:23 +01:00
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
cancellationToken.ThrowIfCancellationRequested();
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await SynchronizeCalendarsAsync(cancellationToken).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
2024-04-18 01:44:37 +02:00
var localCalendars = (await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false))
.Where(c => c.IsSynchronizationEnabled)
.ToList();
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// TODO: Better logging and exception handling.
foreach (var calendar in localCalendars)
{
var request = _calendarService.Events.List(calendar.RemoteCalendarId);
2025-02-16 11:43:30 +01:00
2026-01-06 11:11:37 +01:00
// Fetch individual event instances (including recurring event occurrences)
// rather than recurring event masters. This ensures we get all occurrences
// as separate events that can be stored and displayed directly.
request.SingleEvents = true;
2025-02-16 11:54:23 +01:00
request.ShowDeleted = true;
2025-02-16 11:54:23 +01:00
if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken))
{
// If a sync token is available, perform an incremental sync
request.SyncToken = calendar.SynchronizationDeltaToken;
}
else
{
// If no sync token, perform an initial sync
// Fetch events from the past year
2025-02-16 11:54:23 +01:00
request.TimeMinDateTimeOffset = DateTimeOffset.UtcNow.AddYears(-1);
}
2025-02-16 11:54:23 +01:00
string nextPageToken;
string syncToken;
2025-02-16 11:54:23 +01:00
var allEvents = new List<Event>();
2025-02-16 11:54:23 +01:00
do
{
// Execute the request
var events = await request.ExecuteAsync();
2025-02-16 11:54:23 +01:00
// Process the fetched events
if (events.Items != null)
{
allEvents.AddRange(events.Items);
}
2025-02-16 11:54:23 +01:00
// Get the next page token and sync token
nextPageToken = events.NextPageToken;
syncToken = events.NextSyncToken;
2025-02-16 11:54:23 +01:00
// Set the next page token for subsequent requests
request.PageToken = nextPageToken;
2025-02-16 11:54:23 +01:00
} while (!string.IsNullOrEmpty(nextPageToken));
2025-02-16 11:54:23 +01:00
calendar.SynchronizationDeltaToken = syncToken;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
// allEvents contains new or updated events.
// Process them and create/update local calendar items.
2025-02-16 11:54:23 +01:00
foreach (var @event in allEvents)
{
// TODO: Exception handling for event processing.
// TODO: Also update attendees and other properties.
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.ManageCalendarEventAsync(@event, calendar, Account).ConfigureAwait(false);
2025-02-16 11:43:30 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false);
2025-02-16 11:43:30 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return default;
}
2025-02-16 11:54:23 +01:00
private async Task SynchronizeCalendarsAsync(CancellationToken cancellationToken = default)
{
var calendarListRequest = _calendarService.CalendarList.List();
var calendarListResponse = await calendarListRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (calendarListResponse.Items == null)
{
_logger.Warning("No calendars found for {Name}", Account.Name);
return;
}
2025-02-16 11:54:23 +01:00
var localCalendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
var remotePrimaryCalendarId = GetPrimaryCalendarId(calendarListResponse.Items);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
List<AccountCalendar> insertedCalendars = new();
List<AccountCalendar> updatedCalendars = new();
List<AccountCalendar> deletedCalendars = new();
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
// 1. Handle deleted calendars.
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
foreach (var calendar in localCalendars)
{
var remoteCalendar = calendarListResponse.Items.FirstOrDefault(a => a.Id == calendar.RemoteCalendarId);
if (remoteCalendar == null)
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
// Local calendar doesn't exists remotely. Delete local copy.
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.DeleteAccountCalendarAsync(calendar).ConfigureAwait(false);
deletedCalendars.Add(calendar);
2025-02-16 11:35:43 +01:00
}
2025-02-16 11:54:23 +01:00
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
// Delete the deleted folders from local list.
deletedCalendars.ForEach(a => localCalendars.Remove(a));
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
// 2. Handle update/insert based on remote calendars.
foreach (var calendar in calendarListResponse.Items)
{
var existingLocalCalendar = localCalendars.FirstOrDefault(a => a.RemoteCalendarId == calendar.Id);
if (existingLocalCalendar == null)
{
// Insert new calendar.
var localCalendar = calendar.AsCalendar(Account.Id);
localCalendar.IsPrimary = string.Equals(localCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
2025-02-16 11:54:23 +01:00
insertedCalendars.Add(localCalendar);
}
else
{
2025-02-16 11:54:23 +01:00
// Update existing calendar. Right now we only update the name.
if (ShouldUpdateCalendar(calendar, existingLocalCalendar, remotePrimaryCalendarId))
{
2025-02-16 11:54:23 +01:00
existingLocalCalendar.Name = calendar.Summary;
existingLocalCalendar.TimeZone = calendar.TimeZone;
if (!string.IsNullOrEmpty(calendar.BackgroundColor))
existingLocalCalendar.BackgroundColorHex = calendar.BackgroundColor;
if (!string.IsNullOrEmpty(calendar.ForegroundColor))
existingLocalCalendar.TextColorHex = calendar.ForegroundColor;
existingLocalCalendar.IsPrimary = string.Equals(existingLocalCalendar.RemoteCalendarId, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
2025-02-16 11:54:23 +01:00
updatedCalendars.Add(existingLocalCalendar);
}
else
{
2025-02-16 11:54:23 +01:00
// Remove it from the local folder list to skip additional calendar updates.
localCalendars.Remove(existingLocalCalendar);
2024-04-18 01:44:37 +02:00
}
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var calendar in insertedCalendars)
{
await _gmailChangeProcessor.InsertAccountCalendarAsync(calendar).ConfigureAwait(false);
}
2025-02-16 11:54:23 +01:00
foreach (var calendar in updatedCalendars)
{
await _gmailChangeProcessor.UpdateAccountCalendarAsync(calendar).ConfigureAwait(false);
}
2025-02-16 11:54:23 +01:00
if (insertedCalendars.Any() || deletedCalendars.Any() || updatedCalendars.Any())
{
// TODO: Notify calendar updates.
// WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
}
2025-02-16 11:54:23 +01:00
}
2025-02-23 17:05:46 +01:00
private async Task InitializeArchiveFolderAsync()
{
var localFolders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
// Handling of Gmail special virtual Archive folder.
// We will generate a new virtual folder if doesn't exist.
if (!localFolders.Any(a => a.SpecialFolderType == SpecialFolderType.Archive && a.RemoteFolderId == ServiceConstants.ARCHIVE_LABEL_ID))
2025-02-23 17:05:46 +01:00
{
archiveFolderId = Guid.NewGuid();
var archiveFolder = new MailItemFolder()
{
FolderName = "Archive", // will be localized. N/A
RemoteFolderId = ServiceConstants.ARCHIVE_LABEL_ID,
Id = archiveFolderId.Value,
MailAccountId = Account.Id,
SpecialFolderType = SpecialFolderType.Archive,
IsSynchronizationEnabled = true,
IsSystemFolder = true,
IsSticky = true,
IsHidden = false,
ShowUnreadCount = true
};
await _gmailChangeProcessor.InsertFolderAsync(archiveFolder).ConfigureAwait(false);
// Migration-> User might've already have another special folder for Archive.
// We must remove that type assignment.
// This code can be removed after sometime.
var otherArchiveFolders = localFolders.Where(a => a.SpecialFolderType == SpecialFolderType.Archive && a.Id != archiveFolderId.Value).ToList();
foreach (var otherArchiveFolder in otherArchiveFolders)
{
otherArchiveFolder.SpecialFolderType = SpecialFolderType.Other;
await _gmailChangeProcessor.UpdateFolderAsync(otherArchiveFolder).ConfigureAwait(false);
}
}
else
{
archiveFolderId = localFolders.First(a => a.SpecialFolderType == SpecialFolderType.Archive && a.RemoteFolderId == ServiceConstants.ARCHIVE_LABEL_ID).Id;
}
}
2025-02-16 11:54:23 +01:00
private async Task SynchronizeFoldersAsync(CancellationToken cancellationToken = default)
{
var localFolders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
var folderRequest = _gmailService.Users.Labels.List("me");
2025-02-16 11:54:23 +01:00
var labelsResponse = await folderRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (labelsResponse.Labels == null)
{
2025-02-16 11:54:23 +01:00
_logger.Warning("No folders found for {Name}", Account.Name);
return;
}
2025-02-16 11:54:23 +01:00
List<MailItemFolder> insertedFolders = new();
List<MailItemFolder> updatedFolders = new();
List<MailItemFolder> deletedFolders = new();
2025-02-16 11:54:23 +01:00
// 1. Handle deleted labels.
foreach (var localFolder in localFolders)
{
// Category folder is virtual folder for Wino. Skip it.
if (localFolder.SpecialFolderType == SpecialFolderType.Category) continue;
2025-02-23 17:05:46 +01:00
// Gmail's Archive folder is virtual older for Wino. Skip it.
if (localFolder.SpecialFolderType == SpecialFolderType.Archive) continue;
2025-02-16 11:54:23 +01:00
var remoteFolder = labelsResponse.Labels.FirstOrDefault(a => a.Id == localFolder.RemoteFolderId);
2025-02-16 11:54:23 +01:00
if (remoteFolder == null)
{
2025-02-16 11:54:23 +01:00
// Local folder doesn't exists remotely. Delete local copy.
await _gmailChangeProcessor.DeleteFolderAsync(Account.Id, localFolder.RemoteFolderId).ConfigureAwait(false);
2025-02-16 11:54:23 +01:00
deletedFolders.Add(localFolder);
}
}
2025-02-16 11:54:23 +01:00
// Delete the deleted folders from local list.
deletedFolders.ForEach(a => localFolders.Remove(a));
2025-02-16 11:54:23 +01:00
// 2. Handle update/insert based on remote folders.
foreach (var remoteFolder in labelsResponse.Labels)
{
var existingLocalFolder = localFolders.FirstOrDefault(a => a.RemoteFolderId == remoteFolder.Id);
2025-02-16 11:54:23 +01:00
if (existingLocalFolder == null)
{
// Insert new folder.
var localFolder = remoteFolder.GetLocalFolder(labelsResponse, Account.Id);
2025-02-16 11:54:23 +01:00
insertedFolders.Add(localFolder);
}
else
{
2025-02-16 11:54:23 +01:00
// Update existing folder. Right now we only update the name.
// TODO: Moving folders around different parents. This is not supported right now.
// We will need more comphrensive folder update mechanism to support this.
2025-02-16 11:54:23 +01:00
if (ShouldUpdateFolder(remoteFolder, existingLocalFolder))
{
2025-02-16 11:54:23 +01:00
existingLocalFolder.FolderName = remoteFolder.Name;
existingLocalFolder.TextColorHex = remoteFolder.Color?.TextColor;
existingLocalFolder.BackgroundColorHex = remoteFolder.Color?.BackgroundColor;
2025-02-16 11:54:23 +01:00
updatedFolders.Add(existingLocalFolder);
}
else
{
2025-02-16 11:54:23 +01:00
// Remove it from the local folder list to skip additional folder updates.
localFolders.Remove(existingLocalFolder);
}
}
2025-02-16 11:54:23 +01:00
}
2025-02-16 11:54:23 +01:00
// 3.Process changes in order-> Insert, Update. Deleted ones are already processed.
foreach (var folder in insertedFolders)
{
await _gmailChangeProcessor.InsertFolderAsync(folder).ConfigureAwait(false);
}
2025-02-16 11:54:23 +01:00
foreach (var folder in updatedFolders)
{
await _gmailChangeProcessor.UpdateFolderAsync(folder).ConfigureAwait(false);
2025-02-16 11:35:43 +01:00
}
2025-02-16 11:54:23 +01:00
if (insertedFolders.Any() || deletedFolders.Any() || updatedFolders.Any())
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
WeakReferenceMessenger.Default.Send(new AccountFolderConfigurationUpdated(Account.Id));
}
}
private bool ShouldUpdateCalendar(CalendarListEntry calendarListEntry, AccountCalendar accountCalendar, string remotePrimaryCalendarId)
2025-02-16 11:54:23 +01:00
{
var remoteCalendarName = calendarListEntry.Summary;
var remoteTimeZone = calendarListEntry.TimeZone;
var remoteBackgroundColor = string.IsNullOrEmpty(calendarListEntry.BackgroundColor) ? accountCalendar.BackgroundColorHex : calendarListEntry.BackgroundColor;
var remoteTextColor = string.IsNullOrEmpty(calendarListEntry.ForegroundColor) ? accountCalendar.TextColorHex : calendarListEntry.ForegroundColor;
var remoteIsPrimary = string.Equals(calendarListEntry.Id, remotePrimaryCalendarId, StringComparison.OrdinalIgnoreCase);
bool isNameChanged = !string.Equals(accountCalendar.Name, remoteCalendarName, StringComparison.OrdinalIgnoreCase);
bool isTimeZoneChanged = !string.Equals(accountCalendar.TimeZone, remoteTimeZone, StringComparison.OrdinalIgnoreCase);
bool isBackgroundColorChanged = !string.Equals(accountCalendar.BackgroundColorHex, remoteBackgroundColor, StringComparison.OrdinalIgnoreCase);
bool isTextColorChanged = !string.Equals(accountCalendar.TextColorHex, remoteTextColor, StringComparison.OrdinalIgnoreCase);
bool isPrimaryChanged = accountCalendar.IsPrimary != remoteIsPrimary;
return isNameChanged || isTimeZoneChanged || isBackgroundColorChanged || isTextColorChanged || isPrimaryChanged;
}
private string GetPrimaryCalendarId(IList<CalendarListEntry> remoteCalendars)
{
if (remoteCalendars == null || remoteCalendars.Count == 0)
return string.Empty;
var explicitPrimary = remoteCalendars.FirstOrDefault(c => c.Primary.GetValueOrDefault());
if (explicitPrimary != null)
return explicitPrimary.Id;
var byPrimaryKeyword = remoteCalendars.FirstOrDefault(c => string.Equals(c.Id, "primary", StringComparison.OrdinalIgnoreCase));
if (byPrimaryKeyword != null)
return byPrimaryKeyword.Id;
var byAccountAddress = remoteCalendars.FirstOrDefault(c => string.Equals(c.Id, Account.Address, StringComparison.OrdinalIgnoreCase));
if (byAccountAddress != null)
return byAccountAddress.Id;
return remoteCalendars.First().Id;
2025-02-16 11:54:23 +01:00
}
2025-02-16 11:54:23 +01:00
private bool ShouldUpdateFolder(Label remoteFolder, MailItemFolder existingLocalFolder)
{
var remoteFolderName = GoogleIntegratorExtensions.GetFolderName(remoteFolder.Name);
var localFolderName = GoogleIntegratorExtensions.GetFolderName(existingLocalFolder.FolderName);
2025-02-16 11:54:23 +01:00
bool isNameChanged = !localFolderName.Equals(remoteFolderName, StringComparison.OrdinalIgnoreCase);
bool isColorChanged = existingLocalFolder.BackgroundColorHex != remoteFolder.Color?.BackgroundColor ||
existingLocalFolder.TextColorHex != remoteFolder.Color?.TextColor;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return isNameChanged || isColorChanged;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
/// <summary>
2025-10-30 17:15:05 +01:00
/// Returns a single get request to retrieve the message with the given id.
/// Always uses Metadata format to download only headers and labels - NOT raw MIME content.
/// MIME content is only downloaded when explicitly needed via DownloadMissingMimeMessageAsync.
2025-02-16 11:54:23 +01:00
/// </summary>
/// <param name="messageId">Message to download.</param>
2025-10-30 17:15:05 +01:00
/// <returns>Get request for message with Metadata format.</returns>
private UsersResource.MessagesResource.GetRequest CreateSingleMessageGet(string messageId)
2025-02-16 11:54:23 +01:00
{
var singleRequest = _gmailService.Users.Messages.Get("me", messageId);
2025-10-30 17:15:05 +01:00
// Always use Metadata format for synchronization - this populates Payload.Headers
// but does NOT download the raw MIME content, saving significant bandwidth and time
singleRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Metadata;
return singleRequest;
}
/// <summary>
/// Returns a single get request to retrieve the message with Raw format (includes MIME).
/// Used during delta sync to download full message content.
/// </summary>
/// <param name="messageId">Message to download.</param>
/// <returns>Get request for message with Raw format.</returns>
private UsersResource.MessagesResource.GetRequest CreateSingleMessageGetRaw(string messageId)
{
var singleRequest = _gmailService.Users.Messages.Get("me", messageId);
// Use Raw format to get full MIME content
singleRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return singleRequest;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
/// <summary>
/// Processes the delta changes for the given history changes.
/// Message downloads are not handled here since it's better to batch them.
/// </summary>
/// <param name="listHistoryResponse">List of history changes.</param>
private async Task ProcessHistoryChangesAsync(ListHistoryResponse listHistoryResponse)
{
2025-10-30 17:15:05 +01:00
_logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name);
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
foreach (var history in listHistoryResponse.History)
{
// Handle label additions.
if (history.LabelsAdded is not null)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
foreach (var addedLabel in history.LabelsAdded)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
await HandleLabelAssignmentAsync(addedLabel);
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Handle label removals.
if (history.LabelsRemoved is not null)
{
foreach (var removedLabel in history.LabelsRemoved)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
await HandleLabelRemovalAsync(removedLabel);
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Handle removed messages.
if (history.MessagesDeleted is not null)
{
foreach (var deletedMessage in history.MessagesDeleted)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var messageId = deletedMessage.Message.Id;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Processing message deletion for {MessageId}", messageId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, messageId).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
}
}
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-23 17:05:46 +01:00
private async Task HandleArchiveAssignmentAsync(string archivedMessageId)
{
// Ignore if the message is already in the archive.
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value);
if (archived) return;
_logger.Debug("Processing archive assignment for message {Id}", archivedMessageId);
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false);
}
private async Task HandleUnarchiveAssignmentAsync(string unarchivedMessageId)
{
// Ignore if the message is not in the archive.
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value);
if (!archived) return;
_logger.Debug("Processing un-archive assignment for message {Id}", unarchivedMessageId);
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false);
}
2025-02-16 11:54:23 +01:00
private async Task HandleLabelAssignmentAsync(HistoryLabelAdded addedLabel)
{
var messageId = addedLabel.Message.Id;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Processing label assignment for message {MessageId}", messageId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
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;
}
2025-02-16 11:54:23 +01:00
// When UNREAD label is added mark the message as un-read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// When STARRED label is added mark the message as flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, true).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private async Task HandleLabelRemovalAsync(HistoryLabelRemoved removedLabel)
{
var messageId = removedLabel.Message.Id;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Processing label removed for message {MessageId}", messageId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
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;
}
2025-02-16 11:54:23 +01:00
// When UNREAD label is removed mark the message as read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
// When STARRED label is removed mark the message as un-flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, false).ConfigureAwait(false);
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
// For other labels remove the mail assignment.
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
}
/// <summary>
/// Prepares Gmail Draft object from Google SDK.
/// If provided, ThreadId ties the draft to a thread. Used when replying messages.
/// If provided, DraftId updates the draft instead of creating a new one.
/// </summary>
/// <param name="mimeMessage">MailKit MimeMessage to include as raw message into Gmail request.</param>
/// <param name="messageThreadId">ThreadId that this draft should be tied to.</param>
/// <param name="messageDraftId">Existing DraftId from Gmail to update existing draft.</param>
/// <returns></returns>
private Draft PrepareGmailDraft(MimeMessage mimeMessage, string messageThreadId = "", string messageDraftId = "")
{
mimeMessage.Prepare(EncodingConstraint.None);
var mimeString = mimeMessage.ToString();
var base64UrlEncodedMime = Base64UrlEncoder.Encode(mimeString);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var nativeMessage = new Message()
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Raw = base64UrlEncodedMime,
};
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (!string.IsNullOrEmpty(messageThreadId))
nativeMessage.ThreadId = messageThreadId;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var draft = new Draft()
{
Message = nativeMessage,
Id = messageDraftId
};
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return draft;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
#region Mail Integrations
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> Move(BatchMoveRequest request)
{
var toFolder = request[0].ToFolder;
var fromFolder = request[0].FromFolder;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Sent label can't be removed from mails for Gmail.
// They are automatically assigned by Gmail.
// When you delete sent mail from gmail web portal, it's moved to Trash
// but still has Sent label. It's just hidden from the user.
// Proper assignments will be done later on CreateAssignment call to mimic this behavior.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var batchModifyRequest = new BatchModifyMessagesRequest
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Ids = request.Select(a => a.Item.Id.ToString()).ToList(),
AddLabelIds = [toFolder.RemoteFolderId]
};
2024-11-26 20:03:10 +01:00
2025-02-23 17:05:46 +01:00
// Archived item is being moved to different folder.
// Unarchive will move it to Inbox, so this is a different case.
// We can't remove ARCHIVE label because it's a virtual folder and does not exist in Gmail.
// We will just add the target label and Gmail will handle the rest.
if (fromFolder.SpecialFolderType == SpecialFolderType.Archive)
2025-02-16 11:54:23 +01:00
{
2025-02-23 17:05:46 +01:00
batchModifyRequest.AddLabelIds = [toFolder.RemoteFolderId];
}
else if (fromFolder.SpecialFolderType != SpecialFolderType.Sent)
{
// Only add remove label ids if the source folder is not sent folder.
2025-02-16 11:54:23 +01:00
batchModifyRequest.RemoveLabelIds = [fromFolder.RemoteFolderId];
}
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request)];
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> ChangeFlag(BatchChangeFlagRequest request)
{
bool isFlagged = request[0].IsFlagged;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var batchModifyRequest = new BatchModifyMessagesRequest
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Ids = request.Select(a => a.Item.Id.ToString()).ToList(),
};
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
if (isFlagged)
batchModifyRequest.AddLabelIds = new List<string>() { ServiceConstants.STARRED_LABEL_ID };
else
batchModifyRequest.RemoveLabelIds = new List<string>() { ServiceConstants.STARRED_LABEL_ID };
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request)];
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> MarkRead(BatchMarkReadRequest request)
{
bool readStatus = request[0].IsRead;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var batchModifyRequest = new BatchModifyMessagesRequest
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Ids = request.Select(a => a.Item.Id.ToString()).ToList(),
};
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
if (readStatus)
batchModifyRequest.RemoveLabelIds = new List<string>() { ServiceConstants.UNREAD_LABEL_ID };
else
batchModifyRequest.AddLabelIds = new List<string>() { ServiceConstants.UNREAD_LABEL_ID };
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request)];
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> Delete(BatchDeleteRequest request)
{
var batchModifyRequest = new BatchDeleteMessagesRequest
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Ids = request.Select(a => a.Item.Id.ToString()).ToList(),
};
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Messages.BatchDelete(batchModifyRequest, "me");
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request)];
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> CreateDraft(CreateDraftRequest singleRequest)
{
Draft draft = null;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// It's new mail. Not a reply
if (singleRequest.DraftPreperationRequest.ReferenceMailCopy == null)
draft = PrepareGmailDraft(singleRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage);
else
draft = PrepareGmailDraft(singleRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage,
singleRequest.DraftPreperationRequest.ReferenceMailCopy.ThreadId,
singleRequest.DraftPreperationRequest.ReferenceMailCopy.DraftId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Drafts.Create(draft, "me");
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, singleRequest, singleRequest)];
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> Archive(BatchArchiveRequest request)
{
bool isArchiving = request[0].IsArchiving;
var batchModifyRequest = new BatchModifyMessagesRequest
2024-06-21 23:48:03 +02:00
{
2025-02-16 11:54:23 +01:00
Ids = request.Select(a => a.Item.Id.ToString()).ToList()
};
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
if (isArchiving)
{
batchModifyRequest.RemoveLabelIds = new[] { ServiceConstants.INBOX_LABEL_ID };
2024-06-21 23:48:03 +02:00
}
2025-02-16 11:54:23 +01:00
else
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
batchModifyRequest.AddLabelIds = new[] { ServiceConstants.INBOX_LABEL_ID };
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Messages.BatchModify(batchModifyRequest, "me");
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request)];
}
2024-06-14 00:39:18 +02:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> SendDraft(SendDraftRequest singleDraftRequest)
{
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var message = new Message();
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (!string.IsNullOrEmpty(singleDraftRequest.Item.ThreadId))
{
message.ThreadId = singleDraftRequest.Item.ThreadId;
}
2024-11-26 20:03:10 +01:00
2025-02-16 11:54:23 +01:00
singleDraftRequest.Request.Mime.Prepare(EncodingConstraint.None);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var mimeString = singleDraftRequest.Request.Mime.ToString();
var base64UrlEncodedMime = Base64UrlEncoder.Encode(mimeString);
message.Raw = base64UrlEncodedMime;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var draft = new Draft()
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
Id = singleDraftRequest.Request.MailItem.DraftId,
Message = message
};
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Drafts.Send(draft, "me");
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, singleDraftRequest, singleDraftRequest)];
}
2025-02-22 00:22:00 +01:00
public override async Task<List<MailCopy>> OnlineSearchAsync(string queryText, List<IMailItemFolder> folders, CancellationToken cancellationToken = default)
{
2026-02-12 18:57:55 +01:00
if (string.IsNullOrWhiteSpace(queryText))
return [];
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
static bool IsArchiveFolder(IMailItemFolder folder)
=> folder?.SpecialFolderType == SpecialFolderType.Archive || folder?.RemoteFolderId == ServiceConstants.ARCHIVE_LABEL_ID;
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
var messageIds = new HashSet<string>(StringComparer.Ordinal);
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
async Task CollectMessageIdsAsync(UsersResource.MessagesResource.ListRequest request)
2025-02-22 00:22:00 +01:00
{
2026-02-12 18:57:55 +01:00
string pageToken = null;
do
2025-02-22 00:22:00 +01:00
{
2026-02-12 18:57:55 +01:00
if (!string.IsNullOrEmpty(pageToken))
{
request.PageToken = pageToken;
}
var response = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (response.Messages == null || response.Messages.Count == 0) break;
foreach (var message in response.Messages)
{
if (!string.IsNullOrEmpty(message.Id))
{
messageIds.Add(message.Id);
}
}
pageToken = response.NextPageToken;
} while (!string.IsNullOrEmpty(pageToken));
}
bool hasScopedQuery = queryText.StartsWith("label:", StringComparison.OrdinalIgnoreCase) ||
queryText.StartsWith("in:", StringComparison.OrdinalIgnoreCase);
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
if (hasScopedQuery || folders?.Count == 0)
{
var request = _gmailService.Users.Messages.List("me");
request.Q = queryText;
request.MaxResults = 500;
await CollectMessageIdsAsync(request).ConfigureAwait(false);
}
else
{
foreach (var folder in folders)
2025-02-22 00:22:00 +01:00
{
2026-02-12 18:57:55 +01:00
cancellationToken.ThrowIfCancellationRequested();
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
var request = _gmailService.Users.Messages.List("me");
request.MaxResults = 500;
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
if (IsArchiveFolder(folder))
{
// Gmail archive is virtual. Query via search operator instead of label id.
request.Q = $"in:archive {queryText}".Trim();
}
else
{
request.Q = queryText;
request.LabelIds = new List<string> { folder.RemoteFolderId };
}
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
await CollectMessageIdsAsync(request).ConfigureAwait(false);
}
}
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
if (messageIds.Count == 0)
return [];
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
var messageIdList = messageIds.ToList();
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
// Do not download messages that already exist locally.
var existingMessageIds = await _gmailChangeProcessor.AreMailsExistsAsync(messageIdList).ConfigureAwait(false);
var messagesToDownload = messageIdList.Except(existingMessageIds, StringComparer.Ordinal);
2025-02-22 00:22:00 +01:00
2026-02-12 18:57:55 +01:00
// Download missing messages in batch with metadata only.
await DownloadMessagesInBatchAsync(messagesToDownload, cancellationToken).ConfigureAwait(false);
2025-02-22 00:22:00 +01:00
// Get results from database and return.
2026-02-12 18:57:55 +01:00
return await _gmailChangeProcessor.GetMailCopiesAsync(messageIdList).ConfigureAwait(false);
2025-02-22 00:22:00 +01:00
}
2025-10-30 17:15:05 +01:00
/// <summary>
/// Downloads multiple messages in batches with metadata only (no MIME) and creates mail packages.
/// Uses Gmail batch API to download up to MaximumAllowedBatchRequestSize messages per request.
/// Used for initial sync where MIME is not needed.
/// </summary>
/// <param name="messageIds">List of Gmail message IDs to download</param>
/// <param name="cancellationToken">Cancellation token</param>
private async Task DownloadMessagesInBatchAsync(IEnumerable<string> messageIds, CancellationToken cancellationToken = default)
{
await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Downloads multiple messages in batches with optional MIME content and creates mail packages.
/// Uses Gmail batch API to download up to MaximumAllowedBatchRequestSize messages per request.
/// </summary>
/// <param name="messageIds">List of Gmail message IDs to download</param>
/// <param name="downloadRawMime">True to download Raw format with MIME, false for Metadata only</param>
/// <param name="cancellationToken">Cancellation token</param>
private async Task DownloadMessagesInBatchAsync(IEnumerable<string> messageIds, bool downloadRawMime, CancellationToken cancellationToken = default)
{
var messageIdList = messageIds.ToList();
if (messageIdList.Count == 0) return;
// Split into batches based on MaximumAllowedBatchRequestSize
var batches = messageIdList.Batch((int)MaximumAllowedBatchRequestSize);
foreach (var batch in batches)
{
var batchRequest = new BatchRequest(_gmailService);
var downloadedMessages = new List<Message>();
var batchTasks = new List<Task>();
foreach (var messageId in batch)
{
var request = downloadRawMime ? CreateSingleMessageGetRaw(messageId) : CreateSingleMessageGet(messageId);
batchRequest.Queue<Message>(request, (message, error, index, httpMessage) =>
{
var task = Task.Run(async () =>
{
if (error != null)
{
_logger.Warning("Failed to download message {MessageId}: {Error}", messageId, error.Message);
return;
}
if (message != null)
{
lock (downloadedMessages)
{
downloadedMessages.Add(message);
}
}
});
batchTasks.Add(task);
});
}
// Execute the batch request
await batchRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
await Task.WhenAll(batchTasks).ConfigureAwait(false);
// Process all downloaded messages
foreach (var gmailMessage in downloadedMessages)
{
try
{
// Create mail packages from metadata/raw.
// If Gmail response is Raw format, CreateNewMailPackagesAsync will parse MIME and
// include it in package(s) so it can be saved to disk.
2025-10-30 17:15:05 +01:00
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
{
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
2025-10-30 17:15:05 +01:00
}
}
// Update sync identifier if available
if (gmailMessage.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId.Value).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to process downloaded message {MessageId}", gmailMessage.Id);
}
}
}
}
/// <summary>
/// Downloads a single message by ID with metadata only (no MIME) and creates mail packages.
/// </summary>
/// <param name="messageId">Gmail message ID to download</param>
/// <param name="cancellationToken">Cancellation token</param>
private async Task DownloadSingleMessageMetadataAsync(string messageId, CancellationToken cancellationToken = default)
{
var request = CreateSingleMessageGet(messageId);
var gmailMessage = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (gmailMessage == null)
{
_logger.Warning("Failed to download message metadata for {MessageId}", messageId);
return;
}
// Create mail packages from metadata
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
{
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
// Update sync identifier if available
if (gmailMessage.HistoryId.HasValue)
{
await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId.Value).ConfigureAwait(false);
}
}
2025-10-03 15:46:38 +02:00
public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem,
2025-02-16 11:54:23 +01:00
ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
{
2026-02-08 22:20:38 +01:00
try
{
var request = _gmailService.Users.Messages.Get("me", mailItem.Id);
request.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
2026-02-08 22:20:38 +01:00
var gmailMessage = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
var mimeMessage = gmailMessage.GetGmailMimeMessage();
2026-02-08 22:20:38 +01:00
if (mimeMessage == null)
{
_logger.Warning("Tried to download Gmail Raw Mime with {Id} id and server responded without a data.", mailItem.Id);
return;
}
await _gmailChangeProcessor.SaveMimeFileAsync(mailItem.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
2025-02-16 11:54:23 +01:00
{
2026-02-08 22:20:38 +01:00
_logger.Warning("Gmail message {MailId} not found (404) during MIME download. Deleting locally.", mailItem.Id);
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, mailItem.Id).ConfigureAwait(false);
throw new SynchronizerEntityNotFoundException(ex.Message);
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
}
2026-01-03 23:59:37 +01:00
public override async Task DownloadCalendarAttachmentAsync(
Wino.Core.Domain.Entities.Calendar.CalendarItem calendarItem,
Wino.Core.Domain.Entities.Calendar.CalendarAttachment attachment,
string localFilePath,
CancellationToken cancellationToken = default)
{
try
{
// 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
2026-01-03 23:59:37 +01:00
if (string.IsNullOrEmpty(attachment.RemoteAttachmentId))
{
_logger.Error("RemoteAttachmentId is empty for attachment {AttachmentId}", attachment.Id);
throw new InvalidOperationException("RemoteAttachmentId is required to download Gmail calendar attachment.");
}
// Gmail calendar attachments are links to Google Drive files
// 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
2026-01-03 23:59:37 +01:00
_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.");
}
catch (Exception ex)
{
_logger.Error(ex, "Error downloading Gmail calendar attachment {AttachmentId}", attachment.Id);
throw;
}
}
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> RenameFolder(RenameFolderRequest request)
{
var label = new Label()
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
Name = request.NewFolderName
};
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var networkCall = _gmailService.Users.Labels.Update(label, "me", request.Folder.RemoteFolderId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request, request)];
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> EmptyFolder(EmptyFolderRequest request)
{
// Create batch delete request.
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
var deleteRequests = request.MailsToDelete.Select(a => new DeleteRequest(a));
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return Delete(new BatchDeleteRequest(deleteRequests));
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override List<IRequestBundle<IClientServiceRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true))));
2024-04-18 01:44:37 +02:00
public override List<IRequestBundle<IClientServiceRequest>> DeleteFolder(DeleteFolderRequest request)
{
var networkCall = _gmailService.Users.Labels.Delete("me", request.Folder.RemoteFolderId);
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> CreateSubFolder(CreateSubFolderRequest request)
{
var parentLabelName = request.Folder.FolderName;
try
{
var parentLabel = _gmailService.Users.Labels.Get("me", request.Folder.RemoteFolderId).Execute();
if (!string.IsNullOrWhiteSpace(parentLabel?.Name))
{
parentLabelName = parentLabel.Name;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to resolve full parent label name for {FolderId}. Falling back to local folder name.", request.Folder.RemoteFolderId);
}
var label = new Label()
{
Name = $"{parentLabelName}/{request.NewFolderName}"
};
var networkCall = _gmailService.Users.Labels.Create(label, "me");
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request, request)];
}
2025-02-16 11:54:23 +01:00
#endregion
#region Request Execution
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public override async Task ExecuteNativeRequestsAsync(List<IRequestBundle<IClientServiceRequest>> batchedRequests,
CancellationToken cancellationToken = default)
{
2026-01-27 20:37:18 +01:00
// First apply all UI changes immediately before any batching.
// This ensures UI reflects changes right away, regardless of batch processing.
foreach (var bundle in batchedRequests)
{
bundle.UIChangeRequest?.ApplyUIChanges();
}
// Now batch and execute the network requests.
2025-02-16 11:54:23 +01:00
var batchedBundles = batchedRequests.Batch((int)MaximumAllowedBatchRequestSize);
var bundleCount = batchedBundles.Count();
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
for (int i = 0; i < bundleCount; i++)
{
var bundle = batchedBundles.ElementAt(i);
2024-08-29 22:43:27 +02:00
2025-02-16 11:54:23 +01:00
var nativeBatchRequest = new BatchRequest(_gmailService);
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
var bundleRequestCount = bundle.Count();
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
var bundleTasks = new List<Task>();
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
for (int k = 0; k < bundleRequestCount; k++)
{
var requestBundle = bundle.ElementAt(k);
2026-01-27 20:37:18 +01:00
// UI changes are already applied above before batching.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
nativeBatchRequest.Queue<object>(requestBundle.NativeRequest, (content, error, index, message)
=> bundleTasks.Add(ProcessSingleNativeRequestResponseAsync(requestBundle, error, message, cancellationToken)));
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
await nativeBatchRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
await Task.WhenAll(bundleTasks);
2025-02-16 11:35:43 +01:00
}
2025-02-16 11:54:23 +01:00
}
private async Task ProcessGmailRequestErrorAsync(RequestError error, IRequestBundle<IClientServiceRequest> bundle)
2025-02-16 11:54:23 +01:00
{
if (error == null) return;
2024-04-18 01:44:37 +02:00
// Create error context
var errorContext = new SynchronizerErrorContext
2025-02-16 11:35:43 +01:00
{
2026-02-08 22:20:38 +01:00
Account = Account,
ErrorCode = error.Code,
ErrorMessage = error.Message,
RequestBundle = bundle,
AdditionalData = new Dictionary<string, object>
{
{ "Error", error }
}
};
2024-04-18 01:44:37 +02:00
// Try to handle the error with registered handlers
var handled = await _gmailSynchronizerErrorHandlerFactory.HandleErrorAsync(errorContext);
2024-04-18 01:44:37 +02:00
// If not handled by any specific handler, apply default error handling
if (!handled)
2025-02-16 11:54:23 +01:00
{
// OutOfMemoryException is a known bug in Gmail SDK.
if (error.Code == 0)
{
bundle?.UIChangeRequest?.RevertUIChanges();
throw new OutOfMemoryException(error.Message);
}
// Entity not found.
if (error.Code == 404)
{
bundle?.UIChangeRequest?.RevertUIChanges();
throw new SynchronizerEntityNotFoundException(error.Message);
}
2024-04-18 01:44:37 +02:00
if (!string.IsNullOrEmpty(error.Message))
{
bundle?.UIChangeRequest?.RevertUIChanges();
error.Errors?.ForEach(error => _logger.Error("Unknown Gmail SDK error for {Name}\n{Error}", Account.Name, error));
throw new SynchronizerException(error.Message);
}
2025-02-16 11:54:23 +01:00
}
}
2025-02-16 11:43:30 +01:00
private bool ShouldUpdateSyncIdentifier(ulong? historyId)
{
if (historyId == null) return false;
var newHistoryId = historyId.Value;
2024-04-18 01:44:37 +02:00
return Account.SynchronizationDeltaIdentifier == null ||
(ulong.TryParse(Account.SynchronizationDeltaIdentifier, out ulong currentIdentifier) && newHistoryId > currentIdentifier);
}
private async Task UpdateAccountSyncIdentifierAsync(ulong? historyId)
{
if (ShouldUpdateSyncIdentifier(historyId))
{
Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, historyId.Value.ToString());
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private async Task ProcessSingleNativeRequestResponseAsync(IRequestBundle<IClientServiceRequest> bundle,
RequestError error,
HttpResponseMessage httpResponseMessage,
CancellationToken cancellationToken = default)
{
await ProcessGmailRequestErrorAsync(error, bundle);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (bundle is HttpRequestBundle<IClientServiceRequest, Message> messageBundle)
2024-04-18 01:44:37 +02:00
{
2025-11-14 14:42:05 +01:00
var gmailMessage = await messageBundle.DeserializeBundleAsync(httpResponseMessage, GmailSynchronizerJsonContext.Default.Message, cancellationToken).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (gmailMessage == null) return;
2024-04-18 01:44:37 +02:00
2025-10-30 17:15:05 +01:00
// Create mail packages from the downloaded message
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
{
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId).ConfigureAwait(false);
2025-02-16 11:54:23 +01:00
}
else if (bundle is HttpRequestBundle<IClientServiceRequest, Label> folderBundle)
{
// TODO: Handle new Gmail Label added or updated.
}
else if (bundle is HttpRequestBundle<IClientServiceRequest, Draft> draftBundle && draftBundle.Request is CreateDraftRequest createDraftRequest)
{
// New draft mail is created.
2024-04-18 01:44:37 +02:00
2025-11-14 14:42:05 +01:00
var messageDraft = await draftBundle.DeserializeBundleAsync(httpResponseMessage, GmailSynchronizerJsonContext.Default.Draft, cancellationToken).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (messageDraft == null) return;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var localDraftCopy = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftCopy;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Here we have DraftId, MessageId and ThreadId.
// Update the local copy properties and re-synchronize to get the original message and update history.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// We don't fetch the single message here because it may skip some of the history changes when the
// fetch updates the historyId. Therefore we need to re-synchronize to get the latest history changes
// which will have the original message downloaded eventually.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopy.UniqueId, messageDraft.Message.Id, messageDraft.Id, messageDraft.Message.ThreadId);
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
var options = new MailSynchronizationOptions()
{
AccountId = Account.Id,
Type = MailSynchronizationType.FullFolders
};
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await SynchronizeMailsInternalAsync(options, cancellationToken);
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-23 17:05:46 +01:00
/// <summary>
/// Gmail Archive is a special folder that is not visible in the Gmail web interface.
/// We need to handle it separately.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
private async Task MapArchivedMailsAsync(CancellationToken cancellationToken)
{
2026-02-12 18:57:55 +01:00
if (!archiveFolderId.HasValue) return;
2025-02-23 17:05:46 +01:00
var request = _gmailService.Users.Messages.List("me");
request.Q = "in:archive";
2026-02-12 18:57:55 +01:00
request.MaxResults = 500;
2025-02-23 17:05:46 +01:00
string pageToken = null;
2026-02-12 18:57:55 +01:00
var archivedMessageIds = new HashSet<string>(StringComparer.Ordinal);
2025-02-23 17:05:46 +01:00
do
{
if (!string.IsNullOrEmpty(pageToken)) request.PageToken = pageToken;
2026-02-12 18:57:55 +01:00
var response = await request.ExecuteAsync(cancellationToken).ConfigureAwait(false);
2025-02-23 17:05:46 +01:00
if (response.Messages == null) break;
foreach (var message in response.Messages)
{
2026-02-12 18:57:55 +01:00
if (!string.IsNullOrEmpty(message.Id))
{
archivedMessageIds.Add(message.Id);
}
2025-02-23 17:05:46 +01:00
}
pageToken = response.NextPageToken;
} while (!string.IsNullOrEmpty(pageToken));
2026-02-12 18:57:55 +01:00
var result = await _gmailChangeProcessor.GetGmailArchiveComparisonResultAsync(archiveFolderId.Value, archivedMessageIds.ToList()).ConfigureAwait(false);
var addedArchiveIds = result.Added.Distinct(StringComparer.Ordinal).ToList();
var removedArchiveIds = result.Removed.Distinct(StringComparer.Ordinal).ToList();
2025-02-23 17:05:46 +01:00
2026-02-12 18:57:55 +01:00
if (addedArchiveIds.Count > 0)
2025-02-23 17:05:46 +01:00
{
2026-02-12 18:57:55 +01:00
// Archive sync can surface messages that were never downloaded before.
// Download metadata first so assignment creation can succeed.
var existingBeforeDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
var missingArchiveIds = addedArchiveIds.Except(existingBeforeDownload, StringComparer.Ordinal).ToList();
if (missingArchiveIds.Count > 0)
{
await DownloadMessagesInBatchAsync(missingArchiveIds, cancellationToken).ConfigureAwait(false);
}
var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
foreach (var archiveAddedItem in existingAfterDownload)
{
await HandleArchiveAssignmentAsync(archiveAddedItem).ConfigureAwait(false);
}
2025-02-23 17:05:46 +01:00
}
2026-02-12 18:57:55 +01:00
foreach (var unAarchivedRemovedItem in removedArchiveIds)
2025-02-23 17:05:46 +01:00
{
2026-02-12 18:57:55 +01:00
await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem).ConfigureAwait(false);
2025-02-23 17:05:46 +01:00
}
}
2025-02-16 11:54:23 +01:00
/// <summary>
/// Maps existing Gmail Draft resources to local mail copies.
/// This uses indexed search, therefore it's quite fast.
/// It's safe to execute this after each Draft creation + batch message download.
/// </summary>
private async Task MapDraftIdsAsync(CancellationToken cancellationToken = default)
{
// Check if account has any draft locally.
// There is no point to send this query if there are no local drafts.
bool hasLocalDrafts = await _gmailChangeProcessor.HasAccountAnyDraftAsync(Account.Id).ConfigureAwait(false);
if (!hasLocalDrafts) return;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var drafts = await _gmailService.Users.Drafts.List("me").ExecuteAsync(cancellationToken);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (drafts.Drafts == null)
{
_logger.Information("There are no drafts to map for {Name}", Account.Name);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
2025-02-16 11:35:43 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
foreach (var draft in drafts.Drafts)
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
await _gmailChangeProcessor.MapLocalDraftAsync(draft.Message.Id, draft.Id, draft.Message.ThreadId);
}
}
2024-04-18 01:44:37 +02:00
2025-10-30 17:15:05 +01:00
protected override Task<MailCopy> CreateMinimalMailCopyAsync(Message gmailMessage, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
2025-10-20 18:27:02 +02:00
{
bool isUnread = gmailMessage.GetIsUnread();
bool isFocused = gmailMessage.GetIsFocused();
bool isFlagged = gmailMessage.GetIsFlagged();
bool isDraft = gmailMessage.GetIsDraft();
2025-10-29 18:44:15 +01:00
// Try to get the most accurate date from Gmail's InternalDate first, then fallback to Date header
DateTime creationDate = DateTime.UtcNow;
2025-10-30 17:15:05 +01:00
2025-10-29 18:44:15 +01:00
if (gmailMessage.InternalDate.HasValue)
{
// Gmail's InternalDate is in milliseconds since Unix epoch
creationDate = DateTimeOffset.FromUnixTimeMilliseconds(gmailMessage.InternalDate.Value).UtcDateTime;
}
else
{
// Fallback to parsing the Date header
var dateHeaderValue = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Date", StringComparison.OrdinalIgnoreCase))?.Value;
if (!string.IsNullOrEmpty(dateHeaderValue) && DateTime.TryParse(dateHeaderValue, out var parsedDate))
{
creationDate = parsedDate.ToUniversalTime();
}
}
2025-10-30 17:15:05 +01:00
// Extract From header and parse name/address
var fromHeaderValue = gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
var (fromName, fromAddress) = ExtractNameAndEmailFromHeader(fromHeaderValue);
// Detect calendar invitation by checking Content-Type header (only if calendar access granted)
var itemType = Account.IsCalendarAccessGranted ? GetMailItemTypeFromHeaders(gmailMessage.Payload?.Headers) : MailItemType.Mail;
2025-10-30 17:15:05 +01:00
var copy = new MailCopy()
2025-10-20 18:27:02 +02:00
{
2025-10-29 18:44:15 +01:00
CreationDate = creationDate,
2025-10-20 18:27:02 +02:00
Subject = HttpUtility.HtmlDecode(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Subject", StringComparison.OrdinalIgnoreCase))?.Value ?? ""),
2025-10-30 17:15:05 +01:00
FromName = HttpUtility.HtmlDecode(fromName),
FromAddress = fromAddress,
PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet ?? "").Trim(),
2025-10-20 18:27:02 +02:00
ThreadId = gmailMessage.ThreadId,
2025-10-30 17:15:05 +01:00
Importance = MailImportance.Normal, // Default importance without MIME parsing
2025-10-20 18:27:02 +02:00
Id = gmailMessage.Id,
IsDraft = isDraft,
HasAttachments = gmailMessage.Payload?.Parts?.Any(p => !string.IsNullOrEmpty(p.Filename)) ?? false,
IsRead = !isUnread,
IsFlagged = isFlagged,
IsFocused = isFocused,
2026-02-06 20:13:44 +01:00
InReplyTo = MailHeaderExtensions.StripAngleBrackets(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("In-Reply-To", StringComparison.OrdinalIgnoreCase))?.Value),
MessageId = MailHeaderExtensions.StripAngleBrackets(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Message-Id", StringComparison.OrdinalIgnoreCase))?.Value),
References = MailHeaderExtensions.NormalizeReferences(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value),
FileId = Guid.NewGuid(),
ItemType = itemType
2025-10-20 18:27:02 +02:00
};
2026-02-06 21:46:30 +01:00
// Note: DraftId is NOT set here. Gmail's Draft resource ID is separate from ThreadId
// and can only be obtained from the Drafts API (not Messages API).
// DraftId is populated by:
// - MapLocalDraftAsync (for Wino-created drafts, from CreateDraft response)
// - MapDraftIdsAsync (for all drafts, from Drafts.List API)
2025-10-20 18:27:02 +02:00
2025-10-30 17:15:05 +01:00
return Task.FromResult(copy);
2025-10-20 18:27:02 +02:00
}
2026-02-06 21:46:30 +01:00
/// <summary>
/// Enriches a MailCopy with fields extracted from a parsed MimeMessage.
/// This is needed when messages are downloaded with Raw format (delta sync),
/// because the Gmail API does not populate Payload.Headers in Raw format.
/// Fields already populated (non-null/non-empty) are NOT overwritten.
/// </summary>
private static void EnrichMailCopyFromMime(MailCopy copy, MimeMessage mime)
{
if (copy == null || mime == null) return;
if (string.IsNullOrEmpty(copy.Subject))
copy.Subject = mime.Subject ?? string.Empty;
if (string.IsNullOrEmpty(copy.FromName))
{
var from = mime.From.Mailboxes.FirstOrDefault();
if (from != null)
copy.FromName = from.Name ?? string.Empty;
}
if (string.IsNullOrEmpty(copy.FromAddress))
{
var from = mime.From.Mailboxes.FirstOrDefault();
if (from != null)
copy.FromAddress = from.Address ?? string.Empty;
}
if (string.IsNullOrEmpty(copy.MessageId))
copy.MessageId = mime.MessageId;
if (string.IsNullOrEmpty(copy.InReplyTo))
copy.InReplyTo = mime.InReplyTo;
if (string.IsNullOrEmpty(copy.References) && mime.References?.Count > 0)
copy.References = string.Join(";", mime.References);
if (!copy.HasAttachments && mime.Attachments.Any())
copy.HasAttachments = true;
if (copy.Importance == MailImportance.Normal)
{
copy.Importance = mime.Importance switch
{
MessageImportance.High => MailImportance.High,
MessageImportance.Low => MailImportance.Low,
_ => MailImportance.Normal
};
}
}
/// <summary>
/// Determines MailItemType based on Gmail message headers.
/// Gmail doesn't have EventMessage type like Outlook, but calendar invitations can be detected
/// by checking Content-Type header for text/calendar or multipart/alternative with text/calendar part.
/// </summary>
private static MailItemType GetMailItemTypeFromHeaders(IList<MessagePartHeader> headers)
{
if (headers == null) return MailItemType.Mail;
// 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)
if (contentTypeHeader.Contains("text/calendar", StringComparison.OrdinalIgnoreCase))
{
// 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,
"CANCEL" => MailItemType.CalendarCancellation,
"REPLY" => MailItemType.CalendarResponse,
_ => MailItemType.Mail
};
}
// If no method specified, assume it's an invitation
return MailItemType.CalendarInvitation;
}
}
return MailItemType.Mail;
}
2025-10-20 18:27:02 +02:00
/// <summary>
2025-10-30 17:15:05 +01:00
/// Extracts name and email address from a header value like "Name <email@domain.com>" or "email@domain.com"
2025-10-20 18:27:02 +02:00
/// </summary>
2025-10-30 17:15:05 +01:00
private static (string name, string email) ExtractNameAndEmailFromHeader(string headerValue)
2025-10-20 18:27:02 +02:00
{
2025-10-30 17:15:05 +01:00
if (string.IsNullOrEmpty(headerValue))
return ("", "");
2025-10-20 18:27:02 +02:00
2025-10-30 17:15:05 +01:00
// Try to match "Name <email@domain.com>" format
var match = System.Text.RegularExpressions.Regex.Match(headerValue, @"^(.+?)\s*<(.+?)>$");
if (match.Success)
2025-10-20 18:27:02 +02:00
{
2025-10-30 17:15:05 +01:00
var name = match.Groups[1].Value.Trim().Trim('"');
var email = match.Groups[2].Value.Trim();
return (name, email);
2025-10-20 18:27:02 +02:00
}
2025-10-30 17:15:05 +01:00
// If no angle brackets, assume the whole value is the email with no name
var emailOnly = headerValue.Trim();
return ("", emailOnly);
2025-10-20 18:27:02 +02:00
}
private static IReadOnlyList<AccountContact> ExtractContactsFromGmailMessage(Message message, MimeMessage mimeMessage)
{
var contacts = new Dictionary<string, AccountContact>(StringComparer.OrdinalIgnoreCase);
AddFromHeaders(message?.Payload?.Headers);
if (mimeMessage != null)
{
AddFromInternetAddressList(mimeMessage.From);
AddFromInternetAddressList(mimeMessage.To);
AddFromInternetAddressList(mimeMessage.Cc);
AddFromInternetAddressList(mimeMessage.Bcc);
AddFromInternetAddressList(mimeMessage.ReplyTo);
if (mimeMessage.Sender is MailboxAddress senderMailbox)
{
AddContact(senderMailbox.Address, senderMailbox.Name);
}
}
return contacts.Values.ToList();
void AddFromHeaders(IList<MessagePartHeader> headers)
{
if (headers == null || headers.Count == 0) return;
AddFromHeader("From");
AddFromHeader("Sender");
AddFromHeader("To");
AddFromHeader("Cc");
AddFromHeader("Bcc");
AddFromHeader("Reply-To");
void AddFromHeader(string headerName)
{
var headerValue = headers
.FirstOrDefault(h => h.Name.Equals(headerName, StringComparison.OrdinalIgnoreCase))
?.Value;
if (string.IsNullOrWhiteSpace(headerValue)) return;
try
{
var addresses = InternetAddressList.Parse(headerValue);
foreach (var mailbox in addresses.Mailboxes)
{
AddContact(mailbox.Address, mailbox.Name);
}
}
catch
{
var (name, email) = ExtractNameAndEmailFromHeader(headerValue);
AddContact(email, name);
}
}
}
void AddFromInternetAddressList(InternetAddressList addresses)
{
if (addresses == null) return;
foreach (var mailbox in addresses.Mailboxes)
{
AddContact(mailbox.Address, mailbox.Name);
}
}
void AddContact(string address, string name)
{
var trimmedAddress = address?.Trim();
if (string.IsNullOrWhiteSpace(trimmedAddress)) return;
var displayName = string.IsNullOrWhiteSpace(name) ? trimmedAddress : name.Trim();
contacts[trimmedAddress] = new AccountContact
{
Address = trimmedAddress,
Name = displayName
};
}
}
2025-02-16 11:54:23 +01:00
/// <summary>
/// Creates new mail packages for the given message.
/// AssignedFolder is null since the LabelId is parsed out of the Message.
/// If Gmail Message includes Raw payload, MIME is parsed and attached to packages.
2025-02-16 11:54:23 +01:00
/// </summary>
2025-10-30 17:15:05 +01:00
/// <param name="message">Gmail message to create package for (must have Metadata format).</param>
2025-02-16 11:54:23 +01:00
/// <param name="assignedFolder">Null, not used.</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>New mail package that change processor can use to insert new mail into database.</returns>
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(Message message,
MailItemFolder assignedFolder,
CancellationToken cancellationToken = default)
{
var packageList = new List<NewMailItemPackage>();
MimeMessage mimeMessage = null;
// Raw format is used in delta sync and does not populate Payload.Headers.
// Parse MIME from Raw so we can resolve draft mapping header and persist mime content.
if (!string.IsNullOrEmpty(message?.Raw))
{
try
{
mimeMessage = message.GetGmailMimeMessage();
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to parse MIME from raw Gmail message {MessageId}", message?.Id);
}
}
2024-04-18 01:44:37 +02:00
// Create base MailCopy from metadata only - NO MIME download
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
2024-04-18 01:44:37 +02:00
2026-02-10 21:35:55 +01:00
// Initial sync metadata flow does not include MIME, but calendar invitations need MIME
// for date rendering and invitation-to-calendar mapping.
if (mimeMessage == null && baseMailCopy?.ItemType == MailItemType.CalendarInvitation && !string.IsNullOrEmpty(message?.Id))
{
try
{
var rawRequest = _gmailService.Users.Messages.Get("me", message.Id);
rawRequest.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Raw;
var rawMessage = await rawRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(rawMessage?.Raw))
{
mimeMessage = rawMessage.GetGmailMimeMessage();
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to fetch raw MIME for calendar invitation {MessageId}", message.Id);
}
}
if (mimeMessage != null)
{
// Raw responses don't include metadata headers. Backfill important fields from MIME.
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
}
2026-02-10 21:35:55 +01:00
await TryMapCalendarInvitationAsync(baseMailCopy, mimeMessage, cancellationToken).ConfigureAwait(false);
var extractedContacts = ExtractContactsFromGmailMessage(message, mimeMessage);
// Check for local draft mapping using X-Wino-Draft-Id header.
// For Metadata format we read from Payload.Headers.
// For Raw format (Payload is null), we read from parsed MIME headers.
if (baseMailCopy.IsDraft)
2025-02-16 11:54:23 +01:00
{
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value
?? mimeMessage?.Headers?.FirstOrDefault(h => h.Field.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
2024-04-18 01:44:37 +02:00
2026-02-06 21:46:30 +01:00
if (!string.IsNullOrEmpty(draftIdHeader) && Guid.TryParse(draftIdHeader, out _))
2025-10-30 17:15:05 +01:00
{
if (Guid.TryParse(draftIdHeader, out Guid localDraftCopyUniqueId))
{
// This message belongs to existing local draft copy.
// Map remote ids to local copy and skip creating duplicate rows.
bool isMappingSuccessful = await _gmailChangeProcessor.MapLocalDraftAsync(
Account.Id,
localDraftCopyUniqueId,
baseMailCopy.Id,
baseMailCopy.DraftId,
baseMailCopy.ThreadId).ConfigureAwait(false);
if (isMappingSuccessful)
{
// Keep local draft MIME in sync with the fetched remote raw MIME if available.
if (mimeMessage != null)
{
var mappedDraftCopies = await _gmailChangeProcessor.GetMailCopiesAsync([baseMailCopy.Id]).ConfigureAwait(false);
if (mappedDraftCopies != null)
{
var savedFileIds = new HashSet<Guid>();
foreach (var mappedCopy in mappedDraftCopies)
{
if (mappedCopy.FileId == Guid.Empty || !savedFileIds.Add(mappedCopy.FileId))
continue;
await _gmailChangeProcessor.SaveMimeFileAsync(mappedCopy.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
}
}
}
return null;
}
}
2025-10-30 17:15:05 +01:00
}
2025-02-16 11:54:23 +01:00
}
// 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
2025-02-16 11:54:23 +01:00
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;
2025-02-16 11:54:23 +01:00
foreach (var labelId in message.LabelIds)
2024-04-18 01:44:37 +02:00
{
// 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);
2026-02-06 21:46:30 +01:00
if (mimeMessage != null)
{
EnrichMailCopyFromMime(mailCopyForLabel, mimeMessage);
}
// Ensure all copies share the same Id and FileId
mailCopyForLabel.Id = sharedId;
mailCopyForLabel.FileId = sharedFileId;
packageList.Add(new NewMailItemPackage(mailCopyForLabel, mimeMessage, labelId, extractedContacts));
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:43:30 +01:00
}
2025-02-15 12:53:32 +01:00
2025-02-16 11:54:23 +01:00
return packageList;
}
2025-02-15 12:53:32 +01:00
2026-02-10 21:35:55 +01:00
private async Task TryMapCalendarInvitationAsync(MailCopy baseMailCopy, MimeMessage mimeMessage, CancellationToken cancellationToken)
{
if (baseMailCopy == null || baseMailCopy.ItemType != MailItemType.CalendarInvitation || mimeMessage == null)
return;
var invitationUid = mimeMessage.ExtractInvitationUid();
if (string.IsNullOrWhiteSpace(invitationUid))
return;
var calendars = await _gmailChangeProcessor.GetAccountCalendarsAsync(Account.Id).ConfigureAwait(false);
if (calendars == null || calendars.Count == 0)
return;
foreach (var calendar in calendars)
{
try
{
var listRequest = _calendarService.Events.List(calendar.RemoteCalendarId);
listRequest.ICalUID = invitationUid;
listRequest.MaxResults = 1;
listRequest.SingleEvents = false;
var listResponse = await listRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
var matchedEvent = listResponse?.Items?.FirstOrDefault();
if (matchedEvent == null || string.IsNullOrWhiteSpace(matchedEvent.Id))
continue;
await _gmailChangeProcessor.ManageCalendarEventAsync(matchedEvent, calendar, Account).ConfigureAwait(false);
var localCalendarItem = await _gmailChangeProcessor.GetCalendarItemAsync(calendar.Id, matchedEvent.Id).ConfigureAwait(false);
if (localCalendarItem == null)
return;
await _gmailChangeProcessor.UpsertMailInvitationCalendarMappingAsync(new MailInvitationCalendarMapping()
{
Id = Guid.NewGuid(),
AccountId = Account.Id,
MailCopyId = baseMailCopy.Id,
InvitationUid = invitationUid,
CalendarId = calendar.Id,
CalendarItemId = localCalendarItem.Id,
CalendarRemoteEventId = matchedEvent.Id
}).ConfigureAwait(false);
return;
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to map Gmail calendar invitation mail {MailCopyId} for calendar {CalendarId}", baseMailCopy.Id, calendar.Id);
}
}
}
2025-02-16 11:54:23 +01:00
#endregion
2025-02-16 11:35:43 +01:00
2025-12-30 11:59:54 +01:00
#region Calendar Operations
public override List<IRequestBundle<IClientServiceRequest>> CreateCalendarEvent(CreateCalendarEventRequest request)
{
var calendarItem = request.Item;
var attendees = request.Attendees;
// Get the calendar for this event
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
// Convert CalendarItem to Google Event
var googleEvent = new Event
{
Summary = calendarItem.Title,
Description = calendarItem.Description,
Location = calendarItem.Location,
2026-01-03 19:33:36 +01:00
Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative"
2025-12-30 11:59:54 +01:00
};
// Set start and end time
if (calendarItem.IsAllDayEvent)
{
// All-day events use Date instead of DateTime
googleEvent.Start = new EventDateTime
{
Date = calendarItem.StartDate.ToString("yyyy-MM-dd")
};
googleEvent.End = new EventDateTime
{
Date = calendarItem.EndDate.ToString("yyyy-MM-dd")
};
}
else
{
// Regular events with time
googleEvent.Start = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, TimeSpan.Zero),
TimeZone = calendarItem.StartTimeZone
};
googleEvent.End = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, TimeSpan.Zero),
TimeZone = calendarItem.EndTimeZone
};
}
// Add attendees if any
if (attendees != null && attendees.Count > 0)
{
googleEvent.Attendees = attendees.Select(a => new EventAttendee
{
Email = a.Email,
DisplayName = a.Name,
Optional = a.IsOptionalAttendee
}).ToList();
}
// Create the insert request
var insertRequest = _calendarService.Events.Insert(googleEvent, calendar.RemoteCalendarId);
return [new HttpRequestBundle<IClientServiceRequest>(insertRequest, request)];
}
2026-01-03 19:33:36 +01:00
public override List<IRequestBundle<IClientServiceRequest>> AcceptEvent(AcceptEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot accept event without remote event ID");
}
// For Gmail, we need to patch the event with the user's response status
// Get the current user's email from the account
var userEmail = Account.Address;
// Create a patch event to update only the attendee response
var patchEvent = new Event();
2026-01-03 19:33:36 +01:00
// 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
{
// The API will handle updating the current user's attendee status
Attendees = new List<EventAttendee>
{
new EventAttendee
{
Email = userEmail,
ResponseStatus = "accepted"
}
}
}, 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
2026-01-03 19:33:36 +01:00
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> DeclineEvent(DeclineEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot decline event without remote event ID");
}
var userEmail = Account.Address;
var patchRequest = _calendarService.Events.Patch(new Event
{
Attendees = new List<EventAttendee>
{
new EventAttendee
{
Email = userEmail,
ResponseStatus = "declined",
Comment = request.ResponseMessage
}
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
2026-01-03 19:33:36 +01:00
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> TentativeEvent(TentativeEventRequest request)
{
var calendarItem = request.Item;
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot tentatively accept event without remote event ID");
}
var userEmail = Account.Address;
var patchRequest = _calendarService.Events.Patch(new Event
{
Attendees = new List<EventAttendee>
{
new EventAttendee
{
Email = userEmail,
ResponseStatus = "tentative",
Comment = request.ResponseMessage
}
}
}, calendar.RemoteCalendarId, calendarItem.RemoteEventId);
patchRequest.SendUpdates = !string.IsNullOrEmpty(request.ResponseMessage)
? Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.All
2026-01-03 19:33:36 +01:00
: Google.Apis.Calendar.v3.EventsResource.PatchRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(patchRequest, request)];
}
public override List<IRequestBundle<IClientServiceRequest>> UpdateCalendarEvent(UpdateCalendarEventRequest request)
{
var calendarItem = request.Item;
var attendees = request.Attendees;
// Get the calendar for this event
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot update event without remote event ID");
}
// Convert CalendarItem to Google Event for update
var googleEvent = new Event
{
Summary = calendarItem.Title,
Description = calendarItem.Description,
Location = calendarItem.Location,
Status = calendarItem.Status == CalendarItemStatus.Accepted ? "confirmed" : "tentative",
Transparency = calendarItem.ShowAs == CalendarItemShowAs.Free ? "transparent" : "opaque"
};
// Set start and end time with proper timezone handling
// CalendarItem stores dates in the event's timezone (StartTimeZone/EndTimeZone)
// When user edits in local timezone, the dates are already converted and stored correctly
if (calendarItem.IsAllDayEvent)
{
// All-day events use Date instead of DateTime
googleEvent.Start = new EventDateTime
{
Date = calendarItem.StartDate.ToString("yyyy-MM-dd")
};
googleEvent.End = new EventDateTime
{
Date = calendarItem.EndDate.ToString("yyyy-MM-dd")
};
}
else
{
// Regular events with time
// StartDate and EndDate are stored in the event's timezone
// We preserve the timezone information during update
googleEvent.Start = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.StartDate, TimeSpan.Zero),
TimeZone = calendarItem.StartTimeZone ?? TimeZoneInfo.Local.Id
};
googleEvent.End = new EventDateTime
{
DateTimeDateTimeOffset = new DateTimeOffset(calendarItem.EndDate, TimeSpan.Zero),
TimeZone = calendarItem.EndTimeZone ?? TimeZoneInfo.Local.Id
};
}
// Add attendees if any
if (attendees != null && attendees.Count > 0)
{
googleEvent.Attendees = attendees.Select(a => new EventAttendee
{
Email = a.Email,
DisplayName = a.Name,
Optional = a.IsOptionalAttendee
}).ToList();
}
// Update the event using Google Calendar API
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
: Google.Apis.Calendar.v3.EventsResource.UpdateRequest.SendUpdatesEnum.None;
return [new HttpRequestBundle<IClientServiceRequest>(updateRequest, request)];
}
2026-01-06 11:11:37 +01:00
public override List<IRequestBundle<IClientServiceRequest>> DeleteCalendarEvent(DeleteCalendarEventRequest request)
{
var calendarItem = request.Item;
// Get the calendar for this event
var calendar = calendarItem.AssignedCalendar;
if (calendar == null)
{
throw new InvalidOperationException("Calendar item must have an assigned calendar");
}
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
{
throw new InvalidOperationException("Cannot delete event without remote event ID");
}
// Delete the event using Google Calendar API
var deleteRequest = _calendarService.Events.Delete(calendar.RemoteCalendarId, calendarItem.RemoteEventId);
// Send cancellation notifications to attendees
deleteRequest.SendUpdates = Google.Apis.Calendar.v3.EventsResource.DeleteRequest.SendUpdatesEnum.All;
return [new HttpRequestBundle<IClientServiceRequest>(deleteRequest, request)];
}
2025-12-30 11:59:54 +01:00
#endregion
2025-02-16 11:54:23 +01:00
public override async Task KillSynchronizerAsync()
{
await base.KillSynchronizerAsync();
_gmailService.Dispose();
_peopleService.Dispose();
_calendarService.Dispose();
_googleHttpClient.Dispose();
2024-04-18 01:44:37 +02:00
}
}