Get rid of the mail item queue system. Go back to 6 months initial sync strategy.

This commit is contained in:
Burak Kaan Köse
2025-11-01 12:11:05 +01:00
parent 5186b14905
commit b60832a270
18 changed files with 356 additions and 763 deletions
@@ -36,12 +36,6 @@ public class MailItemFolder : IMailItemFolder
/// </summary>
public string DeltaToken { get; set; }
/// <summary>
/// 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.
/// </summary>
public InitialSynchronizationStatus FolderStatus { get; set; }
// For GMail Labels
public string TextColorHex { get; set; }
public string BackgroundColorHex { get; set; }
@@ -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;
}
@@ -33,11 +33,6 @@ public class MailAccount
/// </summary>
public MailProviderType ProviderType { get; set; }
/// <summary>
/// Gets or sets the initial mail sync status for the account.
/// </summary>
public InitialSynchronizationStatus SynchronizationStatus { get; set; }
/// <summary>
/// For tracking mail change delta.
/// Gmail : historyId
@@ -1,8 +0,0 @@
namespace Wino.Core.Domain.Enums;
public enum InitialSynchronizationStatus
{
None,
IdsFetched,
Completed
}
@@ -162,11 +162,4 @@ public interface IMailService
/// <param name="onlineArchiveMailIds">Retrieved MailCopy ids from search result.</param>
/// <returns>Result model that contains added and removed mail copy ids.</returns>
Task<GmailArchiveComparisonResult> GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List<string> onlineArchiveMailIds);
Task ClearMailItemQueueAsync(Guid accountId);
Task AddMailItemQueueItemsAsync(IEnumerable<MailItemQueue> queueItems);
Task<int> GetMailItemQueueCountAsync(Guid accountId);
Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take);
Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems);
}
@@ -65,13 +65,6 @@ public interface IDefaultChangeProcessor
Task<bool> IsMailExistsInFolderAsync(string messageId, Guid folderId);
Task<List<string>> AreMailsExistsAsync(IEnumerable<string> mailCopyIds);
Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier);
Task ClearMailItemQueueAsync(Guid accountId);
Task AddMailItemQueueItemsAsync(IEnumerable<MailItemQueue> queueItems);
Task<int> GetMailItemQueueCountAsync(Guid accountId);
Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take);
Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take);
Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId);
Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems);
}
public interface IGmailChangeProcessor : IDefaultChangeProcessor
@@ -94,14 +87,6 @@ public interface IOutlookChangeProcessor : IDefaultChangeProcessor
/// <returns>New identifier if success.</returns>
Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string deltaSynchronizationIdentifier);
/// <summary>
/// Updates the initial synchronization completion status for a folder.
/// Used to track whether mail ids have been queued for initial sync.
/// </summary>
/// <param name="folderId">Folder id</param>
/// <param name="isCompleted">Whether initial sync is completed</param>
Task UpdateFolderInitialSyncCompletedAsync(Guid folderId, bool isCompleted);
/// <summary>
/// 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<MailItemQueue> queueItems)
=> MailService.AddMailItemQueueItemsAsync(queueItems);
public Task<int> GetMailItemQueueCountAsync(Guid accountId)
=> MailService.GetMailItemQueueCountAsync(accountId);
public Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take)
=> MailService.GetMailItemQueueAsync(accountId, take);
public Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take)
=> MailService.GetMailItemQueueByFolderAsync(accountId, remoteFolderId, take);
public Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId)
=> MailService.GetMailItemQueueCountByFolderAsync(accountId, remoteFolderId);
public Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems)
=> MailService.UpdateMailItemQueueAsync(queueItems);
public async Task DeleteUserMailCacheAsync(Guid accountId)
{
await _mimeFileService.DeleteUserMimeCacheAsync(accountId).ConfigureAwait(false);
@@ -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.
@@ -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;
+112 -181
View File
@@ -43,20 +43,21 @@ using CalendarService = Google.Apis.Calendar.v3.CalendarService;
namespace Wino.Core.Synchronizers.Mail;
/// <summary>
/// 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
/// </summary>
public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message, Event>, IHttpClientFactory
{
@@ -179,122 +180,146 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
retry:
cancellationToken.ThrowIfCancellationRequested();
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
if (Account.SynchronizationStatus == InitialSynchronizationStatus.None)
var downloadedMessageIds = new List<string>();
// Get all folders to synchronize
var synchronizationFolders = await _gmailChangeProcessor.GetSynchronizationFoldersAsync(options).ConfigureAwait(false);
_logger.Information("Synchronizing {Count} folders for {Name}", synchronizationFolders.Count, Account.Name);
var totalFolders = synchronizationFolders.Count;
for (int i = 0; i < totalFolders; i++)
{
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<MailCopy>());
return MailSynchronizationResult.Completed(unreadNewItems);
}
#region Queue System
private async Task FetchAllEmailIdsAsync()
/// <summary>
/// Synchronizes a single folder by downloading top 1500 messages with metadata only.
/// </summary>
private async Task<List<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
{
var downloadedMessageIds = new List<string>();
cancellationToken.ThrowIfCancellationRequested();
_logger.Debug("Synchronizing folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
try
{
// 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;
}
/// <summary>
/// Downloads top 1500 messages for a folder using Gmail API with metadata only.
/// </summary>
private async Task DownloadMessagesForFolderAsync(MailItemFolder folder, List<string> 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<string>(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<IClientServiceRequest, Message
}
}
private async Task ProcessEmailMetadataFromQueueAsync(CancellationToken cancellationToken = default)
{
// Get total count for progress tracking
var totalInQueue = await _gmailChangeProcessor.GetMailItemQueueCountAsync(Account.Id).ConfigureAwait(false);
var processedCount = 0;
try
{
var totalFailed = 0;
// Continue until all emails in queue are processed
while (true)
{
// Get next batch of unprocessed emails from queue
var mailItemQueue = await _gmailChangeProcessor.GetMailItemQueueAsync(Account.Id, 100).ConfigureAwait(false);
if (mailItemQueue.Count == 0)
break; // No more emails to process
var messageIds = mailItemQueue.Select(q => q.RemoteServerId).ToList();
try
{
// Remove the deleted items from queue first
mailItemQueue.RemoveAll(a => a.ShouldDelete());
var mailChunks = mailItemQueue.Chunk(100);
foreach (var chunk in mailChunks)
{
// Collect message IDs from the chunk
var messageIdsToDownload = chunk.Select(q => q.RemoteServerId).ToList();
try
{
// Download all messages in this chunk using batch API
await DownloadMessagesInBatchAsync(messageIdsToDownload, cancellationToken).ConfigureAwait(false);
// Mark all items in chunk as processed
foreach (var queueItem in chunk)
{
queueItem.IsProcessed = true;
queueItem.ProcessedAt = DateTime.UtcNow;
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<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
+109 -320
View File
@@ -51,23 +51,22 @@ public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
/// <summary>
/// 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)
/// </summary>
public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message, Event>
{
@@ -227,26 +226,22 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
cancellationToken.ThrowIfCancellationRequested();
_logger.Debug("Synchronizing {FolderName} with direct download approach", folder.FolderName);
_logger.Debug("Synchronizing {FolderName} using delta API", folder.FolderName);
// Check if initial sync is completed for this folder
if (folder.FolderStatus != InitialSynchronizationStatus.Completed)
// Check if we have a delta token
if (string.IsNullOrEmpty(folder.DeltaToken))
{
_logger.Debug("Initial sync not completed for folder {FolderName}. Starting mail synchronization.", folder.FolderName);
_logger.Debug("No delta token for folder {FolderName}. Starting initial sync (last 30 days).", folder.FolderName);
// Download mails for initial sync
// Download mails for initial sync (last 30 days)
await DownloadMailsForInitialSyncAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
// Mark initial sync as completed
await _outlookChangeProcessor.UpdateFolderInitialSyncCompletedAsync(folder.Id, true).ConfigureAwait(false);
folder.FolderStatus = InitialSynchronizationStatus.Completed;
}
else
{
// Initial sync is completed, process delta changes and download new mails
_logger.Debug("Initial sync completed for folder {FolderName}. Processing delta changes and downloading new mails.", folder.FolderName);
// Initial sync is completed, process delta changes
_logger.Debug("Delta token exists for folder {FolderName}. Processing incremental changes.", folder.FolderName);
await ProcessDeltaChangesAndDownloadMailsAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
await ProcessDeltaChangesAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
}
await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
@@ -260,24 +255,107 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}
/// <summary>
/// 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.
/// </summary>
private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List<string> 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<Message, DeltaGetResponse>.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<RequestInformation, Message,
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 initial 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 initial sync for folder {FolderName}. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
}
// Re-throw the exception so the synchronization can be retried
throw;
}
catch (Exception ex)
@@ -315,191 +388,6 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}
}
/// <summary>
/// Queues all mail IDs for a folder using Delta API.
/// Only retrieves message IDs to minimize data transfer.
/// </summary>
private async Task QueueMailIdsForFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
{
_logger.Debug("Queuing mail IDs for folder {FolderName}", folder.FolderName);
var mailIds = new List<string>();
// 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<Message, DeltaGetResponse>.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);
}
}
/// <summary>
/// Processes queued mail IDs in batches, downloading metadata only (no MIME).
/// </summary>
private async Task ProcessMailQueueForFolderAsync(MailItemFolder folder, List<string> 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);
}
/// <summary>
/// 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<RequestInformation, Message,
private string GetDeltaTokenFromDeltaLink(string deltaLink)
=> 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<MailCopy> CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
{
// Use centralized method
@@ -774,99 +656,6 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
}
}
private async Task ProcessDeltaChangesAndDownloadMailsAsync(MailItemFolder folder, List<string> 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<Message, DeltaGetResponse>
.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<string> downloadedMessageIds, CancellationToken cancellationToken = default)
{
// Only process delta changes if we have a delta token (not initial sync)
@@ -287,7 +287,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
}
else
{
await ExecuteUIThread(() => { threadViewModel.NotifyPropertyChanges(); });
await ExecuteUIThread(() => { threadViewModel.ThreadEmails = threadViewModel.ThreadEmails; });
}
UpdateUniqueIdHashes(new MailItemViewModel(addedItem), true);
@@ -391,9 +391,13 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
private async Task UpdateExistingItemAsync(MailItemViewModel existingItem, MailCopy updatedItem)
{
UpdateUniqueIdHashes(existingItem, false);
UpdateUniqueIdHashes(new MailItemViewModel(updatedItem), true);
await ExecuteUIThread(() => { existingItem.NotifyPropertyChanges(); });
await ExecuteUIThread(() =>
{
existingItem.MailCopy = updatedItem;
});
UpdateUniqueIdHashes(existingItem, true);
}
/// <summary>
@@ -520,8 +524,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
foreach (var (existing, updated) in itemsToUpdate)
{
UpdateUniqueIdHashes(existing, false);
UpdateUniqueIdHashes(new MailItemViewModel(updated), true);
existing.NotifyPropertyChanges();
existing.MailCopy = updated;
UpdateUniqueIdHashes(existing, true);
}
});
}
@@ -684,17 +688,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
if (itemContainer.ItemViewModel != null)
{
UpdateUniqueIdHashes(itemContainer.ItemViewModel, false);
// Update the MailCopy - this will automatically notify all dependent properties
itemContainer.ItemViewModel.MailCopy = updatedMailCopy;
UpdateUniqueIdHashes(itemContainer.ItemViewModel, true);
}
if (itemContainer.ItemViewModel != null)
// Trigger thread property notifications if this item is in a thread
if (itemContainer.ThreadViewModel != null)
{
itemContainer.ItemViewModel.NotifyPropertyChanges();
itemContainer.ThreadViewModel.ThreadEmails = itemContainer.ThreadViewModel.ThreadEmails;
}
UpdateUniqueIdHashes(new MailItemViewModel(updatedMailCopy), true);
// Call thread notifications if possible.
itemContainer.ThreadViewModel?.NotifyPropertyChanges();
});
}
+1 -1
View File
@@ -559,7 +559,7 @@ public partial class ComposePageViewModel : MailBaseViewModel
{
await ExecuteUIThread(() =>
{
CurrentMailDraftItem.NotifyPropertyChanges();
CurrentMailDraftItem.MailCopy = updatedMail;
DiscardCommand.NotifyCanExecuteChanged();
SendCommand.NotifyCanExecuteChanged();
});
+23 -27
View File
@@ -11,7 +11,29 @@ namespace Wino.Mail.ViewModels.Data;
/// </summary>
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<Guid> GetContainingIds() => [MailCopy.UniqueId];
public IEnumerable<MailItemViewModel> GetSelectedMailItems()
@@ -141,6 +141,28 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
/// </summary>
///
[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<MailItemViewModel> 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));
}
/// <summary>
/// Adds an email to this thread
/// </summary>
@@ -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;
}
/// <summary>
@@ -196,7 +192,8 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
{
if (ThreadEmails.Remove(email))
{
NotifyPropertyChanges();
// Reassign to trigger property change notifications
ThreadEmails = ThreadEmails;
}
}
@@ -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<Tuple<WinoMailItemViewModelListViewItem?, WinoThreadMailItemViewModelListViewItem?, WinoListView?>> 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<WinoMailItemViewModelListViewItem?, WinoThreadMailItemViewModelListViewItem?, WinoListView?>(itemContainer, threadContainer, innerListView);
}
public void ChangeSelectionMode(ListViewSelectionMode mode)
+26 -29
View File
@@ -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);
}
}
+1 -2
View File
@@ -58,8 +58,7 @@ public class DatabaseService : IDatabaseService
typeof(MailAccountPreferences),
typeof(MailAccountAlias),
typeof(Thumbnail),
typeof(KeyboardShortcut),
typeof(MailItemQueue)
typeof(KeyboardShortcut)
);
}
}
-50
View File
@@ -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<int> GetMailItemQueueCountAsync(Guid accountId)
=> Connection.Table<MailItemQueue>().Where(a => a.AccountId == accountId).CountAsync();
public Task<int> GetMailItemQueueCountByFolderAsync(Guid accountId, string remoteFolderId)
=> Connection.Table<MailItemQueue>().Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId).CountAsync();
public Task UpdateMailItemQueueAsync(IEnumerable<MailItemQueue> queueItems)
{
if (queueItems == null || !queueItems.Any())
return Task.CompletedTask;
return Connection.UpdateAllAsync(queueItems);
}
public Task AddMailItemQueueItemsAsync(IEnumerable<MailItemQueue> queueItems)
{
if (queueItems == null || !queueItems.Any())
return Task.CompletedTask;
return Connection.InsertAllAsync(queueItems);
}
public Task<List<MailItemQueue>> GetMailItemQueueAsync(Guid accountId, int take)
{
// Skip not needed. Items are removed as they are processed.
return Connection.Table<MailItemQueue>()
.Where(a => a.AccountId == accountId && !a.IsProcessed)
.OrderBy(a => a.CreatedAt)
.Take(take)
.ToListAsync();
}
public Task<List<MailItemQueue>> GetMailItemQueueByFolderAsync(Guid accountId, string remoteFolderId, int take)
{
// For Outlook per-folder sync
return Connection.Table<MailItemQueue>()
.Where(a => a.AccountId == accountId && a.RemoteFolderId == remoteFolderId && !a.IsProcessed)
.OrderBy(a => a.CreatedAt)
.Take(take)
.ToListAsync();
}
#endregion
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
{
// This unique id is stored in mime headers for Wino to identify remote message with local copy.