5 Commits

Author SHA1 Message Date
Burak Kaan Köse ef12e0ff3a Init translations before showing nwe version notification. 2026-04-25 22:48:31 +02:00
Burak Kaan Köse e7e201758e Handle expired subscriptions. 2026-04-25 22:48:15 +02:00
Burak Kaan Köse 55ae6e1f3a Initial v2 launch notification. 2026-04-25 16:12:49 +02:00
Burak Kaan Köse 3ea8b8ac46 Bulk updates for gmail. 2026-04-25 16:00:49 +02:00
Burak Kaan Köse 8fe40e9fe3 Potential gmail history id fix. 2026-04-23 15:00:55 +02:00
14 changed files with 707 additions and 146 deletions
@@ -34,6 +34,7 @@ public interface IMailService
/// <param name="accountId">Account to remove from</param>
/// <param name="mailCopyId">Mail copy id to remove.</param>
Task DeleteMailAsync(Guid accountId, string mailCopyId);
Task DeleteMailsAsync(Guid accountId, IEnumerable<string> 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<MailFolderAssignmentUpdate> assignments);
Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package);
Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages);
/// <summary>
/// Maps new mail item with the existing local draft copy.
@@ -51,6 +51,11 @@ public interface INotificationBuilder
/// </summary>
void CreateStoreUpdateNotification();
/// <summary>
/// Shows the one-time release migration notification.
/// </summary>
void CreateReleaseMigrationNotification();
/// <summary>
/// Creates a calendar reminder toast for the specified calendar item.
/// </summary>
@@ -0,0 +1,3 @@
namespace Wino.Core.Domain.Models.MailItem;
public sealed record MailFolderAssignmentUpdate(string MailCopyId, string RemoteFolderId);
@@ -704,6 +704,8 @@
"Notifications_WinoUpdatedTitle": "Wino Mail has been updated.",
"Notifications_StoreUpdateAvailableTitle": "Update available",
"Notifications_StoreUpdateAvailableMessage": "A newer version of Wino Mail is ready to install from Microsoft Store.",
"Notifications_ReleaseMigrationTitle": "New Wino Mail & Calendar",
"Notifications_ReleaseMigrationMessage": "Wino Mail got updated to the next version. Please re-create your accounts and start using the next version including calendar, mail templates, shortcuts, and a bunch of other improvements.",
"OnlineSearchFailed_Message": "Failed to perform search\n{0}\n\nListing offline mails.",
"OnlineSearchTry_Line1": "Can't find what you are looking for?",
"OnlineSearchTry_Line2": "Try online search.",
@@ -19,6 +19,32 @@ namespace Wino.Core.Tests.Synchronizers;
public sealed class GmailSynchronizerRequestSuccessTests
{
[Fact]
public async Task UpdateAccountSyncIdentifierAsync_EmptyStoredIdentifier_PersistsFirstHistoryCursor()
{
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
changeProcessor
.Setup(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"))
.ReturnsAsync("123");
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: string.Empty);
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"), Times.Once);
}
[Fact]
public async Task UpdateAccountSyncIdentifierAsync_OlderHistoryCursor_DoesNotRegressStoredCursor()
{
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: "456");
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail()
{
@@ -209,13 +235,15 @@ public sealed class GmailSynchronizerRequestSuccessTests
private static GmailSynchronizer CreateSynchronizer(
IGmailChangeProcessor changeProcessor,
IGmailSynchronizerErrorHandlerFactory? errorFactory = null)
IGmailSynchronizerErrorHandlerFactory? errorFactory = null,
string? synchronizationDeltaIdentifier = null)
{
var account = new MailAccount
{
Id = Guid.NewGuid(),
Name = "Gmail",
Address = "user@example.com"
Address = "user@example.com",
SynchronizationDeltaIdentifier = synchronizationDeltaIdentifier
};
var authenticator = new Mock<IGmailAuthenticator>(MockBehavior.Loose);
@@ -249,4 +277,17 @@ public sealed class GmailSynchronizerRequestSuccessTests
task.Should().NotBeNull();
await task!;
}
private static async Task InvokeUpdateAccountSyncIdentifierAsync(GmailSynchronizer synchronizer, ulong historyId)
{
var method = typeof(GmailSynchronizer).GetMethod(
"UpdateAccountSyncIdentifierAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
method.Should().NotBeNull();
var task = method!.Invoke(synchronizer, [historyId]) as Task;
task.Should().NotBeNull();
await task!;
}
}
@@ -571,6 +571,19 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}
var aiStatus = aiStatusResponse.Result;
if (IsExpiredAiEntitlement(aiStatus.EntitlementStatus))
{
await ExecuteUIThread(() =>
{
_aiPackAddOn.IsPurchased = false;
_aiPackAddOn.HasUsageData = false;
_aiPackAddOn.ErrorText = string.Empty;
_aiPackAddOn.RenewalText = string.Empty;
_aiPackAddOn.UsageResetText = string.Empty;
});
return;
}
if (aiStatus.MonthlyLimit is not int usageLimit || usageLimit <= 0 || aiStatus.Used is not int usageCount)
{
await ExecuteUIThread(() =>
@@ -613,4 +626,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
});
}
}
private static bool IsExpiredAiEntitlement(string? entitlementStatus)
=> string.Equals(entitlementStatus, "Expired", StringComparison.OrdinalIgnoreCase);
}
@@ -30,7 +30,9 @@ public interface IDefaultChangeProcessor
Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead);
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
Task<bool> CreateMailAsync(Guid AccountId, NewMailItemPackage package);
Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages);
Task DeleteMailAsync(Guid accountId, string mailId);
Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailIds);
Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> 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<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates);
Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
Task DeleteUserMailCacheAsync(Guid accountId);
Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
Task<MailInvitationCalendarMapping> 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<MailCopyStateUpdate> updates)
=> MailService.ApplyMailStateUpdatesAsync(updates);
public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
=> MailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId);
public Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
=> MailService.DeleteAssignmentsAsync(accountId, assignments);
public Task DeleteMailAsync(Guid accountId, string mailId)
=> MailService.DeleteMailAsync(accountId, mailId);
public Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailIds)
=> MailService.DeleteMailsAsync(accountId, mailIds);
public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
=> MailService.CreateMailAsync(accountId, package);
public Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> 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<MailFolderAssignmentUpdate> assignments)
=> MailService.CreateAssignmentsAsync(accountId, assignments);
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
=> MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
+163 -52
View File
@@ -1050,6 +1050,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
_logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name);
var pendingStateUpdates = new List<MailCopyStateUpdate>();
var pendingAssignmentCreates = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var pendingAssignmentDeletes = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var deletedMessageIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var history in listHistoryResponse.History)
{
// Handle label additions.
@@ -1057,7 +1062,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
foreach (var addedLabel in history.LabelsAdded)
{
await HandleLabelAssignmentAsync(addedLabel);
await HandleLabelAssignmentAsync(addedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
}
}
@@ -1066,7 +1071,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
foreach (var removedLabel in history.LabelsRemoved)
{
await HandleLabelRemovalAsync(removedLabel);
await HandleLabelRemovalAsync(removedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
}
}
@@ -1079,36 +1084,108 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_logger.Debug("Processing message deletion for {MessageId}", messageId);
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, messageId).ConfigureAwait(false);
deletedMessageIds.Add(messageId);
}
}
}
if (pendingStateUpdates.Count > 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<string, MailFolderAssignmentUpdate> creates,
Dictionary<string, MailFolderAssignmentUpdate> 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<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> 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<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> 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<MailCopyStateUpdate> pendingStateUpdates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
{
var messageId = addedLabel.Message.Id;
@@ -1119,23 +1196,31 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleArchiveAssignmentAsync(messageId).ConfigureAwait(false);
await HandleArchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
continue;
}
// When UNREAD label is added mark the message as un-read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: false));
// When STARRED label is added mark the message as flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, true).ConfigureAwait(false);
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: true));
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
QueueAssignmentChange(
pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(messageId, labelId),
shouldCreate: true);
}
}
private async Task HandleLabelRemovalAsync(HistoryLabelRemoved removedLabel)
private async Task HandleLabelRemovalAsync(
HistoryLabelRemoved removedLabel,
List<MailCopyStateUpdate> pendingStateUpdates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
{
var messageId = removedLabel.Message.Id;
@@ -1146,20 +1231,23 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleUnarchiveAssignmentAsync(messageId).ConfigureAwait(false);
await HandleUnarchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
continue;
}
// When UNREAD label is removed mark the message as read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: true));
// When STARRED label is removed mark the message as un-flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID)
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, false).ConfigureAwait(false);
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: false));
// For other labels remove the mail assignment.
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
QueueAssignmentChange(
pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(messageId, labelId),
shouldCreate: false);
}
}
@@ -1542,6 +1630,8 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
await Task.WhenAll(batchTasks).ConfigureAwait(false);
// Process all downloaded messages
var pendingPackages = new List<NewMailItemPackage>();
foreach (var gmailMessage in downloadedMessages)
{
try
@@ -1552,12 +1642,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
{
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
pendingPackages.AddRange(packages);
// Update sync identifier if available
if (gmailMessage.HistoryId.HasValue)
@@ -1570,6 +1655,11 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_logger.Error(ex, "Failed to process downloaded message {MessageId}", gmailMessage.Id);
}
}
if (pendingPackages.Count > 0)
{
await _gmailChangeProcessor.CreateMailsAsync(Account.Id, pendingPackages).ConfigureAwait(false);
}
}
}
@@ -1592,12 +1682,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create mail packages from metadata
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
if (packages != null && packages.Count > 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
@@ -1897,9 +1984,19 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
if (historyId == null) return false;
var newHistoryId = historyId.Value;
var currentSynchronizationIdentifier = Account.SynchronizationDeltaIdentifier;
return Account.SynchronizationDeltaIdentifier == null ||
(ulong.TryParse(Account.SynchronizationDeltaIdentifier, out ulong currentIdentifier) && newHistoryId > currentIdentifier);
if (string.IsNullOrWhiteSpace(currentSynchronizationIdentifier))
return true;
if (!ulong.TryParse(currentSynchronizationIdentifier, out ulong currentIdentifier))
{
_logger.Warning("Current Gmail history ID '{HistoryId}' is invalid for {Name}. Replacing it with {NewHistoryId}.",
currentSynchronizationIdentifier, Account.Name, newHistoryId);
return true;
}
return newHistoryId > currentIdentifier;
}
private async Task UpdateAccountSyncIdentifierAsync(ulong? historyId)
@@ -1932,12 +2029,9 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create mail packages from the downloaded message
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
if (packages != null && packages.Count > 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);
@@ -1989,25 +2083,27 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
switch (bundle.UIChangeRequest)
{
case BatchMarkReadRequest batchMarkReadRequest:
foreach (var request in batchMarkReadRequest)
{
await _gmailChangeProcessor.ChangeMailReadStatusAsync(request.Item.Id, request.IsRead).ConfigureAwait(false);
}
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
batchMarkReadRequest.Select(request => 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;
}
}
@@ -2065,16 +2161,31 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
}
var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
var pendingArchiveCreates = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var pendingArchiveDeletes = new Dictionary<string, MailFolderAssignmentUpdate>(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<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
var pendingArchiveCreateOverrides = new Dictionary<string, MailFolderAssignmentUpdate>(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);
}
}
@@ -21,6 +21,8 @@ namespace Wino.Mail.ViewModels.Collections;
public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsChangedMessage>
{
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<SelectedItemsC
// Execute all updates in a single UI thread call
if (itemsToUpdate.Count > 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<SelectedItemsC
})
.ToList()).ConfigureAwait(false);
await ExecuteUIThread(() =>
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<SelectedItemsC
if (updates.Count == 0)
return;
await ExecuteUIThread(() =>
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<MailCopy> updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties)
@@ -1018,22 +1029,25 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
return;
}
await ExecuteUIThread(() =>
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);
+2
View File
@@ -479,6 +479,8 @@ public partial class App : WinoApplication,
EnsureAppNotificationRegistration();
await TranslationService.InitializeAsync();
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
.RunIfNeededAsync();
+1 -1
View File
@@ -23,7 +23,7 @@
<Identity
Name="58272BurakKSE.WinoMailPreview"
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
Version="2.0.6.0" />
Version="2.0.7.0" />
<mp:PhoneIdentity PhoneProductId="7b7e90e9-cc55-4409-9769-99b4b5ed6e9b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
@@ -233,6 +233,17 @@ public class NotificationBuilder : INotificationBuilder
ShowNotification(builder, "store-update-available");
}
public void CreateReleaseMigrationNotification()
{
var builder = CreateBuilder();
builder.AddText(Translator.Notifications_ReleaseMigrationTitle);
builder.AddText(Translator.Notifications_ReleaseMigrationMessage);
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
builder.AddButton(CreateDismissButton());
ShowNotification(builder, "release-migration-v2");
}
public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds)
{
if (calendarItem == null)
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Interfaces;
@@ -14,13 +15,16 @@ public sealed class ReleaseLocalAccountDataCleanupService
private readonly IConfigurationService _configurationService;
private readonly IApplicationConfiguration _applicationConfiguration;
private readonly INotificationBuilder _notificationBuilder;
private readonly ILogger _logger = Log.ForContext<ReleaseLocalAccountDataCleanupService>();
public ReleaseLocalAccountDataCleanupService(IConfigurationService configurationService,
IApplicationConfiguration applicationConfiguration)
IApplicationConfiguration applicationConfiguration,
INotificationBuilder notificationBuilder)
{
_configurationService = configurationService;
_applicationConfiguration = applicationConfiguration;
_notificationBuilder = notificationBuilder;
}
public async Task RunIfNeededAsync()
@@ -45,44 +49,70 @@ public sealed class ReleaseLocalAccountDataCleanupService
Path.Combine(publisherPath, LegacyDatabaseFileName)
};
var hadLegacyData = false;
foreach (var targetPath in cleanupTargets)
{
await DeletePathIfExistsAsync(localFolderPath, targetPath).ConfigureAwait(false);
hadLegacyData |= await DeletePathIfExistsAsync(targetPath, localFolderPath, publisherPath).ConfigureAwait(false);
}
_configurationService.Set(CleanupCompletedSettingKey, true);
if (hadLegacyData)
{
_notificationBuilder.CreateReleaseMigrationNotification();
}
_logger.Information("Completed one-time local account data cleanup for release migration.");
}
private async Task DeletePathIfExistsAsync(string localFolderPath, string targetPath)
private async Task<bool> DeletePathIfExistsAsync(string targetPath, params string[] allowedRootPaths)
{
try
{
var fullTargetPath = Path.GetFullPath(targetPath);
var fullLocalFolderPath = Path.GetFullPath(localFolderPath);
if (!fullTargetPath.StartsWith(fullLocalFolderPath, StringComparison.OrdinalIgnoreCase))
if (!allowedRootPaths.Any(rootPath => IsPathUnderAllowedRoot(fullTargetPath, rootPath)))
{
_logger.Warning("Skipped startup cleanup for path outside local folder: {TargetPath}", fullTargetPath);
return;
_logger.Warning("Skipped startup cleanup for path outside allowed roots: {TargetPath}", fullTargetPath);
return false;
}
var targetExists = Directory.Exists(fullTargetPath) || File.Exists(fullTargetPath);
if (Directory.Exists(fullTargetPath))
{
await Task.Run(() => Directory.Delete(fullTargetPath, recursive: true)).ConfigureAwait(false);
_logger.Information("Deleted legacy startup cleanup directory {TargetPath}", fullTargetPath);
return;
return true;
}
if (File.Exists(fullTargetPath))
{
File.Delete(fullTargetPath);
_logger.Information("Deleted legacy startup cleanup file {TargetPath}", fullTargetPath);
return true;
}
return targetExists;
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to delete legacy startup cleanup path {TargetPath}", targetPath);
}
return false;
}
private static bool IsPathUnderAllowedRoot(string fullTargetPath, string rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
return false;
var fullRootPath = Path.GetFullPath(rootPath);
var relativePath = Path.GetRelativePath(fullRootPath, fullTargetPath);
return relativePath != "." &&
!relativePath.StartsWith("..", StringComparison.Ordinal) &&
!Path.IsPathRooted(relativePath);
}
}
+334 -32
View File
@@ -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<string> 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<MailCopy> 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<MailCopy> 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<MailCopy> 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<MailCopy> 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<MailCopy> 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>(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<MailCopy> 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<List<MailCopy>> DeleteMailCopiesAsync(IReadOnlyList<MailCopy> mailCopies, bool preserveMimeFile, bool reportUiChange)
{
if (mailCopies == null || mailCopies.Count == 0)
return [];
var removedMails = new List<MailCopy>(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<MailFolderAssignmentUpdate> 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<MailCopy>(targetAssignments.Count);
var removedMails = new List<MailCopy>();
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<MailFolderAssignmentUpdate> 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<MailCopy>(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<MailCopy> 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<NewMailItemPackage> 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<MailCopy>()
.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<MailCopy>(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<MailCopy>(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<bool> 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);