3 Commits

Author SHA1 Message Date
Burak Kaan Köse af4c9527b0 ui adjustments 2026-04-23 15:18:59 +02:00
Burak Kaan Köse 0b9bdc91fe Potential gmail history id fix. 2026-04-23 15:01:06 +02:00
Burak Kaan Köse c1bda75d9f Migration plan v1 2026-04-23 14:52:52 +02:00
27 changed files with 2229 additions and 677 deletions
@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Core.Domain.Interfaces;
public interface ILegacyLocalMigrationService
{
Task<LegacyLocalMigrationPreview> DetectAsync(CancellationToken cancellationToken = default);
Task<LegacyLocalMigrationResult> ImportAsync(CancellationToken cancellationToken = default);
void MarkPromptDeferred();
}
@@ -34,7 +34,6 @@ 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);
@@ -43,11 +42,8 @@ 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,11 +51,6 @@ 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,46 @@
using System;
using System.Collections.Generic;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Accounts;
public sealed class LegacyLocalMigrationPreview
{
public string SourceDatabasePath { get; init; } = string.Empty;
public bool LegacyDatabaseExists { get; init; }
public bool HasCompletedMigration { get; init; }
public bool IsPromptDeferred { get; init; }
public bool ShouldPrompt { get; init; }
public int LegacyAccountCount { get; init; }
public int ImportableAccountCount { get; init; }
public int DuplicateAccountCount { get; init; }
public int SkippedAccountCount { get; init; }
public int ImportableMergedInboxCount { get; init; }
public int SkippedMergedInboxCount { get; init; }
public IReadOnlyList<LegacyLocalMigrationProviderCount> ProviderCounts { get; init; } = [];
public IReadOnlyList<LegacyLocalMigrationAccountPreview> Accounts { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
public bool HasImportableData => LegacyDatabaseExists && ImportableAccountCount > 0;
}
public sealed class LegacyLocalMigrationProviderCount
{
public MailProviderType ProviderType { get; init; }
public int TotalAccountCount { get; init; }
public int ImportableAccountCount { get; init; }
public int DuplicateAccountCount { get; init; }
}
public sealed class LegacyLocalMigrationAccountPreview
{
public Guid LegacyAccountId { get; init; }
public string Address { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public MailProviderType ProviderType { get; init; }
public SpecialImapProvider SpecialImapProvider { get; init; }
public int Order { get; init; }
public bool CanImport { get; init; }
public bool IsDuplicate { get; init; }
public bool IsCalendarEnabled { get; init; }
}
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Accounts;
public sealed class LegacyLocalMigrationResult
{
public LegacyLocalMigrationPreview Preview { get; init; } = new();
public int ImportedAccountCount { get; init; }
public int SkippedDuplicateAccountCount { get; init; }
public int FailedAccountCount { get; init; }
public int ImportedMergedInboxCount { get; init; }
public int SkippedMergedInboxCount { get; init; }
public IReadOnlyList<LegacyLocalMigrationFailure> Failures { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
public bool HasImportedData => ImportedAccountCount > 0 || ImportedMergedInboxCount > 0;
}
public sealed class LegacyLocalMigrationFailure
{
public string Address { get; init; } = string.Empty;
public MailProviderType ProviderType { get; init; }
public string Message { get; init; } = string.Empty;
}
@@ -1,3 +0,0 @@
namespace Wino.Core.Domain.Models.MailItem;
public sealed record MailFolderAssignmentUpdate(string MailCopyId, string RemoteFolderId);
@@ -704,8 +704,6 @@
"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.",
@@ -1370,6 +1368,26 @@
"WelcomeWindow_ImportInProgress": "Importing preferences and accounts...",
"WelcomeWindow_ImportNoAccountsFound": "No accounts were found to import. If preferences were available, they were restored. Use Get started to add an account manually.",
"WelcomeWindow_ImportDuplicateAccountsSkipped": "{0} imported accounts are already available on this device. Use Get started to add another account manually if needed.",
"LegacyLocalMigration_WelcomeSectionTitle": "Import from your previous Wino version",
"LegacyLocalMigration_WelcomeSectionDescription": "Wino found account details in an older local database on this device. Import them now, then sign in again to finish reconnecting each account.",
"LegacyLocalMigration_PromptTitle": "Import accounts from your previous Wino version?",
"LegacyLocalMigration_ImportAction": "Import previous accounts",
"LegacyLocalMigration_PreviewSummary": "Found {0} account(s) ready to import: {1}.",
"LegacyLocalMigration_PreviewDuplicateSummary": "{0} account(s) already exist on this device and will be skipped.",
"LegacyLocalMigration_PreviewMergedSummary": "{0} merged inbox group(s) can be recreated after import.",
"LegacyLocalMigration_Provider_Outlook": "Outlook",
"LegacyLocalMigration_Provider_Gmail": "Gmail",
"LegacyLocalMigration_Provider_Imap": "IMAP",
"LegacyLocalMigration_Warning_OAuth": "Outlook and Gmail accounts will need you to sign in again to restore mail and calendar access.",
"LegacyLocalMigration_Warning_Imap": "IMAP and CalDAV passwords are never copied. Open the account settings afterward and enter them again.",
"LegacyLocalMigration_Warning_Merged": "Merged inboxes are recreated only when every member account imports successfully.",
"LegacyLocalMigration_Warning_SkippedAccounts": "Skipped {0} legacy account(s) because their provider or primary address could not be read safely.",
"LegacyLocalMigration_Warning_ReadFailed": "Wino found a previous local database, but it could not be read safely for migration.",
"LegacyLocalMigration_ImportAccountsSucceeded": "Imported {0} accounts from the previous local database.",
"LegacyLocalMigration_ImportMergedInboxesSucceeded": "Recreated {0} merged inbox group(s).",
"LegacyLocalMigration_ImportMergedInboxesSkipped": "Skipped {0} merged inbox group(s) because at least one member account could not be imported.",
"LegacyLocalMigration_ImportFailedAccounts": "{0} account(s) could not be imported.",
"LegacyLocalMigration_ImportEmpty": "There are no additional legacy accounts to import from this device.",
"WelcomeWindow_SetupTitle": "Set up your account",
"WelcomeWindow_SetupSubtitle": "Choose your email provider to get started",
"WelcomeWindow_AddAccountButton": "Add account",
@@ -1446,6 +1464,7 @@
"WinoAccount_Management_ExportDialog_AccountsDisclaimer": "Passwords, tokens, and other sensitive information are not synced.",
"WinoAccount_Management_ExportDialog_AccountsRelogin": "Imported accounts on another PC will still need you to sign in again before they can be used.",
"WinoAccount_Management_ExportDialog_InProgress": "Exporting your selected Wino data...",
"LegacyLocalMigration_SettingsSectionTitle": "Import from a previous Wino version",
"WinoAccount_Management_LocalDataSectionTitle": "Transfer with a JSON file",
"WinoAccount_Management_LocalDataSectionDescription": "Import from or export to a local JSON file. Passwords, tokens, and other sensitive information are not included.",
"WinoAccount_Management_LocalDataImportAction": "Import",
@@ -0,0 +1,696 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Moq;
using SQLite;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Tests.Helpers;
using Wino.Services;
using Xunit;
namespace Wino.Core.Tests.Services;
public sealed class LegacyLocalMigrationServiceTests
{
[Fact]
public async Task DetectAsync_ReturnsPreviewCountsAndDuplicatesByProvider()
{
await using var context = await LegacyMigrationTestContext.CreateAsync();
await context.SeedCurrentAccountAsync(
"gmail@example.com",
MailProviderType.Gmail,
"Existing Gmail");
await context.InsertLegacyAccountAsync(
Guid.NewGuid(),
"outlook@example.com",
MailProviderType.Outlook,
order: 0,
name: "Outlook Legacy");
await context.InsertLegacyAccountAsync(
Guid.NewGuid(),
"gmail@example.com",
MailProviderType.Gmail,
order: 1,
name: "Duplicate Gmail");
var imapId = Guid.NewGuid();
await context.InsertLegacyAccountAsync(
imapId,
"imap@example.com",
MailProviderType.IMAP4,
order: 2,
name: "Imported IMAP",
specialImapProvider: SpecialImapProvider.Yahoo);
await context.InsertLegacyServerInformationAsync(
imapId,
address: "imap@example.com",
incomingServer: "imap.mail.yahoo.com",
incomingServerPort: "993",
incomingServerUsername: "imap@example.com",
outgoingServer: "smtp.mail.yahoo.com",
outgoingServerPort: "587",
outgoingServerUsername: "imap@example.com",
calendarSupportMode: ImapCalendarSupportMode.CalDav,
calDavServiceUrl: "https://caldav.calendar.yahoo.com/",
calDavUsername: "imap@example.com");
var preview = await context.Service.DetectAsync();
preview.LegacyDatabaseExists.Should().BeTrue();
preview.ShouldPrompt.Should().BeTrue();
preview.LegacyAccountCount.Should().Be(3);
preview.ImportableAccountCount.Should().Be(2);
preview.DuplicateAccountCount.Should().Be(1);
preview.Accounts.Select(a => a.Address).Should().ContainInOrder(
"outlook@example.com",
"gmail@example.com",
"imap@example.com");
preview.ProviderCounts.Should().ContainSingle(a =>
a.ProviderType == MailProviderType.Outlook &&
a.ImportableAccountCount == 1 &&
a.DuplicateAccountCount == 0);
preview.ProviderCounts.Should().ContainSingle(a =>
a.ProviderType == MailProviderType.Gmail &&
a.ImportableAccountCount == 0 &&
a.DuplicateAccountCount == 1);
preview.ProviderCounts.Should().ContainSingle(a =>
a.ProviderType == MailProviderType.IMAP4 &&
a.ImportableAccountCount == 1 &&
a.DuplicateAccountCount == 0);
}
[Fact]
public async Task ImportAsync_ImportsAccountsPreservesSafePreferencesAndRecreatesMergedInboxes()
{
await using var context = await LegacyMigrationTestContext.CreateAsync();
var mergedInboxId = Guid.NewGuid();
await context.InsertLegacyMergedInboxAsync(mergedInboxId, "Legacy Linked");
var legacyOutlookId = Guid.NewGuid();
var legacyGmailId = Guid.NewGuid();
var legacyImapId = Guid.NewGuid();
var legacySignatureId = Guid.NewGuid();
await context.InsertLegacyAccountAsync(
legacyOutlookId,
"outlook@example.com",
MailProviderType.Outlook,
order: 0,
name: "Outlook Legacy",
senderName: "Outlook Sender",
mergedInboxId: mergedInboxId);
await context.InsertLegacyPreferencesAsync(
legacyOutlookId,
isNotificationsEnabled: false,
isTaskbarBadgeEnabled: false,
shouldAppendMessagesToSentFolder: true,
isFocusedInboxEnabled: false,
signatureIdForNewMessages: legacySignatureId,
signatureIdForFollowingMessages: legacySignatureId);
await context.InsertLegacyAccountAsync(
legacyGmailId,
"gmail@example.com",
MailProviderType.Gmail,
order: 1,
name: "Gmail Legacy",
senderName: "Gmail Sender",
mergedInboxId: mergedInboxId);
await context.InsertLegacyAccountAsync(
legacyImapId,
"imap@example.com",
MailProviderType.IMAP4,
order: 2,
name: "iCloud Legacy",
senderName: "iCloud Sender",
specialImapProvider: SpecialImapProvider.iCloud);
await context.InsertLegacyServerInformationAsync(
legacyImapId,
address: "imap@example.com",
incomingServer: "imap.mail.me.com",
incomingServerPort: "993",
incomingServerUsername: "imap-user",
outgoingServer: "smtp.mail.me.com",
outgoingServerPort: "587",
outgoingServerUsername: "smtp-user",
calendarSupportMode: ImapCalendarSupportMode.CalDav,
calDavServiceUrl: "https://caldav.icloud.com/",
calDavUsername: "imap@example.com",
maxConcurrentClients: 7);
var result = await context.Service.ImportAsync();
result.ImportedAccountCount.Should().Be(3);
result.SkippedDuplicateAccountCount.Should().Be(0);
result.FailedAccountCount.Should().Be(0);
result.ImportedMergedInboxCount.Should().Be(1);
result.SkippedMergedInboxCount.Should().Be(0);
var accounts = await context.AccountService.GetAccountsAsync();
accounts.Should().HaveCount(3);
accounts.Select(a => a.Address).Should().ContainInOrder(
"outlook@example.com",
"gmail@example.com",
"imap@example.com");
var outlookAccount = accounts.Single(a => a.Address == "outlook@example.com");
outlookAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials);
outlookAccount.IsMailAccessGranted.Should().BeTrue();
outlookAccount.IsCalendarAccessGranted.Should().BeTrue();
outlookAccount.Preferences.IsNotificationsEnabled.Should().BeFalse();
outlookAccount.Preferences.IsTaskbarBadgeEnabled.Should().BeFalse();
outlookAccount.Preferences.ShouldAppendMessagesToSentFolder.Should().BeTrue();
outlookAccount.Preferences.IsFocusedInboxEnabled.Should().BeFalse();
outlookAccount.Preferences.SignatureIdForNewMessages.Should().NotBe(legacySignatureId);
outlookAccount.Preferences.SignatureIdForFollowingMessages.Should().NotBe(legacySignatureId);
var gmailAliases = await context.AccountService.GetAccountAliasesAsync(accounts.Single(a => a.Address == "gmail@example.com").Id);
gmailAliases.Should().ContainSingle(a =>
a.IsRootAlias &&
a.IsPrimary &&
a.AliasAddress == "gmail@example.com");
var imapAccount = accounts.Single(a => a.Address == "imap@example.com");
imapAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials);
imapAccount.IsCalendarAccessGranted.Should().BeTrue();
var serverInformation = await context.AccountService.GetAccountCustomServerInformationAsync(imapAccount.Id);
serverInformation.Should().NotBeNull();
serverInformation.IncomingServer.Should().Be("imap.mail.me.com");
serverInformation.OutgoingServer.Should().Be("smtp.mail.me.com");
serverInformation.IncomingServerPassword.Should().BeEmpty();
serverInformation.OutgoingServerPassword.Should().BeEmpty();
serverInformation.CalDavPassword.Should().BeEmpty();
serverInformation.CalDavServiceUrl.Should().Be("https://caldav.icloud.com/");
serverInformation.CalDavUsername.Should().Be("imap@example.com");
serverInformation.MaxConcurrentClients.Should().Be(7);
var mergedInboxIds = accounts
.Where(a => a.Address is "outlook@example.com" or "gmail@example.com")
.Select(a => a.MergedInboxId)
.Distinct()
.ToList();
mergedInboxIds.Should().ContainSingle();
mergedInboxIds[0].Should().NotBeNull();
accounts.Single(a => a.Address == "outlook@example.com").MergedInbox.Name.Should().Be("Legacy Linked");
}
[Fact]
public async Task ImportAsync_WithMissingLegacySchemaColumns_DefaultsSafelyAndSkipsIncompleteMergedInboxes()
{
await using var context = await LegacyMigrationTestContext.CreateAsync(new LegacySchemaOptions(
IncludeCalDavColumns: false,
IncludeCalendarSupportMode: false,
IncludeTaskbarBadgeColumn: false,
IncludeFocusedInboxColumn: false));
await context.SeedCurrentAccountAsync(
"duplicate@icloud.com",
MailProviderType.IMAP4,
"Existing iCloud",
specialImapProvider: SpecialImapProvider.iCloud);
var mergedInboxId = Guid.NewGuid();
var duplicateLegacyAccountId = Guid.NewGuid();
var importableLegacyAccountId = Guid.NewGuid();
await context.InsertLegacyMergedInboxAsync(mergedInboxId, "Legacy Incomplete");
await context.InsertLegacyAccountAsync(
duplicateLegacyAccountId,
"duplicate@icloud.com",
MailProviderType.IMAP4,
order: 0,
name: "Duplicate iCloud",
specialImapProvider: SpecialImapProvider.iCloud,
mergedInboxId: mergedInboxId);
await context.InsertLegacyAccountAsync(
importableLegacyAccountId,
"new@icloud.com",
MailProviderType.IMAP4,
order: 1,
name: "Importable iCloud",
specialImapProvider: SpecialImapProvider.iCloud,
mergedInboxId: mergedInboxId);
await context.InsertLegacyServerInformationAsync(
duplicateLegacyAccountId,
address: "duplicate@icloud.com",
incomingServer: "imap.mail.me.com",
incomingServerPort: "993",
incomingServerUsername: "duplicate",
outgoingServer: "smtp.mail.me.com",
outgoingServerPort: "587",
outgoingServerUsername: "duplicate");
await context.InsertLegacyServerInformationAsync(
importableLegacyAccountId,
address: "new@icloud.com",
incomingServer: "imap.mail.me.com",
incomingServerPort: "993",
incomingServerUsername: "new",
outgoingServer: "smtp.mail.me.com",
outgoingServerPort: "587",
outgoingServerUsername: "new");
var result = await context.Service.ImportAsync();
result.ImportedAccountCount.Should().Be(1);
result.SkippedDuplicateAccountCount.Should().Be(1);
result.ImportedMergedInboxCount.Should().Be(0);
result.SkippedMergedInboxCount.Should().Be(1);
var importedAccount = (await context.AccountService.GetAccountsAsync())
.Single(a => a.Address == "new@icloud.com");
importedAccount.IsCalendarAccessGranted.Should().BeFalse();
importedAccount.MergedInboxId.Should().BeNull();
importedAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials);
var importedServerInfo = await context.AccountService.GetAccountCustomServerInformationAsync(importedAccount.Id);
importedServerInfo.Should().NotBeNull();
importedServerInfo.CalDavServiceUrl.Should().Be("https://caldav.icloud.com/");
importedServerInfo.CalDavUsername.Should().Be("new@icloud.com");
importedServerInfo.CalDavPassword.Should().BeEmpty();
importedServerInfo.CalendarSupportMode.Should().Be(ImapCalendarSupportMode.Disabled);
importedServerInfo.IncomingServerPassword.Should().BeEmpty();
importedServerInfo.OutgoingServerPassword.Should().BeEmpty();
}
private sealed class LegacyMigrationTestContext : IAsyncDisposable
{
private readonly SQLiteAsyncConnection _legacyConnection;
private readonly InMemoryDatabaseService _databaseService;
private readonly string _legacyFolderPath;
public LegacyLocalMigrationService Service { get; }
public AccountService AccountService { get; }
private LegacyMigrationTestContext(string legacyFolderPath,
SQLiteAsyncConnection legacyConnection,
InMemoryDatabaseService databaseService,
AccountService accountService,
LegacyLocalMigrationService service)
{
_legacyFolderPath = legacyFolderPath;
_legacyConnection = legacyConnection;
_databaseService = databaseService;
AccountService = accountService;
Service = service;
}
public static async Task<LegacyMigrationTestContext> CreateAsync(LegacySchemaOptions? schemaOptions = null)
{
var databaseService = new InMemoryDatabaseService();
await databaseService.InitializeAsync();
var preferencesService = new Mock<IPreferencesService>();
preferencesService.SetupProperty(a => a.StartupEntityId);
var accountService = CreateAccountService(databaseService, preferencesService.Object);
var legacyFolderPath = Path.Combine(Path.GetTempPath(), $"legacy-migration-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(legacyFolderPath);
var legacyDatabasePath = Path.Combine(legacyFolderPath, "Wino180.db");
var legacyConnection = new SQLiteAsyncConnection(legacyDatabasePath);
await CreateLegacySchemaAsync(legacyConnection, schemaOptions ?? LegacySchemaOptions.Default);
var applicationConfiguration = new ApplicationConfiguration
{
ApplicationDataFolderPath = legacyFolderPath,
ApplicationTempFolderPath = legacyFolderPath,
PublisherSharedFolderPath = legacyFolderPath
};
var service = new LegacyLocalMigrationService(
applicationConfiguration,
new InMemoryConfigurationService(),
databaseService,
accountService,
new SpecialImapProviderConfigResolver());
return new LegacyMigrationTestContext(
legacyFolderPath,
legacyConnection,
databaseService,
accountService,
service);
}
public async Task SeedCurrentAccountAsync(string address,
MailProviderType providerType,
string name,
SpecialImapProvider specialImapProvider = SpecialImapProvider.None)
{
var accountId = Guid.NewGuid();
CustomServerInformation? serverInformation = null;
if (providerType == MailProviderType.IMAP4)
{
serverInformation = new CustomServerInformation
{
Id = Guid.NewGuid(),
AccountId = accountId,
Address = address,
IncomingServer = "imap.current.test",
IncomingServerPort = "993",
IncomingServerUsername = address,
IncomingServerPassword = "secret",
IncomingServerSocketOption = ImapConnectionSecurity.Auto,
IncomingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword,
OutgoingServer = "smtp.current.test",
OutgoingServerPort = "587",
OutgoingServerUsername = address,
OutgoingServerPassword = "secret",
OutgoingServerSocketOption = ImapConnectionSecurity.Auto,
OutgoingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword,
CalDavServiceUrl = string.Empty,
CalDavUsername = string.Empty,
CalDavPassword = string.Empty,
CalendarSupportMode = ImapCalendarSupportMode.Disabled,
MaxConcurrentClients = 5
};
}
await AccountService.CreateAccountAsync(
new MailAccount
{
Id = accountId,
Name = name,
SenderName = name,
Address = address,
ProviderType = providerType,
SpecialImapProvider = specialImapProvider,
IsMailAccessGranted = true,
IsCalendarAccessGranted = providerType is MailProviderType.Outlook or MailProviderType.Gmail
},
serverInformation);
}
public Task InsertLegacyAccountAsync(Guid accountId,
string address,
MailProviderType providerType,
int order,
string name,
string? senderName = null,
SpecialImapProvider specialImapProvider = SpecialImapProvider.None,
Guid? mergedInboxId = null)
=> InsertRowAsync(
_legacyConnection,
"MailAccount",
("Id", accountId),
("Address", address),
("Name", name),
("SenderName", senderName ?? name),
("ProviderType", (int)providerType),
("SpecialImapProvider", (int)specialImapProvider),
("Order", order),
("AccountColorHex", "#123456"),
("MergedInboxId", mergedInboxId));
public Task InsertLegacyPreferencesAsync(Guid accountId,
bool? isNotificationsEnabled = null,
bool? isTaskbarBadgeEnabled = null,
bool? shouldAppendMessagesToSentFolder = null,
bool? isFocusedInboxEnabled = null,
Guid? signatureIdForNewMessages = null,
Guid? signatureIdForFollowingMessages = null)
{
var values = new List<(string Column, object? Value)>
{
("Id", Guid.NewGuid()),
("AccountId", accountId)
};
if (isNotificationsEnabled.HasValue)
values.Add(("IsNotificationsEnabled", isNotificationsEnabled.Value));
if (isTaskbarBadgeEnabled.HasValue)
values.Add(("IsTaskbarBadgeEnabled", isTaskbarBadgeEnabled.Value));
if (shouldAppendMessagesToSentFolder.HasValue)
values.Add(("ShouldAppendMessagesToSentFolder", shouldAppendMessagesToSentFolder.Value));
if (isFocusedInboxEnabled.HasValue)
values.Add(("IsFocusedInboxEnabled", isFocusedInboxEnabled.Value));
if (signatureIdForNewMessages.HasValue)
values.Add(("SignatureIdForNewMessages", signatureIdForNewMessages.Value));
if (signatureIdForFollowingMessages.HasValue)
values.Add(("SignatureIdForFollowingMessages", signatureIdForFollowingMessages.Value));
return InsertRowAsync(_legacyConnection, "MailAccountPreferences", values.ToArray());
}
public Task InsertLegacyServerInformationAsync(Guid accountId,
string address,
string incomingServer,
string incomingServerPort,
string incomingServerUsername,
string outgoingServer,
string outgoingServerPort,
string outgoingServerUsername,
ImapCalendarSupportMode? calendarSupportMode = null,
string? calDavServiceUrl = null,
string? calDavUsername = null,
int? maxConcurrentClients = null)
{
var values = new List<(string Column, object? Value)>
{
("Id", Guid.NewGuid()),
("AccountId", accountId),
("Address", address),
("IncomingServer", incomingServer),
("IncomingServerPort", incomingServerPort),
("IncomingServerUsername", incomingServerUsername),
("IncomingServerSocketOption", (int)ImapConnectionSecurity.Auto),
("IncomingAuthenticationMethod", (int)ImapAuthenticationMethod.NormalPassword),
("OutgoingServer", outgoingServer),
("OutgoingServerPort", outgoingServerPort),
("OutgoingServerUsername", outgoingServerUsername),
("OutgoingServerSocketOption", (int)ImapConnectionSecurity.Auto),
("OutgoingAuthenticationMethod", (int)ImapAuthenticationMethod.NormalPassword),
("ProxyServer", "proxy.example.com"),
("ProxyServerPort", "8080"),
("MaxConcurrentClients", maxConcurrentClients ?? 5)
};
if (calendarSupportMode.HasValue)
values.Add(("CalendarSupportMode", (int)calendarSupportMode.Value));
if (!string.IsNullOrWhiteSpace(calDavServiceUrl))
values.Add(("CalDavServiceUrl", calDavServiceUrl));
if (!string.IsNullOrWhiteSpace(calDavUsername))
values.Add(("CalDavUsername", calDavUsername));
return InsertRowAsync(_legacyConnection, "CustomServerInformation", values.ToArray());
}
public Task InsertLegacyMergedInboxAsync(Guid mergedInboxId, string name)
=> InsertRowAsync(
_legacyConnection,
"MergedInbox",
("Id", mergedInboxId),
("Name", name));
public async ValueTask DisposeAsync()
{
await _legacyConnection.CloseAsync();
if (Directory.Exists(_legacyFolderPath))
{
Directory.Delete(_legacyFolderPath, recursive: true);
}
await _databaseService.DisposeAsync();
}
}
private sealed class InMemoryConfigurationService : IConfigurationService
{
private readonly Dictionary<string, string?> _localValues = new(StringComparer.Ordinal);
private readonly Dictionary<string, string?> _roamingValues = new(StringComparer.Ordinal);
public bool Contains(string key) => _localValues.ContainsKey(key);
public T Get<T>(string key, T defaultValue = default!)
=> TryGetValue(_localValues, key, defaultValue);
public T GetRoaming<T>(string key, T defaultValue = default!)
=> TryGetValue(_roamingValues, key, defaultValue);
public void Set(string key, object value)
=> _localValues[key] = value?.ToString();
public void SetRoaming(string key, object value)
=> _roamingValues[key] = value?.ToString();
private static T TryGetValue<T>(Dictionary<string, string?> values, string key, T defaultValue)
{
if (!values.TryGetValue(key, out var stringValue) || string.IsNullOrWhiteSpace(stringValue))
{
return defaultValue;
}
if (typeof(T).IsEnum)
{
return (T)Enum.Parse(typeof(T), stringValue);
}
if ((typeof(T) == typeof(Guid) || typeof(T) == typeof(Guid?)) && Guid.TryParse(stringValue, out var guid))
{
return (T)(object)guid;
}
return (T)Convert.ChangeType(stringValue, typeof(T));
}
}
private sealed record LegacySchemaOptions(
bool IncludeCalDavColumns,
bool IncludeCalendarSupportMode,
bool IncludeTaskbarBadgeColumn,
bool IncludeFocusedInboxColumn)
{
public static LegacySchemaOptions Default => new(true, true, true, true);
}
private static async Task CreateLegacySchemaAsync(SQLiteAsyncConnection connection, LegacySchemaOptions options)
{
await connection.ExecuteAsync("""
CREATE TABLE MailAccount (
Id TEXT PRIMARY KEY,
Address TEXT NULL,
Name TEXT NULL,
SenderName TEXT NULL,
ProviderType INTEGER NOT NULL,
SpecialImapProvider INTEGER NOT NULL DEFAULT 0,
[Order] INTEGER NOT NULL DEFAULT 0,
AccountColorHex TEXT NULL,
MergedInboxId TEXT NULL
)
""");
var preferenceColumns = new List<string>
{
"Id TEXT PRIMARY KEY",
"AccountId TEXT NOT NULL",
"IsNotificationsEnabled INTEGER NULL",
"ShouldAppendMessagesToSentFolder INTEGER NULL",
"SignatureIdForNewMessages TEXT NULL",
"SignatureIdForFollowingMessages TEXT NULL"
};
if (options.IncludeTaskbarBadgeColumn)
preferenceColumns.Add("IsTaskbarBadgeEnabled INTEGER NULL");
if (options.IncludeFocusedInboxColumn)
preferenceColumns.Add("IsFocusedInboxEnabled INTEGER NULL");
await connection.ExecuteAsync($"CREATE TABLE MailAccountPreferences ({string.Join(", ", preferenceColumns)})");
var serverColumns = new List<string>
{
"Id TEXT PRIMARY KEY",
"AccountId TEXT NOT NULL",
"Address TEXT NULL",
"IncomingServer TEXT NULL",
"IncomingServerPort TEXT NULL",
"IncomingServerUsername TEXT NULL",
"IncomingServerSocketOption INTEGER NULL",
"IncomingAuthenticationMethod INTEGER NULL",
"OutgoingServer TEXT NULL",
"OutgoingServerPort TEXT NULL",
"OutgoingServerUsername TEXT NULL",
"OutgoingServerSocketOption INTEGER NULL",
"OutgoingAuthenticationMethod INTEGER NULL",
"ProxyServer TEXT NULL",
"ProxyServerPort TEXT NULL",
"MaxConcurrentClients INTEGER NULL"
};
if (options.IncludeCalDavColumns)
{
serverColumns.Add("CalDavServiceUrl TEXT NULL");
serverColumns.Add("CalDavUsername TEXT NULL");
}
if (options.IncludeCalendarSupportMode)
{
serverColumns.Add("CalendarSupportMode INTEGER NULL");
}
await connection.ExecuteAsync($"CREATE TABLE CustomServerInformation ({string.Join(", ", serverColumns)})");
await connection.ExecuteAsync("""
CREATE TABLE MergedInbox (
Id TEXT PRIMARY KEY,
Name TEXT NULL
)
""");
}
private static Task InsertRowAsync(SQLiteAsyncConnection connection, string tableName, params (string Column, object? Value)[] values)
{
var columns = string.Join(", ", values.Select(a => $"[{a.Column}]"));
var placeholders = string.Join(", ", values.Select(_ => "?"));
var arguments = values.Select(a => ConvertLegacyValue(a.Value)).ToArray();
return connection.ExecuteAsync($"INSERT INTO [{tableName}] ({columns}) VALUES ({placeholders})", arguments);
}
private static object? ConvertLegacyValue(object? value)
{
return value switch
{
null => null,
bool boolValue => boolValue ? 1 : 0,
Guid guidValue => guidValue.ToString(),
_ => value
};
}
private static AccountService CreateAccountService(InMemoryDatabaseService databaseService, IPreferencesService preferencesService)
{
var signatureService = new Mock<ISignatureService>();
signatureService
.Setup(a => a.CreateDefaultSignatureAsync(It.IsAny<Guid>()))
.ReturnsAsync((Guid accountId) => new AccountSignature
{
Id = Guid.NewGuid(),
MailAccountId = accountId,
Name = "Default",
HtmlBody = string.Empty
});
return new AccountService(
databaseService,
signatureService.Object,
Mock.Of<IAuthenticationProvider>(),
Mock.Of<IMimeFileService>(),
preferencesService,
Mock.Of<IContactPictureFileService>());
}
}
@@ -571,19 +571,6 @@ 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(() =>
@@ -626,7 +613,4 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
});
}
}
private static bool IsExpiredAiEntitlement(string? entitlementStatus)
=> string.Equals(entitlementStatus, "Expired", StringComparison.OrdinalIgnoreCase);
}
@@ -30,9 +30,7 @@ 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);
@@ -58,9 +56,6 @@ 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);
@@ -161,33 +156,18 @@ 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);
+51 -152
View File
@@ -1050,11 +1050,6 @@ 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.
@@ -1062,7 +1057,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
foreach (var addedLabel in history.LabelsAdded)
{
await HandleLabelAssignmentAsync(addedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
await HandleLabelAssignmentAsync(addedLabel);
}
}
@@ -1071,7 +1066,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
{
foreach (var removedLabel in history.LabelsRemoved)
{
await HandleLabelRemovalAsync(removedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
await HandleLabelRemovalAsync(removedLabel);
}
}
@@ -1084,108 +1079,36 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
_logger.Debug("Processing message deletion for {MessageId}", messageId);
deletedMessageIds.Add(messageId);
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, messageId).ConfigureAwait(false);
}
}
}
}
if (pendingStateUpdates.Count > 0)
private async Task HandleArchiveAssignmentAsync(string archivedMessageId)
{
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 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).ConfigureAwait(false);
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value);
if (archived) return;
_logger.Debug("Processing archive assignment for message {Id}", archivedMessageId);
QueueAssignmentChange(
pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID),
shouldCreate: true);
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false);
}
private async Task HandleUnarchiveAssignmentAsync(
string unarchivedMessageId,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
private async Task HandleUnarchiveAssignmentAsync(string unarchivedMessageId)
{
if (!archiveFolderId.HasValue)
return;
// Ignore if the message is not in the archive.
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value).ConfigureAwait(false);
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value);
if (!archived) return;
_logger.Debug("Processing un-archive assignment for message {Id}", unarchivedMessageId);
QueueAssignmentChange(
pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID),
shouldCreate: false);
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false);
}
private async Task HandleLabelAssignmentAsync(
HistoryLabelAdded addedLabel,
List<MailCopyStateUpdate> pendingStateUpdates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
private async Task HandleLabelAssignmentAsync(HistoryLabelAdded addedLabel)
{
var messageId = addedLabel.Message.Id;
@@ -1196,31 +1119,23 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleArchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
await HandleArchiveAssignmentAsync(messageId).ConfigureAwait(false);
continue;
}
// When UNREAD label is added mark the message as un-read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: false));
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
// When STARRED label is added mark the message as flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID)
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: true));
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, true).ConfigureAwait(false);
QueueAssignmentChange(
pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(messageId, labelId),
shouldCreate: true);
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
}
}
private async Task HandleLabelRemovalAsync(
HistoryLabelRemoved removedLabel,
List<MailCopyStateUpdate> pendingStateUpdates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
private async Task HandleLabelRemovalAsync(HistoryLabelRemoved removedLabel)
{
var messageId = removedLabel.Message.Id;
@@ -1231,23 +1146,20 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// ARCHIVE is a virtual folder - handle it separately
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
{
await HandleUnarchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
await HandleUnarchiveAssignmentAsync(messageId).ConfigureAwait(false);
continue;
}
// When UNREAD label is removed mark the message as read.
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: true));
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
// When STARRED label is removed mark the message as un-flagged.
if (labelId == ServiceConstants.STARRED_LABEL_ID)
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: false));
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, false).ConfigureAwait(false);
QueueAssignmentChange(
pendingAssignmentCreates,
pendingAssignmentDeletes,
new MailFolderAssignmentUpdate(messageId, labelId),
shouldCreate: false);
// For other labels remove the mail assignment.
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
}
}
@@ -1630,8 +1542,6 @@ 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
@@ -1642,7 +1552,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null)
pendingPackages.AddRange(packages);
{
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
// Update sync identifier if available
if (gmailMessage.HistoryId.HasValue)
@@ -1655,11 +1570,6 @@ 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);
}
}
}
@@ -1682,9 +1592,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
// Create mail packages from metadata
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
if (packages != null && packages.Count > 0)
if (packages != null)
{
await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false);
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
// Update sync identifier if available
@@ -2029,9 +1942,12 @@ 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 && packages.Count > 0)
if (packages != null)
{
await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false);
foreach (var package in packages)
{
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
}
}
await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId).ConfigureAwait(false);
@@ -2083,27 +1999,25 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
switch (bundle.UIChangeRequest)
{
case BatchMarkReadRequest batchMarkReadRequest:
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
batchMarkReadRequest.Select(request => new MailCopyStateUpdate(request.Item.Id, IsRead: request.IsRead)))
.ConfigureAwait(false);
foreach (var request in batchMarkReadRequest)
{
await _gmailChangeProcessor.ChangeMailReadStatusAsync(request.Item.Id, request.IsRead).ConfigureAwait(false);
}
break;
case MarkReadRequest markReadRequest:
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
[new MailCopyStateUpdate(markReadRequest.Item.Id, IsRead: markReadRequest.IsRead)])
.ConfigureAwait(false);
await _gmailChangeProcessor.ChangeMailReadStatusAsync(markReadRequest.Item.Id, markReadRequest.IsRead).ConfigureAwait(false);
break;
case BatchChangeFlagRequest batchChangeFlagRequest:
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
batchChangeFlagRequest.Select(request => new MailCopyStateUpdate(request.Item.Id, IsFlagged: request.IsFlagged)))
.ConfigureAwait(false);
foreach (var request in batchChangeFlagRequest)
{
await _gmailChangeProcessor.ChangeFlagStatusAsync(request.Item.Id, request.IsFlagged).ConfigureAwait(false);
}
break;
case ChangeFlagRequest changeFlagRequest:
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
[new MailCopyStateUpdate(changeFlagRequest.Item.Id, IsFlagged: changeFlagRequest.IsFlagged)])
.ConfigureAwait(false);
await _gmailChangeProcessor.ChangeFlagStatusAsync(changeFlagRequest.Item.Id, changeFlagRequest.IsFlagged).ConfigureAwait(false);
break;
}
}
@@ -2161,31 +2075,16 @@ 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, pendingArchiveCreates, pendingArchiveDeletes).ConfigureAwait(false);
}
if (pendingArchiveCreates.Count > 0)
{
await _gmailChangeProcessor.CreateAssignmentsAsync(Account.Id, pendingArchiveCreates.Values.ToList()).ConfigureAwait(false);
await HandleArchiveAssignmentAsync(archiveAddedItem).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, pendingArchiveCreateOverrides, pendingArchiveRemovals).ConfigureAwait(false);
}
if (pendingArchiveRemovals.Count > 0)
{
await _gmailChangeProcessor.DeleteAssignmentsAsync(Account.Id, pendingArchiveRemovals.Values.ToList()).ConfigureAwait(false);
await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem).ConfigureAwait(false);
}
}
@@ -17,6 +17,7 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Calendar;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Synchronization;
@@ -35,6 +36,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
private static readonly UTF8Encoding Utf8WithoutBom = new(false);
private readonly IWinoAccountDataSyncService _syncService;
private readonly ILegacyLocalMigrationService _legacyLocalMigrationService;
private readonly IWinoLogger _winoLogger;
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
private readonly ICalDavClient _calDavClient;
@@ -48,6 +50,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService,
IWinoAccountDataSyncService syncService,
ILegacyLocalMigrationService legacyLocalMigrationService,
IWinoLogger winoLogger,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
ICalDavClient calDavClient,
@@ -56,6 +59,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
{
MailDialogService = dialogService;
_syncService = syncService;
_legacyLocalMigrationService = legacyLocalMigrationService;
_winoLogger = winoLogger;
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
_calDavClient = calDavClient;
@@ -64,8 +68,26 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ExportLocalDataCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportLocalDataCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))]
public partial bool IsDataTransferInProgress { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasLegacyImportAvailable))]
[NotifyPropertyChangedFor(nameof(HasLegacyImportWarnings))]
[NotifyPropertyChangedFor(nameof(LegacyMigrationSummary))]
[NotifyPropertyChangedFor(nameof(LegacyMigrationWarningSummary))]
[NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))]
public partial LegacyLocalMigrationPreview LegacyMigrationPreview { get; set; }
public bool HasLegacyImportAvailable => LegacyMigrationPreview?.HasImportableData == true;
public bool HasLegacyImportWarnings => !string.IsNullOrWhiteSpace(LegacyMigrationWarningSummary);
public string LegacyMigrationSummary => HasLegacyImportAvailable
? LegacyLocalMigrationFormatter.BuildPreviewSummary(LegacyMigrationPreview)
: string.Empty;
public string LegacyMigrationWarningSummary => HasLegacyImportAvailable
? LegacyLocalMigrationFormatter.BuildWarningSummary(LegacyMigrationPreview)
: string.Empty;
[RelayCommand]
private async Task CreateMergedAccountAsync()
{
@@ -314,7 +336,42 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
}
}
[RelayCommand(CanExecute = nameof(CanImportLegacyDatabase))]
private async Task ImportLegacyDatabaseAsync()
{
try
{
await ExecuteUIThread(() => IsDataTransferInProgress = true);
var result = await _legacyLocalMigrationService.ImportAsync().ConfigureAwait(false);
await InitializeAccountsAsync().ConfigureAwait(false);
await RefreshLegacyMigrationPreviewAsync().ConfigureAwait(false);
var messageType = result.FailedAccountCount > 0
? InfoBarMessageType.Warning
: InfoBarMessageType.Success;
DialogService.InfoBarMessage(
result.FailedAccountCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info,
LegacyLocalMigrationFormatter.BuildImportMessage(result),
messageType);
}
catch (Exception ex)
{
DialogService.InfoBarMessage(
Translator.GeneralTitle_Error,
ex.Message,
InfoBarMessageType.Error);
}
finally
{
await ExecuteUIThread(() => IsDataTransferInProgress = false);
}
}
private bool CanTransferLocalData() => !IsDataTransferInProgress;
private bool CanImportLegacyDatabase() => !IsDataTransferInProgress && HasLegacyImportAvailable;
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
{
@@ -350,6 +407,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
Accounts.CollectionChanged += AccountCollectionChanged;
await InitializeAccountsAsync();
await RefreshLegacyMigrationPreviewAsync();
PropertyChanged -= PagePropertyChanged;
PropertyChanged += PagePropertyChanged;
@@ -403,6 +461,19 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
await ManageStorePurchasesAsync().ConfigureAwait(false);
}
private async Task RefreshLegacyMigrationPreviewAsync()
{
try
{
var preview = await _legacyLocalMigrationService.DetectAsync().ConfigureAwait(false);
await ExecuteUIThread(() => LegacyMigrationPreview = preview);
}
catch (Exception)
{
await ExecuteUIThread(() => LegacyMigrationPreview = null);
}
}
private static string BuildExportSuccessMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncExportResult result)
{
var parts = new Collection<string>();
@@ -21,8 +21,6 @@ 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.
@@ -764,12 +762,10 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
// Execute all updates in a single UI thread call
if (itemsToUpdate.Count > 0)
{
foreach (var updateBatch in itemsToUpdate.Chunk(UiMutationBatchSize))
{
await ExecuteUIThread(() =>
{
foreach (var (existing, updated) in updateBatch)
foreach (var (existing, updated) in itemsToUpdate)
{
UpdateUniqueIdHashes(existing, false);
existing.UpdateFrom(updated);
@@ -777,9 +773,8 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
}
});
}
}
// Group items by their grouping key and add them in bounded UI thread calls.
// Group items by their grouping key and add them in a single UI thread call
if (itemsToAdd.Count > 0)
{
var groupedItems = await Task.Run(() => itemsToAdd
@@ -792,23 +787,21 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
})
.ToList()).ConfigureAwait(false);
await ExecuteUIThread(() =>
{
foreach (var groupedItem in groupedItems)
{
var groupKey = groupedItem.Key;
var groupItems = groupedItem.Items;
foreach (var groupBatch in groupItems.Chunk(UiMutationBatchSize))
{
await ExecuteUIThread(() =>
{
// Update caches first so lookup helpers remain consistent during inserts.
foreach (var item in groupBatch)
// Update caches first
foreach (var item in groupItems)
{
UpdateUniqueIdHashes(item, true);
UpdateThreadIdCache(item, true);
}
foreach (var item in groupBatch)
foreach (var item in groupItems)
{
_mailItemSource.InsertItem(groupKey, listComparer, item, listComparer);
@@ -818,11 +811,10 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
_itemToGroupMap[item] = targetGroup;
}
}
}
});
}
}
}
}
public MailItemContainer GetMailItemContainer(Guid uniqueMailId)
{
@@ -980,11 +972,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
if (updates.Count == 0)
return;
foreach (var updateBatch in updates.Chunk(UiMutationBatchSize))
{
await ExecuteUIThread(() =>
{
foreach (var update in updateBatch)
foreach (var update in updates)
{
var existingItem = update.ItemContainer.ItemViewModel;
var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged);
@@ -997,7 +987,6 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
}
});
}
}
private async Task UpdateMailCopiesInternalAsync(IEnumerable<MailCopy> updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties)
{
@@ -1029,11 +1018,9 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
return;
}
foreach (var updateBatch in updates.Chunk(UiMutationBatchSize))
{
await ExecuteUIThread(() =>
{
foreach (var update in updateBatch)
foreach (var update in updates)
{
var updatedMail = update.UpdatedMail;
var itemContainer = update.ItemContainer;
@@ -1048,7 +1035,6 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
}
});
}
}
public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0);
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Wino.Core.Domain;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Mail.ViewModels.Data;
internal static class LegacyLocalMigrationFormatter
{
public static string BuildPreviewSummary(LegacyLocalMigrationPreview preview)
{
if (!preview.HasImportableData)
{
return Translator.LegacyLocalMigration_ImportEmpty;
}
var providerSummary = string.Join(", ", preview.ProviderCounts
.Where(a => a.ImportableAccountCount > 0)
.Select(a => $"{a.ImportableAccountCount} {GetProviderName(a.ProviderType)}"));
var parts = new List<string>
{
string.Format(Translator.LegacyLocalMigration_PreviewSummary, preview.ImportableAccountCount, providerSummary)
};
if (preview.DuplicateAccountCount > 0)
{
parts.Add(string.Format(Translator.LegacyLocalMigration_PreviewDuplicateSummary, preview.DuplicateAccountCount));
}
if (preview.ImportableMergedInboxCount > 0)
{
parts.Add(string.Format(Translator.LegacyLocalMigration_PreviewMergedSummary, preview.ImportableMergedInboxCount));
}
return string.Join(" ", parts.Where(a => !string.IsNullOrWhiteSpace(a)));
}
public static string BuildWarningSummary(LegacyLocalMigrationPreview preview)
=> string.Join(Environment.NewLine, preview.Warnings.Where(a => !string.IsNullOrWhiteSpace(a)).Distinct(StringComparer.Ordinal));
public static string BuildImportMessage(LegacyLocalMigrationResult result)
{
var parts = new List<string>();
if (result.ImportedAccountCount > 0)
{
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportAccountsSucceeded, result.ImportedAccountCount));
}
if (result.SkippedDuplicateAccountCount > 0)
{
parts.Add(string.Format(Translator.WinoAccount_Management_ImportDuplicateAccountsSkipped, result.SkippedDuplicateAccountCount));
}
if (result.ImportedMergedInboxCount > 0)
{
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSucceeded, result.ImportedMergedInboxCount));
}
if (result.SkippedMergedInboxCount > 0)
{
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSkipped, result.SkippedMergedInboxCount));
}
if (result.FailedAccountCount > 0)
{
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportFailedAccounts, result.FailedAccountCount));
}
if (parts.Count == 0)
{
parts.Add(Translator.LegacyLocalMigration_ImportEmpty);
}
if (result.ImportedAccountCount > 0)
{
parts.Add(Translator.WinoAccount_Management_ImportReloginReminder);
}
return string.Join(" ", parts);
}
public static string BuildPromptMessage(LegacyLocalMigrationPreview preview)
{
var summary = BuildPreviewSummary(preview);
var warnings = BuildWarningSummary(preview);
return string.IsNullOrWhiteSpace(warnings)
? summary
: $"{summary}{Environment.NewLine}{Environment.NewLine}{warnings}";
}
private static string GetProviderName(MailProviderType providerType)
{
return providerType switch
{
MailProviderType.Outlook => Translator.LegacyLocalMigration_Provider_Outlook,
MailProviderType.Gmail => Translator.LegacyLocalMigration_Provider_Gmail,
MailProviderType.IMAP4 => Translator.LegacyLocalMigration_Provider_Imap,
_ => providerType.ToString()
};
}
}
+53 -1
View File
@@ -90,10 +90,12 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService;
private readonly IStoreUpdateService _storeUpdateService;
private readonly IShareActivationService _shareActivationService;
private readonly ILegacyLocalMigrationService _legacyLocalMigrationService;
private readonly INativeAppService _nativeAppService;
private readonly IMailService _mailService;
private bool _hasRegisteredPersistentRecipients;
private bool _hasHandledLegacyMigrationPrompt;
private readonly SemaphoreSlim _menuRefreshSemaphore = new(1, 1);
private readonly SemaphoreSlim accountInitFolderUpdateSlim = new SemaphoreSlim(1);
@@ -117,7 +119,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
IStartupBehaviorService startupBehaviorService,
IWebView2RuntimeValidatorService webView2RuntimeValidatorService,
IStoreUpdateService storeUpdateService,
IShareActivationService shareActivationService)
IShareActivationService shareActivationService,
ILegacyLocalMigrationService legacyLocalMigrationService)
{
StatePersistenceService = statePersistanceService;
@@ -141,6 +144,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
_webView2RuntimeValidatorService = webView2RuntimeValidatorService;
_storeUpdateService = storeUpdateService;
_shareActivationService = shareActivationService;
_legacyLocalMigrationService = legacyLocalMigrationService;
}
protected override void OnDispatcherAssigned()
@@ -286,6 +290,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
await ProcessLaunchOptionsAsync();
await HandlePendingShareRequestAsync();
await ValidateWebView2RuntimeAsync();
await PromptLegacyMigrationIfNeededAsync(shouldRunStartupFlows);
if (shouldRunStartupFlows && !Debugger.IsAttached)
{
@@ -298,6 +303,53 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
}
}
private async Task PromptLegacyMigrationIfNeededAsync(bool shouldRunStartupFlows)
{
if (!shouldRunStartupFlows || _hasHandledLegacyMigrationPrompt)
{
return;
}
_hasHandledLegacyMigrationPrompt = true;
var currentAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
if (!currentAccounts.Any())
{
return;
}
var preview = await _legacyLocalMigrationService.DetectAsync().ConfigureAwait(false);
if (!preview.ShouldPrompt || !preview.HasImportableData)
{
return;
}
var shouldImport = await _dialogService.ShowConfirmationDialogAsync(
LegacyLocalMigrationFormatter.BuildPromptMessage(preview),
Translator.LegacyLocalMigration_PromptTitle,
Translator.LegacyLocalMigration_ImportAction);
if (!shouldImport)
{
_legacyLocalMigrationService.MarkPromptDeferred();
return;
}
var result = await _legacyLocalMigrationService.ImportAsync().ConfigureAwait(false);
await RecreateMenuItemsAsync().ConfigureAwait(false);
await RestoreSelectedAccountAfterMenuRefreshAsync(false).ConfigureAwait(false);
var messageType = result.FailedAccountCount > 0
? InfoBarMessageType.Warning
: InfoBarMessageType.Success;
_dialogService.InfoBarMessage(
result.FailedAccountCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info,
LegacyLocalMigrationFormatter.BuildImportMessage(result),
messageType);
}
private async Task ValidateWebView2RuntimeAsync()
{
var isRuntimeAvailable = await _webView2RuntimeValidatorService.IsRuntimeAvailableAsync();
+75 -1
View File
@@ -24,6 +24,7 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
private readonly IUpdateManager _updateManager;
private readonly IMailDialogService _dialogService;
private readonly IWinoAccountDataSyncService _syncService;
private readonly ILegacyLocalMigrationService _legacyLocalMigrationService;
[ObservableProperty]
public partial List<UpdateNoteSection> UpdateSections { get; set; } = [];
@@ -32,21 +33,40 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
[NotifyCanExecuteChangedFor(nameof(GetStartedCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportFromJsonCommand))]
[NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))]
public partial bool IsImportInProgress { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasImportStatus))]
public partial string ImportStatusMessage { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasLegacyImportPreview))]
[NotifyPropertyChangedFor(nameof(HasLegacyImportWarnings))]
[NotifyPropertyChangedFor(nameof(LegacyImportSummary))]
[NotifyPropertyChangedFor(nameof(LegacyImportWarnings))]
[NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))]
public partial LegacyLocalMigrationPreview LegacyMigrationPreview { get; set; }
public bool HasImportStatus => !string.IsNullOrWhiteSpace(ImportStatusMessage);
public bool HasLegacyImportPreview => LegacyMigrationPreview?.HasImportableData == true;
public bool HasLegacyImportWarnings => !string.IsNullOrWhiteSpace(LegacyImportWarnings);
public string LegacyImportSummary => HasLegacyImportPreview
? LegacyLocalMigrationFormatter.BuildPreviewSummary(LegacyMigrationPreview)
: string.Empty;
public string LegacyImportWarnings => HasLegacyImportPreview
? LegacyLocalMigrationFormatter.BuildWarningSummary(LegacyMigrationPreview)
: string.Empty;
public WelcomePageV2ViewModel(IUpdateManager updateManager,
IMailDialogService dialogService,
IWinoAccountDataSyncService syncService)
IWinoAccountDataSyncService syncService,
ILegacyLocalMigrationService legacyLocalMigrationService)
{
_updateManager = updateManager;
_dialogService = dialogService;
_syncService = syncService;
_legacyLocalMigrationService = legacyLocalMigrationService;
}
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
@@ -62,6 +82,15 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
{
UpdateSections = [];
}
try
{
LegacyMigrationPreview = await _legacyLocalMigrationService.DetectAsync();
}
catch (Exception)
{
LegacyMigrationPreview = null;
}
}
[RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))]
@@ -150,7 +179,52 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
}
}
[RelayCommand(CanExecute = nameof(CanImportLegacyDatabase))]
private async Task ImportLegacyDatabaseAsync()
{
await ExecuteUIThread(() => ImportStatusMessage = string.Empty);
try
{
await ExecuteUIThread(() => IsImportInProgress = true);
var result = await _legacyLocalMigrationService.ImportAsync().ConfigureAwait(false);
if (result.ImportedAccountCount > 0)
{
ReportUIChange(new WelcomeImportCompletedMessage(
result.ImportedAccountCount,
LegacyLocalMigrationFormatter.BuildImportMessage(result)));
return;
}
await ExecuteUIThread(() =>
{
ImportStatusMessage = LegacyLocalMigrationFormatter.BuildImportMessage(result);
LegacyMigrationPreview = result.Preview;
});
}
catch (Exception ex)
{
await _dialogService.ShowMessageAsync(ex.Message, Translator.GeneralTitle_Error, WinoCustomMessageDialogIcon.Error);
}
finally
{
try
{
var preview = await _legacyLocalMigrationService.DetectAsync().ConfigureAwait(false);
await ExecuteUIThread(() => LegacyMigrationPreview = preview);
}
catch (Exception)
{
// Keep the current preview if detection fails after import.
}
await ExecuteUIThread(() => IsImportInProgress = false);
}
}
private bool CanOpenWelcomeActions() => !IsImportInProgress;
private bool CanImportLegacyDatabase() => !IsImportInProgress && HasLegacyImportPreview;
private static string BuildInlineImportMessage(WinoAccountSyncImportResult result)
{
+8 -5
View File
@@ -479,10 +479,8 @@ public partial class App : WinoApplication,
EnsureAppNotificationRegistration();
await TranslationService.InitializeAsync();
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
.RunIfNeededAsync();
await Services.GetRequiredService<ILegacyLocalMigrationService>()
.DetectAsync();
await InitializeServicesAsync();
@@ -492,6 +490,9 @@ public partial class App : WinoApplication,
_hasConfiguredAccounts = (await _accountService.GetAccountsAsync()).Any();
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
.RunIfNeededAsync();
_activationInfrastructureInitialized = true;
}
finally
@@ -1504,7 +1505,9 @@ public partial class App : WinoApplication,
Services.GetRequiredService<IMailDialogService>().InfoBarMessage(
Translator.GeneralTitle_Info,
Translator.WinoAccount_Management_ImportReloginReminder,
string.IsNullOrWhiteSpace(message.CompletionMessage)
? Translator.WinoAccount_Management_ImportReloginReminder
: message.CompletionMessage,
InfoBarMessageType.Information);
});
}
+1 -1
View File
@@ -23,7 +23,7 @@
<Identity
Name="58272BurakKSE.WinoMailPreview"
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
Version="2.0.7.0" />
Version="2.0.6.0" />
<mp:PhoneIdentity PhoneProductId="7b7e90e9-cc55-4409-9769-99b4b5ed6e9b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
@@ -233,17 +233,6 @@ 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,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Interfaces;
@@ -11,20 +10,16 @@ namespace Wino.Mail.WinUI.Services;
public sealed class ReleaseLocalAccountDataCleanupService
{
private const string CleanupCompletedSettingKey = "ReleaseLocalAccountDataCleanup_v1_Completed";
private const string LegacyDatabaseFileName = "Wino180.db";
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,
INotificationBuilder notificationBuilder)
IApplicationConfiguration applicationConfiguration)
{
_configurationService = configurationService;
_applicationConfiguration = applicationConfiguration;
_notificationBuilder = notificationBuilder;
}
public async Task RunIfNeededAsync()
@@ -33,7 +28,6 @@ public sealed class ReleaseLocalAccountDataCleanupService
return;
var localFolderPath = _applicationConfiguration.ApplicationDataFolderPath;
var publisherPath = _applicationConfiguration.PublisherSharedFolderPath;
if (string.IsNullOrWhiteSpace(localFolderPath) || !Directory.Exists(localFolderPath))
{
@@ -45,74 +39,47 @@ public sealed class ReleaseLocalAccountDataCleanupService
{
Path.Combine(localFolderPath, "Mime"),
Path.Combine(localFolderPath, "contacts"),
Path.Combine(localFolderPath, "CalendarAttachments"),
Path.Combine(publisherPath, LegacyDatabaseFileName)
Path.Combine(localFolderPath, "CalendarAttachments")
};
var hadLegacyData = false;
foreach (var targetPath in cleanupTargets)
{
hadLegacyData |= await DeletePathIfExistsAsync(targetPath, localFolderPath, publisherPath).ConfigureAwait(false);
await DeletePathIfExistsAsync(localFolderPath, targetPath).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<bool> DeletePathIfExistsAsync(string targetPath, params string[] allowedRootPaths)
private async Task DeletePathIfExistsAsync(string localFolderPath, string targetPath)
{
try
{
var fullTargetPath = Path.GetFullPath(targetPath);
if (!allowedRootPaths.Any(rootPath => IsPathUnderAllowedRoot(fullTargetPath, rootPath)))
{
_logger.Warning("Skipped startup cleanup for path outside allowed roots: {TargetPath}", fullTargetPath);
return false;
}
var fullLocalFolderPath = Path.GetFullPath(localFolderPath);
var targetExists = Directory.Exists(fullTargetPath) || File.Exists(fullTargetPath);
if (!fullTargetPath.StartsWith(fullLocalFolderPath, StringComparison.OrdinalIgnoreCase))
{
_logger.Warning("Skipped startup cleanup for path outside local folder: {TargetPath}", fullTargetPath);
return;
}
if (Directory.Exists(fullTargetPath))
{
await Task.Run(() => Directory.Delete(fullTargetPath, recursive: true)).ConfigureAwait(false);
_logger.Information("Deleted legacy startup cleanup directory {TargetPath}", fullTargetPath);
return true;
return;
}
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);
}
}
@@ -221,6 +221,25 @@
<SymbolIcon Symbol="Account" />
</winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard>
<winuiControls:SettingsCard
Description="{x:Bind ViewModel.LegacyMigrationSummary, Mode=OneWay}"
Header="{x:Bind domain:Translator.LegacyLocalMigration_SettingsSectionTitle, Mode=OneTime}"
Visibility="{x:Bind ViewModel.HasLegacyImportAvailable, Mode=OneWay}">
<StackPanel Spacing="8">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.LegacyMigrationWarningSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind ViewModel.HasLegacyImportWarnings, Mode=OneWay}" />
<Button
Command="{x:Bind ViewModel.ImportLegacyDatabaseCommand}"
Content="{x:Bind domain:Translator.LegacyLocalMigration_ImportAction, Mode=OneTime}" />
</StackPanel>
<winuiControls:SettingsCard.HeaderIcon>
<SymbolIcon Symbol="Sync" />
</winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard>
<winuiControls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionTitle}">
<StackPanel Orientation="Horizontal" Spacing="12">
<Button Command="{x:Bind ViewModel.ImportLocalDataCommand}" Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataImportAction}" />
@@ -15,6 +15,27 @@
MaxWidth="900"
Padding="20"
HorizontalAlignment="Stretch">
<StackPanel Spacing="12">
<winuiControls:SettingsCard
Description="{x:Bind ViewModel.LegacyMigrationSummary, Mode=OneWay}"
Header="{x:Bind domain:Translator.LegacyLocalMigration_SettingsSectionTitle, Mode=OneTime}"
Visibility="{x:Bind ViewModel.HasLegacyImportAvailable, Mode=OneWay}">
<StackPanel Spacing="8">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.LegacyMigrationWarningSummary, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind ViewModel.HasLegacyImportWarnings, Mode=OneWay}" />
<Button
Command="{x:Bind ViewModel.ImportLegacyDatabaseCommand}"
Content="{x:Bind domain:Translator.LegacyLocalMigration_ImportAction, Mode=OneTime}" />
</StackPanel>
<winuiControls:SettingsCard.HeaderIcon>
<SymbolIcon Symbol="Sync" />
</winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard>
<winuiControls:SettingsCard
Description="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionDescription}"
Header="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionTitle}">
@@ -30,6 +51,7 @@
<SymbolIcon Symbol="Sync" />
</winuiControls:SettingsCard.HeaderIcon>
</winuiControls:SettingsCard>
</StackPanel>
</Grid>
</Border>
</abstract:ManageAccountsPageAbstract>
+34
View File
@@ -127,6 +127,40 @@
Grid.Row="3"
MaxWidth="600"
HorizontalAlignment="Center">
<Border
Margin="0,0,0,16"
Padding="16"
HorizontalAlignment="Stretch"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="12"
Visibility="{x:Bind ViewModel.HasLegacyImportPreview, Mode=OneWay}">
<StackPanel Spacing="8">
<TextBlock
HorizontalAlignment="Center"
FontSize="16"
FontWeight="SemiBold"
Text="{x:Bind domain:Translator.LegacyLocalMigration_WelcomeSectionTitle, Mode=OneTime}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind domain:Translator.LegacyLocalMigration_WelcomeSectionDescription, Mode=OneTime}"
TextWrapping="WrapWholeWords" />
<TextBlock Text="{x:Bind ViewModel.LegacyImportSummary, Mode=OneWay}" TextWrapping="WrapWholeWords" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.LegacyImportWarnings, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind ViewModel.HasLegacyImportWarnings, Mode=OneWay}" />
<Button
HorizontalAlignment="Center"
Command="{x:Bind ViewModel.ImportLegacyDatabaseCommand}"
Content="{x:Bind domain:Translator.LegacyLocalMigration_ImportAction, Mode=OneTime}"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
</Border>
<HyperlinkButton
HorizontalAlignment="Center"
Command="{x:Bind ViewModel.ImportFromWinoAccountCommand}"
@@ -1,3 +1,3 @@
namespace Wino.Messaging.UI;
public record WelcomeImportCompletedMessage(int ImportedMailboxCount) : UIMessageBase<WelcomeImportCompletedMessage>;
public record WelcomeImportCompletedMessage(int ImportedMailboxCount, string CompletionMessage = "") : UIMessageBase<WelcomeImportCompletedMessage>;
@@ -0,0 +1,881 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Serilog;
using SQLite;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Messaging.Client.Accounts;
namespace Wino.Services;
public sealed class LegacyLocalMigrationService : ILegacyLocalMigrationService
{
private const string LegacyDatabaseFileName = "Wino180.db";
private const string MigrationCompletedSettingKey = "LegacyLocalMigration_v2_Completed";
private const string PromptDeferredSettingKey = "LegacyLocalMigration_v2_PromptDeferred";
private const int DefaultMaxConcurrentClients = 5;
private readonly IApplicationConfiguration _applicationConfiguration;
private readonly IConfigurationService _configurationService;
private readonly IDatabaseService _databaseService;
private readonly IAccountService _accountService;
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
private readonly ILogger _logger = Log.ForContext<LegacyLocalMigrationService>();
public LegacyLocalMigrationService(IApplicationConfiguration applicationConfiguration,
IConfigurationService configurationService,
IDatabaseService databaseService,
IAccountService accountService,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver)
{
_applicationConfiguration = applicationConfiguration;
_configurationService = configurationService;
_databaseService = databaseService;
_accountService = accountService;
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
}
public void MarkPromptDeferred()
=> _configurationService.Set(PromptDeferredSettingKey, true);
public async Task<LegacyLocalMigrationPreview> DetectAsync(CancellationToken cancellationToken = default)
{
var (_, preview) = await LoadPreviewContextAsync(cancellationToken).ConfigureAwait(false);
return preview;
}
public async Task<LegacyLocalMigrationResult> ImportAsync(CancellationToken cancellationToken = default)
{
var (snapshot, preview) = await LoadPreviewContextAsync(cancellationToken).ConfigureAwait(false);
if (snapshot == null || !preview.LegacyDatabaseExists)
{
return new LegacyLocalMigrationResult
{
Preview = preview,
Warnings = preview.Warnings
};
}
_configurationService.Set(PromptDeferredSettingKey, false);
var failures = new List<LegacyLocalMigrationFailure>();
var importedAccounts = new Dictionary<Guid, MailAccount>();
var skippedDuplicateAccountCount = preview.Accounts.Count(a => a.IsDuplicate);
var importedAccountCount = 0;
var currentAccounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
var nextOrder = currentAccounts.Count;
foreach (var previewAccount in preview.Accounts
.OrderBy(a => a.Order)
.ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase))
{
cancellationToken.ThrowIfCancellationRequested();
if (!previewAccount.CanImport ||
!snapshot.AccountsById.TryGetValue(previewAccount.LegacyAccountId, out var legacyAccount))
{
continue;
}
try
{
var importedAccount = CreateImportedAccount(legacyAccount, nextOrder);
var serverInformation = CreateImportedServerInformation(legacyAccount, importedAccount);
await _accountService.CreateAccountAsync(importedAccount, serverInformation).ConfigureAwait(false);
await _accountService.CreateRootAliasAsync(importedAccount.Id, importedAccount.Address).ConfigureAwait(false);
ApplyLegacyPreferences(importedAccount, legacyAccount.Preferences);
importedAccount.Order = nextOrder;
await _accountService.UpdateAccountAsync(importedAccount).ConfigureAwait(false);
importedAccounts[legacyAccount.LegacyAccountId] = importedAccount;
importedAccountCount++;
nextOrder++;
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to import legacy account {LegacyAccountId} ({Address})", legacyAccount.LegacyAccountId, legacyAccount.Address);
failures.Add(new LegacyLocalMigrationFailure
{
Address = legacyAccount.Address,
ProviderType = legacyAccount.ProviderType,
Message = ex.Message
});
}
}
var (importedMergedInboxCount, skippedMergedInboxCount) = await ImportMergedInboxesAsync(snapshot, importedAccounts).ConfigureAwait(false);
if (importedAccountCount > 0 || importedMergedInboxCount > 0)
{
WeakReferenceMessenger.Default.Send(new AccountsMenuRefreshRequested(false));
}
_configurationService.Set(MigrationCompletedSettingKey, failures.Count == 0);
return new LegacyLocalMigrationResult
{
Preview = preview,
ImportedAccountCount = importedAccountCount,
SkippedDuplicateAccountCount = skippedDuplicateAccountCount,
FailedAccountCount = failures.Count,
ImportedMergedInboxCount = importedMergedInboxCount,
SkippedMergedInboxCount = skippedMergedInboxCount,
Failures = failures,
Warnings = preview.Warnings
};
}
private async Task<(LegacySnapshot? Snapshot, LegacyLocalMigrationPreview Preview)> LoadPreviewContextAsync(CancellationToken cancellationToken)
{
var legacyDatabasePath = GetLegacyDatabasePath();
if (string.IsNullOrWhiteSpace(legacyDatabasePath) || !File.Exists(legacyDatabasePath))
{
return (null, CreateEmptyPreview(legacyDatabasePath));
}
SQLiteAsyncConnection? connection = null;
try
{
connection = new SQLiteAsyncConnection(
legacyDatabasePath,
SQLiteOpenFlags.ReadOnly | SQLiteOpenFlags.SharedCache,
storeDateTimeAsTicks: false);
var snapshot = await LoadSnapshotAsync(connection, cancellationToken).ConfigureAwait(false);
var existingAddressKeys = await TryGetExistingAddressKeysAsync().ConfigureAwait(false);
return (snapshot, BuildPreview(legacyDatabasePath, snapshot, existingAddressKeys));
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to inspect legacy database at {LegacyDatabasePath}", legacyDatabasePath);
return (null, CreateUnreadablePreview(legacyDatabasePath));
}
finally
{
if (connection != null)
{
await connection.CloseAsync().ConfigureAwait(false);
}
}
}
private async Task<LegacySnapshot> LoadSnapshotAsync(SQLiteAsyncConnection connection, CancellationToken cancellationToken)
{
var snapshot = new LegacySnapshot();
var accountColumns = await GetColumnSetAsync(connection, nameof(MailAccount)).ConfigureAwait(false);
if (accountColumns.Count == 0)
{
return snapshot;
}
var accountRows = await connection.QueryAsync<LegacyMailAccountRow>(BuildMailAccountQuery(accountColumns)).ConfigureAwait(false);
snapshot.TotalLegacyAccountCount = accountRows.Count;
var preferenceRows = await QueryRowsIfTableExistsAsync<LegacyMailAccountPreferencesRow>(
connection,
nameof(MailAccountPreferences),
BuildMailAccountPreferencesQuery).ConfigureAwait(false);
var serverRows = await QueryRowsIfTableExistsAsync<LegacyCustomServerInformationRow>(
connection,
nameof(CustomServerInformation),
BuildCustomServerInformationQuery).ConfigureAwait(false);
var mergedInboxRows = await QueryRowsIfTableExistsAsync<LegacyMergedInboxRow>(
connection,
nameof(MergedInbox),
BuildMergedInboxQuery).ConfigureAwait(false);
snapshot.MergedInboxNamesById = mergedInboxRows
.Where(a => a.Id != Guid.Empty && !string.IsNullOrWhiteSpace(a.Name))
.GroupBy(a => a.Id)
.ToDictionary(a => a.Key, a => NormalizeOptionalText(a.First().Name), EqualityComparer<Guid>.Default);
var preferencesByAccountId = preferenceRows
.Where(a => a.AccountId != Guid.Empty)
.GroupBy(a => a.AccountId)
.ToDictionary(a => a.Key, a => a.First(), EqualityComparer<Guid>.Default);
var serverByAccountId = serverRows
.Where(a => a.AccountId != Guid.Empty)
.GroupBy(a => a.AccountId)
.ToDictionary(a => a.Key, a => a.First(), EqualityComparer<Guid>.Default);
foreach (var row in accountRows)
{
cancellationToken.ThrowIfCancellationRequested();
if (row.Id == Guid.Empty ||
!TryMapProviderType(row.ProviderType, out var providerType))
{
snapshot.InvalidAccountCount++;
continue;
}
var normalizedAddress = NormalizeOptionalText(row.Address);
if (string.IsNullOrWhiteSpace(normalizedAddress))
{
snapshot.InvalidAccountCount++;
continue;
}
preferencesByAccountId.TryGetValue(row.Id, out var preferences);
serverByAccountId.TryGetValue(row.Id, out var serverInformation);
var candidate = new LegacyAccountCandidate
{
LegacyAccountId = row.Id,
Address = normalizedAddress,
Name = NormalizeDisplayName(row.Name, normalizedAddress),
SenderName = NormalizeDisplayName(row.SenderName, NormalizeDisplayName(row.Name, normalizedAddress)),
ProviderType = providerType,
SpecialImapProvider = MapSpecialImapProvider(row.SpecialImapProvider),
Order = Math.Max(0, row.Order ?? 0),
AccountColorHex = NormalizeOptionalText(row.AccountColorHex),
LegacyMergedInboxId = row.MergedInboxId,
Preferences = preferences,
ServerInformation = serverInformation
};
snapshot.Accounts.Add(candidate);
snapshot.AccountsById[candidate.LegacyAccountId] = candidate;
}
snapshot.Accounts = snapshot.Accounts
.OrderBy(a => a.Order)
.ThenBy(a => a.Address, StringComparer.OrdinalIgnoreCase)
.ToList();
return snapshot;
}
private async Task<List<T>> QueryRowsIfTableExistsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>(
SQLiteAsyncConnection connection,
string tableName,
Func<HashSet<string>, string> sqlFactory) where T : new()
{
var columns = await GetColumnSetAsync(connection, tableName).ConfigureAwait(false);
if (columns.Count == 0)
{
return [];
}
return await connection.QueryAsync<T>(sqlFactory(columns)).ConfigureAwait(false);
}
private async Task<HashSet<string>> GetColumnSetAsync(SQLiteAsyncConnection connection, string tableName)
{
var tableInfo = await connection.GetTableInfoAsync(tableName).ConfigureAwait(false);
return tableInfo
.Select(a => a.Name)
.Where(a => !string.IsNullOrWhiteSpace(a))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private async Task<HashSet<string>> TryGetExistingAddressKeysAsync()
{
if (_databaseService.Connection == null)
{
return [];
}
try
{
var accounts = await _accountService.GetAccountsAsync().ConfigureAwait(false);
return accounts
.Select(a => CreateAddressKey(a.Address))
.Where(a => !string.IsNullOrWhiteSpace(a))
.ToHashSet(StringComparer.Ordinal);
}
catch (Exception ex)
{
_logger.Debug(ex, "Skipping duplicate detection against the current database because account data is not available yet.");
return [];
}
}
private LegacyLocalMigrationPreview BuildPreview(string legacyDatabasePath, LegacySnapshot snapshot, HashSet<string> existingAddressKeys)
{
var seenAddressKeys = new HashSet<string>(existingAddressKeys, StringComparer.Ordinal);
var accountPreviewItems = new List<LegacyLocalMigrationAccountPreview>();
foreach (var account in snapshot.Accounts)
{
var addressKey = CreateAddressKey(account.Address);
var isDuplicate = !seenAddressKeys.Add(addressKey);
var isCalendarEnabled = account.ProviderType switch
{
MailProviderType.Outlook or MailProviderType.Gmail => true,
MailProviderType.IMAP4 => ResolveCalendarSupportMode(account) != ImapCalendarSupportMode.Disabled,
_ => false
};
accountPreviewItems.Add(new LegacyLocalMigrationAccountPreview
{
LegacyAccountId = account.LegacyAccountId,
Address = account.Address,
DisplayName = account.Name,
ProviderType = account.ProviderType,
SpecialImapProvider = account.SpecialImapProvider,
Order = account.Order,
CanImport = !isDuplicate,
IsDuplicate = isDuplicate,
IsCalendarEnabled = isCalendarEnabled
});
}
var importableMergedInboxCount = 0;
var skippedMergedInboxCount = 0;
foreach (var group in accountPreviewItems
.Where(a => snapshot.AccountsById[a.LegacyAccountId].LegacyMergedInboxId.HasValue)
.GroupBy(a => snapshot.AccountsById[a.LegacyAccountId].LegacyMergedInboxId!.Value))
{
var members = group.ToList();
var hasReadableMergedInbox = snapshot.MergedInboxNamesById.TryGetValue(group.Key, out var mergedInboxName) &&
!string.IsNullOrWhiteSpace(mergedInboxName);
if (members.Count >= 2 && hasReadableMergedInbox && members.All(a => a.CanImport))
{
importableMergedInboxCount++;
}
else
{
skippedMergedInboxCount++;
}
}
var providerCounts = accountPreviewItems
.GroupBy(a => a.ProviderType)
.OrderBy(a => a.Key)
.Select(group => new LegacyLocalMigrationProviderCount
{
ProviderType = group.Key,
TotalAccountCount = group.Count(),
ImportableAccountCount = group.Count(a => a.CanImport),
DuplicateAccountCount = group.Count(a => a.IsDuplicate)
})
.ToList();
var hasCompletedMigration = _configurationService.Get(MigrationCompletedSettingKey, false);
var isPromptDeferred = _configurationService.Get(PromptDeferredSettingKey, false);
var importableAccountCount = accountPreviewItems.Count(a => a.CanImport);
var warnings = BuildWarnings(snapshot, accountPreviewItems, skippedMergedInboxCount);
return new LegacyLocalMigrationPreview
{
SourceDatabasePath = legacyDatabasePath,
LegacyDatabaseExists = true,
HasCompletedMigration = hasCompletedMigration,
IsPromptDeferred = isPromptDeferred,
ShouldPrompt = importableAccountCount > 0 && !hasCompletedMigration && !isPromptDeferred,
LegacyAccountCount = snapshot.TotalLegacyAccountCount,
ImportableAccountCount = importableAccountCount,
DuplicateAccountCount = accountPreviewItems.Count(a => a.IsDuplicate),
SkippedAccountCount = snapshot.InvalidAccountCount,
ImportableMergedInboxCount = importableMergedInboxCount,
SkippedMergedInboxCount = skippedMergedInboxCount,
ProviderCounts = providerCounts,
Accounts = accountPreviewItems,
Warnings = warnings
};
}
private static IReadOnlyList<string> BuildWarnings(LegacySnapshot snapshot,
IReadOnlyCollection<LegacyLocalMigrationAccountPreview> accountPreviewItems,
int skippedMergedInboxCount)
{
var warnings = new List<string>();
if (accountPreviewItems.Any(a => a.CanImport && (a.ProviderType == MailProviderType.Outlook || a.ProviderType == MailProviderType.Gmail)))
{
warnings.Add(Translator.LegacyLocalMigration_Warning_OAuth);
}
if (accountPreviewItems.Any(a => a.CanImport && a.ProviderType == MailProviderType.IMAP4))
{
warnings.Add(Translator.LegacyLocalMigration_Warning_Imap);
}
if (accountPreviewItems.Any(a => snapshot.AccountsById[a.LegacyAccountId].LegacyMergedInboxId.HasValue))
{
warnings.Add(Translator.LegacyLocalMigration_Warning_Merged);
}
if (snapshot.InvalidAccountCount > 0)
{
warnings.Add(string.Format(Translator.LegacyLocalMigration_Warning_SkippedAccounts, snapshot.InvalidAccountCount));
}
if (skippedMergedInboxCount > 0)
{
warnings.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSkipped, skippedMergedInboxCount));
}
return warnings;
}
private MailAccount CreateImportedAccount(LegacyAccountCandidate account, int order)
{
var isCalendarAccessGranted = account.ProviderType switch
{
MailProviderType.Outlook or MailProviderType.Gmail => true,
MailProviderType.IMAP4 => ResolveCalendarSupportMode(account) != ImapCalendarSupportMode.Disabled,
_ => false
};
return new MailAccount
{
Id = Guid.NewGuid(),
Address = account.Address,
Name = NormalizeDisplayName(account.Name, account.Address),
SenderName = NormalizeDisplayName(account.SenderName, NormalizeDisplayName(account.Name, account.Address)),
ProviderType = account.ProviderType,
SpecialImapProvider = account.SpecialImapProvider,
SynchronizationDeltaIdentifier = string.Empty,
CalendarSynchronizationDeltaIdentifier = string.Empty,
AccountColorHex = account.AccountColorHex,
Base64ProfilePictureData = string.Empty,
Order = order,
AttentionReason = AccountAttentionReason.InvalidCredentials,
IsMailAccessGranted = true,
IsCalendarAccessGranted = isCalendarAccessGranted,
CreatedAt = DateTime.UtcNow,
InitialSynchronizationRange = InitialSynchronizationRange.SixMonths
};
}
private CustomServerInformation? CreateImportedServerInformation(LegacyAccountCandidate account, MailAccount importedAccount)
{
if (account.ProviderType != MailProviderType.IMAP4)
{
return null;
}
var legacyServer = account.ServerInformation;
var fallbackServer = GetSpecialProviderFallback(account);
return new CustomServerInformation
{
Id = Guid.NewGuid(),
AccountId = importedAccount.Id,
Address = importedAccount.Address,
IncomingServer = FirstNonEmpty(legacyServer?.IncomingServer, fallbackServer?.IncomingServer),
IncomingServerUsername = FirstNonEmpty(legacyServer?.IncomingServerUsername, fallbackServer?.IncomingServerUsername),
IncomingServerPassword = string.Empty,
IncomingServerPort = FirstNonEmpty(legacyServer?.IncomingServerPort, fallbackServer?.IncomingServerPort),
IncomingServerType = CustomIncomingServerType.IMAP4,
OutgoingServer = FirstNonEmpty(legacyServer?.OutgoingServer, fallbackServer?.OutgoingServer),
OutgoingServerPort = FirstNonEmpty(legacyServer?.OutgoingServerPort, fallbackServer?.OutgoingServerPort),
OutgoingServerUsername = FirstNonEmpty(legacyServer?.OutgoingServerUsername, fallbackServer?.OutgoingServerUsername),
OutgoingServerPassword = string.Empty,
CalDavServiceUrl = FirstNonEmpty(legacyServer?.CalDavServiceUrl, fallbackServer?.CalDavServiceUrl),
CalDavUsername = FirstNonEmpty(legacyServer?.CalDavUsername, fallbackServer?.CalDavUsername),
CalDavPassword = string.Empty,
CalendarSupportMode = ResolveCalendarSupportMode(account),
IncomingServerSocketOption = MapConnectionSecurity(legacyServer?.IncomingServerSocketOption, fallbackServer?.IncomingServerSocketOption),
IncomingAuthenticationMethod = MapAuthenticationMethod(legacyServer?.IncomingAuthenticationMethod, fallbackServer?.IncomingAuthenticationMethod),
OutgoingServerSocketOption = MapConnectionSecurity(legacyServer?.OutgoingServerSocketOption, fallbackServer?.OutgoingServerSocketOption),
OutgoingAuthenticationMethod = MapAuthenticationMethod(legacyServer?.OutgoingAuthenticationMethod, fallbackServer?.OutgoingAuthenticationMethod),
ProxyServer = NormalizeOptionalText(legacyServer?.ProxyServer),
ProxyServerPort = NormalizeOptionalText(legacyServer?.ProxyServerPort),
MaxConcurrentClients = legacyServer?.MaxConcurrentClients is int maxConcurrentClients && maxConcurrentClients > 0
? maxConcurrentClients
: fallbackServer?.MaxConcurrentClients > 0
? fallbackServer.MaxConcurrentClients
: DefaultMaxConcurrentClients
};
}
private static void ApplyLegacyPreferences(MailAccount account, LegacyMailAccountPreferencesRow? legacyPreferences)
{
if (account.Preferences == null || legacyPreferences == null)
{
return;
}
if (legacyPreferences.IsNotificationsEnabled.HasValue)
{
account.Preferences.IsNotificationsEnabled = legacyPreferences.IsNotificationsEnabled.Value;
}
if (legacyPreferences.IsTaskbarBadgeEnabled.HasValue)
{
account.Preferences.IsTaskbarBadgeEnabled = legacyPreferences.IsTaskbarBadgeEnabled.Value;
}
if (legacyPreferences.ShouldAppendMessagesToSentFolder.HasValue)
{
account.Preferences.ShouldAppendMessagesToSentFolder = legacyPreferences.ShouldAppendMessagesToSentFolder.Value;
}
if (account.ProviderType == MailProviderType.Outlook && legacyPreferences.IsFocusedInboxEnabled.HasValue)
{
account.Preferences.IsFocusedInboxEnabled = legacyPreferences.IsFocusedInboxEnabled.Value;
}
}
private async Task<(int ImportedCount, int SkippedCount)> ImportMergedInboxesAsync(LegacySnapshot snapshot, IReadOnlyDictionary<Guid, MailAccount> importedAccounts)
{
var importedCount = 0;
var skippedCount = 0;
foreach (var group in snapshot.Accounts
.Where(a => a.LegacyMergedInboxId.HasValue)
.GroupBy(a => a.LegacyMergedInboxId!.Value))
{
var members = group.ToList();
if (members.Count < 2 ||
!snapshot.MergedInboxNamesById.TryGetValue(group.Key, out var mergedInboxName) ||
string.IsNullOrWhiteSpace(mergedInboxName))
{
skippedCount++;
continue;
}
var importedMembers = members
.Where(a => importedAccounts.ContainsKey(a.LegacyAccountId))
.Select(a => importedAccounts[a.LegacyAccountId])
.ToList();
if (importedMembers.Count != members.Count)
{
skippedCount++;
continue;
}
try
{
await _accountService.CreateMergeAccountsAsync(
new MergedInbox { Name = mergedInboxName },
importedMembers).ConfigureAwait(false);
importedCount++;
}
catch (Exception ex)
{
_logger.Warning(ex, "Failed to import legacy merged inbox {LegacyMergedInboxId}", group.Key);
skippedCount++;
}
}
return (importedCount, skippedCount);
}
private CustomServerInformation? GetSpecialProviderFallback(LegacyAccountCandidate account)
{
if (account.SpecialImapProvider == SpecialImapProvider.None)
{
return null;
}
return _specialImapProviderConfigResolver.GetServerInformation(
new MailAccount
{
Address = account.Address,
SenderName = account.SenderName,
ProviderType = MailProviderType.IMAP4,
SpecialImapProvider = account.SpecialImapProvider
},
new AccountCreationDialogResult(
MailProviderType.IMAP4,
account.Name,
new SpecialImapProviderDetails(
account.Address,
string.Empty,
account.SenderName,
account.SpecialImapProvider,
ImapCalendarSupportMode.CalDav),
account.AccountColorHex,
InitialSynchronizationRange.SixMonths,
true,
true));
}
private static ImapCalendarSupportMode ResolveCalendarSupportMode(LegacyAccountCandidate account)
{
var rawValue = account.ServerInformation?.CalendarSupportMode;
return rawValue is int intValue && Enum.IsDefined(typeof(ImapCalendarSupportMode), intValue)
? (ImapCalendarSupportMode)intValue
: ImapCalendarSupportMode.Disabled;
}
private static ImapConnectionSecurity MapConnectionSecurity(int? rawValue, ImapConnectionSecurity? fallbackValue)
{
if (rawValue.HasValue && Enum.IsDefined(typeof(ImapConnectionSecurity), rawValue.Value))
{
return (ImapConnectionSecurity)rawValue.Value;
}
return fallbackValue ?? ImapConnectionSecurity.Auto;
}
private static ImapAuthenticationMethod MapAuthenticationMethod(int? rawValue, ImapAuthenticationMethod? fallbackValue)
{
if (rawValue.HasValue && Enum.IsDefined(typeof(ImapAuthenticationMethod), rawValue.Value))
{
return (ImapAuthenticationMethod)rawValue.Value;
}
return fallbackValue ?? ImapAuthenticationMethod.Auto;
}
private static bool TryMapProviderType(int? rawValue, out MailProviderType providerType)
{
providerType = default;
if (!rawValue.HasValue ||
!Enum.IsDefined(typeof(MailProviderType), rawValue.Value))
{
return false;
}
providerType = (MailProviderType)rawValue.Value;
return providerType is MailProviderType.Outlook or MailProviderType.Gmail or MailProviderType.IMAP4;
}
private static SpecialImapProvider MapSpecialImapProvider(int? rawValue)
{
if (!rawValue.HasValue ||
!Enum.IsDefined(typeof(SpecialImapProvider), rawValue.Value))
{
return SpecialImapProvider.None;
}
return (SpecialImapProvider)rawValue.Value;
}
private static string BuildMailAccountQuery(HashSet<string> columns)
{
return $"""
SELECT
{SelectColumnOrFallback(columns, "Id")},
{SelectColumnOrFallback(columns, "Address")},
{SelectColumnOrFallback(columns, "Name")},
{SelectColumnOrFallback(columns, "SenderName")},
{SelectColumnOrFallback(columns, "ProviderType")},
{SelectColumnOrFallback(columns, "SpecialImapProvider")},
{SelectColumnOrFallback(columns, "Order", "0")},
{SelectColumnOrFallback(columns, "AccountColorHex")},
{SelectColumnOrFallback(columns, "MergedInboxId")}
FROM [{nameof(MailAccount)}]
ORDER BY [Order] ASC, [Address] COLLATE NOCASE
""";
}
private static string BuildMailAccountPreferencesQuery(HashSet<string> columns)
{
return $"""
SELECT
{SelectColumnOrFallback(columns, "AccountId")},
{SelectColumnOrFallback(columns, "IsNotificationsEnabled")},
{SelectColumnOrFallback(columns, "IsTaskbarBadgeEnabled")},
{SelectColumnOrFallback(columns, "ShouldAppendMessagesToSentFolder")},
{SelectColumnOrFallback(columns, "IsFocusedInboxEnabled")}
FROM [{nameof(MailAccountPreferences)}]
""";
}
private static string BuildCustomServerInformationQuery(HashSet<string> columns)
{
return $"""
SELECT
{SelectColumnOrFallback(columns, "AccountId")},
{SelectColumnOrFallback(columns, "Address")},
{SelectColumnOrFallback(columns, "IncomingServer")},
{SelectColumnOrFallback(columns, "IncomingServerPort")},
{SelectColumnOrFallback(columns, "IncomingServerUsername")},
{SelectColumnOrFallback(columns, "IncomingServerSocketOption")},
{SelectColumnOrFallback(columns, "IncomingAuthenticationMethod")},
{SelectColumnOrFallback(columns, "OutgoingServer")},
{SelectColumnOrFallback(columns, "OutgoingServerPort")},
{SelectColumnOrFallback(columns, "OutgoingServerUsername")},
{SelectColumnOrFallback(columns, "OutgoingServerSocketOption")},
{SelectColumnOrFallback(columns, "OutgoingAuthenticationMethod")},
{SelectColumnOrFallback(columns, "CalDavServiceUrl")},
{SelectColumnOrFallback(columns, "CalDavUsername")},
{SelectColumnOrFallback(columns, "CalendarSupportMode")},
{SelectColumnOrFallback(columns, "ProxyServer")},
{SelectColumnOrFallback(columns, "ProxyServerPort")},
{SelectColumnOrFallback(columns, "MaxConcurrentClients")}
FROM [{nameof(CustomServerInformation)}]
""";
}
private static string BuildMergedInboxQuery(HashSet<string> columns)
{
return $"""
SELECT
{SelectColumnOrFallback(columns, "Id")},
{SelectColumnOrFallback(columns, "Name")}
FROM [{nameof(MergedInbox)}]
""";
}
private static string SelectColumnOrFallback(HashSet<string> columns, string columnName, string fallbackSql = "NULL")
{
return columns.Contains(columnName)
? $"[{columnName}] AS [{columnName}]"
: $"{fallbackSql} AS [{columnName}]";
}
private static string NormalizeDisplayName(string? value, string fallback)
{
var normalized = NormalizeOptionalText(value);
return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized;
}
private static string NormalizeOptionalText(string? value)
=> string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim();
private static string FirstNonEmpty(string? primary, string? secondary)
{
var normalizedPrimary = NormalizeOptionalText(primary);
if (!string.IsNullOrWhiteSpace(normalizedPrimary))
{
return normalizedPrimary;
}
return NormalizeOptionalText(secondary);
}
private static string CreateAddressKey(string? address)
=> NormalizeOptionalText(address).ToLowerInvariant();
private string GetLegacyDatabasePath()
{
var publisherSharedFolderPath = _applicationConfiguration.PublisherSharedFolderPath;
return string.IsNullOrWhiteSpace(publisherSharedFolderPath)
? string.Empty
: Path.Combine(publisherSharedFolderPath, LegacyDatabaseFileName);
}
private LegacyLocalMigrationPreview CreateEmptyPreview(string legacyDatabasePath)
{
var hasCompletedMigration = _configurationService.Get(MigrationCompletedSettingKey, false);
var isPromptDeferred = _configurationService.Get(PromptDeferredSettingKey, false);
return new LegacyLocalMigrationPreview
{
SourceDatabasePath = legacyDatabasePath,
LegacyDatabaseExists = false,
HasCompletedMigration = hasCompletedMigration,
IsPromptDeferred = isPromptDeferred,
ShouldPrompt = false
};
}
private LegacyLocalMigrationPreview CreateUnreadablePreview(string legacyDatabasePath)
{
var hasCompletedMigration = _configurationService.Get(MigrationCompletedSettingKey, false);
var isPromptDeferred = _configurationService.Get(PromptDeferredSettingKey, false);
return new LegacyLocalMigrationPreview
{
SourceDatabasePath = legacyDatabasePath,
LegacyDatabaseExists = true,
HasCompletedMigration = hasCompletedMigration,
IsPromptDeferred = isPromptDeferred,
ShouldPrompt = false,
Warnings = [Translator.LegacyLocalMigration_Warning_ReadFailed]
};
}
private sealed class LegacySnapshot
{
public int TotalLegacyAccountCount { get; set; }
public int InvalidAccountCount { get; set; }
public List<LegacyAccountCandidate> Accounts { get; set; } = [];
public Dictionary<Guid, LegacyAccountCandidate> AccountsById { get; } = [];
public Dictionary<Guid, string> MergedInboxNamesById { get; set; } = [];
}
private sealed class LegacyAccountCandidate
{
public Guid LegacyAccountId { get; init; }
public string Address { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string SenderName { get; init; } = string.Empty;
public MailProviderType ProviderType { get; init; }
public SpecialImapProvider SpecialImapProvider { get; init; }
public int Order { get; init; }
public string AccountColorHex { get; init; } = string.Empty;
public Guid? LegacyMergedInboxId { get; init; }
public LegacyMailAccountPreferencesRow? Preferences { get; init; }
public LegacyCustomServerInformationRow? ServerInformation { get; init; }
}
private sealed class LegacyMailAccountRow
{
public Guid Id { get; set; }
public string? Address { get; set; }
public string? Name { get; set; }
public string? SenderName { get; set; }
public int? ProviderType { get; set; }
public int? SpecialImapProvider { get; set; }
public int? Order { get; set; }
public string? AccountColorHex { get; set; }
public Guid? MergedInboxId { get; set; }
}
private sealed class LegacyMailAccountPreferencesRow
{
public Guid AccountId { get; set; }
public bool? ShouldAppendMessagesToSentFolder { get; set; }
public bool? IsNotificationsEnabled { get; set; }
public bool? IsFocusedInboxEnabled { get; set; }
public bool? IsTaskbarBadgeEnabled { get; set; }
}
private sealed class LegacyCustomServerInformationRow
{
public Guid AccountId { get; set; }
public string? Address { get; set; }
public string? IncomingServer { get; set; }
public string? IncomingServerPort { get; set; }
public string? IncomingServerUsername { get; set; }
public int? IncomingServerSocketOption { get; set; }
public int? IncomingAuthenticationMethod { get; set; }
public string? OutgoingServer { get; set; }
public string? OutgoingServerPort { get; set; }
public string? OutgoingServerUsername { get; set; }
public int? OutgoingServerSocketOption { get; set; }
public int? OutgoingAuthenticationMethod { get; set; }
public string? CalDavServiceUrl { get; set; }
public string? CalDavUsername { get; set; }
public int? CalendarSupportMode { get; set; }
public string? ProxyServer { get; set; }
public string? ProxyServerPort { get; set; }
public int? MaxConcurrentClients { get; set; }
}
private sealed class LegacyMergedInboxRow
{
public Guid Id { get; set; }
public string? Name { get; set; }
}
}
+30 -332
View File
@@ -727,70 +727,32 @@ public class MailService : BaseDatabaseService, IMailService
public async Task DeleteMailAsync(Guid accountId, string mailCopyId)
{
await DeleteMailsAsync(accountId, [mailCopyId]).ConfigureAwait(false);
}
var allMails = await GetMailCopiesByIdAsync([mailCopyId]).ConfigureAwait(false);
public async Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailCopyIds)
foreach (var mailItem in allMails)
{
var targetMailIds = mailCopyIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.Ordinal)
.ToList() ?? [];
// 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.
if (targetMailIds.Count == 0)
return;
var allMails = await GetMailCopiesByIdAsync(targetMailIds).ConfigureAwait(false);
await DeleteMailCopiesAsync(allMails, preserveMimeFile: false, reportUiChange: true).ConfigureAwait(false);
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).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<MailCopy> InsertMailAsync(MailCopy mailCopy, bool reportUiChange)
private async Task InsertMailAsync(MailCopy mailCopy)
{
if (mailCopy == null)
{
_logger.Warning("Null mail passed to InsertMailAsync call.");
return null;
return;
}
if (mailCopy.FolderId == Guid.Empty)
{
_logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id);
return null;
return;
}
_logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
@@ -798,27 +760,21 @@ public class MailService : BaseDatabaseService, IMailService
await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
if (reportUiChange)
ReportAddedMails([hydratedMailCopy]);
return hydratedMailCopy;
ReportUIChange(new MailAddedMessage(hydratedMailCopy, EntityUpdateSource.Server));
}
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 null;
return;
}
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
existingMailCopy ??= mailCopy.UniqueId != Guid.Empty
var existingMailCopy = mailCopy.UniqueId != Guid.Empty
? await Connection.FindAsync<MailCopy>(mailCopy.UniqueId).ConfigureAwait(false)
: null;
@@ -831,19 +787,16 @@ public class MailService : BaseDatabaseService, IMailService
await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
if (reportUiChange)
ReportUpdatedMails([hydratedMailCopy]);
return hydratedMailCopy;
ReportUIChange(new MailUpdatedMessage(hydratedMailCopy, EntityUpdateSource.Server));
}
private async Task<MailCopy> DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile, bool reportUiChange)
private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile)
{
if (mailCopy == null)
{
_logger.Warning("Null mail passed to DeleteMailAsync call.");
return null;
return;
}
_logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
@@ -859,31 +812,7 @@ public class MailService : BaseDatabaseService, IMailService
await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
}
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;
ReportUIChange(new MailRemovedMessage(mailCopy, EntityUpdateSource.Server));
}
#endregion
@@ -1092,40 +1021,6 @@ 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.
@@ -1138,14 +1033,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 (null, null);
return;
}
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 (null, null);
return;
}
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
@@ -1154,12 +1049,9 @@ public class MailService : BaseDatabaseService, IMailService
{
_logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId);
return (null, null);
return;
}
MailCopy removedMail = null;
var mailCopyToInsert = mailCopy;
if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent &&
localFolder.SpecialFolderType == SpecialFolderType.Deleted)
{
@@ -1170,52 +1062,21 @@ 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.
mailCopyToInsert = CloneMailCopy(mailCopy);
removedMail = await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true, reportUiChange: false).ConfigureAwait(false);
await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true).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.
mailCopyToInsert.UniqueId = Guid.NewGuid();
mailCopyToInsert.FolderId = localFolder.Id;
mailCopyToInsert.AssignedFolder = localFolder;
mailCopy.UniqueId = Guid.NewGuid();
mailCopy.FolderId = localFolder.Id;
mailCopy.AssignedFolder = localFolder;
var addedMail = await InsertMailAsync(mailCopyToInsert, reportUiChange: false).ConfigureAwait(false);
return (addedMail, removedMail);
await InsertMailAsync(mailCopy).ConfigureAwait(false);
}
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);
@@ -1223,7 +1084,7 @@ public class MailService : BaseDatabaseService, IMailService
{
_logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId);
return null;
return;
}
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
@@ -1232,50 +1093,10 @@ public class MailService : BaseDatabaseService, IMailService
{
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
return null;
return;
}
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]
};
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false);
}
public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
@@ -1292,7 +1113,7 @@ public class MailService : BaseDatabaseService, IMailService
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
var insertMailTask = InsertMailAsync(mailCopy, reportUiChange: true);
var insertMailTask = InsertMailAsync(mailCopy);
await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
@@ -1304,129 +1125,6 @@ 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);
@@ -1495,7 +1193,7 @@ public class MailService : BaseDatabaseService, IMailService
{
mailCopy.UniqueId = existingCopyItem.UniqueId;
await UpdateMailAsync(mailCopy, reportUiChange: true, existingMailCopy: existingCopyItem).ConfigureAwait(false);
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
@@ -1512,7 +1210,7 @@ public class MailService : BaseDatabaseService, IMailService
await DeleteMailAsync(accountId, mailCopy.Id).ConfigureAwait(false);
}
await InsertMailAsync(mailCopy, reportUiChange: true).ConfigureAwait(false);
await InsertMailAsync(mailCopy).ConfigureAwait(false);
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
+1
View File
@@ -30,6 +30,7 @@ public static class ServicesContainerSetup
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
services.AddTransient<ICalendarContextMenuItemService, CalendarContextMenuItemService>();
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
services.AddTransient<ILegacyLocalMigrationService, LegacyLocalMigrationService>();
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>();