diff --git a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs
index 0ea9974e..bb1a2cae 100644
--- a/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs
+++ b/Wino.Core.Domain/Entities/Mail/MailItemFolder.cs
@@ -36,12 +36,6 @@ public class MailItemFolder : IMailItemFolder
///
public string DeltaToken { get; set; }
- ///
- /// Whether initial synchronization of mail ids is completed for this folder.
- /// Used to determine if we should queue all mail ids first or start downloading from queue.
- ///
- public InitialSynchronizationStatus FolderStatus { get; set; }
-
// For GMail Labels
public string TextColorHex { get; set; }
public string BackgroundColorHex { get; set; }
diff --git a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs b/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs
deleted file mode 100644
index 25a73ceb..00000000
--- a/Wino.Core.Domain/Entities/Mail/MailItemQueue.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-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 string RemoteFolderId { get; set; } // For Outlook per-folder sync
- 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 889d1729..8f40f70d 100644
--- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs
+++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs
@@ -33,11 +33,6 @@ 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
deleted file mode 100644
index 0fb7fb97..00000000
--- a/Wino.Core.Domain/Enums/InitialSynchronizationStatus.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-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 5a81fb3e..e6dfd7aa 100644
--- a/Wino.Core.Domain/Interfaces/IMailService.cs
+++ b/Wino.Core.Domain/Interfaces/IMailService.cs
@@ -162,11 +162,4 @@ 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> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
- Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
- Task UpdateMailItemQueueAsync(IEnumerable queueItems);
}
diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
index ae4cbc1b..a7a98ea3 100644
--- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs
@@ -65,13 +65,6 @@ 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> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
- Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
- Task UpdateMailItemQueueAsync(IEnumerable queueItems);
}
public interface IGmailChangeProcessor : IDefaultChangeProcessor
@@ -94,14 +87,6 @@ public interface IOutlookChangeProcessor : IDefaultChangeProcessor
/// New identifier if success.
Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string deltaSynchronizationIdentifier);
- ///
- /// Updates the initial synchronization completion status for a folder.
- /// Used to track whether mail ids have been queued for initial sync.
- ///
- /// Folder id
- /// Whether initial sync is completed
- Task UpdateFolderInitialSyncCompletedAsync(Guid folderId, bool isCompleted);
-
///
/// Outlook may expire folder's delta token after a while.
/// Recommended action for this scenario is to reset token and do full sync.
@@ -215,27 +200,6 @@ 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> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take)
- => MailService.GetMailItemQueueByFolderAsync(accountId, remoteFolderId, take);
-
- public Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId)
- => MailService.GetMailItemQueueCountByFolderAsync(accountId, remoteFolderId);
-
- 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/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
index 9532c29b..3b72c33e 100644
--- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
@@ -38,12 +38,6 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
public Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string synchronizationIdentifier)
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId);
- public Task UpdateFolderInitialSyncCompletedAsync(Guid folderId, bool isCompleted)
- {
- var status = isCompleted ? InitialSynchronizationStatus.Completed : InitialSynchronizationStatus.None;
- return Connection.ExecuteAsync("UPDATE MailItemFolder SET FolderStatus = ? WHERE Id = ?", status, folderId);
- }
-
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
{
// We parse the occurrences based on the parent event.
diff --git a/Wino.Core/Synchronizers/Errors/Outlook/DeltaTokenExpiredHandler.cs b/Wino.Core/Synchronizers/Errors/Outlook/DeltaTokenExpiredHandler.cs
index 4f219740..b62c690d 100644
--- a/Wino.Core/Synchronizers/Errors/Outlook/DeltaTokenExpiredHandler.cs
+++ b/Wino.Core/Synchronizers/Errors/Outlook/DeltaTokenExpiredHandler.cs
@@ -43,19 +43,16 @@ public class DeltaTokenExpiredHandler : ISynchronizerErrorHandler
// Reset the account's delta synchronization identifier
await _outlookChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(error.Account.Id, string.Empty).ConfigureAwait(false);
- // Get all folders for the account and reset their delta tokens and initial sync status
+ // Get all folders for the account and reset their delta tokens
var folders = await _outlookChangeProcessor.GetLocalFoldersAsync(error.Account.Id).ConfigureAwait(false);
foreach (var folder in folders)
{
- // Reset folder delta token
+ // Reset folder delta token to force full re-sync (last 30 days)
await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, string.Empty).ConfigureAwait(false);
-
- // Reset initial sync completion status to force full re-sync
- await _outlookChangeProcessor.UpdateFolderInitialSyncCompletedAsync(folder.Id, false).ConfigureAwait(false);
}
- _logger.Information("Successfully reset synchronization state for account {AccountName} ({AccountId}). Next sync will be a full re-sync.",
+ _logger.Information("Successfully reset synchronization state for account {AccountName} ({AccountId}). Next sync will download last 30 days.",
error.Account.Name, error.Account.Id);
return true;
diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs
index 4302fc67..e59edfc6 100644
--- a/Wino.Core/Synchronizers/GmailSynchronizer.cs
+++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs
@@ -43,20 +43,21 @@ using CalendarService = Google.Apis.Calendar.v3.CalendarService;
namespace Wino.Core.Synchronizers.Mail;
///
-/// Gmail synchronizer implementation.
+/// Gmail synchronizer implementation with per-folder history ID synchronization.
///
-/// 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
+/// SYNCHRONIZATION STRATEGY:
+/// - Uses Gmail History API for both initial and incremental sync
+/// - Initial sync: Downloads top 1500 messages per folder with metadata only
+/// - Incremental sync: Uses history ID to get only changes since last sync
+/// - Messages are downloaded with metadata only (no MIME content during sync)
+/// - MIME files are downloaded on-demand when user explicitly reads a message
///
/// Key implementation details:
-/// - CreateNewMailPackagesAsync: Creates MailCopy from metadata, passes null for MimeMessage
-/// - CreateMinimalMailCopyAsync: Extracts all MailCopy fields from Gmail Metadata format (Payload.Headers)
+/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
+/// - DownloadMessagesForFolderAsync: Downloads top 1500 messages for initial sync
+/// - SynchronizeDeltaAsync: Processes incremental changes using history ID
+/// - CreateMinimalMailCopyAsync: Extracts MailCopy fields from Gmail Metadata format
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
-/// - CreateSingleMessageGet: Always uses Metadata format (not Raw format) for synchronization
///
public class GmailSynchronizer : WinoSynchronizer, IHttpClientFactory
{
@@ -179,122 +180,146 @@ public class GmailSynchronizer : WinoSynchronizer();
+
+ // Get all folders to synchronize
+ var synchronizationFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
+
+ _logger.Information("Synchronizing {Count} folders for {Name}", synchronizationFolders.Count, Account.Name);
+
+ var totalFolders = synchronizationFolders.Count;
+
+ for (int i = 0; i < totalFolders; i++)
{
- UpdateSyncProgress(0, 0, "Fetching email IDs...");
- await FetchAllEmailIdsAsync().ConfigureAwait(false);
- await CompleteAccountSyncStatusAsync();
- UpdateSyncProgress(0, 0, "Email IDs fetched");
+ var folder = synchronizationFolders[i];
+
+ // Update progress based on folder completion
+ UpdateSyncProgress(totalFolders, totalFolders - (i + 1), $"Syncing {folder.FolderName}...");
+
+ var folderDownloadedMessageIds = await SynchronizeFolderAsync(folder, cancellationToken).ConfigureAwait(false);
+ downloadedMessageIds.AddRange(folderDownloadedMessageIds);
}
+ // Process incremental changes using history API if we have a history ID
if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
{
UpdateSyncProgress(0, 0, "Synchronizing changes...");
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
- await CompleteAccountSyncStatusAsync();
UpdateSyncProgress(0, 0, "Changes synchronized");
}
- if (Account.SynchronizationStatus == InitialSynchronizationStatus.IdsFetched)
- {
- UpdateSyncProgress(0, 0, "Processing email metadata...");
- await ProcessEmailMetadataFromQueueAsync(cancellationToken);
- UpdateSyncProgress(0, 0, "Email metadata processed");
- }
+ // Get all unread new downloaded items for notifications
+ var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
- if (Account.SynchronizationStatus == InitialSynchronizationStatus.Completed)
- {
- UpdateSyncProgress(0, 0, "Synchronization completed");
- }
-
- return MailSynchronizationResult.Completed(new List());
+ return MailSynchronizationResult.Completed(unreadNewItems);
}
- #region Queue System
-
- private async Task FetchAllEmailIdsAsync()
+ ///
+ /// Synchronizes a single folder by downloading top 1500 messages with metadata only.
+ ///
+ private async Task> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
{
+ var downloadedMessageIds = new List();
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _logger.Debug("Synchronizing folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
+
try
{
- // 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);
+ // Download top 1500 messages for this folder
+ await DownloadMessagesForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
- var totalFetched = 0;
+ if (downloadedMessageIds.Any())
+ {
+ _logger.Information("Downloaded {Count} messages for folder {FolderName}", downloadedMessageIds.Count, folder.FolderName);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Error synchronizing folder {FolderName}", folder.FolderName);
+ throw;
+ }
+
+ return downloadedMessageIds;
+ }
+
+ ///
+ /// Downloads top 1500 messages for a folder using Gmail API with metadata only.
+ ///
+ private async Task DownloadMessagesForFolderAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken)
+ {
+ _logger.Debug("Downloading messages for folder {FolderName}", folder.FolderName);
+
+ try
+ {
+ var totalDownloaded = 0;
string pageToken = null;
- var pageCount = 0;
+
+ // Gmail API returns messages newest first by default
+ // We'll download up to 1500 messages per folder
+ var remainingToDownload = (int)InitialMessageDownloadCountPerFolder;
do
{
- try
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var request = _gmailService.Users.Messages.List("me");
+ request.LabelIds = new Google.Apis.Util.Repeatable(new[] { folder.RemoteFolderId });
+ request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
+ request.PageToken = pageToken;
+
+ var response = await request.ExecuteAsync(cancellationToken);
+
+ if (response.Messages != null && response.Messages.Count > 0)
{
- pageCount++;
-
- // 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 messageIds = response.Messages.Select(m => m.Id).ToList();
- var response = await request.ExecuteAsync();
+ // Download metadata in batches
+ await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
- 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
- });
+ downloadedMessageIds.AddRange(messageIds);
+ totalDownloaded += messageIds.Count;
+ remainingToDownload -= messageIds.Count;
- await _gmailChangeProcessor.AddMailItemQueueItemsAsync(queueEntries1).ConfigureAwait(false);
+ _logger.Debug("Downloaded {Count} messages for folder {FolderName} (total: {Total})", messageIds.Count, folder.FolderName, totalDownloaded);
- totalFetched += queueEntries1.Count();
-
- // Update progress - we don't know total count, so show indeterminate with status
- UpdateSyncProgress(0, 0, $"Fetched {totalFetched} email IDs (page {pageCount})");
- }
-
- pageToken = response.NextPageToken;
+ // Update progress
+ UpdateSyncProgress(0, 0, $"Downloaded {totalDownloaded} messages from {folder.FolderName}");
}
- 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
- }
+ pageToken = response.NextPageToken;
+
+ // Stop if we've downloaded enough messages or no more pages
+ if (remainingToDownload <= 0 || string.IsNullOrEmpty(pageToken))
+ break;
+
} while (!string.IsNullOrEmpty(pageToken));
-
- // Final update with total count
- UpdateSyncProgress(0, 0, $"Fetched {totalFetched} email IDs total");
+
+ // Store history ID for future incremental syncs
+ var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync(cancellationToken);
+ Account.SynchronizationDeltaIdentifier = profile.HistoryId.ToString();
+ await _gmailChangeProcessor.UpdateAccountAsync(Account).ConfigureAwait(false);
+
+ _logger.Information("Completed downloading {Count} messages for folder {FolderName}", totalDownloaded, folder.FolderName);
}
- catch (Exception)
+ catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
+ _logger.Warning("Rate limit exceeded while downloading messages for folder {FolderName}. Retrying after delay.", folder.FolderName);
+ await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Error downloading messages for folder {FolderName}", folder.FolderName);
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)
@@ -340,100 +365,6 @@ public class GmailSynchronizer : WinoSynchronizer 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;
- processedCount++;
- }
- }
- catch (Exception)
- {
- // Mark all items in chunk as failed
- foreach (var queueItem in chunk)
- {
- queueItem.IsProcessed = false;
- queueItem.ProcessedAt = null;
- queueItem.FailedCount++;
- totalFailed++;
- processedCount++; // Count failed items as processed for progress
- }
- }
-
- await _gmailChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false);
-
- // Update progress based on processed items
- if (totalInQueue > 0)
- {
- var remainingItems = totalInQueue - processedCount;
- UpdateSyncProgress(totalInQueue, remainingItems, $"Processing emails: {processedCount}/{totalInQueue}");
- }
-
- // 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);
diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs
index d06b42e0..4391a2fd 100644
--- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs
+++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs
@@ -51,23 +51,22 @@ public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
///
-/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
+/// Outlook synchronizer implementation with delta token synchronization.
///
/// SYNCHRONIZATION STRATEGY:
-/// - Uses per-folder queue system (unlike Gmail's per-account queue)
-/// - During sync (initial/delta), only message metadata is downloaded (no MIME content)
-/// - Messages are queued by folder using MailItemQueue with RemoteFolderId
-/// - MailCopy objects are created from Graph API metadata fields only
+/// - Uses delta API for both initial and incremental sync
+/// - Initial sync: Downloads last 30 days of emails with metadata only
+/// - Incremental sync: Uses delta token to get only changes since last sync
+/// - Messages are downloaded with metadata only (no MIME content during sync)
/// - MIME files are downloaded on-demand when user explicitly reads a message
-/// - This dramatically reduces bandwidth usage and sync time
///
/// Key implementation details:
-/// - QueueMailIdsForFolderAsync: Queues all mail IDs for a folder using Delta API
-/// - ProcessMailQueueForFolderAsync: Downloads metadata in batches from queue
-/// - DownloadMessageMetadataBatchAsync: Concurrently downloads metadata for batches
-/// - CreateMailCopyFromMessage: Centralized method to create MailCopy from Message (metadata only)
+/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
+/// - DownloadMailsForInitialSyncAsync: Downloads last 30 days using delta API with filter
+/// - ProcessDeltaChangesAsync: Processes incremental changes using delta token
+/// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API
+/// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
-/// - CreateNewMailPackagesAsync: Only used for search results and special cases (downloads MIME)
///
public class OutlookSynchronizer : WinoSynchronizer
{
@@ -227,26 +226,22 @@ public class OutlookSynchronizer : WinoSynchronizer
- /// Downloads mails for initial synchronization using Delta API and queue-based system.
- /// First, queues all mail IDs, then downloads metadata in batches.
+ /// Downloads mails for initial synchronization using Delta API with 30-day filter.
+ /// Downloads metadata only (no MIME content) for messages received in the last 30 days.
///
private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken)
{
- _logger.Debug("Starting initial mail download for folder {FolderName}", folder.FolderName);
+ _logger.Debug("Starting initial mail download for folder {FolderName} (last 6 months)", folder.FolderName);
try
{
- // Step 1: Queue all mail IDs using Delta API
- await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false);
+ // Calculate date 6 months ago
+ var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6);
+ var filterDate = sixMonthsAgo.ToString("yyyy-MM-ddTHH:mm:ssZ");
- // Step 2: Process queued mail IDs in batches
- await ProcessMailQueueForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
+ _logger.Information("Downloading messages received after {FilterDate} for folder {FolderName}", filterDate, folder.FolderName);
+
+ // Use Delta API with receivedDateTime filter for last 6 months
+ var messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
+ {
+ config.QueryParameters.Select = outlookMessageSelectParameters;
+ config.QueryParameters.Orderby = ["receivedDateTime desc"];
+ config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}";
+ }, cancellationToken).ConfigureAwait(false);
+
+ var totalProcessed = 0;
+
+ // Use PageIterator to process all messages
+ var messageIterator = PageIterator.CreatePageIterator(_graphClient, messageCollectionPage, async (message) =>
+ {
+ try
+ {
+ await _handleItemRetrievalSemaphore.WaitAsync();
+
+ if (!IsResourceDeleted(message.AdditionalData) && !IsNotRealMessageType(message))
+ {
+ // Check if message already exists
+ bool mailExists = await _outlookChangeProcessor.IsMailExistsInFolderAsync(message.Id, folder.Id).ConfigureAwait(false);
+
+ if (!mailExists)
+ {
+ // Create MailCopy from metadata
+ var mailCopy = await CreateMailCopyFromMessageAsync(message, folder).ConfigureAwait(false);
+
+ if (mailCopy != null)
+ {
+ // Create package without MIME
+ var package = new NewMailItemPackage(mailCopy, null, folder.RemoteFolderId);
+ bool isInserted = await _outlookChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
+
+ if (isInserted)
+ {
+ downloadedMessageIds.Add(mailCopy.Id);
+ totalProcessed++;
+
+ // Update progress periodically
+ if (totalProcessed % 50 == 0)
+ {
+ UpdateSyncProgress(0, 0, $"Downloaded {totalProcessed} messages from {folder.FolderName}");
+ }
+ }
+ }
+ }
+ else
+ {
+ _logger.Debug("Mail {MailId} already exists in folder {FolderName}, skipping", message.Id, folder.FolderName);
+ }
+ }
+
+ return true; // Continue processing
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Failed to process message {MessageId} during initial sync for folder {FolderName}", message.Id, folder.FolderName);
+ return true; // Continue despite error
+ }
+ finally
+ {
+ _handleItemRetrievalSemaphore.Release();
+ }
+ });
+
+ await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
+
+ // Extract and store delta token for future incremental syncs
+ if (!string.IsNullOrEmpty(messageIterator.Deltalink))
+ {
+ var deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink);
+ await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
+ await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
+ folder.DeltaToken = deltaToken;
+ _logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName);
+ }
+ else
+ {
+ _logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName);
+ }
+
+ _logger.Information("Initial sync completed for folder {FolderName}. Downloaded {Count} messages", folder.FolderName, totalProcessed);
}
catch (ApiException apiException)
{
- // Try to handle the error using the error handling factory
+ // Handle API errors
var errorContext = new SynchronizerErrorContext
{
Account = Account,
@@ -290,22 +368,17 @@ public class OutlookSynchronizer : WinoSynchronizer
- /// Queues all mail IDs for a folder using Delta API.
- /// Only retrieves message IDs to minimize data transfer.
- ///
- private async Task QueueMailIdsForFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
- {
- _logger.Debug("Queuing mail IDs for folder {FolderName}", folder.FolderName);
-
- var mailIds = new List();
-
- // Always use Delta API for initial sync - this ensures proper delta token setup for future incremental syncs
- DeltaGetResponse messageCollectionPage = null;
-
- if (string.IsNullOrEmpty(folder.DeltaToken))
- {
- messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) =>
- {
- config.QueryParameters.Select = ["Id"]; // Only get the message Ids
- config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
- // config.QueryParameters.Top = (int)InitialMessageDownloadCountPerFolder;
- }, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
- {
- config.QueryParameters.Select = ["Id"]; // Only get the message Ids
- config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
- });
-
- requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
- requestInformation.QueryParameters.Add("%24deltatoken", folder.DeltaToken);
-
- messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation, DeltaGetResponse.CreateFromDiscriminatorValue, cancellationToken: cancellationToken);
- }
-
- // Use PageIterator to iterate through all messages and collect IDs
- var messageIterator = PageIterator.CreatePageIterator(_graphClient, messageCollectionPage, (message) =>
- {
- if (!IsResourceDeleted(message.AdditionalData))
- {
- mailIds.Add(message.Id);
- }
-
- // Iterator must continue all the time to receive delta token at the end.
- return true;
- });
-
- await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
-
- // Extract delta token from the iterator's delta link
- string deltaToken = null;
- if (!string.IsNullOrEmpty(messageIterator.Deltalink))
- {
- deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink);
- }
-
- // Queue all mail IDs for processing
- if (mailIds.Any())
- {
- var queueEntries = mailIds.Select(id => new MailItemQueue
- {
- Id = Guid.CreateVersion7(),
- AccountId = Account.Id,
- RemoteServerId = id,
- RemoteFolderId = folder.RemoteFolderId,
- IsProcessed = false,
- CreatedAt = DateTime.UtcNow
- });
-
- await _outlookChangeProcessor.AddMailItemQueueItemsAsync(queueEntries).ConfigureAwait(false);
-
- _logger.Information("Queued {Count} mail IDs for folder {FolderName}", mailIds.Count, folder.FolderName);
- }
- else
- {
- _logger.Information("No mail ids found to queue for folder {FolderName}", folder.FolderName);
- }
-
- // Store the delta token for future incremental syncs - always store when available
- if (!string.IsNullOrEmpty(deltaToken))
- {
- await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
- await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
- folder.DeltaToken = deltaToken;
- _logger.Information("Stored delta token for folder {FolderName} - future syncs will be incremental", folder.FolderName);
- }
- else
- {
- _logger.Warning("No delta token received for folder {FolderName} - future syncs may re-download messages", folder.FolderName);
- }
- }
-
- ///
- /// Processes queued mail IDs in batches, downloading metadata only (no MIME).
- ///
- private async Task ProcessMailQueueForFolderAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken)
- {
- var totalInQueue = await _outlookChangeProcessor.GetMailItemQueueCountByFolderAsync(Account.Id, folder.RemoteFolderId).ConfigureAwait(false);
-
- if (totalInQueue == 0)
- {
- _logger.Information("No mails in queue for folder {FolderName}", folder.FolderName);
- return;
- }
-
- _logger.Information("Processing {Count} queued mails for folder {FolderName}", totalInQueue, folder.FolderName);
-
- var totalFailed = 0;
- var totalProcessed = 0;
-
- // Set initial progress for queue processing
- UpdateSyncProgress(totalInQueue, totalInQueue, $"Downloading {folder.FolderName}...");
-
- // Continue until all emails in queue are processed
- while (true)
- {
- // Get next batch of unprocessed emails from queue
- var mailItemQueue = await _outlookChangeProcessor.GetMailItemQueueByFolderAsync(Account.Id, folder.RemoteFolderId, 100).ConfigureAwait(false);
-
- if (mailItemQueue.Count == 0)
- break; // No more emails to process
-
- // Remove the items that should be deleted from queue first
- mailItemQueue.RemoveAll(a => a.ShouldDelete());
-
- var mailChunks = mailItemQueue.Chunk(20); // Process 20 at a time
-
- foreach (var chunk in mailChunks)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- // Collect message IDs from the chunk
- var messageIdsToDownload = chunk.Select(q => q.RemoteServerId).ToList();
-
- try
- {
- // Download all messages in this chunk concurrently
- var chunkDownloadedIds = await DownloadMessageMetadataBatchAsync(messageIdsToDownload, folder, true, cancellationToken).ConfigureAwait(false);
-
- downloadedMessageIds.AddRange(chunkDownloadedIds);
-
- // Mark all items in chunk as processed
- foreach (var queueItem in chunk)
- {
- queueItem.IsProcessed = true;
- queueItem.ProcessedAt = DateTime.UtcNow;
- totalProcessed++;
- }
-
- // Update progress with remaining items
- var remainingItems = totalInQueue - totalProcessed;
- UpdateSyncProgress(totalInQueue, remainingItems, $"Downloading {folder.FolderName} ({totalProcessed}/{totalInQueue})");
- }
- catch (Exception ex)
- {
- _logger.Error(ex, "Failed to download chunk of messages for folder {FolderName}", folder.FolderName);
-
- // Mark all items in chunk as failed
- foreach (var queueItem in chunk)
- {
- queueItem.IsProcessed = false;
- queueItem.ProcessedAt = null;
- queueItem.FailedCount++;
- totalFailed++;
- }
- }
-
- await _outlookChangeProcessor.UpdateMailItemQueueAsync(mailItemQueue).ConfigureAwait(false);
-
- // If too many failures, pause to avoid hitting rate limits
- if (totalFailed > 50)
- {
- _logger.Warning("Too many failures ({Count}), pausing for 10 seconds", totalFailed);
- await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
- totalFailed = 0; // Reset counter
- }
- }
-
- _logger.Debug("Processed batch: {Processed}/{Total} for folder {FolderName}", totalProcessed, totalInQueue, folder.FolderName);
- }
-
- _logger.Information("Completed processing queue for folder {FolderName}. Processed: {Count}", folder.FolderName, totalProcessed);
- }
-
///
/// Downloads metadata for a batch of messages using Graph SDK batch API (no MIME content).
/// Processes up to 20 messages per batch request as per MaximumAllowedBatchRequestSize.
@@ -713,12 +601,6 @@ public class OutlookSynchronizer : WinoSynchronizer Regex.Split(deltaLink, "deltatoken=")[1];
- protected override async Task QueueMailIdsForInitialSyncAsync(MailItemFolder folder, CancellationToken cancellationToken = default)
- {
- // Queue all mail IDs for the folder
- await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false);
- }
-
protected override async Task CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
// Use centralized method
@@ -774,99 +656,6 @@ public class OutlookSynchronizer : WinoSynchronizer downloadedMessageIds, CancellationToken cancellationToken = default)
- {
- // Process delta changes and download new mails with metadata only (no MIME)
- if (string.IsNullOrEmpty(folder.DeltaToken))
- {
- _logger.Debug("No delta token available for folder {FolderName}. Skipping delta sync.", folder.FolderName);
- return;
- }
-
- try
- {
- var currentDeltaToken = folder.DeltaToken;
-
- _logger.Debug("Processing delta changes for folder {FolderName} with token {DeltaToken}", folder.FolderName, currentDeltaToken.Substring(0, Math.Min(10, currentDeltaToken.Length)) + "...");
- _logger.Debug("Delta sync will include all message properties to detect updates (IsRead, Flag, etc.)");
-
- // Always use Delta endpoint with proper configuration
- var requestInformation = _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.ToGetRequestInformation((config) =>
- {
- config.QueryParameters.Select = outlookMessageSelectParameters; // Include all necessary fields for detecting updates
- config.QueryParameters.Orderby = ["receivedDateTime desc"]; // Sort by received date desc
- });
-
- requestInformation.UrlTemplate = requestInformation.UrlTemplate.Insert(requestInformation.UrlTemplate.Length - 1, ",%24deltatoken");
- requestInformation.QueryParameters.Add("%24deltatoken", currentDeltaToken);
-
- var messageCollectionPage = await _graphClient.RequestAdapter.SendAsync(requestInformation,
- DeltaGetResponse.CreateFromDiscriminatorValue,
- cancellationToken: cancellationToken);
-
- // Use PageIterator to process delta changes (both new messages and updates)
- var messageIterator = PageIterator
- .CreatePageIterator(_graphClient, messageCollectionPage, async (message) =>
- {
- try
- {
- await HandleItemRetrievedAsync(message, folder, downloadedMessageIds, cancellationToken);
- return true;
- }
- catch (Exception ex)
- {
- _logger.Error(ex, "Failed to handle delta item {MessageId} for folder {FolderName}", message.Id, folder.FolderName);
- return true; // Continue processing other items
- }
- });
-
- await messageIterator.IterateAsync(cancellationToken).ConfigureAwait(false);
-
- // Update delta token for next sync - always store when there are no nextPageToken remaining
- if (!string.IsNullOrEmpty(messageIterator.Deltalink))
- {
- var deltaToken = GetDeltaTokenFromDeltaLink(messageIterator.Deltalink);
- await _outlookChangeProcessor.UpdateFolderDeltaSynchronizationIdentifierAsync(folder.Id, deltaToken).ConfigureAwait(false);
- folder.DeltaToken = deltaToken; // Update in-memory object too
- _logger.Debug("Updated delta token for folder {FolderName} after processing delta changes", folder.FolderName);
- }
- }
- catch (ApiException apiException)
- {
- // Try to handle the error using the error handling factory
- var errorContext = new SynchronizerErrorContext
- {
- Account = Account,
- ErrorCode = (int?)apiException.ResponseStatusCode,
- ErrorMessage = $"API error during delta sync: {apiException.Message}",
- Exception = apiException
- };
-
- var handled = await _errorHandlingFactory.HandleErrorAsync(errorContext).ConfigureAwait(false);
-
- if (handled)
- {
- // The error handler has processed the error (e.g., DeltaTokenExpiredHandler for 410)
- // Update in-memory folder state if it was a delta token expiration
- if (apiException.ResponseStatusCode == 410)
- {
- folder.DeltaToken = string.Empty;
- folder.FolderStatus = InitialSynchronizationStatus.None;
- _logger.Information("API error handled successfully for folder {FolderName} during delta sync. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
- }
- }
- else
- {
- // No handler could process this error, log and re-throw
- _logger.Error(apiException, "Unhandled API error during delta sync for folder {FolderName}. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
- }
- }
- catch (Exception ex)
- {
- _logger.Error(ex, "Error processing delta changes for folder {FolderName}", folder.FolderName);
- }
- }
-
private async Task ProcessDeltaChangesAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken = default)
{
// Only process delta changes if we have a delta token (not initial sync)
diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs
index 03329bd6..e8d87389 100644
--- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs
+++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs
@@ -287,7 +287,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { threadViewModel.NotifyPropertyChanges(); });
+ await ExecuteUIThread(() => { threadViewModel.ThreadEmails = threadViewModel.ThreadEmails; });
}
UpdateUniqueIdHashes(new MailItemViewModel(addedItem), true);
@@ -391,9 +391,13 @@ public class WinoMailCollection : ObservableRecipient, IRecipient { existingItem.NotifyPropertyChanges(); });
+
+ await ExecuteUIThread(() =>
+ {
+ existingItem.MailCopy = updatedItem;
+ });
+
+ UpdateUniqueIdHashes(existingItem, true);
}
///
@@ -520,8 +524,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient
{
- CurrentMailDraftItem.NotifyPropertyChanges();
+ CurrentMailDraftItem.MailCopy = updatedMail;
DiscardCommand.NotifyCanExecuteChanged();
SendCommand.NotifyCanExecuteChanged();
});
diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs
index 874e090a..64f8af97 100644
--- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs
+++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs
@@ -11,7 +11,29 @@ namespace Wino.Mail.ViewModels.Data;
///
public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, IMailListItem
{
- public MailCopy MailCopy { get; } = mailCopy;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CreationDate))]
+ [NotifyPropertyChangedFor(nameof(IsFlagged))]
+ [NotifyPropertyChangedFor(nameof(FromName))]
+ [NotifyPropertyChangedFor(nameof(IsFocused))]
+ [NotifyPropertyChangedFor(nameof(IsRead))]
+ [NotifyPropertyChangedFor(nameof(IsDraft))]
+ [NotifyPropertyChangedFor(nameof(DraftId))]
+ [NotifyPropertyChangedFor(nameof(Id))]
+ [NotifyPropertyChangedFor(nameof(Subject))]
+ [NotifyPropertyChangedFor(nameof(PreviewText))]
+ [NotifyPropertyChangedFor(nameof(FromAddress))]
+ [NotifyPropertyChangedFor(nameof(HasAttachments))]
+ [NotifyPropertyChangedFor(nameof(Importance))]
+ [NotifyPropertyChangedFor(nameof(ThreadId))]
+ [NotifyPropertyChangedFor(nameof(MessageId))]
+ [NotifyPropertyChangedFor(nameof(References))]
+ [NotifyPropertyChangedFor(nameof(InReplyTo))]
+ [NotifyPropertyChangedFor(nameof(FileId))]
+ [NotifyPropertyChangedFor(nameof(FolderId))]
+ [NotifyPropertyChangedFor(nameof(UniqueId))]
+ [NotifyPropertyChangedFor(nameof(Base64ContactPicture))]
+ public partial MailCopy MailCopy { get; set; } = mailCopy;
[ObservableProperty]
public partial bool IsDisplayedInThread { get; set; }
@@ -149,32 +171,6 @@ public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient,
set => SetProperty(MailCopy.SenderContact.Base64ContactPicture, value, MailCopy, (u, n) => u.SenderContact.Base64ContactPicture = n);
}
- public void NotifyPropertyChanges()
- {
- // Raise on property changes for all observable properties.
- OnPropertyChanged(nameof(CreationDate));
- OnPropertyChanged(nameof(IsFlagged));
- OnPropertyChanged(nameof(FromName));
- OnPropertyChanged(nameof(IsFocused));
- OnPropertyChanged(nameof(IsRead));
- OnPropertyChanged(nameof(IsDraft));
- OnPropertyChanged(nameof(DraftId));
- OnPropertyChanged(nameof(Id));
- OnPropertyChanged(nameof(Subject));
- OnPropertyChanged(nameof(PreviewText));
- OnPropertyChanged(nameof(FromAddress));
- OnPropertyChanged(nameof(HasAttachments));
- OnPropertyChanged(nameof(Importance));
- OnPropertyChanged(nameof(ThreadId));
- OnPropertyChanged(nameof(MessageId));
- OnPropertyChanged(nameof(References));
- OnPropertyChanged(nameof(InReplyTo));
- OnPropertyChanged(nameof(FileId));
- OnPropertyChanged(nameof(FolderId));
- OnPropertyChanged(nameof(UniqueId));
- OnPropertyChanged(nameof(Base64ContactPicture));
- }
-
public IEnumerable GetContainingIds() => [MailCopy.UniqueId];
public IEnumerable GetSelectedMailItems()
diff --git a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs
index d9d8e91a..6239c987 100644
--- a/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs
+++ b/Wino.Mail.ViewModels/Data/ThreadMailItemViewModel.cs
@@ -141,6 +141,28 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
///
///
[ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(EmailCount))]
+ [NotifyPropertyChangedFor(nameof(Subject))]
+ [NotifyPropertyChangedFor(nameof(FromName))]
+ [NotifyPropertyChangedFor(nameof(CreationDate))]
+ [NotifyPropertyChangedFor(nameof(FromAddress))]
+ [NotifyPropertyChangedFor(nameof(PreviewText))]
+ [NotifyPropertyChangedFor(nameof(HasAttachments))]
+ [NotifyPropertyChangedFor(nameof(IsFlagged))]
+ [NotifyPropertyChangedFor(nameof(IsFocused))]
+ [NotifyPropertyChangedFor(nameof(IsRead))]
+ [NotifyPropertyChangedFor(nameof(IsDraft))]
+ [NotifyPropertyChangedFor(nameof(DraftId))]
+ [NotifyPropertyChangedFor(nameof(Id))]
+ [NotifyPropertyChangedFor(nameof(Importance))]
+ [NotifyPropertyChangedFor(nameof(ThreadId))]
+ [NotifyPropertyChangedFor(nameof(MessageId))]
+ [NotifyPropertyChangedFor(nameof(References))]
+ [NotifyPropertyChangedFor(nameof(InReplyTo))]
+ [NotifyPropertyChangedFor(nameof(FileId))]
+ [NotifyPropertyChangedFor(nameof(FolderId))]
+ [NotifyPropertyChangedFor(nameof(UniqueId))]
+ [NotifyPropertyChangedFor(nameof(Base64ContactPicture))]
public partial ObservableCollection ThreadEmails { get; set; } = [];
private MailItemViewModel latestMailViewModel => ThreadEmails.OrderByDescending(e => e.MailCopy?.CreationDate).FirstOrDefault()!;
@@ -150,33 +172,6 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
_threadId = threadId;
}
- public void NotifyPropertyChanges()
- {
- OnPropertyChanged(nameof(Subject));
- OnPropertyChanged(nameof(FromName));
- OnPropertyChanged(nameof(CreationDate));
- OnPropertyChanged(nameof(FromAddress));
- OnPropertyChanged(nameof(PreviewText));
- OnPropertyChanged(nameof(HasAttachments));
- OnPropertyChanged(nameof(IsFlagged));
- OnPropertyChanged(nameof(IsFocused));
- OnPropertyChanged(nameof(IsRead));
- OnPropertyChanged(nameof(IsDraft));
- OnPropertyChanged(nameof(DraftId));
- OnPropertyChanged(nameof(Id));
- OnPropertyChanged(nameof(Importance));
- OnPropertyChanged(nameof(ThreadId));
- OnPropertyChanged(nameof(MessageId));
- OnPropertyChanged(nameof(References));
- OnPropertyChanged(nameof(InReplyTo));
- OnPropertyChanged(nameof(FileId));
- OnPropertyChanged(nameof(FolderId));
- OnPropertyChanged(nameof(UniqueId));
- OnPropertyChanged(nameof(ThreadEmails));
- OnPropertyChanged(nameof(EmailCount));
- OnPropertyChanged(nameof(Base64ContactPicture));
- }
-
///
/// Adds an email to this thread
///
@@ -186,7 +181,8 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander ThreadId '{_threadId}'");
ThreadEmails.Add(email);
- NotifyPropertyChanges();
+ // Reassign to trigger property change notifications
+ ThreadEmails = ThreadEmails;
}
///
@@ -196,7 +192,8 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
{
if (ThreadEmails.Remove(email))
{
- NotifyPropertyChanges();
+ // Reassign to trigger property change notifications
+ ThreadEmails = ThreadEmails;
}
}
diff --git a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs
index d7b7cf7c..dba93f81 100644
--- a/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs
+++ b/Wino.Mail.WinUI/Controls/ListView/WinoListView.cs
@@ -1,4 +1,6 @@
-using System.Linq;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
using System.Windows.Input;
using CommunityToolkit.WinUI;
using Microsoft.UI.Xaml;
@@ -118,13 +120,14 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
return null;
}
- public WinoThreadMailItemViewModelListViewItem? GetThreadMailItemContainer(ThreadMailItemViewModel threadMailItemViewModel)
- => ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem;
-
- public bool SelectMailItemContainer(MailItemViewModel mailItemViewModel)
+ public async Task> GetItemContainersAsync(MailItemViewModel mailItemViewModel)
{
WinoMailItemViewModelListViewItem? itemContainer = null;
WinoThreadMailItemViewModelListViewItem? threadContainer = null;
+ WinoListView? innerListView = null;
+
+ int retryCount = 0;
+ int maxRetries = 5;
foreach (var item in Items)
{
@@ -132,12 +135,38 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
{
itemContainer = ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem;
+ // Not realized yet.
+ if (itemContainer == null)
+ {
+ ScrollIntoView(mailItemViewModel);
+
+ // Wait for the container to be generated.
+ while (itemContainer == null && retryCount < maxRetries)
+ {
+ await Task.Delay(100); // Wait a bit for the UI to update
+ itemContainer = ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem;
+ retryCount++;
+ }
+ }
+
break;
}
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(mailItemViewModel.MailCopy.UniqueId))
{
threadContainer = ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem;
+ if (threadContainer == null)
+ {
+ ScrollIntoView(threadMailItemViewModel);
+
+ while (threadContainer == null && retryCount < maxRetries)
+ {
+ await Task.Delay(100); // Wait a bit for the UI to update
+ threadContainer = ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem;
+ retryCount++;
+ }
+ }
+
// Try to get the inner WinoListView.
if (threadContainer != null)
{
@@ -147,25 +176,16 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
if (innerListViewControl != null)
{
+ innerListView = innerListViewControl;
+ // TODO: What if it wasn't realized in the thread?
itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem;
}
}
-
break;
}
}
- if (itemContainer != null)
- {
- itemContainer.IsSelected = true;
- return true;
- }
- else if (threadContainer != null)
- {
- return true;
- }
-
- return false;
+ return new Tuple(itemContainer, threadContainer, innerListView);
}
public void ChangeSelectionMode(ListViewSelectionMode mode)
diff --git a/Wino.Mail.WinUI/Views/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/MailListPage.xaml.cs
index 5b6869ae..f027f3f6 100644
--- a/Wino.Mail.WinUI/Views/MailListPage.xaml.cs
+++ b/Wino.Mail.WinUI/Views/MailListPage.xaml.cs
@@ -315,46 +315,30 @@ public sealed partial class MailListPage : MailListPageAbstract,
{
if (message.SelectedMailViewModel == null) return;
- await ViewModel.ExecuteUIThread(async () =>
+ await DispatcherQueue.EnqueueAsync(async () =>
{
// MailListView.ClearSelections(message.SelectedMailViewModel, true);
- int retriedSelectionCount = 0;
- trySelection:
+ var collectionContainer = await MailListView.GetItemContainersAsync(message.SelectedMailViewModel);
- bool isSelected = MailListView.SelectMailItemContainer(message.SelectedMailViewModel);
-
- if (!isSelected)
- {
- for (int i = retriedSelectionCount; i < 5;)
- {
- // Retry with delay until the container is realized. Max 1 second.
- await Task.Delay(200);
-
- retriedSelectionCount++;
-
- goto trySelection;
- }
- }
+ if (collectionContainer.Item1 == null && collectionContainer.Item2 == null) return;
// Automatically scroll to the selected item.
// This is useful when creating draft.
- if (isSelected && message.ScrollToItem)
+ if (message.ScrollToItem)
{
- var collectionContainer = ViewModel.MailCollection.GetMailItemContainer(message.SelectedMailViewModel.MailCopy.UniqueId);
-
// Scroll to thread if available.
// Find the item index on the UI. This is different than ListView.
int scrollIndex = -1;
- if (collectionContainer.ThreadViewModel != null)
+ if (collectionContainer.Item2 != null)
{
- scrollIndex = ViewModel.MailCollection.IndexOf(collectionContainer.ThreadViewModel);
+ scrollIndex = ViewModel.MailCollection.IndexOf(collectionContainer.Item2.Item);
}
- else if (collectionContainer.ItemViewModel != null)
+ else if (collectionContainer.Item1 != null)
{
- scrollIndex = ViewModel.MailCollection.IndexOf(collectionContainer.ItemViewModel);
+ scrollIndex = ViewModel.MailCollection.IndexOf(collectionContainer.Item1.Item);
}
if (scrollIndex >= 0)
@@ -362,6 +346,12 @@ public sealed partial class MailListPage : MailListPageAbstract,
await MailListView.SmoothScrollIntoViewWithIndexAsync(scrollIndex);
}
}
+
+ var listView = collectionContainer.Item3 ?? MailListView;
+ var mailItemViewModelContainer = collectionContainer.Item1;
+ var threadMailItemViewModelContainer = collectionContainer.Item2;
+
+ await WinoClickItemInternalAsync(listView, collectionContainer.Item1?.Item ?? null);
});
}
@@ -564,14 +554,14 @@ public sealed partial class MailListPage : MailListPageAbstract,
}
}
- private async void WinoListViewItemClicked(object sender, ItemClickEventArgs e)
+ private async Task WinoClickItemInternalAsync(WinoListView listView, object? clickedItem)
{
- if (sender is not WinoListView listView) return;
+ if (clickedItem == null) return;
bool isSelectedItemFromThread = listView.IsThreadListView;
bool isCtrlPressed = KeyPressService.IsCtrlKeyPressed();
- bool isClickingThreadItem = e.ClickedItem is ThreadMailItemViewModel;
+ bool isClickingThreadItem = clickedItem is ThreadMailItemViewModel;
// Unselect all items. It's single selection.
if (!isCtrlPressed)
@@ -584,11 +574,11 @@ public sealed partial class MailListPage : MailListPageAbstract,
}
}
- if (e.ClickedItem is MailItemViewModel mailListItem)
+ if (clickedItem is MailItemViewModel mailListItem)
{
mailListItem.IsSelected = !mailListItem.IsSelected;
}
- else if (e.ClickedItem is ThreadMailItemViewModel threadMailItemViewModel)
+ else if (clickedItem is ThreadMailItemViewModel threadMailItemViewModel)
{
// Extended selection mode handling for threads
if (isCtrlPressed)
@@ -654,4 +644,11 @@ public sealed partial class MailListPage : MailListPageAbstract,
}
}
}
+
+ private async void WinoListViewItemClicked(object sender, ItemClickEventArgs e)
+ {
+ if (sender is not WinoListView listView) return;
+
+ await WinoClickItemInternalAsync(listView, e.ClickedItem);
+ }
}
diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs
index 15fe46d3..49d2fa2e 100644
--- a/Wino.Services/DatabaseService.cs
+++ b/Wino.Services/DatabaseService.cs
@@ -58,8 +58,7 @@ public class DatabaseService : IDatabaseService
typeof(MailAccountPreferences),
typeof(MailAccountAlias),
typeof(Thumbnail),
- typeof(KeyboardShortcut),
- typeof(MailItemQueue)
+ typeof(KeyboardShortcut)
);
}
}
diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs
index 9da2f03d..75744284 100644
--- a/Wino.Services/MailService.cs
+++ b/Wino.Services/MailService.cs
@@ -806,56 +806,6 @@ public class MailService : BaseDatabaseService, IMailService
}
}
- #region Mail Queue
-
- public Task ClearMailItemQueueAsync(Guid accountId)
- => Connection.ExecuteAsync("DELETE FROM MailItemQueue WHERE AccountId = ?", accountId);
-
- public Task GetMailItemQueueCountAsync(Guid accountId)
- => Connection.Table().Where(a => a.AccountId == accountId).CountAsync();
-
- public Task GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId)
- => Connection.Table().Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId).CountAsync();
-
- public Task UpdateMailItemQueueAsync(IEnumerable queueItems)
- {
- if (queueItems == null || !queueItems.Any())
- return Task.CompletedTask;
-
- return Connection.UpdateAllAsync(queueItems);
- }
-
- public Task AddMailItemQueueItemsAsync(IEnumerable queueItems)
- {
- if (queueItems == null || !queueItems.Any())
- return Task.CompletedTask;
-
- return Connection.InsertAllAsync(queueItems);
- }
-
- public Task> GetMailItemQueueAsync(Guid accountId, int take)
- {
- // Skip not needed. Items are removed as they are processed.
-
- return Connection.Table()
- .Where(a => a.AccountId == accountId && !a.IsProcessed)
- .OrderBy(a => a.CreatedAt)
- .Take(take)
- .ToListAsync();
- }
-
- public Task> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take)
- {
- // For Outlook per-folder sync
- return Connection.Table()
- .Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId && !a.IsProcessed)
- .OrderBy(a => a.CreatedAt)
- .Take(take)
- .ToListAsync();
- }
-
- #endregion
-
private async Task CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
{
// This unique id is stored in mime headers for Wino to identify remote message with local copy.