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.