Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af4c9527b0 | |||
| 0b9bdc91fe | |||
| c1bda75d9f |
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1368,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",
|
||||
@@ -1444,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>());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,32 @@ namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public sealed class GmailSynchronizerRequestSuccessTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateAccountSyncIdentifierAsync_EmptyStoredIdentifier_PersistsFirstHistoryCursor()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
changeProcessor
|
||||
.Setup(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"))
|
||||
.ReturnsAsync("123");
|
||||
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: string.Empty);
|
||||
|
||||
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
|
||||
|
||||
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), "123"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAccountSyncIdentifierAsync_OlderHistoryCursor_DoesNotRegressStoredCursor()
|
||||
{
|
||||
var changeProcessor = new Mock<IGmailChangeProcessor>(MockBehavior.Strict);
|
||||
var synchronizer = CreateSynchronizer(changeProcessor.Object, synchronizationDeltaIdentifier: "456");
|
||||
|
||||
await InvokeUpdateAccountSyncIdentifierAsync(synchronizer, 123);
|
||||
|
||||
changeProcessor.Verify(x => x.UpdateAccountDeltaSynchronizationIdentifierAsync(It.IsAny<Guid>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail()
|
||||
{
|
||||
@@ -209,13 +235,15 @@ public sealed class GmailSynchronizerRequestSuccessTests
|
||||
|
||||
private static GmailSynchronizer CreateSynchronizer(
|
||||
IGmailChangeProcessor changeProcessor,
|
||||
IGmailSynchronizerErrorHandlerFactory? errorFactory = null)
|
||||
IGmailSynchronizerErrorHandlerFactory? errorFactory = null,
|
||||
string? synchronizationDeltaIdentifier = null)
|
||||
{
|
||||
var account = new MailAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Gmail",
|
||||
Address = "user@example.com"
|
||||
Address = "user@example.com",
|
||||
SynchronizationDeltaIdentifier = synchronizationDeltaIdentifier
|
||||
};
|
||||
|
||||
var authenticator = new Mock<IGmailAuthenticator>(MockBehavior.Loose);
|
||||
@@ -249,4 +277,17 @@ public sealed class GmailSynchronizerRequestSuccessTests
|
||||
task.Should().NotBeNull();
|
||||
await task!;
|
||||
}
|
||||
|
||||
private static async Task InvokeUpdateAccountSyncIdentifierAsync(GmailSynchronizer synchronizer, ulong historyId)
|
||||
{
|
||||
var method = typeof(GmailSynchronizer).GetMethod(
|
||||
"UpdateAccountSyncIdentifierAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
method.Should().NotBeNull();
|
||||
|
||||
var task = method!.Invoke(synchronizer, [historyId]) as Task;
|
||||
task.Should().NotBeNull();
|
||||
await task!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1897,9 +1897,19 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
if (historyId == null) return false;
|
||||
|
||||
var newHistoryId = historyId.Value;
|
||||
var currentSynchronizationIdentifier = Account.SynchronizationDeltaIdentifier;
|
||||
|
||||
return Account.SynchronizationDeltaIdentifier == null ||
|
||||
(ulong.TryParse(Account.SynchronizationDeltaIdentifier, out ulong currentIdentifier) && newHistoryId > currentIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(currentSynchronizationIdentifier))
|
||||
return true;
|
||||
|
||||
if (!ulong.TryParse(currentSynchronizationIdentifier, out ulong currentIdentifier))
|
||||
{
|
||||
_logger.Warning("Current Gmail history ID '{HistoryId}' is invalid for {Name}. Replacing it with {NewHistoryId}.",
|
||||
currentSynchronizationIdentifier, Account.Name, newHistoryId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return newHistoryId > currentIdentifier;
|
||||
}
|
||||
|
||||
private async Task UpdateAccountSyncIdentifierAsync(ulong? historyId)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -479,8 +479,8 @@ public partial class App : WinoApplication,
|
||||
|
||||
EnsureAppNotificationRegistration();
|
||||
|
||||
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
|
||||
.RunIfNeededAsync();
|
||||
await Services.GetRequiredService<ILegacyLocalMigrationService>()
|
||||
.DetectAsync();
|
||||
|
||||
await InitializeServicesAsync();
|
||||
|
||||
@@ -490,6 +490,9 @@ public partial class App : WinoApplication,
|
||||
|
||||
_hasConfiguredAccounts = (await _accountService.GetAccountsAsync()).Any();
|
||||
|
||||
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
|
||||
.RunIfNeededAsync();
|
||||
|
||||
_activationInfrastructureInitialized = true;
|
||||
}
|
||||
finally
|
||||
@@ -1502,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
@@ -29,7 +28,6 @@ public sealed class ReleaseLocalAccountDataCleanupService
|
||||
return;
|
||||
|
||||
var localFolderPath = _applicationConfiguration.ApplicationDataFolderPath;
|
||||
var publisherPath = _applicationConfiguration.PublisherSharedFolderPath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(localFolderPath) || !Directory.Exists(localFolderPath))
|
||||
{
|
||||
@@ -41,8 +39,7 @@ public sealed class ReleaseLocalAccountDataCleanupService
|
||||
{
|
||||
Path.Combine(localFolderPath, "Mime"),
|
||||
Path.Combine(localFolderPath, "contacts"),
|
||||
Path.Combine(localFolderPath, "CalendarAttachments"),
|
||||
Path.Combine(publisherPath, LegacyDatabaseFileName)
|
||||
Path.Combine(localFolderPath, "CalendarAttachments")
|
||||
};
|
||||
|
||||
foreach (var targetPath in cleanupTargets)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>();
|
||||
|
||||
Reference in New Issue
Block a user