Get rid of the mail item queue system. Go back to 6 months initial sync strategy.
This commit is contained in:
@@ -36,12 +36,6 @@ public class MailItemFolder : IMailItemFolder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string DeltaToken { get; set; }
|
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
|
// For GMail Labels
|
||||||
public string TextColorHex { get; set; }
|
public string TextColorHex { get; set; }
|
||||||
public string BackgroundColorHex { 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>
|
/// </summary>
|
||||||
public MailProviderType ProviderType { get; set; }
|
public MailProviderType ProviderType { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the initial mail sync status for the account.
|
|
||||||
/// </summary>
|
|
||||||
public InitialSynchronizationStatus SynchronizationStatus { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// For tracking mail change delta.
|
/// For tracking mail change delta.
|
||||||
/// Gmail : historyId
|
/// 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>
|
/// <param name="onlineArchiveMailIds">Retrieved MailCopy ids from search result.</param>
|
||||||
/// <returns>Result model that contains added and removed mail copy ids.</returns>
|
/// <returns>Result model that contains added and removed mail copy ids.</returns>
|
||||||
Task<GmailArchiveComparisonResult> GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List<string> onlineArchiveMailIds);
|
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<bool> IsMailExistsInFolderAsync(string messageId, Guid folderId);
|
||||||
Task<List<string>> AreMailsExistsAsync(IEnumerable<string> mailCopyIds);
|
Task<List<string>> AreMailsExistsAsync(IEnumerable<string> mailCopyIds);
|
||||||
Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string synchronizationDeltaIdentifier);
|
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
|
public interface IGmailChangeProcessor : IDefaultChangeProcessor
|
||||||
@@ -94,14 +87,6 @@ public interface IOutlookChangeProcessor : IDefaultChangeProcessor
|
|||||||
/// <returns>New identifier if success.</returns>
|
/// <returns>New identifier if success.</returns>
|
||||||
Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string deltaSynchronizationIdentifier);
|
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>
|
/// <summary>
|
||||||
/// Outlook may expire folder's delta token after a while.
|
/// Outlook may expire folder's delta token after a while.
|
||||||
/// Recommended action for this scenario is to reset token and do full sync.
|
/// 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)
|
public Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken)
|
||||||
=> CalendarService.UpdateCalendarDeltaSynchronizationToken(calendarId, 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)
|
public async Task DeleteUserMailCacheAsync(Guid accountId)
|
||||||
{
|
{
|
||||||
await _mimeFileService.DeleteUserMimeCacheAsync(accountId).ConfigureAwait(false);
|
await _mimeFileService.DeleteUserMimeCacheAsync(accountId).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
|||||||
public Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string synchronizationIdentifier)
|
public Task UpdateFolderDeltaSynchronizationIdentifierAsync(Guid folderId, string synchronizationIdentifier)
|
||||||
=> Connection.ExecuteAsync("UPDATE MailItemFolder SET DeltaToken = ? WHERE Id = ?", synchronizationIdentifier, folderId);
|
=> 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)
|
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
|
||||||
{
|
{
|
||||||
// We parse the occurrences based on the parent event.
|
// We parse the occurrences based on the parent event.
|
||||||
|
|||||||
@@ -43,19 +43,16 @@ public class DeltaTokenExpiredHandler : ISynchronizerErrorHandler
|
|||||||
// Reset the account's delta synchronization identifier
|
// Reset the account's delta synchronization identifier
|
||||||
await _outlookChangeProcessor.UpdateAccountDeltaSynchronizationIdentifierAsync(error.Account.Id, string.Empty).ConfigureAwait(false);
|
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);
|
var folders = await _outlookChangeProcessor.GetLocalFoldersAsync(error.Account.Id).ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var folder in folders)
|
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);
|
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);
|
error.Account.Name, error.Account.Id);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -43,20 +43,21 @@ using CalendarService = Google.Apis.Calendar.v3.CalendarService;
|
|||||||
namespace Wino.Core.Synchronizers.Mail;
|
namespace Wino.Core.Synchronizers.Mail;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gmail synchronizer implementation.
|
/// Gmail synchronizer implementation with per-folder history ID synchronization.
|
||||||
///
|
///
|
||||||
/// IMPORTANT: This synchronizer implements METADATA-ONLY synchronization strategy:
|
/// SYNCHRONIZATION STRATEGY:
|
||||||
/// - During sync (initial/delta), only message metadata is downloaded (headers, labels, snippet)
|
/// - Uses Gmail History API for both initial and incremental sync
|
||||||
/// - NO raw MIME content is downloaded during synchronization
|
/// - Initial sync: Downloads top 1500 messages per folder with metadata only
|
||||||
/// - Messages are created with Format=Metadata which populates Payload.Headers but NOT Raw content
|
/// - Incremental sync: Uses history ID to get only changes since last sync
|
||||||
/// - MIME files are downloaded on-demand only when user explicitly reads a message
|
/// - Messages are downloaded with metadata only (no MIME content during sync)
|
||||||
/// - This dramatically reduces bandwidth usage and sync time, especially for accounts with many messages
|
/// - MIME files are downloaded on-demand when user explicitly reads a message
|
||||||
///
|
///
|
||||||
/// Key implementation details:
|
/// Key implementation details:
|
||||||
/// - CreateNewMailPackagesAsync: Creates MailCopy from metadata, passes null for MimeMessage
|
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
|
||||||
/// - CreateMinimalMailCopyAsync: Extracts all MailCopy fields from Gmail Metadata format (Payload.Headers)
|
/// - 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
|
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
|
||||||
/// - CreateSingleMessageGet: Always uses Metadata format (not Raw format) for synchronization
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message, Event>, IHttpClientFactory
|
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;
|
if (options.Type == MailSynchronizationType.FoldersOnly) return MailSynchronizationResult.Empty;
|
||||||
|
|
||||||
retry:
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
|
bool isInitialSync = string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier);
|
||||||
|
|
||||||
_logger.Debug("Is initial synchronization: {IsInitialSync}", isInitialSync);
|
_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...");
|
var folder = synchronizationFolders[i];
|
||||||
await FetchAllEmailIdsAsync().ConfigureAwait(false);
|
|
||||||
await CompleteAccountSyncStatusAsync();
|
// Update progress based on folder completion
|
||||||
UpdateSyncProgress(0, 0, "Email IDs fetched");
|
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))
|
if (!string.IsNullOrEmpty(Account.SynchronizationDeltaIdentifier))
|
||||||
{
|
{
|
||||||
UpdateSyncProgress(0, 0, "Synchronizing changes...");
|
UpdateSyncProgress(0, 0, "Synchronizing changes...");
|
||||||
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
|
await SynchronizeDeltaAsync(options, cancellationToken).ConfigureAwait(false);
|
||||||
await CompleteAccountSyncStatusAsync();
|
|
||||||
UpdateSyncProgress(0, 0, "Changes synchronized");
|
UpdateSyncProgress(0, 0, "Changes synchronized");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Account.SynchronizationStatus == InitialSynchronizationStatus.IdsFetched)
|
// Get all unread new downloaded items for notifications
|
||||||
{
|
var unreadNewItems = await _gmailChangeProcessor.GetDownloadedUnreadMailsAsync(Account.Id, downloadedMessageIds).ConfigureAwait(false);
|
||||||
UpdateSyncProgress(0, 0, "Processing email metadata...");
|
|
||||||
await ProcessEmailMetadataFromQueueAsync(cancellationToken);
|
return MailSynchronizationResult.Completed(unreadNewItems);
|
||||||
UpdateSyncProgress(0, 0, "Email metadata processed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Account.SynchronizationStatus == InitialSynchronizationStatus.Completed)
|
/// <summary>
|
||||||
|
/// Synchronizes a single folder by downloading top 1500 messages with metadata only.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<string>> SynchronizeFolderAsync(MailItemFolder folder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
UpdateSyncProgress(0, 0, "Synchronization completed");
|
var downloadedMessageIds = new List<string>();
|
||||||
}
|
|
||||||
|
|
||||||
return MailSynchronizationResult.Completed(new List<MailCopy>());
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
|
||||||
|
|
||||||
#region Queue System
|
_logger.Debug("Synchronizing folder {FolderName} (label: {LabelId})", folder.FolderName, folder.RemoteFolderId);
|
||||||
|
|
||||||
private async Task FetchAllEmailIdsAsync()
|
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// If this method is hit, we don't need previous state for this table,
|
// Download top 1500 messages for this folder
|
||||||
// we just clean it first to make sure nothing was left before.
|
await DownloadMessagesForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
|
||||||
await _gmailChangeProcessor.ClearMailItemQueueAsync(Account.Id).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;
|
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
|
do
|
||||||
{
|
{
|
||||||
try
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
{
|
|
||||||
pageCount++;
|
|
||||||
|
|
||||||
// Use maximum page size of 500 for efficiency
|
|
||||||
var request = _gmailService.Users.Messages.List("me");
|
var request = _gmailService.Users.Messages.List("me");
|
||||||
request.MaxResults = 500;
|
request.LabelIds = new Google.Apis.Util.Repeatable<string>(new[] { folder.RemoteFolderId });
|
||||||
request.IncludeSpamTrash = true;
|
request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500
|
||||||
request.PageToken = pageToken;
|
request.PageToken = pageToken;
|
||||||
|
|
||||||
var response = await request.ExecuteAsync();
|
var response = await request.ExecuteAsync(cancellationToken);
|
||||||
|
|
||||||
if (response.Messages != null)
|
if (response.Messages != null && response.Messages.Count > 0)
|
||||||
{
|
{
|
||||||
var queueEntries1 = response.Messages.Select(x => new MailItemQueue
|
var messageIds = response.Messages.Select(m => m.Id).ToList();
|
||||||
{
|
|
||||||
Id = Guid.CreateVersion7(),
|
|
||||||
AccountId = Account.Id,
|
|
||||||
RemoteServerId = x.Id,
|
|
||||||
IsProcessed = false,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
|
|
||||||
await _gmailChangeProcessor.AddMailItemQueueItemsAsync(queueEntries1).ConfigureAwait(false);
|
// Download metadata in batches
|
||||||
|
await DownloadMessagesInBatchAsync(messageIds, downloadRawMime: false, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
totalFetched += queueEntries1.Count();
|
downloadedMessageIds.AddRange(messageIds);
|
||||||
|
totalDownloaded += messageIds.Count;
|
||||||
|
remainingToDownload -= messageIds.Count;
|
||||||
|
|
||||||
// Update progress - we don't know total count, so show indeterminate with status
|
_logger.Debug("Downloaded {Count} messages for folder {FolderName} (total: {Total})", messageIds.Count, folder.FolderName, totalDownloaded);
|
||||||
UpdateSyncProgress(0, 0, $"Fetched {totalFetched} email IDs (page {pageCount})");
|
|
||||||
|
// Update progress
|
||||||
|
UpdateSyncProgress(0, 0, $"Downloaded {totalDownloaded} messages from {folder.FolderName}");
|
||||||
}
|
}
|
||||||
|
|
||||||
pageToken = response.NextPageToken;
|
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));
|
||||||
|
|
||||||
|
// 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 (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
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 downloading messages for folder {FolderName}. Retrying after delay.", folder.FolderName);
|
||||||
_logger.Warning("Rate limit exceeded while fetching email IDs for {Name}. Retrying after delay.", Account.Name);
|
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
|
||||||
continue; // Retry the same page
|
|
||||||
}
|
|
||||||
} while (!string.IsNullOrEmpty(pageToken));
|
|
||||||
|
|
||||||
// Final update with total count
|
|
||||||
UpdateSyncProgress(0, 0, $"Fetched {totalFetched} email IDs total");
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
|
|
||||||
private async Task CompleteAccountSyncStatusAsync()
|
|
||||||
{
|
{
|
||||||
// Set history ID immediately after fetching email IDs for future incremental syncs
|
_logger.Error(ex, "Error downloading messages for folder {FolderName}", folder.FolderName);
|
||||||
var profile = await _gmailService.Users.GetProfile("me").ExecuteAsync();
|
throw;
|
||||||
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)
|
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)
|
protected override async Task<CalendarSynchronizationResult> SynchronizeCalendarEventsInternalAsync(CalendarSynchronizationOptions options, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
|
_logger.Information("Internal calendar synchronization started for {Name}", Account.Name);
|
||||||
|
|||||||
@@ -51,23 +51,22 @@ public partial class OutlookSynchronizerJsonContext : JsonSerializerContext;
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outlook synchronizer implementation with queue-based metadata-only synchronization.
|
/// Outlook synchronizer implementation with delta token synchronization.
|
||||||
///
|
///
|
||||||
/// SYNCHRONIZATION STRATEGY:
|
/// SYNCHRONIZATION STRATEGY:
|
||||||
/// - Uses per-folder queue system (unlike Gmail's per-account queue)
|
/// - Uses delta API for both initial and incremental sync
|
||||||
/// - During sync (initial/delta), only message metadata is downloaded (no MIME content)
|
/// - Initial sync: Downloads last 30 days of emails with metadata only
|
||||||
/// - Messages are queued by folder using MailItemQueue with RemoteFolderId
|
/// - Incremental sync: Uses delta token to get only changes since last sync
|
||||||
/// - MailCopy objects are created from Graph API metadata fields only
|
/// - Messages are downloaded with metadata only (no MIME content during sync)
|
||||||
/// - MIME files are downloaded on-demand when user explicitly reads a message
|
/// - MIME files are downloaded on-demand when user explicitly reads a message
|
||||||
/// - This dramatically reduces bandwidth usage and sync time
|
|
||||||
///
|
///
|
||||||
/// Key implementation details:
|
/// Key implementation details:
|
||||||
/// - QueueMailIdsForFolderAsync: Queues all mail IDs for a folder using Delta API
|
/// - SynchronizeFolderAsync: Main entry point for per-folder synchronization
|
||||||
/// - ProcessMailQueueForFolderAsync: Downloads metadata in batches from queue
|
/// - DownloadMailsForInitialSyncAsync: Downloads last 30 days using delta API with filter
|
||||||
/// - DownloadMessageMetadataBatchAsync: Concurrently downloads metadata for batches
|
/// - ProcessDeltaChangesAsync: Processes incremental changes using delta token
|
||||||
/// - CreateMailCopyFromMessage: Centralized method to create MailCopy from Message (metadata only)
|
/// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API
|
||||||
|
/// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata
|
||||||
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
|
/// - DownloadMissingMimeMessageAsync: Downloads raw MIME only when explicitly requested
|
||||||
/// - CreateNewMailPackagesAsync: Only used for search results and special cases (downloads MIME)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message, Event>
|
public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message, Event>
|
||||||
{
|
{
|
||||||
@@ -227,26 +226,22 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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
|
// Check if we have a delta token
|
||||||
if (folder.FolderStatus != InitialSynchronizationStatus.Completed)
|
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);
|
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
|
else
|
||||||
{
|
{
|
||||||
// Initial sync is completed, process delta changes and download new mails
|
// Initial sync is completed, process delta changes
|
||||||
_logger.Debug("Initial sync completed for folder {FolderName}. Processing delta changes and downloading new mails.", folder.FolderName);
|
_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);
|
await _outlookChangeProcessor.UpdateFolderLastSyncDateAsync(folder.Id).ConfigureAwait(false);
|
||||||
@@ -260,24 +255,107 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads mails for initial synchronization using Delta API and queue-based system.
|
/// Downloads mails for initial synchronization using Delta API with 30-day filter.
|
||||||
/// First, queues all mail IDs, then downloads metadata in batches.
|
/// Downloads metadata only (no MIME content) for messages received in the last 30 days.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List<string> downloadedMessageIds, CancellationToken cancellationToken)
|
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
|
try
|
||||||
{
|
{
|
||||||
// Step 1: Queue all mail IDs using Delta API
|
// Calculate date 6 months ago
|
||||||
await QueueMailIdsForFolderAsync(folder, cancellationToken).ConfigureAwait(false);
|
var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6);
|
||||||
|
var filterDate = sixMonthsAgo.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||||
|
|
||||||
// Step 2: Process queued mail IDs in batches
|
_logger.Information("Downloading messages received after {FilterDate} for folder {FolderName}", filterDate, folder.FolderName);
|
||||||
await ProcessMailQueueForFolderAsync(folder, downloadedMessageIds, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
// 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)
|
catch (ApiException apiException)
|
||||||
{
|
{
|
||||||
// Try to handle the error using the error handling factory
|
// Handle API errors
|
||||||
var errorContext = new SynchronizerErrorContext
|
var errorContext = new SynchronizerErrorContext
|
||||||
{
|
{
|
||||||
Account = Account,
|
Account = Account,
|
||||||
@@ -290,22 +368,17 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
|||||||
|
|
||||||
if (handled)
|
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)
|
if (apiException.ResponseStatusCode == 410)
|
||||||
{
|
{
|
||||||
folder.DeltaToken = string.Empty;
|
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);
|
_logger.Information("API error handled successfully for folder {FolderName} during initial sync. Error: {ErrorCode}", folder.FolderName, apiException.ResponseStatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
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);
|
_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;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>
|
/// <summary>
|
||||||
/// Downloads metadata for a batch of messages using Graph SDK batch API (no MIME content).
|
/// 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.
|
/// 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)
|
private string GetDeltaTokenFromDeltaLink(string deltaLink)
|
||||||
=> Regex.Split(deltaLink, "deltatoken=")[1];
|
=> 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)
|
protected override async Task<MailCopy> CreateMinimalMailCopyAsync(Message message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Use centralized method
|
// 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)
|
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)
|
// Only process delta changes if we have a delta token (not initial sync)
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() => { threadViewModel.NotifyPropertyChanges(); });
|
await ExecuteUIThread(() => { threadViewModel.ThreadEmails = threadViewModel.ThreadEmails; });
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateUniqueIdHashes(new MailItemViewModel(addedItem), true);
|
UpdateUniqueIdHashes(new MailItemViewModel(addedItem), true);
|
||||||
@@ -391,9 +391,13 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
private async Task UpdateExistingItemAsync(MailItemViewModel existingItem, MailCopy updatedItem)
|
private async Task UpdateExistingItemAsync(MailItemViewModel existingItem, MailCopy updatedItem)
|
||||||
{
|
{
|
||||||
UpdateUniqueIdHashes(existingItem, false);
|
UpdateUniqueIdHashes(existingItem, false);
|
||||||
UpdateUniqueIdHashes(new MailItemViewModel(updatedItem), true);
|
|
||||||
|
|
||||||
await ExecuteUIThread(() => { existingItem.NotifyPropertyChanges(); });
|
await ExecuteUIThread(() =>
|
||||||
|
{
|
||||||
|
existingItem.MailCopy = updatedItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
UpdateUniqueIdHashes(existingItem, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -520,8 +524,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
foreach (var (existing, updated) in itemsToUpdate)
|
foreach (var (existing, updated) in itemsToUpdate)
|
||||||
{
|
{
|
||||||
UpdateUniqueIdHashes(existing, false);
|
UpdateUniqueIdHashes(existing, false);
|
||||||
UpdateUniqueIdHashes(new MailItemViewModel(updated), true);
|
existing.MailCopy = updated;
|
||||||
existing.NotifyPropertyChanges();
|
UpdateUniqueIdHashes(existing, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -684,17 +688,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
if (itemContainer.ItemViewModel != null)
|
if (itemContainer.ItemViewModel != null)
|
||||||
{
|
{
|
||||||
UpdateUniqueIdHashes(itemContainer.ItemViewModel, false);
|
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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ public partial class ComposePageViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
CurrentMailDraftItem.NotifyPropertyChanges();
|
CurrentMailDraftItem.MailCopy = updatedMail;
|
||||||
DiscardCommand.NotifyCanExecuteChanged();
|
DiscardCommand.NotifyCanExecuteChanged();
|
||||||
SendCommand.NotifyCanExecuteChanged();
|
SendCommand.NotifyCanExecuteChanged();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,29 @@ namespace Wino.Mail.ViewModels.Data;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MailItemViewModel(MailCopy mailCopy) : ObservableRecipient, IMailListItem
|
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]
|
[ObservableProperty]
|
||||||
public partial bool IsDisplayedInThread { get; set; }
|
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);
|
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<Guid> GetContainingIds() => [MailCopy.UniqueId];
|
||||||
|
|
||||||
public IEnumerable<MailItemViewModel> GetSelectedMailItems()
|
public IEnumerable<MailItemViewModel> GetSelectedMailItems()
|
||||||
|
|||||||
@@ -141,6 +141,28 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
///
|
///
|
||||||
[ObservableProperty]
|
[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; } = [];
|
public partial ObservableCollection<MailItemViewModel> ThreadEmails { get; set; } = [];
|
||||||
|
|
||||||
private MailItemViewModel latestMailViewModel => ThreadEmails.OrderByDescending(e => e.MailCopy?.CreationDate).FirstOrDefault()!;
|
private MailItemViewModel latestMailViewModel => ThreadEmails.OrderByDescending(e => e.MailCopy?.CreationDate).FirstOrDefault()!;
|
||||||
@@ -150,33 +172,6 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
_threadId = threadId;
|
_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>
|
/// <summary>
|
||||||
/// Adds an email to this thread
|
/// Adds an email to this thread
|
||||||
/// </summary>
|
/// </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}'");
|
throw new ArgumentException($"Email ThreadId '{email.MailCopy.ThreadId}' does not match expander ThreadId '{_threadId}'");
|
||||||
|
|
||||||
ThreadEmails.Add(email);
|
ThreadEmails.Add(email);
|
||||||
NotifyPropertyChanges();
|
// Reassign to trigger property change notifications
|
||||||
|
ThreadEmails = ThreadEmails;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -196,7 +192,8 @@ public partial class ThreadMailItemViewModel : ObservableRecipient, IMailListIte
|
|||||||
{
|
{
|
||||||
if (ThreadEmails.Remove(email))
|
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 System.Windows.Input;
|
||||||
using CommunityToolkit.WinUI;
|
using CommunityToolkit.WinUI;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
@@ -118,13 +120,14 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public WinoThreadMailItemViewModelListViewItem? GetThreadMailItemContainer(ThreadMailItemViewModel threadMailItemViewModel)
|
public async Task<Tuple<WinoMailItemViewModelListViewItem?, WinoThreadMailItemViewModelListViewItem?, WinoListView?>> GetItemContainersAsync(MailItemViewModel mailItemViewModel)
|
||||||
=> ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem;
|
|
||||||
|
|
||||||
public bool SelectMailItemContainer(MailItemViewModel mailItemViewModel)
|
|
||||||
{
|
{
|
||||||
WinoMailItemViewModelListViewItem? itemContainer = null;
|
WinoMailItemViewModelListViewItem? itemContainer = null;
|
||||||
WinoThreadMailItemViewModelListViewItem? threadContainer = null;
|
WinoThreadMailItemViewModelListViewItem? threadContainer = null;
|
||||||
|
WinoListView? innerListView = null;
|
||||||
|
|
||||||
|
int retryCount = 0;
|
||||||
|
int maxRetries = 5;
|
||||||
|
|
||||||
foreach (var item in Items)
|
foreach (var item in Items)
|
||||||
{
|
{
|
||||||
@@ -132,12 +135,38 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
|
|||||||
{
|
{
|
||||||
itemContainer = ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(mailItemViewModel.MailCopy.UniqueId))
|
else if (item is ThreadMailItemViewModel threadMailItemViewModel && threadMailItemViewModel.HasUniqueId(mailItemViewModel.MailCopy.UniqueId))
|
||||||
{
|
{
|
||||||
threadContainer = ContainerFromItem(threadMailItemViewModel) as WinoThreadMailItemViewModelListViewItem;
|
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.
|
// Try to get the inner WinoListView.
|
||||||
if (threadContainer != null)
|
if (threadContainer != null)
|
||||||
{
|
{
|
||||||
@@ -147,25 +176,16 @@ public partial class WinoListView : Microsoft.UI.Xaml.Controls.ListView
|
|||||||
|
|
||||||
if (innerListViewControl != null)
|
if (innerListViewControl != null)
|
||||||
{
|
{
|
||||||
|
innerListView = innerListViewControl;
|
||||||
|
// TODO: What if it wasn't realized in the thread?
|
||||||
itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem;
|
itemContainer = innerListViewControl.ContainerFromItem(mailItemViewModel) as WinoMailItemViewModelListViewItem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemContainer != null)
|
return new Tuple<WinoMailItemViewModelListViewItem?, WinoThreadMailItemViewModelListViewItem?, WinoListView?>(itemContainer, threadContainer, innerListView);
|
||||||
{
|
|
||||||
itemContainer.IsSelected = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else if (threadContainer != null)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ChangeSelectionMode(ListViewSelectionMode mode)
|
public void ChangeSelectionMode(ListViewSelectionMode mode)
|
||||||
|
|||||||
@@ -315,46 +315,30 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
{
|
{
|
||||||
if (message.SelectedMailViewModel == null) return;
|
if (message.SelectedMailViewModel == null) return;
|
||||||
|
|
||||||
await ViewModel.ExecuteUIThread(async () =>
|
await DispatcherQueue.EnqueueAsync(async () =>
|
||||||
{
|
{
|
||||||
// MailListView.ClearSelections(message.SelectedMailViewModel, true);
|
// MailListView.ClearSelections(message.SelectedMailViewModel, true);
|
||||||
|
|
||||||
int retriedSelectionCount = 0;
|
var collectionContainer = await MailListView.GetItemContainersAsync(message.SelectedMailViewModel);
|
||||||
trySelection:
|
|
||||||
|
|
||||||
bool isSelected = MailListView.SelectMailItemContainer(message.SelectedMailViewModel);
|
if (collectionContainer.Item1 == null && collectionContainer.Item2 == null) return;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically scroll to the selected item.
|
// Automatically scroll to the selected item.
|
||||||
// This is useful when creating draft.
|
// 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.
|
// Scroll to thread if available.
|
||||||
// Find the item index on the UI. This is different than ListView.
|
// Find the item index on the UI. This is different than ListView.
|
||||||
|
|
||||||
int scrollIndex = -1;
|
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)
|
if (scrollIndex >= 0)
|
||||||
@@ -362,6 +346,12 @@ public sealed partial class MailListPage : MailListPageAbstract,
|
|||||||
await MailListView.SmoothScrollIntoViewWithIndexAsync(scrollIndex);
|
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 isSelectedItemFromThread = listView.IsThreadListView;
|
||||||
bool isCtrlPressed = KeyPressService.IsCtrlKeyPressed();
|
bool isCtrlPressed = KeyPressService.IsCtrlKeyPressed();
|
||||||
|
|
||||||
bool isClickingThreadItem = e.ClickedItem is ThreadMailItemViewModel;
|
bool isClickingThreadItem = clickedItem is ThreadMailItemViewModel;
|
||||||
|
|
||||||
// Unselect all items. It's single selection.
|
// Unselect all items. It's single selection.
|
||||||
if (!isCtrlPressed)
|
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;
|
mailListItem.IsSelected = !mailListItem.IsSelected;
|
||||||
}
|
}
|
||||||
else if (e.ClickedItem is ThreadMailItemViewModel threadMailItemViewModel)
|
else if (clickedItem is ThreadMailItemViewModel threadMailItemViewModel)
|
||||||
{
|
{
|
||||||
// Extended selection mode handling for threads
|
// Extended selection mode handling for threads
|
||||||
if (isCtrlPressed)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ public class DatabaseService : IDatabaseService
|
|||||||
typeof(MailAccountPreferences),
|
typeof(MailAccountPreferences),
|
||||||
typeof(MailAccountAlias),
|
typeof(MailAccountAlias),
|
||||||
typeof(Thumbnail),
|
typeof(Thumbnail),
|
||||||
typeof(KeyboardShortcut),
|
typeof(KeyboardShortcut)
|
||||||
typeof(MailItemQueue)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
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.
|
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
|
||||||
|
|||||||
Reference in New Issue
Block a user