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);