diff --git a/Directory.Packages.props b/Directory.Packages.props
index e824166e..81cc018f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -52,7 +52,7 @@
-
+
@@ -64,7 +64,7 @@
-
+
diff --git a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs
new file mode 100644
index 00000000..0123e97f
--- /dev/null
+++ b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs
@@ -0,0 +1,19 @@
+using System;
+using SQLite;
+
+namespace Wino.Core.Domain.Entities.Mail;
+
+public class MailItemQueue
+{
+ [PrimaryKey]
+ public Guid Id { get; set; }
+ public Guid AccountId { get; set; }
+ public string RemoteServerId { get; set; }
+ public bool IsProcessed { get; set; }
+ public int FailedCount { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime? ProcessedAt { get; set; }
+
+ public bool IsRecent() => (DateTime.UtcNow - CreatedAt).TotalDays <= 7;
+ public bool ShouldDelete() => IsProcessed || FailedCount >= 30;
+}
diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs
index 8f40f70d..889d1729 100644
--- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs
+++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs
@@ -33,6 +33,11 @@ public class MailAccount
///
public MailProviderType ProviderType { get; set; }
+ ///
+ /// Gets or sets the initial mail sync status for the account.
+ ///
+ public InitialSynchronizationStatus SynchronizationStatus { get; set; }
+
///
/// For tracking mail change delta.
/// Gmail : historyId
diff --git a/Wino.Core.Domain/Enums/InitialSynchronizationStatus.cs b/Wino.Core.Domain/Enums/InitialSynchronizationStatus.cs
new file mode 100644
index 00000000..0fb7fb97
--- /dev/null
+++ b/Wino.Core.Domain/Enums/InitialSynchronizationStatus.cs
@@ -0,0 +1,8 @@
+namespace Wino.Core.Domain.Enums;
+
+public enum InitialSynchronizationStatus
+{
+ None,
+ IdsFetched,
+ Completed
+}
diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs
index e6dfd7aa..d6824e80 100644
--- a/Wino.Core.Domain/Interfaces/IMailService.cs
+++ b/Wino.Core.Domain/Interfaces/IMailService.cs
@@ -162,4 +162,9 @@ public interface IMailService
/// Retrieved MailCopy ids from search result.
/// Result model that contains added and removed mail copy ids.
Task GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List onlineArchiveMailIds);
+ Task ClearMailItemQueueAsync(Guid accountId);
+ Task AddMailItemQueueItemsAsync(IEnumerable queueItems);
+ Task GetMailItemQueueCountAsync(Guid accountId);
+ Task> GetMailItemQueueAsync(Guid accountId, int take);
+ Task UpdateMailItemQueueAsync(IEnumerable queueItems);
}
diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs
index 8e19322a..bbfe9a90 100644
--- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs
+++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs
@@ -1,17 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Web;
using Google.Apis.Calendar.v3.Data;
using Google.Apis.Gmail.v1.Data;
-using MimeKit;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Enums;
using Wino.Core.Misc;
using Wino.Services;
-using Wino.Services.Extensions;
namespace Wino.Core.Extensions;
@@ -121,41 +118,6 @@ public static class GoogleIntegratorExtensions
return GetNormalizedLabelName(lastPart);
}
- ///
- /// Returns MailCopy out of native Gmail message and converted MimeMessage of that native messaage.
- ///
- /// Gmail Message
- /// MimeMessage representation of that native message.
- /// MailCopy object that is ready to be inserted to database.
- public static MailCopy AsMailCopy(this Message gmailMessage, MimeMessage mimeMessage)
- {
- bool isUnread = gmailMessage.GetIsUnread();
- bool isFocused = gmailMessage.GetIsFocused();
- bool isFlagged = gmailMessage.GetIsFlagged();
- bool isDraft = gmailMessage.GetIsDraft();
-
- return new MailCopy()
- {
- CreationDate = mimeMessage.Date.UtcDateTime,
- Subject = HttpUtility.HtmlDecode(mimeMessage.Subject),
- FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage),
- FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage),
- PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet),
- ThreadId = gmailMessage.ThreadId,
- Importance = (MailImportance)mimeMessage.Importance,
- Id = gmailMessage.Id,
- IsDraft = isDraft,
- HasAttachments = mimeMessage.Attachments.Any(),
- IsRead = !isUnread,
- IsFlagged = isFlagged,
- IsFocused = isFocused,
- InReplyTo = mimeMessage.InReplyTo,
- MessageId = mimeMessage.MessageId,
- References = mimeMessage.References.GetReferences(),
- FileId = Guid.NewGuid()
- };
- }
-
public static List GetRemoteAliases(this ListSendAsResponse response)
{
return response?.SendAs?.Select(a => new RemoteAccountAlias()
diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
index 14247be8..a74504f0 100644
--- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
@@ -65,6 +65,11 @@ public interface IDefaultChangeProcessor
Task IsMailExistsInFolderAsync(string messageId, Guid folderId);
Task> AreMailsExistsAsync(IEnumerable mailCopyIds);
Task UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier);
+ Task ClearMailItemQueueAsync(Guid accountId);
+ Task AddMailItemQueueItemsAsync(IEnumerable queueItems);
+ Task GetMailItemQueueCountAsync(Guid accountId);
+ Task> GetMailItemQueueAsync(Guid accountId, int take);
+ Task UpdateMailItemQueueAsync(IEnumerable queueItems);
}
public interface IGmailChangeProcessor : IDefaultChangeProcessor
@@ -208,6 +213,21 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
public Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken)
=> CalendarService.UpdateCalendarDeltaSynchronizationToken(calendarId, deltaToken);
+ public Task ClearMailItemQueueAsync(Guid accountId)
+ => MailService.ClearMailItemQueueAsync(accountId);
+
+ public Task AddMailItemQueueItemsAsync(IEnumerable queueItems)
+ => MailService.AddMailItemQueueItemsAsync(queueItems);
+
+ public Task GetMailItemQueueCountAsync(Guid accountId)
+ => MailService.GetMailItemQueueCountAsync(accountId);
+
+ public Task> GetMailItemQueueAsync(Guid accountId, int take)
+ => MailService.GetMailItemQueueAsync(accountId, take);
+
+ public Task UpdateMailItemQueueAsync(IEnumerable queueItems)
+ => MailService.UpdateMailItemQueueAsync(queueItems);
+
public async Task DeleteUserMailCacheAsync(Guid accountId)
{
await _mimeFileService.DeleteUserMimeCacheAsync(accountId).ConfigureAwait(false);
diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs
index 63bb9a80..f2a921e6 100644
--- a/Wino.Core/Synchronizers/GmailSynchronizer.cs
+++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs
@@ -42,13 +42,31 @@ using CalendarService = Google.Apis.Calendar.v3.CalendarService;
namespace Wino.Core.Synchronizers.Mail;
+///
+/// Gmail synchronizer implementation.
+///
+/// IMPORTANT: This synchronizer implements METADATA-ONLY synchronization strategy:
+/// - During sync (initial/delta), only message metadata is downloaded (headers, labels, snippet)
+/// - NO raw MIME content is downloaded during synchronization
+/// - Messages are created with Format=Metadata which populates Payload.Headers but NOT Raw content
+/// - MIME files are downloaded on-demand only when user explicitly reads a message
+/// - This dramatically reduces bandwidth usage and sync time, especially for accounts with many messages
+///
+/// Key implementation details:
+/// - CreateNewMailPackagesAsync: Creates MailCopy from metadata, passes null for MimeMessage
+/// - CreateMinimalMailCopyAsync: Extracts all MailCopy fields from Gmail Metadata format (Payload.Headers)
+/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
+/// - CreateSingleMessageGet: Always uses Metadata format (not Raw format) for synchronization
+///
public class GmailSynchronizer : WinoSynchronizer, IHttpClientFactory
{
public override uint BatchModificationSize => 1000;
+ ///
/// Maximum messages to fetch per folder during initial sync (1500).
- /// For each folder: first 50 messages are downloaded with MIME, next 500 with metadata only.
- /// Messages beyond 550 per folder are skipped during initial sync.
+ /// 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.
+ ///
public override uint InitialMessageDownloadCountPerFolder => 1500;
// It's actually 100. But Gmail SDK has internal bug for Out of Memory exception.
@@ -67,9 +85,6 @@ public class GmailSynchronizer : WinoSynchronizer _folderDownloadCounts = new();
-
public GmailSynchronizer(MailAccount account,
IGmailAuthenticator authenticator,
IGmailChangeProcessor gmailChangeProcessor,
@@ -169,256 +184,229 @@ public class GmailSynchronizer : WinoSynchronizer();
-
- var deltaChanges = new List(); // For tracking delta changes.
- var listChanges = new List(); // For tracking initial sync changes.
-
- /* Processing flow order is important to preserve the validity of history.
- * 1 - Process added mails. Because we need to create the mail first before assigning it to labels.
- * 2 - Process label assignments.
- * 3 - Process removed mails.
- * This affects reporting progres if done individually for each history change.
- * Therefore we need to process all changes in one go after the fetch.
- */
-
- if (isInitialSync)
+ if (Account.SynchronizationStatus == InitialSynchronizationStatus.None)
{
- // Get all folders that need synchronization
- var folders = await _gmailChangeProcessor.GetLocalFoldersAsync(Account.Id).ConfigureAwait(false);
- var syncFolders = folders.Where(f =>
- f.IsSynchronizationEnabled &&
- f.SpecialFolderType != SpecialFolderType.Category &&
- f.SpecialFolderType != SpecialFolderType.Archive).ToList();
-
- // Reset folder download counts for this sync
- _folderDownloadCounts.Clear();
-
- // Download messages for each folder separately
- foreach (var folder in syncFolders)
- {
- var messageRequest = _gmailService.Users.Messages.List("me");
- messageRequest.MaxResults = InitialMessageDownloadCountPerFolder;
- messageRequest.LabelIds = new[] { folder.RemoteFolderId };
- // messageRequest.OrderBy = "internalDate desc"; // Get latest messages first
- messageRequest.IncludeSpamTrash = true;
-
- string nextPageToken = null;
- uint downloadedCount = 0;
- int folderMimeDownloadCount = 0;
- int folderMetadataDownloadCount = 0;
- const int maxMetadataOnlyMessagesPerLabel = 500;
-
- do
- {
- if (!string.IsNullOrEmpty(nextPageToken))
- {
- messageRequest.PageToken = nextPageToken;
- }
-
- var result = await messageRequest.ExecuteAsync(cancellationToken);
- nextPageToken = result.NextPageToken;
-
- if (result.Messages != null)
- {
- downloadedCount += (uint)result.Messages.Count;
- listChanges.Add(result);
-
- // Process messages in this folder: first 50 with MIME, next 500 with metadata only
- foreach (var message in result.Messages)
- {
- if (folderMimeDownloadCount < InitialSyncMimeDownloadCount)
- {
- // Download with MIME for first 50 messages
- await DownloadSingleMessageAsync(message.Id, true, cancellationToken).ConfigureAwait(false);
- folderMimeDownloadCount++;
- _logger.Debug("Downloaded MIME message {MessageId} ({Count}/{MaxCount}) for folder {FolderName}",
- message.Id, folderMimeDownloadCount, InitialSyncMimeDownloadCount, folder.FolderName);
- }
- else if (folderMetadataDownloadCount < maxMetadataOnlyMessagesPerLabel)
- {
- // Download metadata only for next 500 messages
- await DownloadSingleMessageAsync(message.Id, false, cancellationToken).ConfigureAwait(false);
- folderMetadataDownloadCount++;
- _logger.Debug("Downloaded metadata message {MessageId} ({Count}/{MaxCount}) for folder {FolderName}",
- message.Id, folderMetadataDownloadCount, maxMetadataOnlyMessagesPerLabel, folder.FolderName);
- }
- // Messages beyond 50 MIME + 500 metadata are skipped for initial sync
- }
- }
-
- // Stop if we've downloaded enough messages for this folder or reached the metadata limit
- if (downloadedCount >= InitialMessageDownloadCountPerFolder ||
- (folderMimeDownloadCount >= InitialSyncMimeDownloadCount && folderMetadataDownloadCount >= maxMetadataOnlyMessagesPerLabel))
- {
- break;
- }
-
- } while (!string.IsNullOrEmpty(nextPageToken));
-
- _logger.Information("Downloaded {Count} messages for folder {Folder} (first {MimeCount} with MIME, {MetadataCount} metadata-only)",
- downloadedCount, folder.FolderName, Math.Min(folderMimeDownloadCount, InitialSyncMimeDownloadCount), folderMetadataDownloadCount);
-
- // Track how many messages we've downloaded with MIME for this folder
- _folderDownloadCounts[folder.RemoteFolderId] = folderMimeDownloadCount;
- }
- }
- else
- {
- var startHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier);
- var nextPageToken = ulong.Parse(Account.SynchronizationDeltaIdentifier).ToString();
-
- var historyRequest = _gmailService.Users.History.List("me");
- historyRequest.StartHistoryId = startHistoryId;
-
- try
- {
- while (!string.IsNullOrEmpty(nextPageToken))
- {
- // If this is the first delta check, start from the last history id.
- // Otherwise start from the next page token. We set them both to the same value for start.
- // For each different page we set the page token to the next page token.
-
- bool isFirstDeltaCheck = nextPageToken == startHistoryId.ToString();
-
- if (!isFirstDeltaCheck)
- historyRequest.PageToken = nextPageToken;
-
- var historyResponse = await historyRequest.ExecuteAsync(cancellationToken);
-
- nextPageToken = historyResponse.NextPageToken;
-
- if (historyResponse.History == null)
- continue;
-
- deltaChanges.Add(historyResponse);
- }
- }
- catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
- {
- // History ID is too old or expired, need to do a full sync.
- // Theoratically we need to delete the local cache and start from scratch.
-
- _logger.Warning("History ID {StartHistoryId} is expired for {Name}. Will remove user's mail cache and do full sync.", startHistoryId, Account.Name);
-
- await _gmailChangeProcessor.DeleteUserMailCacheAsync(Account.Id).ConfigureAwait(false);
-
- Account.SynchronizationDeltaIdentifier = string.Empty;
-
- await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
-
- goto retry;
- }
+ await FetchAllEmailIdsAsync().ConfigureAwait(false);
+ await CompleteAccountSyncStatusAsync();
}
- // For delta sync or messages beyond the MIME download limit, add to missing messages list
- if (!isInitialSync)
+ if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
{
- // For delta sync, add all message IDs - they will be downloaded with MIME
- missingMessageIds.AddRange(listChanges.Where(a => a.Messages != null).SelectMany(a => a.Messages).Select(a => a.Id));
- }
- else
- {
- // For initial sync, messages are now downloaded immediately during folder processing
- // (first 50 with MIME, next 500 metadata-only per folder)
- // No remaining messages to process here
- _logger.Information("Initial sync completed: messages downloaded per folder (50 MIME + up to 500 metadata-only)");
+ await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
+ await CompleteAccountSyncStatusAsync();
}
- // Add missing message ids from delta changes.
- foreach (var historyResponse in deltaChanges)
+ if (Account.SynchronizationStatus == InitialSynchronizationStatus.IdsFetched)
{
- var addedMessageIds = historyResponse.History
- .Where(a => a.MessagesAdded != null)
- .SelectMany(a => a.MessagesAdded)
- .Where(a => a.Message != null)
- .Select(a => a.Message.Id);
-
- missingMessageIds.AddRange(addedMessageIds);
+ await ProcessEmailMetadataFromQueueAsync(cancellationToken);
}
- // Start downloading remaining messages (metadata only for initial sync beyond first 50, full download for delta sync).
- foreach (var messageId in missingMessageIds)
+ if (Account.SynchronizationStatus == InitialSynchronizationStatus.Completed)
{
- // For initial sync, download without MIME (metadata only) since MIME was already downloaded for first 50
- // For delta sync, download with MIME as usual
- bool downloadMime = !isInitialSync;
- await DownloadSingleMessageAsync(messageId, downloadMime, cancellationToken).ConfigureAwait(false);
+
}
- // Map archive assignments if there are any changes reported.
- if (listChanges.Any() || deltaChanges.Any())
- {
- await MapArchivedMailsAsync(cancellationToken).ConfigureAwait(false);
- }
-
- // Map remote drafts to local drafts.
- await MapDraftIdsAsync(cancellationToken).ConfigureAwait(false);
-
- // Start processing delta changes.
- foreach (var historyResponse in deltaChanges)
- {
- await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
- }
-
- // Take the max history id from delta changes and update the account sync modifier.
-
- if (deltaChanges.Any())
- {
- var maxHistoryId = deltaChanges.Where(a => a.HistoryId != null).Max(a => a.HistoryId);
-
- await UpdateAccountSyncIdentifierAsync(maxHistoryId);
-
- if (maxHistoryId != null)
- {
- // TODO: This is not good. Centralize the identifier fetch and prevent direct access here.
- // Account.SynchronizationDeltaIdentifier = await _gmailChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(Account.Id, maxHistoryId.ToString()).ConfigureAwait(false);
-
- _logger.Debug("Final sync identifier {SynchronizationDeltaIdentifier}", Account.SynchronizationDeltaIdentifier);
- }
- }
-
- // Get all unred new downloaded items and return in the result.
- // This is primarily used in notifications.
-
- var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, missingMessageIds).ConfigureAwait(false);
-
- return MailSynchronizationResult.Completed(unreadNewItems);
+ return MailSynchronizationResult.Completed(new List());
}
- private async Task DownloadSingleMessageAsync(string messageId, CancellationToken cancellationToken = default)
- {
- await DownloadSingleMessageAsync(messageId, true, cancellationToken).ConfigureAwait(false);
- }
+ #region Queue System
- private async Task DownloadSingleMessageAsync(string messageId, bool downloadMime, CancellationToken cancellationToken = default)
+ private async Task FetchAllEmailIdsAsync()
{
- // Google .NET SDK has memory issues with batch downloading messages which will not be fixed since the library is in maintenance mode.
- // https://github.com/googleapis/google-api-dotnet-client/issues/2603
- // This method will be used to download messages one by one to prevent memory spikes.
-
try
{
- var singleRequest = CreateSingleMessageGet(messageId, downloadMime);
- var downloadedMessage = await singleRequest.ExecuteAsync(cancellationToken).ConfigureAwait(false);
+ // If this method is hit, we don't need previous state for this table,
+ // we just clean it first to make sure nothing was left before.
+ await _gmailChangeProcessor.ClearMailItemQueueAsync(Account.Id).ConfigureAwait(false);
- if (downloadMime)
- {
- await HandleSingleItemDownloadedCallbackAsync(downloadedMessage, null, messageId, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- await HandleSingleItemDownloadedCallbackMinimalAsync(downloadedMessage, null, messageId, cancellationToken).ConfigureAwait(false);
- }
+ var totalFetched = 0;
+ string? pageToken = null;
- await UpdateAccountSyncIdentifierAsync(downloadedMessage.HistoryId).ConfigureAwait(false);
+ do
+ {
+ try
+ {
+ // Use maximum page size of 500 for efficiency
+ var request = _gmailService.Users.Messages.List("me");
+ request.MaxResults = 500;
+ request.IncludeSpamTrash = true;
+ request.PageToken = pageToken;
+
+ var response = await request.ExecuteAsync();
+
+ if (response.Messages != null)
+ {
+ var queueEntries1 = response.Messages.Select(x => new MailItemQueue
+ {
+ Id = Guid.CreateVersion7(),
+ AccountId = Account.Id,
+ RemoteServerId = x.Id,
+ IsProcessed = false,
+ CreatedAt = DateTime.UtcNow
+ });
+
+ await _gmailChangeProcessor.AddMailItemQueueItemsAsync(queueEntries1).ConfigureAwait(false);
+
+ totalFetched += queueEntries1.Count();
+ }
+
+ pageToken = response.NextPageToken;
+ }
+ catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
+ {
+ // Handle 429 rate limit errors by waiting and retrying
+ _logger.Warning("Rate limit exceeded while fetching email IDs for {Name}. Retrying after delay.", Account.Name);
+
+ await Task.Delay(TimeSpan.FromSeconds(10));
+ continue; // Retry the same page
+ }
+ } while (!string.IsNullOrEmpty(pageToken));
}
catch (Exception ex)
{
- _logger.Error(ex, "Error while downloading message {MessageId} for {Name}", messageId, Account.Name);
+ throw;
}
}
+ private async Task CompleteAccountSyncStatusAsync()
+ {
+ // Set history ID immediately after fetching email IDs for future incremental syncs
+ var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync();
+ Account.SynchronizationDeltaIdentifier = profile.HistoryId.ToString();
+
+ // Update account sync status
+ Account.SynchronizationStatus = InitialSynchronizationStatus.IdsFetched;
+
+ await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
+ }
+
+ private async Task SynchronizeDeltaAsync(MailSynchronizationOptions options, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var historyRequest = _gmailService.Users.History.List("me");
+ historyRequest.StartHistoryId = ulong.Parse(Account.SynchronizationDeltaIdentifier!);
+
+ var historyResponse = await historyRequest.ExecuteAsync();
+
+ if (historyResponse.History != null)
+ {
+ var addedMessageIds = new List();
+
+ // Collect all added messages first
+ foreach (var historyRecord in historyResponse.History)
+ {
+ if (historyRecord.MessagesAdded != null)
+ {
+ addedMessageIds.AddRange(historyRecord.MessagesAdded.Select(ma => ma.Message.Id));
+ }
+ }
+
+ // Process added messages in batches if any
+ // During delta sync, download with Raw format to get MIME content
+ if (addedMessageIds.Count != 0)
+ {
+ await DownloadMessagesInBatchAsync(addedMessageIds, downloadRawMime: true, cancellationToken).ConfigureAwait(false);
+ }
+
+ // Process other history changes
+ foreach (var historyRecord in historyResponse.History)
+ {
+ await ProcessHistoryChangesAsync(historyResponse).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (Exception)
+ {
+
+ throw;
+ }
+ }
+
+ private async Task ProcessEmailMetadataFromQueueAsync(CancellationToken cancellationToken = default)
+ {
+ // Get total count for progress tracking
+ var totalInQueue = await _gmailChangeProcessor.GetMailItemQueueCountAsync(Account.Id).ConfigureAwait(false);
+
+ try
+ {
+ var totalFailed = 0;
+
+ // Continue until all emails in queue are processed
+ while (true)
+ {
+ // Get next batch of unprocessed emails from queue
+ var mailItemQueue = await _gmailChangeProcessor.GetMailItemQueueAsync(Account.Id, 100).ConfigureAwait(false);
+
+ if (mailItemQueue.Count == 0)
+ break; // No more emails to process
+
+ var messageIds = mailItemQueue.Select(q => q.RemoteServerId).ToList();
+
+ try
+ {
+ // Remove the deleted items from queue first
+ mailItemQueue.RemoveAll(a => a.ShouldDelete());
+
+ var mailChunks = mailItemQueue.Chunk(100);
+
+ foreach (var chunk in mailChunks)
+ {
+ // Collect message IDs from the chunk
+ var messageIdsToDownload = chunk.Select(q => q.RemoteServerId).ToList();
+
+ try
+ {
+ // Download all messages in this chunk using batch API
+ await DownloadMessagesInBatchAsync(messageIdsToDownload, cancellationToken).ConfigureAwait(false);
+
+ // Mark all items in chunk as processed
+ foreach (var queueItem in chunk)
+ {
+ queueItem.IsProcessed = true;
+ queueItem.ProcessedAt = DateTime.UtcNow;
+ }
+ }
+ catch (Exception)
+ {
+ // Mark all items in chunk as failed
+ foreach (var queueItem in chunk)
+ {
+ queueItem.IsProcessed = false;
+ queueItem.ProcessedAt = null;
+ queueItem.FailedCount++;
+ totalFailed++;
+ }
+ }
+
+ await _gmailChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false);
+
+ // If too many failures, pause to avoid hitting rate limits
+ if (totalFailed > 85) await Task.Delay(TimeSpan.FromSeconds(10));
+ }
+ }
+ catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
+ {
+ // Handle 429 rate limit errors by waiting and retrying
+ await Task.Delay(TimeSpan.FromSeconds(10));
+ continue; // Retry the same batch
+ }
+ }
+
+ // Update account sync status to completed
+ Account.SynchronizationStatus = InitialSynchronizationStatus.Completed;
+
+ await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ }
+
+
+ #endregion
+
protected override async Task SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
@@ -737,21 +725,35 @@ public class GmailSynchronizer : WinoSynchronizer
- /// Returns a single get request to retrieve the message with the given id
+ /// 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.
///
/// Message to download.
- /// True to download raw MIME content, false to download metadata only (headers, labels, etc.)
- /// Get request for message with appropriate format.
- private UsersResource.MessagesResource.GetRequest CreateSingleMessageGet(string messageId, bool downloadMime = true)
+ /// Get request for message with Metadata format.
+ private UsersResource.MessagesResource.GetRequest CreateSingleMessageGet(string messageId)
{
var singleRequest = _gmailService.Users.Messages.Get("me", messageId);
-
- // Use Raw format when downloading MIME content, Metadata format when downloading headers only
- // This is critical: Raw format doesn't populate Payload.Headers, Metadata format does!
- // Previously always used Raw format, causing FromAddress/FromName to be empty when downloadMime=false
- singleRequest.Format = downloadMime
- ? UsersResource.MessagesResource.GetRequest.FormatEnum.Raw
- : UsersResource.MessagesResource.GetRequest.FormatEnum.Metadata;
+
+ // 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;
+ }
+
+ ///
+ /// Returns a single get request to retrieve the message with Raw format (includes MIME).
+ /// Used during delta sync to download full message content.
+ ///
+ /// Message to download.
+ /// Get request for message with Raw format.
+ 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;
return singleRequest;
}
@@ -763,7 +765,7 @@ public class GmailSynchronizer : WinoSynchronizerList of history changes.
private async Task ProcessHistoryChangesAsync(ListHistoryResponse listHistoryResponse)
{
- _logger.Debug("Processing delta change {HistoryId} for {Name}", Account.Name, listHistoryResponse.HistoryId.GetValueOrDefault());
+ _logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name);
foreach (var history in listHistoryResponse.History)
{
@@ -1094,17 +1096,167 @@ public class GmailSynchronizer : WinoSynchronizer
+ /// 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.
+ ///
+ /// List of Gmail message IDs to download
+ /// Cancellation token
+ private async Task DownloadMessagesInBatchAsync(IEnumerable messageIds, CancellationToken cancellationToken = default)
+ {
+ await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// 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.
+ ///
+ /// List of Gmail message IDs to download
+ /// True to download Raw format with MIME, false for Metadata only
+ /// Cancellation token
+ private async Task DownloadMessagesInBatchAsync(IEnumerable 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();
+ var batchTasks = new List();
+
+ foreach (var messageId in batch)
+ {
+ var request = downloadRawMime ? CreateSingleMessageGetRaw(messageId) : CreateSingleMessageGet(messageId);
+
+ batchRequest.Queue(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
+ {
+ MimeMessage mimeMessage = null;
+
+ // Extract MIME if we downloaded raw format
+ if (downloadRawMime)
+ {
+ mimeMessage = gmailMessage.GetGmailMimeMessage();
+
+ if (mimeMessage == null)
+ {
+ _logger.Warning("Failed to parse MIME for message {MessageId}", gmailMessage.Id);
+ }
+ }
+
+ // Create mail packages from metadata (or raw if downloaded)
+ var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
+
+ if (packages != null)
+ {
+ // For Gmail, multiple packages can share the same message (different labels/folders)
+ // They should all share the same FileId so MIME is stored only once
+ Guid sharedFileId = Guid.NewGuid();
+
+ foreach (var package in packages)
+ {
+ // Set the same FileId for all copies
+ package.Copy.FileId = sharedFileId;
+
+ // Create the mail copy with the MIME (if downloaded)
+ var packageWithMime = downloadRawMime && mimeMessage != null
+ ? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
+ : package;
+
+ await _gmailChangeProcessor.CreateMailAsync(Account.Id, packageWithMime).ConfigureAwait(false);
+ }
+ }
+
+ // 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);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Downloads a single message by ID with metadata only (no MIME) and creates mail packages.
+ ///
+ /// Gmail message ID to download
+ /// Cancellation token
+ 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);
+ }
+ }
+
public override async Task DownloadMissingMimeMessageAsync(MailCopy mailItem,
ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
@@ -1230,111 +1382,6 @@ public class GmailSynchronizer : WinoSynchronizer
- /// Handles after each single message download with minimal metadata only (no MIME).
- /// This involves adding the Gmail message into Wino database with minimal properties.
- ///
- /// Gmail message
- /// Request error
- /// Message ID being downloaded
- /// Cancellation token
- private async Task HandleSingleItemDownloadedCallbackMinimalAsync(Message message,
- RequestError error,
- string downloadingMessageId,
- CancellationToken cancellationToken = default)
- {
- try
- {
- await ProcessGmailRequestErrorAsync(error, null);
- }
- catch (OutOfMemoryException)
- {
- _logger.Warning("Gmail SDK got OutOfMemoryException due to bug in the SDK");
- }
- catch (SynchronizerEntityNotFoundException)
- {
- _logger.Warning("Resource not found for {DownloadingMessageId}", downloadingMessageId);
- }
- catch (SynchronizerException synchronizerException)
- {
- _logger.Error("Gmail SDK returned error for {DownloadingMessageId}\n{SynchronizerException}", downloadingMessageId, synchronizerException);
- }
-
- if (message == null)
- {
- _logger.Warning("Skipped GMail message download for {DownloadingMessageId}", downloadingMessageId);
-
- return null;
- }
-
- // Use minimal package creation for metadata-only download
- var mailPackage = await CreateNewMailPackagesMinimalAsync(message, cancellationToken);
-
- // If CreateNewMailPackagesMinimalAsync returns null it means local draft mapping is done.
- // We don't need to insert anything else.
- if (mailPackage == null) return message;
-
- foreach (var package in mailPackage)
- {
- await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
- }
-
- return message;
- }
-
- ///
- /// Handles after each single message download.
- /// This involves adding the Gmail message into Wino database.
- ///
- ///
- ///
- ///
- ///
- private async Task HandleSingleItemDownloadedCallbackAsync(Message message,
- RequestError error,
- string downloadingMessageId,
- CancellationToken cancellationToken = default)
- {
- try
- {
- await ProcessGmailRequestErrorAsync(error, null);
- }
- catch (OutOfMemoryException)
- {
- _logger.Warning("Gmail SDK got OutOfMemoryException due to bug in the SDK");
- }
- catch (SynchronizerEntityNotFoundException)
- {
- _logger.Warning("Resource not found for {DownloadingMessageId}", downloadingMessageId);
- }
- catch (SynchronizerException synchronizerException)
- {
- _logger.Error("Gmail SDK returned error for {DownloadingMessageId}\n{SynchronizerException}", downloadingMessageId, synchronizerException);
- }
-
- if (message == null)
- {
- _logger.Warning("Skipped GMail message download for {DownloadingMessageId}", downloadingMessageId);
-
- return null;
- }
-
- // Gmail has LabelId property for each message.
- // Therefore we can pass null as the assigned folder safely.
- var mailPackage = await CreateNewMailPackagesAsync(message, null, cancellationToken);
-
- // If CreateNewMailPackagesAsync returns null it means local draft mapping is done.
- // We don't need to insert anything else.
- if (mailPackage == null) return message;
-
- foreach (var package in mailPackage)
- {
- await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
- }
-
- return message;
- }
-
private bool ShouldUpdateSyncIdentifier(ulong? historyId)
{
if (historyId == null) return false;
@@ -1366,15 +1413,21 @@ public class GmailSynchronizer : WinoSynchronizer folderBundle)
{
- var gmailLabel = await folderBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false);
-
- if (gmailLabel == null) return;
-
// TODO: Handle new Gmail Label added or updated.
}
else if (bundle is HttpRequestBundle draftBundle && draftBundle.Request is CreateDraftRequest createDraftRequest)
@@ -1480,13 +1533,7 @@ public class GmailSynchronizer : WinoSynchronizer
- /// Creates a minimal MailCopy from Gmail message without downloading MIME content.
- /// This includes only the information available from the Gmail message metadata.
- ///
- /// Gmail message
- /// MailCopy with minimal properties
- private static MailCopy CreateMinimalMailCopy(Message gmailMessage)
+ protected override Task CreateMinimalMailCopyAsync(Message gmailMessage, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
bool isUnread = gmailMessage.GetIsUnread();
bool isFocused = gmailMessage.GetIsFocused();
@@ -1495,7 +1542,7 @@ public class GmailSynchronizer : WinoSynchronizer h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? "";
+ var (fromName, fromAddress) = ExtractNameAndEmailFromHeader(fromHeaderValue);
+
+ var copy = new MailCopy()
{
CreationDate = creationDate,
Subject = HttpUtility.HtmlDecode(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("Subject", StringComparison.OrdinalIgnoreCase))?.Value ?? ""),
- FromName = HttpUtility.HtmlDecode(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? ""),
- FromAddress = ExtractEmailFromHeader(gmailMessage.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals("From", StringComparison.OrdinalIgnoreCase))?.Value ?? ""),
- PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet),
+ FromName = HttpUtility.HtmlDecode(fromName),
+ FromAddress = fromAddress,
+ PreviewText = HttpUtility.HtmlDecode(gmailMessage.Snippet ?? "").Trim(),
ThreadId = gmailMessage.ThreadId,
- Importance = MailImportance.Normal, // Default importance without MIME
+ Importance = MailImportance.Normal, // Default importance without MIME parsing
Id = gmailMessage.Id,
IsDraft = isDraft,
HasAttachments = gmailMessage.Payload?.Parts?.Any(p => !string.IsNullOrEmpty(p.Filename)) ?? false,
@@ -1531,56 +1582,43 @@ public class GmailSynchronizer : WinoSynchronizer h.Name.Equals("References", StringComparison.OrdinalIgnoreCase))?.Value,
FileId = Guid.NewGuid()
};
+
+ // Set DraftId if this is a draft
+ if (copy.IsDraft)
+ copy.DraftId = copy.ThreadId;
+
+ return Task.FromResult(copy);
}
///
- /// Extracts email address from a header value like "Name " or "email@domain.com"
+ /// Extracts name and email address from a header value like "Name " or "email@domain.com"
///
- private static string ExtractEmailFromHeader(string headerValue)
+ private static (string name, string email) ExtractNameAndEmailFromHeader(string headerValue)
{
- if (string.IsNullOrEmpty(headerValue)) return "";
+ if (string.IsNullOrEmpty(headerValue))
+ return ("", "");
- var match = System.Text.RegularExpressions.Regex.Match(headerValue, @"<(.+?)>");
+ // Try to match "Name " format
+ var match = System.Text.RegularExpressions.Regex.Match(headerValue, @"^(.+?)\s*<(.+?)>$");
if (match.Success)
- return match.Groups[1].Value;
-
- // If no angle brackets, assume the whole value is the email
- return headerValue.Trim();
- }
-
- ///
- /// Creates new mail packages for the given message with minimal metadata only (no MIME download).
- /// This is used for messages beyond the initial MIME download limit during synchronization.
- ///
- /// Gmail message to create package for.
- /// Cancellation token
- /// New mail package with minimal metadata only.
- private async Task> CreateNewMailPackagesMinimalAsync(Message message, CancellationToken cancellationToken = default)
- {
- var packageList = new List();
-
- // Create mail copy with minimal properties - no MIME download
- var mailCopy = CreateMinimalMailCopy(message);
-
- // Since this is metadata-only download, we can't check for draft mapping via MIME headers
- // Draft mapping will be handled during delta synchronization or when MIME is downloaded on-demand
-
- if (message.LabelIds is not null)
{
- foreach (var labelId in message.LabelIds)
- {
- packageList.Add(new NewMailItemPackage(mailCopy, null, labelId));
- }
+ var name = match.Groups[1].Value.Trim().Trim('"');
+ var email = match.Groups[2].Value.Trim();
+ return (name, email);
}
- return packageList;
+ // If no angle brackets, assume the whole value is the email with no name
+ var emailOnly = headerValue.Trim();
+ return ("", emailOnly);
}
///
/// Creates new mail packages for the given message.
/// AssignedFolder is null since the LabelId is parsed out of the Message.
+ /// NOTE: This method does NOT download MIME content during synchronization.
+ /// MIME is only downloaded when user explicitly reads the message.
///
- /// Gmail message to create package for.
+ /// Gmail message to create package for (must have Metadata format).
/// Null, not used.
/// Cancellation token
/// New mail package that change processor can use to insert new mail into database.
@@ -1590,33 +1628,33 @@ public class GmailSynchronizer : WinoSynchronizer();
- MimeMessage mimeMessage = message.GetGmailMimeMessage();
- var mailCopy = message.AsMailCopy(mimeMessage);
+ // Create MailCopy from metadata only - NO MIME download
+ var mailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
- // Check whether this message is mapped to any local draft.
- // Previously we were using Draft resource response as mapping drafts.
- // This seem to be a worse approach. Now both Outlook and Gmail use X-Wino-Draft-Id header to map drafts.
- // This is a better approach since we don't need to fetch the draft resource to get the draft id.
-
- if (mailCopy.IsDraft
- && mimeMessage.Headers.Contains(Domain.Constants.WinoLocalDraftHeader)
- && Guid.TryParse(mimeMessage.Headers[Domain.Constants.WinoLocalDraftHeader], out Guid localDraftCopyUniqueId))
+ // Check for local draft mapping using X-Wino-Draft-Id header from metadata
+ if (mailCopy.IsDraft)
{
- // This message belongs to existing local draft copy.
- // We don't need to create a new mail copy for this message, just update the existing one.
+ var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
- bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
+ if (!string.IsNullOrEmpty(draftIdHeader) && Guid.TryParse(draftIdHeader, out Guid localDraftCopyUniqueId))
+ {
+ // This message belongs to existing local draft copy.
+ // We don't need to create a new mail copy for this message, just update the existing one.
- if (isMappingSuccesfull) return null;
+ bool isMappingSuccesfull = await _gmailChangeProcessor.MapLocalDraftAsync(Account.Id, localDraftCopyUniqueId, mailCopy.Id, mailCopy.DraftId, mailCopy.ThreadId);
- // Local copy doesn't exists. Continue execution to insert mail copy.
+ if (isMappingSuccesfull) return null;
+
+ // Local copy doesn't exists. Continue execution to insert mail copy.
+ }
}
if (message.LabelIds is not null)
{
foreach (var labelId in message.LabelIds)
{
- packageList.Add(new NewMailItemPackage(mailCopy, mimeMessage, labelId));
+ // Pass null for MimeMessage - it will be downloaded later when user reads the mail
+ packageList.Add(new NewMailItemPackage(mailCopy, null, labelId));
}
}
diff --git a/Wino.Core/Synchronizers/WinoSynchronizer.cs b/Wino.Core/Synchronizers/WinoSynchronizer.cs
index 1d32c489..26eac5d9 100644
--- a/Wino.Core/Synchronizers/WinoSynchronizer.cs
+++ b/Wino.Core/Synchronizers/WinoSynchronizer.cs
@@ -41,22 +41,27 @@ public abstract class WinoSynchronizer
/// How many items must be downloaded per folder when the folder is first synchronized.
+ /// Only metadata is downloaded during sync - MIME content is fetched on-demand when user reads mail.
///
public abstract uint InitialMessageDownloadCountPerFolder { get; }
///
- /// Number of MIME messages to download during initial synchronization per folder.
- /// For the first messages in each folder during initial sync, both metadata and MIME content will be downloaded.
- /// Subsequent messages will only have metadata downloaded, with MIME content fetched on-demand.
+ /// DEPRECATED: MIME messages are no longer downloaded during synchronization.
+ /// MIME content is only downloaded when explicitly needed (e.g., when user reads a message).
+ /// This property is kept for backward compatibility but is no longer used.
///
- public virtual int InitialSyncMimeDownloadCount => 50;
+ [Obsolete("MIME messages are no longer downloaded during sync. Use DownloadMissingMimeMessageAsync instead.")]
+ public virtual int InitialSyncMimeDownloadCount => 0;
///
- /// Creates a new Wino Mail Item package out of native message type with full Mime.
+ /// Creates a new Wino Mail Item package out of native message type with metadata only.
+ /// NO MIME content is downloaded during synchronization - only headers and essential metadata.
+ /// MIME will be downloaded on-demand when user explicitly reads the message.
///
/// Native message type for the synchronizer.
+ /// Folder to assign the mail to.
/// Cancellation token
- /// Package that encapsulates downloaded Mime and additional information for adding new mail.
+ /// Package with MailCopy metadata. MimeMessage will be null during sync.
public abstract Task> CreateNewMailPackagesAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default);
///
@@ -86,13 +91,15 @@ public abstract class WinoSynchronizer
/// Creates a MailCopy object with minimal properties from the native message type.
- /// This is used for queue-based sync to avoid downloading full MIME messages.
- /// Only overridden by synchronizers that support the new queue-based sync.
+ /// This is used during synchronization to create mail entries WITHOUT downloading MIME content.
+ /// Only metadata (headers, labels, flags) is extracted from the native message format.
+ /// MIME content will be downloaded later on-demand when user reads the message.
+ /// Only overridden by synchronizers that support metadata-only synchronization.
///
/// Native message type
/// Folder this message belongs to
/// Cancellation token
- /// MailCopy with minimal properties
+ /// MailCopy with minimal properties populated from metadata
protected virtual Task CreateMinimalMailCopyAsync(TMessageType message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default) => Task.FromResult(null);
///
diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs
index ab39f8e2..65a2ff1c 100644
--- a/Wino.Mail.ViewModels/MailListPageViewModel.cs
+++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs
@@ -543,6 +543,7 @@ public partial class MailListPageViewModel : MailBaseViewModel,
{
if (IsInitializingFolder || IsOnlineSearchEnabled) return;
+ Debug.WriteLine("Loading more...");
await ExecuteUIThread(() => { IsInitializingFolder = true; });
var initializationOptions = new MailListInitializationOptions(ActiveFolder.HandlingFolders,
diff --git a/Wino.Mail.WinUI/Views/MailListPage.xaml b/Wino.Mail.WinUI/Views/MailListPage.xaml
index 66d4a01d..180a876b 100644
--- a/Wino.Mail.WinUI/Views/MailListPage.xaml
+++ b/Wino.Mail.WinUI/Views/MailListPage.xaml
@@ -92,8 +92,8 @@
ContextRequested="MailItemContextRequested"
CreationDate="{x:Bind CreationDate}"
DisplayMode="{Binding ElementName=root, Path=ViewModel.PreferencesService.MailItemDisplayMode, Mode=OneWay}"
- FromAddress="{x:Bind FromAddress}"
- FromName="{x:Bind FromName}"
+ FromAddress="{x:Bind FromAddress, Mode=OneWay}"
+ FromName="{x:Bind FromName, Mode=OneWay}"
HasAttachments="{x:Bind HasAttachments, Mode=OneWay}"
HoverActionExecutedCommand="{Binding ElementName=root, Path=ViewModel.ExecuteHoverActionCommand}"
IsAvatarVisible="{Binding ElementName=root, Path=ViewModel.PreferencesService.IsShowSenderPicturesEnabled, Mode=OneWay}"
@@ -302,7 +302,6 @@
Grid.Row="1"
Grid.Column="2"
Orientation="Horizontal">
-