From 3ea8b8ac4643dc0149a3475b2f3d5391e9661bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sat, 25 Apr 2026 16:00:49 +0200 Subject: [PATCH] Bulk updates for gmail. --- Wino.Core.Domain/Interfaces/IMailService.cs | 4 + .../MailItem/MailFolderAssignmentUpdate.cs | 3 + .../Processors/DefaultChangeProcessor.cs | 20 + Wino.Core/Synchronizers/GmailSynchronizer.cs | 201 +++++++--- .../Collections/WinoMailCollection.cs | 114 +++--- Wino.Services/MailService.cs | 366 ++++++++++++++++-- 6 files changed, 576 insertions(+), 132 deletions(-) create mode 100644 Wino.Core.Domain/Models/MailItem/MailFolderAssignmentUpdate.cs diff --git a/Wino.Core.Domain/Interfaces/IMailService.cs b/Wino.Core.Domain/Interfaces/IMailService.cs index 5b4d4420..d10d12e2 100644 --- a/Wino.Core.Domain/Interfaces/IMailService.cs +++ b/Wino.Core.Domain/Interfaces/IMailService.cs @@ -34,6 +34,7 @@ public interface IMailService /// Account to remove from /// Mail copy id to remove. Task DeleteMailAsync(Guid accountId, string mailCopyId); + Task DeleteMailsAsync(Guid accountId, IEnumerable mailCopyIds); Task ChangeReadStatusAsync(string mailCopyId, bool isRead); Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged); @@ -42,8 +43,11 @@ public interface IMailService Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); + Task CreateAssignmentsAsync(Guid accountId, IEnumerable assignments); + Task DeleteAssignmentsAsync(Guid accountId, IEnumerable assignments); Task CreateMailAsync(Guid accountId, NewMailItemPackage package); + Task CreateMailsAsync(Guid accountId, IReadOnlyList packages); /// /// Maps new mail item with the existing local draft copy. diff --git a/Wino.Core.Domain/Models/MailItem/MailFolderAssignmentUpdate.cs b/Wino.Core.Domain/Models/MailItem/MailFolderAssignmentUpdate.cs new file mode 100644 index 00000000..b03cec72 --- /dev/null +++ b/Wino.Core.Domain/Models/MailItem/MailFolderAssignmentUpdate.cs @@ -0,0 +1,3 @@ +namespace Wino.Core.Domain.Models.MailItem; + +public sealed record MailFolderAssignmentUpdate(string MailCopyId, string RemoteFolderId); diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 6c271231..774139d3 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -30,7 +30,9 @@ public interface IDefaultChangeProcessor Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead); Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged); Task CreateMailAsync(Guid AccountId, NewMailItemPackage package); + Task CreateMailsAsync(Guid accountId, IReadOnlyList packages); Task DeleteMailAsync(Guid accountId, string mailId); + Task DeleteMailsAsync(Guid accountId, IEnumerable mailIds); Task> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable downloadedMailCopyIds); Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId); Task DeleteFolderAsync(Guid accountId, string remoteFolderId); @@ -56,6 +58,9 @@ public interface IDefaultChangeProcessor Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken); Task> GetMailCopiesAsync(IEnumerable mailCopyIds); Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package); + Task ApplyMailStateUpdatesAsync(IEnumerable updates); + Task CreateAssignmentsAsync(Guid accountId, IEnumerable assignments); + Task DeleteAssignmentsAsync(Guid accountId, IEnumerable assignments); Task DeleteUserMailCacheAsync(Guid accountId); Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping); Task GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId); @@ -156,18 +161,33 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead) => MailService.ChangeReadStatusAsync(mailCopyId, isRead); + public Task ApplyMailStateUpdatesAsync(IEnumerable updates) + => MailService.ApplyMailStateUpdatesAsync(updates); + public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) => MailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId); + public Task DeleteAssignmentsAsync(Guid accountId, IEnumerable assignments) + => MailService.DeleteAssignmentsAsync(accountId, assignments); + public Task DeleteMailAsync(Guid accountId, string mailId) => MailService.DeleteMailAsync(accountId, mailId); + public Task DeleteMailsAsync(Guid accountId, IEnumerable mailIds) + => MailService.DeleteMailsAsync(accountId, mailIds); + public Task CreateMailAsync(Guid accountId, NewMailItemPackage package) => MailService.CreateMailAsync(accountId, package); + public Task CreateMailsAsync(Guid accountId, IReadOnlyList packages) + => MailService.CreateMailsAsync(accountId, packages); + public Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package) => MailService.CreateMailRawAsync(account, mailItemFolder, package); + public Task CreateAssignmentsAsync(Guid accountId, IEnumerable assignments) + => MailService.CreateAssignmentsAsync(accountId, assignments); + public Task MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId) => MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId); diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index b9ef8c61..87433887 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -1050,6 +1050,11 @@ public class GmailSynchronizer : WinoSynchronizer(); + var pendingAssignmentCreates = new Dictionary(StringComparer.Ordinal); + var pendingAssignmentDeletes = new Dictionary(StringComparer.Ordinal); + var deletedMessageIds = new HashSet(StringComparer.Ordinal); + foreach (var history in listHistoryResponse.History) { // Handle label additions. @@ -1057,7 +1062,7 @@ public class GmailSynchronizer : WinoSynchronizer 0) + { + await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(pendingStateUpdates).ConfigureAwait(false); + } + + if (pendingAssignmentCreates.Count > 0) + { + await _gmailChangeProcessor.CreateAssignmentsAsync(Account.Id, pendingAssignmentCreates.Values.ToList()).ConfigureAwait(false); + } + + if (pendingAssignmentDeletes.Count > 0) + { + await _gmailChangeProcessor.DeleteAssignmentsAsync(Account.Id, pendingAssignmentDeletes.Values.ToList()).ConfigureAwait(false); + } + + if (deletedMessageIds.Count > 0) + { + await _gmailChangeProcessor.DeleteMailsAsync(Account.Id, deletedMessageIds).ConfigureAwait(false); + } } - private async Task HandleArchiveAssignmentAsync(string archivedMessageId) + private static string GetAssignmentChangeKey(string messageId, string labelId) + => $"{messageId}\u001f{labelId}"; + + private static void QueueAssignmentChange( + Dictionary creates, + Dictionary deletes, + MailFolderAssignmentUpdate assignment, + bool shouldCreate) { + if (assignment == null || + string.IsNullOrWhiteSpace(assignment.MailCopyId) || + string.IsNullOrWhiteSpace(assignment.RemoteFolderId)) + { + return; + } + + var key = GetAssignmentChangeKey(assignment.MailCopyId, assignment.RemoteFolderId); + + if (shouldCreate) + { + deletes.Remove(key); + creates[key] = assignment; + } + else + { + creates.Remove(key); + deletes[key] = assignment; + } + } + + private async Task HandleArchiveAssignmentAsync( + string archivedMessageId, + Dictionary pendingAssignmentCreates, + Dictionary pendingAssignmentDeletes) + { + if (!archiveFolderId.HasValue) + return; + // Ignore if the message is already in the archive. - bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value); + bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value).ConfigureAwait(false); if (archived) return; _logger.Debug("Processing archive assignment for message {Id}", archivedMessageId); - - await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false); + QueueAssignmentChange( + pendingAssignmentCreates, + pendingAssignmentDeletes, + new MailFolderAssignmentUpdate(archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID), + shouldCreate: true); } - private async Task HandleUnarchiveAssignmentAsync(string unarchivedMessageId) + private async Task HandleUnarchiveAssignmentAsync( + string unarchivedMessageId, + Dictionary pendingAssignmentCreates, + Dictionary pendingAssignmentDeletes) { + if (!archiveFolderId.HasValue) + return; + // Ignore if the message is not in the archive. - bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value); + bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value).ConfigureAwait(false); if (!archived) return; _logger.Debug("Processing un-archive assignment for message {Id}", unarchivedMessageId); - - await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false); + QueueAssignmentChange( + pendingAssignmentCreates, + pendingAssignmentDeletes, + new MailFolderAssignmentUpdate(unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID), + shouldCreate: false); } - private async Task HandleLabelAssignmentAsync(HistoryLabelAdded addedLabel) + private async Task HandleLabelAssignmentAsync( + HistoryLabelAdded addedLabel, + List pendingStateUpdates, + Dictionary pendingAssignmentCreates, + Dictionary pendingAssignmentDeletes) { var messageId = addedLabel.Message.Id; @@ -1119,23 +1196,31 @@ public class GmailSynchronizer : WinoSynchronizer pendingStateUpdates, + Dictionary pendingAssignmentCreates, + Dictionary pendingAssignmentDeletes) { var messageId = removedLabel.Message.Id; @@ -1146,20 +1231,23 @@ public class GmailSynchronizer : WinoSynchronizer(); + foreach (var gmailMessage in downloadedMessages) { try @@ -1552,12 +1642,7 @@ public class GmailSynchronizer : WinoSynchronizer 0) + { + await _gmailChangeProcessor.CreateMailsAsync(Account.Id, pendingPackages).ConfigureAwait(false); + } } } @@ -1592,12 +1682,9 @@ public class GmailSynchronizer : WinoSynchronizer 0) { - foreach (var package in packages) - { - await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); - } + await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false); } // Update sync identifier if available @@ -1942,12 +2029,9 @@ public class GmailSynchronizer : WinoSynchronizer 0) { - foreach (var package in packages) - { - await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false); - } + await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false); } await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId).ConfigureAwait(false); @@ -1999,25 +2083,27 @@ public class GmailSynchronizer : WinoSynchronizer new MailCopyStateUpdate(request.Item.Id, IsRead: request.IsRead))) + .ConfigureAwait(false); break; case MarkReadRequest markReadRequest: - await _gmailChangeProcessor.ChangeMailReadStatusAsync(markReadRequest.Item.Id, markReadRequest.IsRead).ConfigureAwait(false); + await _gmailChangeProcessor.ApplyMailStateUpdatesAsync( + [new MailCopyStateUpdate(markReadRequest.Item.Id, IsRead: markReadRequest.IsRead)]) + .ConfigureAwait(false); break; case BatchChangeFlagRequest batchChangeFlagRequest: - foreach (var request in batchChangeFlagRequest) - { - await _gmailChangeProcessor.ChangeFlagStatusAsync(request.Item.Id, request.IsFlagged).ConfigureAwait(false); - } + await _gmailChangeProcessor.ApplyMailStateUpdatesAsync( + batchChangeFlagRequest.Select(request => new MailCopyStateUpdate(request.Item.Id, IsFlagged: request.IsFlagged))) + .ConfigureAwait(false); break; case ChangeFlagRequest changeFlagRequest: - await _gmailChangeProcessor.ChangeFlagStatusAsync(changeFlagRequest.Item.Id, changeFlagRequest.IsFlagged).ConfigureAwait(false); + await _gmailChangeProcessor.ApplyMailStateUpdatesAsync( + [new MailCopyStateUpdate(changeFlagRequest.Item.Id, IsFlagged: changeFlagRequest.IsFlagged)]) + .ConfigureAwait(false); break; } } @@ -2075,16 +2161,31 @@ public class GmailSynchronizer : WinoSynchronizer(StringComparer.Ordinal); + var pendingArchiveDeletes = new Dictionary(StringComparer.Ordinal); foreach (var archiveAddedItem in existingAfterDownload) { - await HandleArchiveAssignmentAsync(archiveAddedItem).ConfigureAwait(false); + await HandleArchiveAssignmentAsync(archiveAddedItem, pendingArchiveCreates, pendingArchiveDeletes).ConfigureAwait(false); + } + + if (pendingArchiveCreates.Count > 0) + { + await _gmailChangeProcessor.CreateAssignmentsAsync(Account.Id, pendingArchiveCreates.Values.ToList()).ConfigureAwait(false); } } + var pendingArchiveRemovals = new Dictionary(StringComparer.Ordinal); + var pendingArchiveCreateOverrides = new Dictionary(StringComparer.Ordinal); + foreach (var unAarchivedRemovedItem in removedArchiveIds) { - await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem).ConfigureAwait(false); + await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem, pendingArchiveCreateOverrides, pendingArchiveRemovals).ConfigureAwait(false); + } + + if (pendingArchiveRemovals.Count > 0) + { + await _gmailChangeProcessor.DeleteAssignmentsAsync(Account.Id, pendingArchiveRemovals.Values.ToList()).ConfigureAwait(false); } } diff --git a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs index e6ca7a7e..e1f76d03 100644 --- a/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs +++ b/Wino.Mail.ViewModels/Collections/WinoMailCollection.cs @@ -21,6 +21,8 @@ namespace Wino.Mail.ViewModels.Collections; public class WinoMailCollection : ObservableRecipient, IRecipient { + private const int UiMutationBatchSize = 40; + // We cache each mail copy id for faster access on updates. // If the item provider here for update or removal doesn't exist here // we can ignore the operation. @@ -763,18 +765,21 @@ public class WinoMailCollection : ObservableRecipient, IRecipient 0) { - await ExecuteUIThread(() => + foreach (var updateBatch in itemsToUpdate.Chunk(UiMutationBatchSize)) { - foreach (var (existing, updated) in itemsToUpdate) + await ExecuteUIThread(() => { - UpdateUniqueIdHashes(existing, false); - existing.UpdateFrom(updated); - UpdateUniqueIdHashes(existing, true); - } - }); + foreach (var (existing, updated) in updateBatch) + { + UpdateUniqueIdHashes(existing, false); + existing.UpdateFrom(updated); + UpdateUniqueIdHashes(existing, true); + } + }); + } } - // Group items by their grouping key and add them in a single UI thread call + // Group items by their grouping key and add them in bounded UI thread calls. if (itemsToAdd.Count > 0) { var groupedItems = await Task.Run(() => itemsToAdd @@ -787,32 +792,35 @@ public class WinoMailCollection : ObservableRecipient, IRecipient + foreach (var groupedItem in groupedItems) { - foreach (var groupedItem in groupedItems) + var groupKey = groupedItem.Key; + var groupItems = groupedItem.Items; + + foreach (var groupBatch in groupItems.Chunk(UiMutationBatchSize)) { - var groupKey = groupedItem.Key; - var groupItems = groupedItem.Items; - - // Update caches first - foreach (var item in groupItems) + await ExecuteUIThread(() => { - UpdateUniqueIdHashes(item, true); - UpdateThreadIdCache(item, true); - } - - foreach (var item in groupItems) - { - _mailItemSource.InsertItem(groupKey, listComparer, item, listComparer); - - var targetGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey); - if (targetGroup != null) + // Update caches first so lookup helpers remain consistent during inserts. + foreach (var item in groupBatch) { - _itemToGroupMap[item] = targetGroup; + UpdateUniqueIdHashes(item, true); + UpdateThreadIdCache(item, true); } - } + + foreach (var item in groupBatch) + { + _mailItemSource.InsertItem(groupKey, listComparer, item, listComparer); + + var targetGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey); + if (targetGroup != null) + { + _itemToGroupMap[item] = targetGroup; + } + } + }); } - }); + } } } @@ -972,20 +980,23 @@ public class WinoMailCollection : ObservableRecipient, IRecipient + foreach (var updateBatch in updates.Chunk(UiMutationBatchSize)) { - foreach (var update in updates) + await ExecuteUIThread(() => { - var existingItem = update.ItemContainer.ItemViewModel; - var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged); - existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated; - - if (update.ItemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None) + foreach (var update in updateBatch) { - update.ItemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); + var existingItem = update.ItemContainer.ItemViewModel; + var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged); + existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated; + + if (update.ItemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None) + { + update.ItemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); + } } - } - }); + }); + } } private async Task UpdateMailCopiesInternalAsync(IEnumerable updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties) @@ -1018,22 +1029,25 @@ public class WinoMailCollection : ObservableRecipient, IRecipient + foreach (var updateBatch in updates.Chunk(UiMutationBatchSize)) { - foreach (var update in updates) + await ExecuteUIThread(() => { - var updatedMail = update.UpdatedMail; - var itemContainer = update.ItemContainer; - var existingItem = itemContainer.ItemViewModel; - var appliedChanges = existingItem.UpdateFrom(updatedMail, changedProperties); - existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated; - - if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None) + foreach (var update in updateBatch) { - itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); + var updatedMail = update.UpdatedMail; + var itemContainer = update.ItemContainer; + var existingItem = itemContainer.ItemViewModel; + var appliedChanges = existingItem.UpdateFrom(updatedMail, changedProperties); + existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated; + + if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None) + { + itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges); + } } - } - }); + }); + } } public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0); diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index b6b6f153..ee208e23 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -727,32 +727,70 @@ public class MailService : BaseDatabaseService, IMailService public async Task DeleteMailAsync(Guid accountId, string mailCopyId) { - var allMails = await GetMailCopiesByIdAsync([mailCopyId]).ConfigureAwait(false); + await DeleteMailsAsync(accountId, [mailCopyId]).ConfigureAwait(false); + } - foreach (var mailItem in allMails) - { - // Delete mime file as well. - // Even though Gmail might have multiple copies for the same mail, we only have one MIME file for all. - // Their FileId is inserted same. + public async Task DeleteMailsAsync(Guid accountId, IEnumerable mailCopyIds) + { + var targetMailIds = mailCopyIds? + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList() ?? []; - await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false); - } + if (targetMailIds.Count == 0) + return; + + var allMails = await GetMailCopiesByIdAsync(targetMailIds).ConfigureAwait(false); + await DeleteMailCopiesAsync(allMails, preserveMimeFile: false, reportUiChange: true).ConfigureAwait(false); + } + + private void ReportAddedMails(IReadOnlyList addedMails) + { + if (addedMails == null || addedMails.Count == 0) + return; + + if (addedMails.Count == 1) + ReportUIChange(new MailAddedMessage(addedMails[0], EntityUpdateSource.Server)); + else + ReportUIChange(new BulkMailAddedMessage(addedMails, EntityUpdateSource.Server)); + } + + private void ReportUpdatedMails(IReadOnlyList updatedMails, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None) + { + if (updatedMails == null || updatedMails.Count == 0) + return; + + if (updatedMails.Count == 1) + ReportUIChange(new MailUpdatedMessage(updatedMails[0], EntityUpdateSource.Server, changedProperties)); + else + ReportUIChange(new BulkMailUpdatedMessage(updatedMails, EntityUpdateSource.Server, changedProperties)); + } + + private void ReportRemovedMails(IReadOnlyList removedMails) + { + if (removedMails == null || removedMails.Count == 0) + return; + + if (removedMails.Count == 1) + ReportUIChange(new MailRemovedMessage(removedMails[0], EntityUpdateSource.Server)); + else + ReportUIChange(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.Server)); } #region Repository Calls - private async Task InsertMailAsync(MailCopy mailCopy) + private async Task InsertMailAsync(MailCopy mailCopy, bool reportUiChange) { if (mailCopy == null) { _logger.Warning("Null mail passed to InsertMailAsync call."); - return; + return null; } if (mailCopy.FolderId == Guid.Empty) { _logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id); - return; + return null; } _logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName); @@ -760,21 +798,27 @@ public class MailService : BaseDatabaseService, IMailService await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false); - ReportUIChange(new MailAddedMessage(hydratedMailCopy, EntityUpdateSource.Server)); + if (reportUiChange) + ReportAddedMails([hydratedMailCopy]); + + return hydratedMailCopy; } public async Task UpdateMailAsync(MailCopy mailCopy) + => await UpdateMailAsync(mailCopy, reportUiChange: true).ConfigureAwait(false); + + private async Task UpdateMailAsync(MailCopy mailCopy, bool reportUiChange, MailCopy existingMailCopy = null) { if (mailCopy == null) { _logger.Warning("Null mail passed to UpdateMailAsync call."); - return; + return null; } _logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId); - var existingMailCopy = mailCopy.UniqueId != Guid.Empty + existingMailCopy ??= mailCopy.UniqueId != Guid.Empty ? await Connection.FindAsync(mailCopy.UniqueId).ConfigureAwait(false) : null; @@ -787,16 +831,19 @@ public class MailService : BaseDatabaseService, IMailService await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false); var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false); - ReportUIChange(new MailUpdatedMessage(hydratedMailCopy, EntityUpdateSource.Server)); + if (reportUiChange) + ReportUpdatedMails([hydratedMailCopy]); + + return hydratedMailCopy; } - private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile) + private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile, bool reportUiChange) { if (mailCopy == null) { _logger.Warning("Null mail passed to DeleteMailAsync call."); - return; + return null; } _logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName); @@ -812,7 +859,31 @@ public class MailService : BaseDatabaseService, IMailService await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false); } - ReportUIChange(new MailRemovedMessage(mailCopy, EntityUpdateSource.Server)); + if (reportUiChange) + ReportRemovedMails([mailCopy]); + + return mailCopy; + } + + private async Task> DeleteMailCopiesAsync(IReadOnlyList mailCopies, bool preserveMimeFile, bool reportUiChange) + { + if (mailCopies == null || mailCopies.Count == 0) + return []; + + var removedMails = new List(mailCopies.Count); + + foreach (var mailCopy in mailCopies) + { + var removedMail = await DeleteMailInternalAsync(mailCopy, preserveMimeFile, reportUiChange: false).ConfigureAwait(false); + + if (removedMail != null) + removedMails.Add(removedMail); + } + + if (reportUiChange) + ReportRemovedMails(removedMails); + + return removedMails; } #endregion @@ -1021,6 +1092,40 @@ public class MailService : BaseDatabaseService, IMailService } public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) + => await CreateAssignmentsAsync(accountId, [new MailFolderAssignmentUpdate(mailCopyId, remoteFolderId)]).ConfigureAwait(false); + + public async Task CreateAssignmentsAsync(Guid accountId, IEnumerable assignments) + { + var targetAssignments = assignments? + .Where(x => x != null && + !string.IsNullOrWhiteSpace(x.MailCopyId) && + !string.IsNullOrWhiteSpace(x.RemoteFolderId)) + .GroupBy(x => $"{x.MailCopyId}\u001f{x.RemoteFolderId}", StringComparer.Ordinal) + .Select(group => group.First()) + .ToList() ?? []; + + if (targetAssignments.Count == 0) + return; + + var addedMails = new List(targetAssignments.Count); + var removedMails = new List(); + + foreach (var assignment in targetAssignments) + { + var (addedMail, removedMail) = await CreateAssignmentInternalAsync(accountId, assignment.MailCopyId, assignment.RemoteFolderId).ConfigureAwait(false); + + if (removedMail != null) + removedMails.Add(removedMail); + + if (addedMail != null) + addedMails.Add(addedMail); + } + + ReportRemovedMails(removedMails); + ReportAddedMails(addedMails); + } + + private async Task<(MailCopy AddedMail, MailCopy RemovedMail)> CreateAssignmentInternalAsync(Guid accountId, string mailCopyId, string remoteFolderId) { // Note: Folder might not be available at the moment due to user not syncing folders before the delta processing. // This is a problem, because assignments won't be created. @@ -1033,14 +1138,14 @@ public class MailService : BaseDatabaseService, IMailService _logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId); _logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId); - return; + return (null, null); } if (await IsMailExistsAsync(mailCopyId, localFolder.Id).ConfigureAwait(false)) { _logger.Debug("Skipping assignment creation for {MailCopyId} because folder {FolderId} already has a local copy.", mailCopyId, localFolder.Id); - return; + return (null, null); } var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId); @@ -1049,9 +1154,12 @@ public class MailService : BaseDatabaseService, IMailService { _logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId); - return; + return (null, null); } + MailCopy removedMail = null; + var mailCopyToInsert = mailCopy; + if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent && localFolder.SpecialFolderType == SpecialFolderType.Deleted) { @@ -1062,21 +1170,52 @@ public class MailService : BaseDatabaseService, IMailService // This way item will only be visible in Trash folder as in Gmail Web UI. // Don't delete MIME file since if exists. - await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true).ConfigureAwait(false); + mailCopyToInsert = CloneMailCopy(mailCopy); + removedMail = await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true, reportUiChange: false).ConfigureAwait(false); } // Copy one of the mail copy and assign it to the new folder. // We don't need to create a new MIME pack. // Therefore FileId is not changed for the new MailCopy. - mailCopy.UniqueId = Guid.NewGuid(); - mailCopy.FolderId = localFolder.Id; - mailCopy.AssignedFolder = localFolder; + mailCopyToInsert.UniqueId = Guid.NewGuid(); + mailCopyToInsert.FolderId = localFolder.Id; + mailCopyToInsert.AssignedFolder = localFolder; - await InsertMailAsync(mailCopy).ConfigureAwait(false); + var addedMail = await InsertMailAsync(mailCopyToInsert, reportUiChange: false).ConfigureAwait(false); + return (addedMail, removedMail); } public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId) + => await DeleteAssignmentsAsync(accountId, [new MailFolderAssignmentUpdate(mailCopyId, remoteFolderId)]).ConfigureAwait(false); + + public async Task DeleteAssignmentsAsync(Guid accountId, IEnumerable assignments) + { + var targetAssignments = assignments? + .Where(x => x != null && + !string.IsNullOrWhiteSpace(x.MailCopyId) && + !string.IsNullOrWhiteSpace(x.RemoteFolderId)) + .GroupBy(x => $"{x.MailCopyId}\u001f{x.RemoteFolderId}", StringComparer.Ordinal) + .Select(group => group.First()) + .ToList() ?? []; + + if (targetAssignments.Count == 0) + return; + + var removedMails = new List(targetAssignments.Count); + + foreach (var assignment in targetAssignments) + { + var removedMail = await DeleteAssignmentInternalAsync(accountId, assignment.MailCopyId, assignment.RemoteFolderId).ConfigureAwait(false); + + if (removedMail != null) + removedMails.Add(removedMail); + } + + ReportRemovedMails(removedMails); + } + + private async Task DeleteAssignmentInternalAsync(Guid accountId, string mailCopyId, string remoteFolderId) { var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false); @@ -1084,7 +1223,7 @@ public class MailService : BaseDatabaseService, IMailService { _logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId); - return; + return null; } var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId); @@ -1093,10 +1232,50 @@ public class MailService : BaseDatabaseService, IMailService { _logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId); - return; + return null; } - await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false); + return await DeleteMailInternalAsync(mailItem, preserveMimeFile: false, reportUiChange: false).ConfigureAwait(false); + } + + private static MailCopy CloneMailCopy(MailCopy source) + { + if (source == null) + return null; + + return new MailCopy + { + UniqueId = source.UniqueId, + Id = source.Id, + FolderId = source.FolderId, + ThreadId = source.ThreadId, + MessageId = source.MessageId, + References = source.References, + InReplyTo = source.InReplyTo, + FromName = source.FromName, + FromAddress = source.FromAddress, + Subject = source.Subject, + PreviewText = source.PreviewText, + CreationDate = source.CreationDate, + Importance = source.Importance, + IsRead = source.IsRead, + IsFlagged = source.IsFlagged, + IsPinned = source.IsPinned, + IsFocused = source.IsFocused, + HasAttachments = source.HasAttachments, + ItemType = source.ItemType, + DraftId = source.DraftId, + IsDraft = source.IsDraft, + FileId = source.FileId, + AssignedFolder = source.AssignedFolder, + AssignedAccount = source.AssignedAccount, + SenderContact = source.SenderContact, + IsReadReceiptRequested = source.IsReadReceiptRequested, + ReadReceiptStatus = source.ReadReceiptStatus, + ReadReceiptAcknowledgedAtUtc = source.ReadReceiptAcknowledgedAtUtc, + ReadReceiptMessageUniqueId = source.ReadReceiptMessageUniqueId, + Categories = source.Categories == null ? [] : [.. source.Categories] + }; } public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package) @@ -1113,7 +1292,7 @@ public class MailService : BaseDatabaseService, IMailService await SaveContactsForPackageAsync(package).ConfigureAwait(false); var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id); - var insertMailTask = InsertMailAsync(mailCopy); + var insertMailTask = InsertMailAsync(mailCopy, reportUiChange: true); await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); @@ -1125,6 +1304,129 @@ public class MailService : BaseDatabaseService, IMailService } + public async Task CreateMailsAsync(Guid accountId, IReadOnlyList packages) + { + var targetPackages = packages? + .Where(package => package != null) + .ToList() ?? []; + + if (targetPackages.Count == 0) + return; + + var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); + + if (account == null) + return; + + if (account.ProviderType != MailProviderType.Gmail) + { + foreach (var package in targetPackages) + await CreateMailAsync(accountId, package).ConfigureAwait(false); + + return; + } + + var pendingInserts = new List<(MailCopy MailCopy, NewMailItemPackage Package, MimeMessage MimeMessage)>(); + var pendingUpdates = new List<(MailCopy MailCopy, MailCopy ExistingMailCopy, NewMailItemPackage Package, MimeMessage MimeMessage)>(); + + foreach (var package in targetPackages) + { + if (string.IsNullOrEmpty(package.AssignedRemoteFolderId)) + { + _logger.Warning("Remote folder id is not set for {MailCopyId}.", package.Copy?.Id); + _logger.Warning("Ignoring creation of mail."); + continue; + } + + var assignedFolder = await _folderService.GetFolderAsync(accountId, package.AssignedRemoteFolderId).ConfigureAwait(false); + + if (assignedFolder == null) + { + _logger.Warning("Assigned folder not found for {MailCopyId}.", package.Copy?.Id); + _logger.Warning("Ignoring creation of mail."); + continue; + } + + var mailCopy = package.Copy; + var mimeMessage = package.Mime; + + mailCopy.UniqueId = Guid.NewGuid(); + mailCopy.AssignedAccount = account; + mailCopy.AssignedFolder = assignedFolder; + mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false); + mailCopy.FolderId = assignedFolder.Id; + + if (mimeMessage != null) + { + var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId, mailCopy.FileId).ConfigureAwait(false); + + if (!isMimeExists) + { + bool isMimeSaved = await _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, accountId).ConfigureAwait(false); + + if (!isMimeSaved) + { + _logger.Warning("Failed to save mime file for {MailCopyId}.", mailCopy.Id); + } + } + } + + await SaveContactsForPackageAsync(package).ConfigureAwait(false); + + var existingCopyItem = await Connection.Table() + .FirstOrDefaultAsync(a => a.Id == mailCopy.Id && a.FolderId == assignedFolder.Id) + .ConfigureAwait(false); + + if (existingCopyItem != null) + { + mailCopy.UniqueId = existingCopyItem.UniqueId; + pendingUpdates.Add((mailCopy, existingCopyItem, package, mimeMessage)); + } + else + { + pendingInserts.Add((mailCopy, package, mimeMessage)); + } + } + + var insertedMails = new List(pendingInserts.Count); + foreach (var pendingInsert in pendingInserts) + { + var insertedMail = await InsertMailAsync(pendingInsert.MailCopy, reportUiChange: false).ConfigureAwait(false); + + if (insertedMail != null) + insertedMails.Add(insertedMail); + } + + var updatedMails = new List(pendingUpdates.Count); + foreach (var pendingUpdate in pendingUpdates) + { + var updatedMail = await UpdateMailAsync( + pendingUpdate.MailCopy, + reportUiChange: false, + existingMailCopy: pendingUpdate.ExistingMailCopy).ConfigureAwait(false); + + if (updatedMail != null) + updatedMails.Add(updatedMail); + } + + ReportAddedMails(insertedMails); + ReportUpdatedMails(updatedMails); + + foreach (var pendingInsert in pendingInserts) + { + await ReplaceMailCategoriesForPackageAsync(accountId, pendingInsert.MailCopy, pendingInsert.Package).ConfigureAwait(false); + await _sentMailReceiptService.TrackSentMailAsync(pendingInsert.MailCopy, pendingInsert.MimeMessage).ConfigureAwait(false); + await _sentMailReceiptService.ProcessIncomingReceiptAsync(pendingInsert.MailCopy, pendingInsert.MimeMessage).ConfigureAwait(false); + } + + foreach (var pendingUpdate in pendingUpdates) + { + await ReplaceMailCategoriesForPackageAsync(accountId, pendingUpdate.MailCopy, pendingUpdate.Package).ConfigureAwait(false); + await _sentMailReceiptService.TrackSentMailAsync(pendingUpdate.MailCopy, pendingUpdate.MimeMessage).ConfigureAwait(false); + await _sentMailReceiptService.ProcessIncomingReceiptAsync(pendingUpdate.MailCopy, pendingUpdate.MimeMessage).ConfigureAwait(false); + } + } + public async Task CreateMailAsync(Guid accountId, NewMailItemPackage package) { var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); @@ -1193,7 +1495,7 @@ public class MailService : BaseDatabaseService, IMailService { mailCopy.UniqueId = existingCopyItem.UniqueId; - await UpdateMailAsync(mailCopy).ConfigureAwait(false); + await UpdateMailAsync(mailCopy, reportUiChange: true, existingMailCopy: existingCopyItem).ConfigureAwait(false); await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false); @@ -1210,7 +1512,7 @@ public class MailService : BaseDatabaseService, IMailService await DeleteMailAsync(accountId, mailCopy.Id).ConfigureAwait(false); } - await InsertMailAsync(mailCopy).ConfigureAwait(false); + await InsertMailAsync(mailCopy, reportUiChange: true).ConfigureAwait(false); await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false); await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false); await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);