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();
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ public interface IMailService
|
|||||||
/// <param name="accountId">Account to remove from</param>
|
/// <param name="accountId">Account to remove from</param>
|
||||||
/// <param name="mailCopyId">Mail copy id to remove.</param>
|
/// <param name="mailCopyId">Mail copy id to remove.</param>
|
||||||
Task DeleteMailAsync(Guid accountId, string mailCopyId);
|
Task DeleteMailAsync(Guid accountId, string mailCopyId);
|
||||||
Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailCopyIds);
|
|
||||||
|
|
||||||
Task ChangeReadStatusAsync(string mailCopyId, bool isRead);
|
Task ChangeReadStatusAsync(string mailCopyId, bool isRead);
|
||||||
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
|
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
|
||||||
@@ -43,11 +42,8 @@ public interface IMailService
|
|||||||
|
|
||||||
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
||||||
Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
|
||||||
Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
|
|
||||||
Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
|
|
||||||
|
|
||||||
Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package);
|
Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package);
|
||||||
Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps new mail item with the existing local draft copy.
|
/// Maps new mail item with the existing local draft copy.
|
||||||
|
|||||||
@@ -51,11 +51,6 @@ public interface INotificationBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void CreateStoreUpdateNotification();
|
void CreateStoreUpdateNotification();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shows the one-time release migration notification.
|
|
||||||
/// </summary>
|
|
||||||
void CreateReleaseMigrationNotification();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a calendar reminder toast for the specified calendar item.
|
/// Creates a calendar reminder toast for the specified calendar item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Models.Accounts;
|
||||||
|
|
||||||
|
public sealed class LegacyLocalMigrationPreview
|
||||||
|
{
|
||||||
|
public string SourceDatabasePath { get; init; } = string.Empty;
|
||||||
|
public bool LegacyDatabaseExists { get; init; }
|
||||||
|
public bool HasCompletedMigration { get; init; }
|
||||||
|
public bool IsPromptDeferred { get; init; }
|
||||||
|
public bool ShouldPrompt { get; init; }
|
||||||
|
public int LegacyAccountCount { get; init; }
|
||||||
|
public int ImportableAccountCount { get; init; }
|
||||||
|
public int DuplicateAccountCount { get; init; }
|
||||||
|
public int SkippedAccountCount { get; init; }
|
||||||
|
public int ImportableMergedInboxCount { get; init; }
|
||||||
|
public int SkippedMergedInboxCount { get; init; }
|
||||||
|
public IReadOnlyList<LegacyLocalMigrationProviderCount> ProviderCounts { get; init; } = [];
|
||||||
|
public IReadOnlyList<LegacyLocalMigrationAccountPreview> Accounts { get; init; } = [];
|
||||||
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||||
|
|
||||||
|
public bool HasImportableData => LegacyDatabaseExists && ImportableAccountCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LegacyLocalMigrationProviderCount
|
||||||
|
{
|
||||||
|
public MailProviderType ProviderType { get; init; }
|
||||||
|
public int TotalAccountCount { get; init; }
|
||||||
|
public int ImportableAccountCount { get; init; }
|
||||||
|
public int DuplicateAccountCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LegacyLocalMigrationAccountPreview
|
||||||
|
{
|
||||||
|
public Guid LegacyAccountId { get; init; }
|
||||||
|
public string Address { get; init; } = string.Empty;
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
public MailProviderType ProviderType { get; init; }
|
||||||
|
public SpecialImapProvider SpecialImapProvider { get; init; }
|
||||||
|
public int Order { get; init; }
|
||||||
|
public bool CanImport { get; init; }
|
||||||
|
public bool IsDuplicate { get; init; }
|
||||||
|
public bool IsCalendarEnabled { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Wino.Core.Domain.Models.Accounts;
|
||||||
|
|
||||||
|
public sealed class LegacyLocalMigrationResult
|
||||||
|
{
|
||||||
|
public LegacyLocalMigrationPreview Preview { get; init; } = new();
|
||||||
|
public int ImportedAccountCount { get; init; }
|
||||||
|
public int SkippedDuplicateAccountCount { get; init; }
|
||||||
|
public int FailedAccountCount { get; init; }
|
||||||
|
public int ImportedMergedInboxCount { get; init; }
|
||||||
|
public int SkippedMergedInboxCount { get; init; }
|
||||||
|
public IReadOnlyList<LegacyLocalMigrationFailure> Failures { get; init; } = [];
|
||||||
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||||
|
|
||||||
|
public bool HasImportedData => ImportedAccountCount > 0 || ImportedMergedInboxCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LegacyLocalMigrationFailure
|
||||||
|
{
|
||||||
|
public string Address { get; init; } = string.Empty;
|
||||||
|
public MailProviderType ProviderType { get; init; }
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace Wino.Core.Domain.Models.MailItem;
|
|
||||||
|
|
||||||
public sealed record MailFolderAssignmentUpdate(string MailCopyId, string RemoteFolderId);
|
|
||||||
@@ -704,8 +704,6 @@
|
|||||||
"Notifications_WinoUpdatedTitle": "Wino Mail has been updated.",
|
"Notifications_WinoUpdatedTitle": "Wino Mail has been updated.",
|
||||||
"Notifications_StoreUpdateAvailableTitle": "Update available",
|
"Notifications_StoreUpdateAvailableTitle": "Update available",
|
||||||
"Notifications_StoreUpdateAvailableMessage": "A newer version of Wino Mail is ready to install from Microsoft Store.",
|
"Notifications_StoreUpdateAvailableMessage": "A newer version of Wino Mail is ready to install from Microsoft Store.",
|
||||||
"Notifications_ReleaseMigrationTitle": "New Wino Mail & Calendar",
|
|
||||||
"Notifications_ReleaseMigrationMessage": "Wino Mail got updated to the next version. Please re-create your accounts and start using the next version including calendar, mail templates, shortcuts, and a bunch of other improvements.",
|
|
||||||
"OnlineSearchFailed_Message": "Failed to perform search\n{0}\n\nListing offline mails.",
|
"OnlineSearchFailed_Message": "Failed to perform search\n{0}\n\nListing offline mails.",
|
||||||
"OnlineSearchTry_Line1": "Can't find what you are looking for?",
|
"OnlineSearchTry_Line1": "Can't find what you are looking for?",
|
||||||
"OnlineSearchTry_Line2": "Try online search.",
|
"OnlineSearchTry_Line2": "Try online search.",
|
||||||
@@ -1370,6 +1368,26 @@
|
|||||||
"WelcomeWindow_ImportInProgress": "Importing preferences and accounts...",
|
"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_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.",
|
"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_SetupTitle": "Set up your account",
|
||||||
"WelcomeWindow_SetupSubtitle": "Choose your email provider to get started",
|
"WelcomeWindow_SetupSubtitle": "Choose your email provider to get started",
|
||||||
"WelcomeWindow_AddAccountButton": "Add account",
|
"WelcomeWindow_AddAccountButton": "Add account",
|
||||||
@@ -1446,6 +1464,7 @@
|
|||||||
"WinoAccount_Management_ExportDialog_AccountsDisclaimer": "Passwords, tokens, and other sensitive information are not synced.",
|
"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_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...",
|
"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_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_LocalDataSectionDescription": "Import from or export to a local JSON file. Passwords, tokens, and other sensitive information are not included.",
|
||||||
"WinoAccount_Management_LocalDataImportAction": "Import",
|
"WinoAccount_Management_LocalDataImportAction": "Import",
|
||||||
|
|||||||
@@ -0,0 +1,696 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using SQLite;
|
||||||
|
using Wino.Core.Domain.Entities.Mail;
|
||||||
|
using Wino.Core.Domain.Entities.Shared;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Tests.Helpers;
|
||||||
|
using Wino.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Wino.Core.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class LegacyLocalMigrationServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task DetectAsync_ReturnsPreviewCountsAndDuplicatesByProvider()
|
||||||
|
{
|
||||||
|
await using var context = await LegacyMigrationTestContext.CreateAsync();
|
||||||
|
|
||||||
|
await context.SeedCurrentAccountAsync(
|
||||||
|
"gmail@example.com",
|
||||||
|
MailProviderType.Gmail,
|
||||||
|
"Existing Gmail");
|
||||||
|
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"outlook@example.com",
|
||||||
|
MailProviderType.Outlook,
|
||||||
|
order: 0,
|
||||||
|
name: "Outlook Legacy");
|
||||||
|
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"gmail@example.com",
|
||||||
|
MailProviderType.Gmail,
|
||||||
|
order: 1,
|
||||||
|
name: "Duplicate Gmail");
|
||||||
|
|
||||||
|
var imapId = Guid.NewGuid();
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
imapId,
|
||||||
|
"imap@example.com",
|
||||||
|
MailProviderType.IMAP4,
|
||||||
|
order: 2,
|
||||||
|
name: "Imported IMAP",
|
||||||
|
specialImapProvider: SpecialImapProvider.Yahoo);
|
||||||
|
|
||||||
|
await context.InsertLegacyServerInformationAsync(
|
||||||
|
imapId,
|
||||||
|
address: "imap@example.com",
|
||||||
|
incomingServer: "imap.mail.yahoo.com",
|
||||||
|
incomingServerPort: "993",
|
||||||
|
incomingServerUsername: "imap@example.com",
|
||||||
|
outgoingServer: "smtp.mail.yahoo.com",
|
||||||
|
outgoingServerPort: "587",
|
||||||
|
outgoingServerUsername: "imap@example.com",
|
||||||
|
calendarSupportMode: ImapCalendarSupportMode.CalDav,
|
||||||
|
calDavServiceUrl: "https://caldav.calendar.yahoo.com/",
|
||||||
|
calDavUsername: "imap@example.com");
|
||||||
|
|
||||||
|
var preview = await context.Service.DetectAsync();
|
||||||
|
|
||||||
|
preview.LegacyDatabaseExists.Should().BeTrue();
|
||||||
|
preview.ShouldPrompt.Should().BeTrue();
|
||||||
|
preview.LegacyAccountCount.Should().Be(3);
|
||||||
|
preview.ImportableAccountCount.Should().Be(2);
|
||||||
|
preview.DuplicateAccountCount.Should().Be(1);
|
||||||
|
preview.Accounts.Select(a => a.Address).Should().ContainInOrder(
|
||||||
|
"outlook@example.com",
|
||||||
|
"gmail@example.com",
|
||||||
|
"imap@example.com");
|
||||||
|
|
||||||
|
preview.ProviderCounts.Should().ContainSingle(a =>
|
||||||
|
a.ProviderType == MailProviderType.Outlook &&
|
||||||
|
a.ImportableAccountCount == 1 &&
|
||||||
|
a.DuplicateAccountCount == 0);
|
||||||
|
|
||||||
|
preview.ProviderCounts.Should().ContainSingle(a =>
|
||||||
|
a.ProviderType == MailProviderType.Gmail &&
|
||||||
|
a.ImportableAccountCount == 0 &&
|
||||||
|
a.DuplicateAccountCount == 1);
|
||||||
|
|
||||||
|
preview.ProviderCounts.Should().ContainSingle(a =>
|
||||||
|
a.ProviderType == MailProviderType.IMAP4 &&
|
||||||
|
a.ImportableAccountCount == 1 &&
|
||||||
|
a.DuplicateAccountCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportAsync_ImportsAccountsPreservesSafePreferencesAndRecreatesMergedInboxes()
|
||||||
|
{
|
||||||
|
await using var context = await LegacyMigrationTestContext.CreateAsync();
|
||||||
|
|
||||||
|
var mergedInboxId = Guid.NewGuid();
|
||||||
|
await context.InsertLegacyMergedInboxAsync(mergedInboxId, "Legacy Linked");
|
||||||
|
|
||||||
|
var legacyOutlookId = Guid.NewGuid();
|
||||||
|
var legacyGmailId = Guid.NewGuid();
|
||||||
|
var legacyImapId = Guid.NewGuid();
|
||||||
|
var legacySignatureId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
legacyOutlookId,
|
||||||
|
"outlook@example.com",
|
||||||
|
MailProviderType.Outlook,
|
||||||
|
order: 0,
|
||||||
|
name: "Outlook Legacy",
|
||||||
|
senderName: "Outlook Sender",
|
||||||
|
mergedInboxId: mergedInboxId);
|
||||||
|
|
||||||
|
await context.InsertLegacyPreferencesAsync(
|
||||||
|
legacyOutlookId,
|
||||||
|
isNotificationsEnabled: false,
|
||||||
|
isTaskbarBadgeEnabled: false,
|
||||||
|
shouldAppendMessagesToSentFolder: true,
|
||||||
|
isFocusedInboxEnabled: false,
|
||||||
|
signatureIdForNewMessages: legacySignatureId,
|
||||||
|
signatureIdForFollowingMessages: legacySignatureId);
|
||||||
|
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
legacyGmailId,
|
||||||
|
"gmail@example.com",
|
||||||
|
MailProviderType.Gmail,
|
||||||
|
order: 1,
|
||||||
|
name: "Gmail Legacy",
|
||||||
|
senderName: "Gmail Sender",
|
||||||
|
mergedInboxId: mergedInboxId);
|
||||||
|
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
legacyImapId,
|
||||||
|
"imap@example.com",
|
||||||
|
MailProviderType.IMAP4,
|
||||||
|
order: 2,
|
||||||
|
name: "iCloud Legacy",
|
||||||
|
senderName: "iCloud Sender",
|
||||||
|
specialImapProvider: SpecialImapProvider.iCloud);
|
||||||
|
|
||||||
|
await context.InsertLegacyServerInformationAsync(
|
||||||
|
legacyImapId,
|
||||||
|
address: "imap@example.com",
|
||||||
|
incomingServer: "imap.mail.me.com",
|
||||||
|
incomingServerPort: "993",
|
||||||
|
incomingServerUsername: "imap-user",
|
||||||
|
outgoingServer: "smtp.mail.me.com",
|
||||||
|
outgoingServerPort: "587",
|
||||||
|
outgoingServerUsername: "smtp-user",
|
||||||
|
calendarSupportMode: ImapCalendarSupportMode.CalDav,
|
||||||
|
calDavServiceUrl: "https://caldav.icloud.com/",
|
||||||
|
calDavUsername: "imap@example.com",
|
||||||
|
maxConcurrentClients: 7);
|
||||||
|
|
||||||
|
var result = await context.Service.ImportAsync();
|
||||||
|
|
||||||
|
result.ImportedAccountCount.Should().Be(3);
|
||||||
|
result.SkippedDuplicateAccountCount.Should().Be(0);
|
||||||
|
result.FailedAccountCount.Should().Be(0);
|
||||||
|
result.ImportedMergedInboxCount.Should().Be(1);
|
||||||
|
result.SkippedMergedInboxCount.Should().Be(0);
|
||||||
|
|
||||||
|
var accounts = await context.AccountService.GetAccountsAsync();
|
||||||
|
accounts.Should().HaveCount(3);
|
||||||
|
accounts.Select(a => a.Address).Should().ContainInOrder(
|
||||||
|
"outlook@example.com",
|
||||||
|
"gmail@example.com",
|
||||||
|
"imap@example.com");
|
||||||
|
|
||||||
|
var outlookAccount = accounts.Single(a => a.Address == "outlook@example.com");
|
||||||
|
outlookAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials);
|
||||||
|
outlookAccount.IsMailAccessGranted.Should().BeTrue();
|
||||||
|
outlookAccount.IsCalendarAccessGranted.Should().BeTrue();
|
||||||
|
outlookAccount.Preferences.IsNotificationsEnabled.Should().BeFalse();
|
||||||
|
outlookAccount.Preferences.IsTaskbarBadgeEnabled.Should().BeFalse();
|
||||||
|
outlookAccount.Preferences.ShouldAppendMessagesToSentFolder.Should().BeTrue();
|
||||||
|
outlookAccount.Preferences.IsFocusedInboxEnabled.Should().BeFalse();
|
||||||
|
outlookAccount.Preferences.SignatureIdForNewMessages.Should().NotBe(legacySignatureId);
|
||||||
|
outlookAccount.Preferences.SignatureIdForFollowingMessages.Should().NotBe(legacySignatureId);
|
||||||
|
|
||||||
|
var gmailAliases = await context.AccountService.GetAccountAliasesAsync(accounts.Single(a => a.Address == "gmail@example.com").Id);
|
||||||
|
gmailAliases.Should().ContainSingle(a =>
|
||||||
|
a.IsRootAlias &&
|
||||||
|
a.IsPrimary &&
|
||||||
|
a.AliasAddress == "gmail@example.com");
|
||||||
|
|
||||||
|
var imapAccount = accounts.Single(a => a.Address == "imap@example.com");
|
||||||
|
imapAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials);
|
||||||
|
imapAccount.IsCalendarAccessGranted.Should().BeTrue();
|
||||||
|
|
||||||
|
var serverInformation = await context.AccountService.GetAccountCustomServerInformationAsync(imapAccount.Id);
|
||||||
|
serverInformation.Should().NotBeNull();
|
||||||
|
serverInformation.IncomingServer.Should().Be("imap.mail.me.com");
|
||||||
|
serverInformation.OutgoingServer.Should().Be("smtp.mail.me.com");
|
||||||
|
serverInformation.IncomingServerPassword.Should().BeEmpty();
|
||||||
|
serverInformation.OutgoingServerPassword.Should().BeEmpty();
|
||||||
|
serverInformation.CalDavPassword.Should().BeEmpty();
|
||||||
|
serverInformation.CalDavServiceUrl.Should().Be("https://caldav.icloud.com/");
|
||||||
|
serverInformation.CalDavUsername.Should().Be("imap@example.com");
|
||||||
|
serverInformation.MaxConcurrentClients.Should().Be(7);
|
||||||
|
|
||||||
|
var mergedInboxIds = accounts
|
||||||
|
.Where(a => a.Address is "outlook@example.com" or "gmail@example.com")
|
||||||
|
.Select(a => a.MergedInboxId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
mergedInboxIds.Should().ContainSingle();
|
||||||
|
mergedInboxIds[0].Should().NotBeNull();
|
||||||
|
accounts.Single(a => a.Address == "outlook@example.com").MergedInbox.Name.Should().Be("Legacy Linked");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ImportAsync_WithMissingLegacySchemaColumns_DefaultsSafelyAndSkipsIncompleteMergedInboxes()
|
||||||
|
{
|
||||||
|
await using var context = await LegacyMigrationTestContext.CreateAsync(new LegacySchemaOptions(
|
||||||
|
IncludeCalDavColumns: false,
|
||||||
|
IncludeCalendarSupportMode: false,
|
||||||
|
IncludeTaskbarBadgeColumn: false,
|
||||||
|
IncludeFocusedInboxColumn: false));
|
||||||
|
|
||||||
|
await context.SeedCurrentAccountAsync(
|
||||||
|
"duplicate@icloud.com",
|
||||||
|
MailProviderType.IMAP4,
|
||||||
|
"Existing iCloud",
|
||||||
|
specialImapProvider: SpecialImapProvider.iCloud);
|
||||||
|
|
||||||
|
var mergedInboxId = Guid.NewGuid();
|
||||||
|
var duplicateLegacyAccountId = Guid.NewGuid();
|
||||||
|
var importableLegacyAccountId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await context.InsertLegacyMergedInboxAsync(mergedInboxId, "Legacy Incomplete");
|
||||||
|
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
duplicateLegacyAccountId,
|
||||||
|
"duplicate@icloud.com",
|
||||||
|
MailProviderType.IMAP4,
|
||||||
|
order: 0,
|
||||||
|
name: "Duplicate iCloud",
|
||||||
|
specialImapProvider: SpecialImapProvider.iCloud,
|
||||||
|
mergedInboxId: mergedInboxId);
|
||||||
|
|
||||||
|
await context.InsertLegacyAccountAsync(
|
||||||
|
importableLegacyAccountId,
|
||||||
|
"new@icloud.com",
|
||||||
|
MailProviderType.IMAP4,
|
||||||
|
order: 1,
|
||||||
|
name: "Importable iCloud",
|
||||||
|
specialImapProvider: SpecialImapProvider.iCloud,
|
||||||
|
mergedInboxId: mergedInboxId);
|
||||||
|
|
||||||
|
await context.InsertLegacyServerInformationAsync(
|
||||||
|
duplicateLegacyAccountId,
|
||||||
|
address: "duplicate@icloud.com",
|
||||||
|
incomingServer: "imap.mail.me.com",
|
||||||
|
incomingServerPort: "993",
|
||||||
|
incomingServerUsername: "duplicate",
|
||||||
|
outgoingServer: "smtp.mail.me.com",
|
||||||
|
outgoingServerPort: "587",
|
||||||
|
outgoingServerUsername: "duplicate");
|
||||||
|
|
||||||
|
await context.InsertLegacyServerInformationAsync(
|
||||||
|
importableLegacyAccountId,
|
||||||
|
address: "new@icloud.com",
|
||||||
|
incomingServer: "imap.mail.me.com",
|
||||||
|
incomingServerPort: "993",
|
||||||
|
incomingServerUsername: "new",
|
||||||
|
outgoingServer: "smtp.mail.me.com",
|
||||||
|
outgoingServerPort: "587",
|
||||||
|
outgoingServerUsername: "new");
|
||||||
|
|
||||||
|
var result = await context.Service.ImportAsync();
|
||||||
|
|
||||||
|
result.ImportedAccountCount.Should().Be(1);
|
||||||
|
result.SkippedDuplicateAccountCount.Should().Be(1);
|
||||||
|
result.ImportedMergedInboxCount.Should().Be(0);
|
||||||
|
result.SkippedMergedInboxCount.Should().Be(1);
|
||||||
|
|
||||||
|
var importedAccount = (await context.AccountService.GetAccountsAsync())
|
||||||
|
.Single(a => a.Address == "new@icloud.com");
|
||||||
|
|
||||||
|
importedAccount.IsCalendarAccessGranted.Should().BeFalse();
|
||||||
|
importedAccount.MergedInboxId.Should().BeNull();
|
||||||
|
importedAccount.AttentionReason.Should().Be(AccountAttentionReason.InvalidCredentials);
|
||||||
|
|
||||||
|
var importedServerInfo = await context.AccountService.GetAccountCustomServerInformationAsync(importedAccount.Id);
|
||||||
|
importedServerInfo.Should().NotBeNull();
|
||||||
|
importedServerInfo.CalDavServiceUrl.Should().Be("https://caldav.icloud.com/");
|
||||||
|
importedServerInfo.CalDavUsername.Should().Be("new@icloud.com");
|
||||||
|
importedServerInfo.CalDavPassword.Should().BeEmpty();
|
||||||
|
importedServerInfo.CalendarSupportMode.Should().Be(ImapCalendarSupportMode.Disabled);
|
||||||
|
importedServerInfo.IncomingServerPassword.Should().BeEmpty();
|
||||||
|
importedServerInfo.OutgoingServerPassword.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LegacyMigrationTestContext : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly SQLiteAsyncConnection _legacyConnection;
|
||||||
|
private readonly InMemoryDatabaseService _databaseService;
|
||||||
|
private readonly string _legacyFolderPath;
|
||||||
|
|
||||||
|
public LegacyLocalMigrationService Service { get; }
|
||||||
|
public AccountService AccountService { get; }
|
||||||
|
|
||||||
|
private LegacyMigrationTestContext(string legacyFolderPath,
|
||||||
|
SQLiteAsyncConnection legacyConnection,
|
||||||
|
InMemoryDatabaseService databaseService,
|
||||||
|
AccountService accountService,
|
||||||
|
LegacyLocalMigrationService service)
|
||||||
|
{
|
||||||
|
_legacyFolderPath = legacyFolderPath;
|
||||||
|
_legacyConnection = legacyConnection;
|
||||||
|
_databaseService = databaseService;
|
||||||
|
AccountService = accountService;
|
||||||
|
Service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<LegacyMigrationTestContext> CreateAsync(LegacySchemaOptions? schemaOptions = null)
|
||||||
|
{
|
||||||
|
var databaseService = new InMemoryDatabaseService();
|
||||||
|
await databaseService.InitializeAsync();
|
||||||
|
|
||||||
|
var preferencesService = new Mock<IPreferencesService>();
|
||||||
|
preferencesService.SetupProperty(a => a.StartupEntityId);
|
||||||
|
|
||||||
|
var accountService = CreateAccountService(databaseService, preferencesService.Object);
|
||||||
|
|
||||||
|
var legacyFolderPath = Path.Combine(Path.GetTempPath(), $"legacy-migration-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(legacyFolderPath);
|
||||||
|
|
||||||
|
var legacyDatabasePath = Path.Combine(legacyFolderPath, "Wino180.db");
|
||||||
|
var legacyConnection = new SQLiteAsyncConnection(legacyDatabasePath);
|
||||||
|
|
||||||
|
await CreateLegacySchemaAsync(legacyConnection, schemaOptions ?? LegacySchemaOptions.Default);
|
||||||
|
|
||||||
|
var applicationConfiguration = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationDataFolderPath = legacyFolderPath,
|
||||||
|
ApplicationTempFolderPath = legacyFolderPath,
|
||||||
|
PublisherSharedFolderPath = legacyFolderPath
|
||||||
|
};
|
||||||
|
|
||||||
|
var service = new LegacyLocalMigrationService(
|
||||||
|
applicationConfiguration,
|
||||||
|
new InMemoryConfigurationService(),
|
||||||
|
databaseService,
|
||||||
|
accountService,
|
||||||
|
new SpecialImapProviderConfigResolver());
|
||||||
|
|
||||||
|
return new LegacyMigrationTestContext(
|
||||||
|
legacyFolderPath,
|
||||||
|
legacyConnection,
|
||||||
|
databaseService,
|
||||||
|
accountService,
|
||||||
|
service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SeedCurrentAccountAsync(string address,
|
||||||
|
MailProviderType providerType,
|
||||||
|
string name,
|
||||||
|
SpecialImapProvider specialImapProvider = SpecialImapProvider.None)
|
||||||
|
{
|
||||||
|
var accountId = Guid.NewGuid();
|
||||||
|
CustomServerInformation? serverInformation = null;
|
||||||
|
|
||||||
|
if (providerType == MailProviderType.IMAP4)
|
||||||
|
{
|
||||||
|
serverInformation = new CustomServerInformation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AccountId = accountId,
|
||||||
|
Address = address,
|
||||||
|
IncomingServer = "imap.current.test",
|
||||||
|
IncomingServerPort = "993",
|
||||||
|
IncomingServerUsername = address,
|
||||||
|
IncomingServerPassword = "secret",
|
||||||
|
IncomingServerSocketOption = ImapConnectionSecurity.Auto,
|
||||||
|
IncomingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword,
|
||||||
|
OutgoingServer = "smtp.current.test",
|
||||||
|
OutgoingServerPort = "587",
|
||||||
|
OutgoingServerUsername = address,
|
||||||
|
OutgoingServerPassword = "secret",
|
||||||
|
OutgoingServerSocketOption = ImapConnectionSecurity.Auto,
|
||||||
|
OutgoingAuthenticationMethod = ImapAuthenticationMethod.NormalPassword,
|
||||||
|
CalDavServiceUrl = string.Empty,
|
||||||
|
CalDavUsername = string.Empty,
|
||||||
|
CalDavPassword = string.Empty,
|
||||||
|
CalendarSupportMode = ImapCalendarSupportMode.Disabled,
|
||||||
|
MaxConcurrentClients = 5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await AccountService.CreateAccountAsync(
|
||||||
|
new MailAccount
|
||||||
|
{
|
||||||
|
Id = accountId,
|
||||||
|
Name = name,
|
||||||
|
SenderName = name,
|
||||||
|
Address = address,
|
||||||
|
ProviderType = providerType,
|
||||||
|
SpecialImapProvider = specialImapProvider,
|
||||||
|
IsMailAccessGranted = true,
|
||||||
|
IsCalendarAccessGranted = providerType is MailProviderType.Outlook or MailProviderType.Gmail
|
||||||
|
},
|
||||||
|
serverInformation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InsertLegacyAccountAsync(Guid accountId,
|
||||||
|
string address,
|
||||||
|
MailProviderType providerType,
|
||||||
|
int order,
|
||||||
|
string name,
|
||||||
|
string? senderName = null,
|
||||||
|
SpecialImapProvider specialImapProvider = SpecialImapProvider.None,
|
||||||
|
Guid? mergedInboxId = null)
|
||||||
|
=> InsertRowAsync(
|
||||||
|
_legacyConnection,
|
||||||
|
"MailAccount",
|
||||||
|
("Id", accountId),
|
||||||
|
("Address", address),
|
||||||
|
("Name", name),
|
||||||
|
("SenderName", senderName ?? name),
|
||||||
|
("ProviderType", (int)providerType),
|
||||||
|
("SpecialImapProvider", (int)specialImapProvider),
|
||||||
|
("Order", order),
|
||||||
|
("AccountColorHex", "#123456"),
|
||||||
|
("MergedInboxId", mergedInboxId));
|
||||||
|
|
||||||
|
public Task InsertLegacyPreferencesAsync(Guid accountId,
|
||||||
|
bool? isNotificationsEnabled = null,
|
||||||
|
bool? isTaskbarBadgeEnabled = null,
|
||||||
|
bool? shouldAppendMessagesToSentFolder = null,
|
||||||
|
bool? isFocusedInboxEnabled = null,
|
||||||
|
Guid? signatureIdForNewMessages = null,
|
||||||
|
Guid? signatureIdForFollowingMessages = null)
|
||||||
|
{
|
||||||
|
var values = new List<(string Column, object? Value)>
|
||||||
|
{
|
||||||
|
("Id", Guid.NewGuid()),
|
||||||
|
("AccountId", accountId)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNotificationsEnabled.HasValue)
|
||||||
|
values.Add(("IsNotificationsEnabled", isNotificationsEnabled.Value));
|
||||||
|
|
||||||
|
if (isTaskbarBadgeEnabled.HasValue)
|
||||||
|
values.Add(("IsTaskbarBadgeEnabled", isTaskbarBadgeEnabled.Value));
|
||||||
|
|
||||||
|
if (shouldAppendMessagesToSentFolder.HasValue)
|
||||||
|
values.Add(("ShouldAppendMessagesToSentFolder", shouldAppendMessagesToSentFolder.Value));
|
||||||
|
|
||||||
|
if (isFocusedInboxEnabled.HasValue)
|
||||||
|
values.Add(("IsFocusedInboxEnabled", isFocusedInboxEnabled.Value));
|
||||||
|
|
||||||
|
if (signatureIdForNewMessages.HasValue)
|
||||||
|
values.Add(("SignatureIdForNewMessages", signatureIdForNewMessages.Value));
|
||||||
|
|
||||||
|
if (signatureIdForFollowingMessages.HasValue)
|
||||||
|
values.Add(("SignatureIdForFollowingMessages", signatureIdForFollowingMessages.Value));
|
||||||
|
|
||||||
|
return InsertRowAsync(_legacyConnection, "MailAccountPreferences", values.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InsertLegacyServerInformationAsync(Guid accountId,
|
||||||
|
string address,
|
||||||
|
string incomingServer,
|
||||||
|
string incomingServerPort,
|
||||||
|
string incomingServerUsername,
|
||||||
|
string outgoingServer,
|
||||||
|
string outgoingServerPort,
|
||||||
|
string outgoingServerUsername,
|
||||||
|
ImapCalendarSupportMode? calendarSupportMode = null,
|
||||||
|
string? calDavServiceUrl = null,
|
||||||
|
string? calDavUsername = null,
|
||||||
|
int? maxConcurrentClients = null)
|
||||||
|
{
|
||||||
|
var values = new List<(string Column, object? Value)>
|
||||||
|
{
|
||||||
|
("Id", Guid.NewGuid()),
|
||||||
|
("AccountId", accountId),
|
||||||
|
("Address", address),
|
||||||
|
("IncomingServer", incomingServer),
|
||||||
|
("IncomingServerPort", incomingServerPort),
|
||||||
|
("IncomingServerUsername", incomingServerUsername),
|
||||||
|
("IncomingServerSocketOption", (int)ImapConnectionSecurity.Auto),
|
||||||
|
("IncomingAuthenticationMethod", (int)ImapAuthenticationMethod.NormalPassword),
|
||||||
|
("OutgoingServer", outgoingServer),
|
||||||
|
("OutgoingServerPort", outgoingServerPort),
|
||||||
|
("OutgoingServerUsername", outgoingServerUsername),
|
||||||
|
("OutgoingServerSocketOption", (int)ImapConnectionSecurity.Auto),
|
||||||
|
("OutgoingAuthenticationMethod", (int)ImapAuthenticationMethod.NormalPassword),
|
||||||
|
("ProxyServer", "proxy.example.com"),
|
||||||
|
("ProxyServerPort", "8080"),
|
||||||
|
("MaxConcurrentClients", maxConcurrentClients ?? 5)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (calendarSupportMode.HasValue)
|
||||||
|
values.Add(("CalendarSupportMode", (int)calendarSupportMode.Value));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(calDavServiceUrl))
|
||||||
|
values.Add(("CalDavServiceUrl", calDavServiceUrl));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(calDavUsername))
|
||||||
|
values.Add(("CalDavUsername", calDavUsername));
|
||||||
|
|
||||||
|
return InsertRowAsync(_legacyConnection, "CustomServerInformation", values.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task InsertLegacyMergedInboxAsync(Guid mergedInboxId, string name)
|
||||||
|
=> InsertRowAsync(
|
||||||
|
_legacyConnection,
|
||||||
|
"MergedInbox",
|
||||||
|
("Id", mergedInboxId),
|
||||||
|
("Name", name));
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _legacyConnection.CloseAsync();
|
||||||
|
|
||||||
|
if (Directory.Exists(_legacyFolderPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_legacyFolderPath, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _databaseService.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InMemoryConfigurationService : IConfigurationService
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string?> _localValues = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, string?> _roamingValues = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public bool Contains(string key) => _localValues.ContainsKey(key);
|
||||||
|
|
||||||
|
public T Get<T>(string key, T defaultValue = default!)
|
||||||
|
=> TryGetValue(_localValues, key, defaultValue);
|
||||||
|
|
||||||
|
public T GetRoaming<T>(string key, T defaultValue = default!)
|
||||||
|
=> TryGetValue(_roamingValues, key, defaultValue);
|
||||||
|
|
||||||
|
public void Set(string key, object value)
|
||||||
|
=> _localValues[key] = value?.ToString();
|
||||||
|
|
||||||
|
public void SetRoaming(string key, object value)
|
||||||
|
=> _roamingValues[key] = value?.ToString();
|
||||||
|
|
||||||
|
private static T TryGetValue<T>(Dictionary<string, string?> values, string key, T defaultValue)
|
||||||
|
{
|
||||||
|
if (!values.TryGetValue(key, out var stringValue) || string.IsNullOrWhiteSpace(stringValue))
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof(T).IsEnum)
|
||||||
|
{
|
||||||
|
return (T)Enum.Parse(typeof(T), stringValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((typeof(T) == typeof(Guid) || typeof(T) == typeof(Guid?)) && Guid.TryParse(stringValue, out var guid))
|
||||||
|
{
|
||||||
|
return (T)(object)guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (T)Convert.ChangeType(stringValue, typeof(T));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record LegacySchemaOptions(
|
||||||
|
bool IncludeCalDavColumns,
|
||||||
|
bool IncludeCalendarSupportMode,
|
||||||
|
bool IncludeTaskbarBadgeColumn,
|
||||||
|
bool IncludeFocusedInboxColumn)
|
||||||
|
{
|
||||||
|
public static LegacySchemaOptions Default => new(true, true, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CreateLegacySchemaAsync(SQLiteAsyncConnection connection, LegacySchemaOptions options)
|
||||||
|
{
|
||||||
|
await connection.ExecuteAsync("""
|
||||||
|
CREATE TABLE MailAccount (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
Address TEXT NULL,
|
||||||
|
Name TEXT NULL,
|
||||||
|
SenderName TEXT NULL,
|
||||||
|
ProviderType INTEGER NOT NULL,
|
||||||
|
SpecialImapProvider INTEGER NOT NULL DEFAULT 0,
|
||||||
|
[Order] INTEGER NOT NULL DEFAULT 0,
|
||||||
|
AccountColorHex TEXT NULL,
|
||||||
|
MergedInboxId TEXT NULL
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
|
||||||
|
var preferenceColumns = new List<string>
|
||||||
|
{
|
||||||
|
"Id TEXT PRIMARY KEY",
|
||||||
|
"AccountId TEXT NOT NULL",
|
||||||
|
"IsNotificationsEnabled INTEGER NULL",
|
||||||
|
"ShouldAppendMessagesToSentFolder INTEGER NULL",
|
||||||
|
"SignatureIdForNewMessages TEXT NULL",
|
||||||
|
"SignatureIdForFollowingMessages TEXT NULL"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.IncludeTaskbarBadgeColumn)
|
||||||
|
preferenceColumns.Add("IsTaskbarBadgeEnabled INTEGER NULL");
|
||||||
|
|
||||||
|
if (options.IncludeFocusedInboxColumn)
|
||||||
|
preferenceColumns.Add("IsFocusedInboxEnabled INTEGER NULL");
|
||||||
|
|
||||||
|
await connection.ExecuteAsync($"CREATE TABLE MailAccountPreferences ({string.Join(", ", preferenceColumns)})");
|
||||||
|
|
||||||
|
var serverColumns = new List<string>
|
||||||
|
{
|
||||||
|
"Id TEXT PRIMARY KEY",
|
||||||
|
"AccountId TEXT NOT NULL",
|
||||||
|
"Address TEXT NULL",
|
||||||
|
"IncomingServer TEXT NULL",
|
||||||
|
"IncomingServerPort TEXT NULL",
|
||||||
|
"IncomingServerUsername TEXT NULL",
|
||||||
|
"IncomingServerSocketOption INTEGER NULL",
|
||||||
|
"IncomingAuthenticationMethod INTEGER NULL",
|
||||||
|
"OutgoingServer TEXT NULL",
|
||||||
|
"OutgoingServerPort TEXT NULL",
|
||||||
|
"OutgoingServerUsername TEXT NULL",
|
||||||
|
"OutgoingServerSocketOption INTEGER NULL",
|
||||||
|
"OutgoingAuthenticationMethod INTEGER NULL",
|
||||||
|
"ProxyServer TEXT NULL",
|
||||||
|
"ProxyServerPort TEXT NULL",
|
||||||
|
"MaxConcurrentClients INTEGER NULL"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.IncludeCalDavColumns)
|
||||||
|
{
|
||||||
|
serverColumns.Add("CalDavServiceUrl TEXT NULL");
|
||||||
|
serverColumns.Add("CalDavUsername TEXT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.IncludeCalendarSupportMode)
|
||||||
|
{
|
||||||
|
serverColumns.Add("CalendarSupportMode INTEGER NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.ExecuteAsync($"CREATE TABLE CustomServerInformation ({string.Join(", ", serverColumns)})");
|
||||||
|
await connection.ExecuteAsync("""
|
||||||
|
CREATE TABLE MergedInbox (
|
||||||
|
Id TEXT PRIMARY KEY,
|
||||||
|
Name TEXT NULL
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task InsertRowAsync(SQLiteAsyncConnection connection, string tableName, params (string Column, object? Value)[] values)
|
||||||
|
{
|
||||||
|
var columns = string.Join(", ", values.Select(a => $"[{a.Column}]"));
|
||||||
|
var placeholders = string.Join(", ", values.Select(_ => "?"));
|
||||||
|
var arguments = values.Select(a => ConvertLegacyValue(a.Value)).ToArray();
|
||||||
|
|
||||||
|
return connection.ExecuteAsync($"INSERT INTO [{tableName}] ({columns}) VALUES ({placeholders})", arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ConvertLegacyValue(object? value)
|
||||||
|
{
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
null => null,
|
||||||
|
bool boolValue => boolValue ? 1 : 0,
|
||||||
|
Guid guidValue => guidValue.ToString(),
|
||||||
|
_ => value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AccountService CreateAccountService(InMemoryDatabaseService databaseService, IPreferencesService preferencesService)
|
||||||
|
{
|
||||||
|
var signatureService = new Mock<ISignatureService>();
|
||||||
|
signatureService
|
||||||
|
.Setup(a => a.CreateDefaultSignatureAsync(It.IsAny<Guid>()))
|
||||||
|
.ReturnsAsync((Guid accountId) => new AccountSignature
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
MailAccountId = accountId,
|
||||||
|
Name = "Default",
|
||||||
|
HtmlBody = string.Empty
|
||||||
|
});
|
||||||
|
|
||||||
|
return new AccountService(
|
||||||
|
databaseService,
|
||||||
|
signatureService.Object,
|
||||||
|
Mock.Of<IAuthenticationProvider>(),
|
||||||
|
Mock.Of<IMimeFileService>(),
|
||||||
|
preferencesService,
|
||||||
|
Mock.Of<IContactPictureFileService>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -571,19 +571,6 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var aiStatus = aiStatusResponse.Result;
|
var aiStatus = aiStatusResponse.Result;
|
||||||
if (IsExpiredAiEntitlement(aiStatus.EntitlementStatus))
|
|
||||||
{
|
|
||||||
await ExecuteUIThread(() =>
|
|
||||||
{
|
|
||||||
_aiPackAddOn.IsPurchased = false;
|
|
||||||
_aiPackAddOn.HasUsageData = false;
|
|
||||||
_aiPackAddOn.ErrorText = string.Empty;
|
|
||||||
_aiPackAddOn.RenewalText = string.Empty;
|
|
||||||
_aiPackAddOn.UsageResetText = string.Empty;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aiStatus.MonthlyLimit is not int usageLimit || usageLimit <= 0 || aiStatus.Used is not int usageCount)
|
if (aiStatus.MonthlyLimit is not int usageLimit || usageLimit <= 0 || aiStatus.Used is not int usageCount)
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
await ExecuteUIThread(() =>
|
||||||
@@ -626,7 +613,4 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsExpiredAiEntitlement(string? entitlementStatus)
|
|
||||||
=> string.Equals(entitlementStatus, "Expired", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ public interface IDefaultChangeProcessor
|
|||||||
Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead);
|
Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead);
|
||||||
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
|
Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged);
|
||||||
Task<bool> CreateMailAsync(Guid AccountId, NewMailItemPackage package);
|
Task<bool> CreateMailAsync(Guid AccountId, NewMailItemPackage package);
|
||||||
Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages);
|
|
||||||
Task DeleteMailAsync(Guid accountId, string mailId);
|
Task DeleteMailAsync(Guid accountId, string mailId);
|
||||||
Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailIds);
|
|
||||||
Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds);
|
Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds);
|
||||||
Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId);
|
Task SaveMimeFileAsync(Guid fileId, MimeMessage mimeMessage, Guid accountId);
|
||||||
Task DeleteFolderAsync(Guid accountId, string remoteFolderId);
|
Task DeleteFolderAsync(Guid accountId, string remoteFolderId);
|
||||||
@@ -58,9 +56,6 @@ public interface IDefaultChangeProcessor
|
|||||||
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
|
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
|
||||||
Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
|
Task<List<MailCopy>> GetMailCopiesAsync(IEnumerable<string> mailCopyIds);
|
||||||
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
|
Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package);
|
||||||
Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates);
|
|
||||||
Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
|
|
||||||
Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments);
|
|
||||||
Task DeleteUserMailCacheAsync(Guid accountId);
|
Task DeleteUserMailCacheAsync(Guid accountId);
|
||||||
Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
|
Task UpsertMailInvitationCalendarMappingAsync(MailInvitationCalendarMapping mapping);
|
||||||
Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId);
|
Task<MailInvitationCalendarMapping> GetMailInvitationCalendarMappingAsync(Guid accountId, string mailCopyId);
|
||||||
@@ -161,33 +156,18 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
|
|||||||
public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead)
|
public Task ChangeMailReadStatusAsync(string mailCopyId, bool isRead)
|
||||||
=> MailService.ChangeReadStatusAsync(mailCopyId, isRead);
|
=> MailService.ChangeReadStatusAsync(mailCopyId, isRead);
|
||||||
|
|
||||||
public Task ApplyMailStateUpdatesAsync(IEnumerable<MailCopyStateUpdate> updates)
|
|
||||||
=> MailService.ApplyMailStateUpdatesAsync(updates);
|
|
||||||
|
|
||||||
public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
public Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
||||||
=> MailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId);
|
=> MailService.DeleteAssignmentAsync(accountId, mailCopyId, remoteFolderId);
|
||||||
|
|
||||||
public Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
|
|
||||||
=> MailService.DeleteAssignmentsAsync(accountId, assignments);
|
|
||||||
|
|
||||||
public Task DeleteMailAsync(Guid accountId, string mailId)
|
public Task DeleteMailAsync(Guid accountId, string mailId)
|
||||||
=> MailService.DeleteMailAsync(accountId, mailId);
|
=> MailService.DeleteMailAsync(accountId, mailId);
|
||||||
|
|
||||||
public Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailIds)
|
|
||||||
=> MailService.DeleteMailsAsync(accountId, mailIds);
|
|
||||||
|
|
||||||
public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
|
public Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
|
||||||
=> MailService.CreateMailAsync(accountId, package);
|
=> MailService.CreateMailAsync(accountId, package);
|
||||||
|
|
||||||
public Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages)
|
|
||||||
=> MailService.CreateMailsAsync(accountId, packages);
|
|
||||||
|
|
||||||
public Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
|
public Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
|
||||||
=> MailService.CreateMailRawAsync(account, mailItemFolder, package);
|
=> MailService.CreateMailRawAsync(account, mailItemFolder, package);
|
||||||
|
|
||||||
public Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
|
|
||||||
=> MailService.CreateAssignmentsAsync(accountId, assignments);
|
|
||||||
|
|
||||||
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
|
public Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
|
||||||
=> MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
|
=> MailService.MapLocalDraftAsync(accountId, localDraftCopyUniqueId, newMailCopyId, newDraftId, newThreadId);
|
||||||
|
|
||||||
|
|||||||
@@ -1050,11 +1050,6 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
{
|
{
|
||||||
_logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name);
|
_logger.Debug("Processing delta change {HistoryId} for {Name}", listHistoryResponse.HistoryId.GetValueOrDefault(), Account.Name);
|
||||||
|
|
||||||
var pendingStateUpdates = new List<MailCopyStateUpdate>();
|
|
||||||
var pendingAssignmentCreates = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
|
|
||||||
var pendingAssignmentDeletes = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
|
|
||||||
var deletedMessageIds = new HashSet<string>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
foreach (var history in listHistoryResponse.History)
|
foreach (var history in listHistoryResponse.History)
|
||||||
{
|
{
|
||||||
// Handle label additions.
|
// Handle label additions.
|
||||||
@@ -1062,7 +1057,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
{
|
{
|
||||||
foreach (var addedLabel in history.LabelsAdded)
|
foreach (var addedLabel in history.LabelsAdded)
|
||||||
{
|
{
|
||||||
await HandleLabelAssignmentAsync(addedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
|
await HandleLabelAssignmentAsync(addedLabel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,7 +1066,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
{
|
{
|
||||||
foreach (var removedLabel in history.LabelsRemoved)
|
foreach (var removedLabel in history.LabelsRemoved)
|
||||||
{
|
{
|
||||||
await HandleLabelRemovalAsync(removedLabel, pendingStateUpdates, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
|
await HandleLabelRemovalAsync(removedLabel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,108 +1079,36 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
|
|
||||||
_logger.Debug("Processing message deletion for {MessageId}", messageId);
|
_logger.Debug("Processing message deletion for {MessageId}", messageId);
|
||||||
|
|
||||||
deletedMessageIds.Add(messageId);
|
await _gmailChangeProcessor.DeleteMailAsync(Account.Id, messageId).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingStateUpdates.Count > 0)
|
|
||||||
{
|
|
||||||
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(pendingStateUpdates).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingAssignmentCreates.Count > 0)
|
|
||||||
{
|
|
||||||
await _gmailChangeProcessor.CreateAssignmentsAsync(Account.Id, pendingAssignmentCreates.Values.ToList()).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingAssignmentDeletes.Count > 0)
|
|
||||||
{
|
|
||||||
await _gmailChangeProcessor.DeleteAssignmentsAsync(Account.Id, pendingAssignmentDeletes.Values.ToList()).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedMessageIds.Count > 0)
|
|
||||||
{
|
|
||||||
await _gmailChangeProcessor.DeleteMailsAsync(Account.Id, deletedMessageIds).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetAssignmentChangeKey(string messageId, string labelId)
|
private async Task HandleArchiveAssignmentAsync(string archivedMessageId)
|
||||||
=> $"{messageId}\u001f{labelId}";
|
|
||||||
|
|
||||||
private static void QueueAssignmentChange(
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> creates,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> deletes,
|
|
||||||
MailFolderAssignmentUpdate assignment,
|
|
||||||
bool shouldCreate)
|
|
||||||
{
|
{
|
||||||
if (assignment == null ||
|
|
||||||
string.IsNullOrWhiteSpace(assignment.MailCopyId) ||
|
|
||||||
string.IsNullOrWhiteSpace(assignment.RemoteFolderId))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var key = GetAssignmentChangeKey(assignment.MailCopyId, assignment.RemoteFolderId);
|
|
||||||
|
|
||||||
if (shouldCreate)
|
|
||||||
{
|
|
||||||
deletes.Remove(key);
|
|
||||||
creates[key] = assignment;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
creates.Remove(key);
|
|
||||||
deletes[key] = assignment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleArchiveAssignmentAsync(
|
|
||||||
string archivedMessageId,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
|
|
||||||
{
|
|
||||||
if (!archiveFolderId.HasValue)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Ignore if the message is already in the archive.
|
// Ignore if the message is already in the archive.
|
||||||
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value).ConfigureAwait(false);
|
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(archivedMessageId, archiveFolderId.Value);
|
||||||
|
|
||||||
if (archived) return;
|
if (archived) return;
|
||||||
|
|
||||||
_logger.Debug("Processing archive assignment for message {Id}", archivedMessageId);
|
_logger.Debug("Processing archive assignment for message {Id}", archivedMessageId);
|
||||||
QueueAssignmentChange(
|
|
||||||
pendingAssignmentCreates,
|
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false);
|
||||||
pendingAssignmentDeletes,
|
|
||||||
new MailFolderAssignmentUpdate(archivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID),
|
|
||||||
shouldCreate: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleUnarchiveAssignmentAsync(
|
private async Task HandleUnarchiveAssignmentAsync(string unarchivedMessageId)
|
||||||
string unarchivedMessageId,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
|
|
||||||
{
|
{
|
||||||
if (!archiveFolderId.HasValue)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Ignore if the message is not in the archive.
|
// Ignore if the message is not in the archive.
|
||||||
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value).ConfigureAwait(false);
|
bool archived = await _gmailChangeProcessor.IsMailExistsInFolderAsync(unarchivedMessageId, archiveFolderId.Value);
|
||||||
if (!archived) return;
|
if (!archived) return;
|
||||||
|
|
||||||
_logger.Debug("Processing un-archive assignment for message {Id}", unarchivedMessageId);
|
_logger.Debug("Processing un-archive assignment for message {Id}", unarchivedMessageId);
|
||||||
QueueAssignmentChange(
|
|
||||||
pendingAssignmentCreates,
|
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID).ConfigureAwait(false);
|
||||||
pendingAssignmentDeletes,
|
|
||||||
new MailFolderAssignmentUpdate(unarchivedMessageId, ServiceConstants.ARCHIVE_LABEL_ID),
|
|
||||||
shouldCreate: false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleLabelAssignmentAsync(
|
private async Task HandleLabelAssignmentAsync(HistoryLabelAdded addedLabel)
|
||||||
HistoryLabelAdded addedLabel,
|
|
||||||
List<MailCopyStateUpdate> pendingStateUpdates,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
|
|
||||||
{
|
{
|
||||||
var messageId = addedLabel.Message.Id;
|
var messageId = addedLabel.Message.Id;
|
||||||
|
|
||||||
@@ -1196,31 +1119,23 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
// ARCHIVE is a virtual folder - handle it separately
|
// ARCHIVE is a virtual folder - handle it separately
|
||||||
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
|
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
|
||||||
{
|
{
|
||||||
await HandleArchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
|
await HandleArchiveAssignmentAsync(messageId).ConfigureAwait(false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When UNREAD label is added mark the message as un-read.
|
// When UNREAD label is added mark the message as un-read.
|
||||||
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
||||||
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: false));
|
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, false).ConfigureAwait(false);
|
||||||
|
|
||||||
// When STARRED label is added mark the message as flagged.
|
// When STARRED label is added mark the message as flagged.
|
||||||
if (labelId == ServiceConstants.STARRED_LABEL_ID)
|
if (labelId == ServiceConstants.STARRED_LABEL_ID)
|
||||||
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: true));
|
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, true).ConfigureAwait(false);
|
||||||
|
|
||||||
QueueAssignmentChange(
|
await _gmailChangeProcessor.CreateAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
|
||||||
pendingAssignmentCreates,
|
|
||||||
pendingAssignmentDeletes,
|
|
||||||
new MailFolderAssignmentUpdate(messageId, labelId),
|
|
||||||
shouldCreate: true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleLabelRemovalAsync(
|
private async Task HandleLabelRemovalAsync(HistoryLabelRemoved removedLabel)
|
||||||
HistoryLabelRemoved removedLabel,
|
|
||||||
List<MailCopyStateUpdate> pendingStateUpdates,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentCreates,
|
|
||||||
Dictionary<string, MailFolderAssignmentUpdate> pendingAssignmentDeletes)
|
|
||||||
{
|
{
|
||||||
var messageId = removedLabel.Message.Id;
|
var messageId = removedLabel.Message.Id;
|
||||||
|
|
||||||
@@ -1231,23 +1146,20 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
// ARCHIVE is a virtual folder - handle it separately
|
// ARCHIVE is a virtual folder - handle it separately
|
||||||
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
|
if (labelId == ServiceConstants.ARCHIVE_LABEL_ID)
|
||||||
{
|
{
|
||||||
await HandleUnarchiveAssignmentAsync(messageId, pendingAssignmentCreates, pendingAssignmentDeletes).ConfigureAwait(false);
|
await HandleUnarchiveAssignmentAsync(messageId).ConfigureAwait(false);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When UNREAD label is removed mark the message as read.
|
// When UNREAD label is removed mark the message as read.
|
||||||
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
if (labelId == ServiceConstants.UNREAD_LABEL_ID)
|
||||||
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsRead: true));
|
await _gmailChangeProcessor.ChangeMailReadStatusAsync(messageId, true).ConfigureAwait(false);
|
||||||
|
|
||||||
// When STARRED label is removed mark the message as un-flagged.
|
// When STARRED label is removed mark the message as un-flagged.
|
||||||
if (labelId == ServiceConstants.STARRED_LABEL_ID)
|
if (labelId == ServiceConstants.STARRED_LABEL_ID)
|
||||||
pendingStateUpdates.Add(new MailCopyStateUpdate(messageId, IsFlagged: false));
|
await _gmailChangeProcessor.ChangeFlagStatusAsync(messageId, false).ConfigureAwait(false);
|
||||||
|
|
||||||
QueueAssignmentChange(
|
// For other labels remove the mail assignment.
|
||||||
pendingAssignmentCreates,
|
await _gmailChangeProcessor.DeleteAssignmentAsync(Account.Id, messageId, labelId).ConfigureAwait(false);
|
||||||
pendingAssignmentDeletes,
|
|
||||||
new MailFolderAssignmentUpdate(messageId, labelId),
|
|
||||||
shouldCreate: false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1630,8 +1542,6 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
await Task.WhenAll(batchTasks).ConfigureAwait(false);
|
await Task.WhenAll(batchTasks).ConfigureAwait(false);
|
||||||
|
|
||||||
// Process all downloaded messages
|
// Process all downloaded messages
|
||||||
var pendingPackages = new List<NewMailItemPackage>();
|
|
||||||
|
|
||||||
foreach (var gmailMessage in downloadedMessages)
|
foreach (var gmailMessage in downloadedMessages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1642,7 +1552,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
|
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (packages != null)
|
if (packages != null)
|
||||||
pendingPackages.AddRange(packages);
|
{
|
||||||
|
foreach (var package in packages)
|
||||||
|
{
|
||||||
|
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update sync identifier if available
|
// Update sync identifier if available
|
||||||
if (gmailMessage.HistoryId.HasValue)
|
if (gmailMessage.HistoryId.HasValue)
|
||||||
@@ -1655,11 +1570,6 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
_logger.Error(ex, "Failed to process downloaded message {MessageId}", gmailMessage.Id);
|
_logger.Error(ex, "Failed to process downloaded message {MessageId}", gmailMessage.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingPackages.Count > 0)
|
|
||||||
{
|
|
||||||
await _gmailChangeProcessor.CreateMailsAsync(Account.Id, pendingPackages).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1682,9 +1592,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
// Create mail packages from metadata
|
// Create mail packages from metadata
|
||||||
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
|
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (packages != null && packages.Count > 0)
|
if (packages != null)
|
||||||
{
|
{
|
||||||
await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false);
|
foreach (var package in packages)
|
||||||
|
{
|
||||||
|
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sync identifier if available
|
// Update sync identifier if available
|
||||||
@@ -2029,9 +1942,12 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
// Create mail packages from the downloaded message
|
// Create mail packages from the downloaded message
|
||||||
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
|
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (packages != null && packages.Count > 0)
|
if (packages != null)
|
||||||
{
|
{
|
||||||
await _gmailChangeProcessor.CreateMailsAsync(Account.Id, packages).ConfigureAwait(false);
|
foreach (var package in packages)
|
||||||
|
{
|
||||||
|
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId).ConfigureAwait(false);
|
await UpdateAccountSyncIdentifierAsync(gmailMessage.HistoryId).ConfigureAwait(false);
|
||||||
@@ -2083,27 +1999,25 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
switch (bundle.UIChangeRequest)
|
switch (bundle.UIChangeRequest)
|
||||||
{
|
{
|
||||||
case BatchMarkReadRequest batchMarkReadRequest:
|
case BatchMarkReadRequest batchMarkReadRequest:
|
||||||
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
|
foreach (var request in batchMarkReadRequest)
|
||||||
batchMarkReadRequest.Select(request => new MailCopyStateUpdate(request.Item.Id, IsRead: request.IsRead)))
|
{
|
||||||
.ConfigureAwait(false);
|
await _gmailChangeProcessor.ChangeMailReadStatusAsync(request.Item.Id, request.IsRead).ConfigureAwait(false);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MarkReadRequest markReadRequest:
|
case MarkReadRequest markReadRequest:
|
||||||
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
|
await _gmailChangeProcessor.ChangeMailReadStatusAsync(markReadRequest.Item.Id, markReadRequest.IsRead).ConfigureAwait(false);
|
||||||
[new MailCopyStateUpdate(markReadRequest.Item.Id, IsRead: markReadRequest.IsRead)])
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case BatchChangeFlagRequest batchChangeFlagRequest:
|
case BatchChangeFlagRequest batchChangeFlagRequest:
|
||||||
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
|
foreach (var request in batchChangeFlagRequest)
|
||||||
batchChangeFlagRequest.Select(request => new MailCopyStateUpdate(request.Item.Id, IsFlagged: request.IsFlagged)))
|
{
|
||||||
.ConfigureAwait(false);
|
await _gmailChangeProcessor.ChangeFlagStatusAsync(request.Item.Id, request.IsFlagged).ConfigureAwait(false);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case ChangeFlagRequest changeFlagRequest:
|
case ChangeFlagRequest changeFlagRequest:
|
||||||
await _gmailChangeProcessor.ApplyMailStateUpdatesAsync(
|
await _gmailChangeProcessor.ChangeFlagStatusAsync(changeFlagRequest.Item.Id, changeFlagRequest.IsFlagged).ConfigureAwait(false);
|
||||||
[new MailCopyStateUpdate(changeFlagRequest.Item.Id, IsFlagged: changeFlagRequest.IsFlagged)])
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2161,31 +2075,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
|||||||
}
|
}
|
||||||
|
|
||||||
var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
|
var existingAfterDownload = await _gmailChangeProcessor.AreMailsExistsAsync(addedArchiveIds).ConfigureAwait(false);
|
||||||
var pendingArchiveCreates = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
|
|
||||||
var pendingArchiveDeletes = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
foreach (var archiveAddedItem in existingAfterDownload)
|
foreach (var archiveAddedItem in existingAfterDownload)
|
||||||
{
|
{
|
||||||
await HandleArchiveAssignmentAsync(archiveAddedItem, pendingArchiveCreates, pendingArchiveDeletes).ConfigureAwait(false);
|
await HandleArchiveAssignmentAsync(archiveAddedItem).ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingArchiveCreates.Count > 0)
|
|
||||||
{
|
|
||||||
await _gmailChangeProcessor.CreateAssignmentsAsync(Account.Id, pendingArchiveCreates.Values.ToList()).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingArchiveRemovals = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
|
|
||||||
var pendingArchiveCreateOverrides = new Dictionary<string, MailFolderAssignmentUpdate>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
foreach (var unAarchivedRemovedItem in removedArchiveIds)
|
foreach (var unAarchivedRemovedItem in removedArchiveIds)
|
||||||
{
|
{
|
||||||
await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem, pendingArchiveCreateOverrides, pendingArchiveRemovals).ConfigureAwait(false);
|
await HandleUnarchiveAssignmentAsync(unAarchivedRemovedItem).ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingArchiveRemovals.Count > 0)
|
|
||||||
{
|
|
||||||
await _gmailChangeProcessor.DeleteAssignmentsAsync(Account.Id, pendingArchiveRemovals.Values.ToList()).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ using Wino.Core.Domain.Entities.Shared;
|
|||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
using Wino.Core.Domain.Exceptions;
|
using Wino.Core.Domain.Exceptions;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
using Wino.Core.Domain.Models.Navigation;
|
using Wino.Core.Domain.Models.Navigation;
|
||||||
using Wino.Core.Domain.Models.Synchronization;
|
using Wino.Core.Domain.Models.Synchronization;
|
||||||
@@ -35,6 +36,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
|
|||||||
private static readonly UTF8Encoding Utf8WithoutBom = new(false);
|
private static readonly UTF8Encoding Utf8WithoutBom = new(false);
|
||||||
|
|
||||||
private readonly IWinoAccountDataSyncService _syncService;
|
private readonly IWinoAccountDataSyncService _syncService;
|
||||||
|
private readonly ILegacyLocalMigrationService _legacyLocalMigrationService;
|
||||||
private readonly IWinoLogger _winoLogger;
|
private readonly IWinoLogger _winoLogger;
|
||||||
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
|
private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver;
|
||||||
private readonly ICalDavClient _calDavClient;
|
private readonly ICalDavClient _calDavClient;
|
||||||
@@ -48,6 +50,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
|
|||||||
IStoreManagementService storeManagementService,
|
IStoreManagementService storeManagementService,
|
||||||
IWinoAccountProfileService winoAccountProfileService,
|
IWinoAccountProfileService winoAccountProfileService,
|
||||||
IWinoAccountDataSyncService syncService,
|
IWinoAccountDataSyncService syncService,
|
||||||
|
ILegacyLocalMigrationService legacyLocalMigrationService,
|
||||||
IWinoLogger winoLogger,
|
IWinoLogger winoLogger,
|
||||||
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
|
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
|
||||||
ICalDavClient calDavClient,
|
ICalDavClient calDavClient,
|
||||||
@@ -56,6 +59,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
|
|||||||
{
|
{
|
||||||
MailDialogService = dialogService;
|
MailDialogService = dialogService;
|
||||||
_syncService = syncService;
|
_syncService = syncService;
|
||||||
|
_legacyLocalMigrationService = legacyLocalMigrationService;
|
||||||
_winoLogger = winoLogger;
|
_winoLogger = winoLogger;
|
||||||
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
|
_specialImapProviderConfigResolver = specialImapProviderConfigResolver;
|
||||||
_calDavClient = calDavClient;
|
_calDavClient = calDavClient;
|
||||||
@@ -64,8 +68,26 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportLocalDataCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ExportLocalDataCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ImportLocalDataCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ImportLocalDataCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))]
|
||||||
public partial bool IsDataTransferInProgress { get; set; }
|
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]
|
[RelayCommand]
|
||||||
private async Task CreateMergedAccountAsync()
|
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 CanTransferLocalData() => !IsDataTransferInProgress;
|
||||||
|
private bool CanImportLegacyDatabase() => !IsDataTransferInProgress && HasLegacyImportAvailable;
|
||||||
|
|
||||||
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
public override void OnNavigatedFrom(NavigationMode mode, object parameters)
|
||||||
{
|
{
|
||||||
@@ -350,6 +407,7 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
|
|||||||
Accounts.CollectionChanged += AccountCollectionChanged;
|
Accounts.CollectionChanged += AccountCollectionChanged;
|
||||||
|
|
||||||
await InitializeAccountsAsync();
|
await InitializeAccountsAsync();
|
||||||
|
await RefreshLegacyMigrationPreviewAsync();
|
||||||
|
|
||||||
PropertyChanged -= PagePropertyChanged;
|
PropertyChanged -= PagePropertyChanged;
|
||||||
PropertyChanged += PagePropertyChanged;
|
PropertyChanged += PagePropertyChanged;
|
||||||
@@ -403,6 +461,19 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
|
|||||||
await ManageStorePurchasesAsync().ConfigureAwait(false);
|
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)
|
private static string BuildExportSuccessMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncExportResult result)
|
||||||
{
|
{
|
||||||
var parts = new Collection<string>();
|
var parts = new Collection<string>();
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ namespace Wino.Mail.ViewModels.Collections;
|
|||||||
|
|
||||||
public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsChangedMessage>
|
public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsChangedMessage>
|
||||||
{
|
{
|
||||||
private const int UiMutationBatchSize = 40;
|
|
||||||
|
|
||||||
// We cache each mail copy id for faster access on updates.
|
// We cache each mail copy id for faster access on updates.
|
||||||
// If the item provider here for update or removal doesn't exist here
|
// If the item provider here for update or removal doesn't exist here
|
||||||
// we can ignore the operation.
|
// we can ignore the operation.
|
||||||
@@ -765,21 +763,18 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
// Execute all updates in a single UI thread call
|
// Execute all updates in a single UI thread call
|
||||||
if (itemsToUpdate.Count > 0)
|
if (itemsToUpdate.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var updateBatch in itemsToUpdate.Chunk(UiMutationBatchSize))
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
foreach (var (existing, updated) in itemsToUpdate)
|
||||||
{
|
{
|
||||||
foreach (var (existing, updated) in updateBatch)
|
UpdateUniqueIdHashes(existing, false);
|
||||||
{
|
existing.UpdateFrom(updated);
|
||||||
UpdateUniqueIdHashes(existing, false);
|
UpdateUniqueIdHashes(existing, true);
|
||||||
existing.UpdateFrom(updated);
|
}
|
||||||
UpdateUniqueIdHashes(existing, true);
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group items by their grouping key and add them in bounded UI thread calls.
|
// Group items by their grouping key and add them in a single UI thread call
|
||||||
if (itemsToAdd.Count > 0)
|
if (itemsToAdd.Count > 0)
|
||||||
{
|
{
|
||||||
var groupedItems = await Task.Run(() => itemsToAdd
|
var groupedItems = await Task.Run(() => itemsToAdd
|
||||||
@@ -792,35 +787,32 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
})
|
})
|
||||||
.ToList()).ConfigureAwait(false);
|
.ToList()).ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var groupedItem in groupedItems)
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
var groupKey = groupedItem.Key;
|
foreach (var groupedItem in groupedItems)
|
||||||
var groupItems = groupedItem.Items;
|
|
||||||
|
|
||||||
foreach (var groupBatch in groupItems.Chunk(UiMutationBatchSize))
|
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
var groupKey = groupedItem.Key;
|
||||||
|
var groupItems = groupedItem.Items;
|
||||||
|
|
||||||
|
// Update caches first
|
||||||
|
foreach (var item in groupItems)
|
||||||
{
|
{
|
||||||
// Update caches first so lookup helpers remain consistent during inserts.
|
UpdateUniqueIdHashes(item, true);
|
||||||
foreach (var item in groupBatch)
|
UpdateThreadIdCache(item, true);
|
||||||
{
|
}
|
||||||
UpdateUniqueIdHashes(item, true);
|
|
||||||
UpdateThreadIdCache(item, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var item in groupBatch)
|
foreach (var item in groupItems)
|
||||||
{
|
{
|
||||||
_mailItemSource.InsertItem(groupKey, listComparer, item, listComparer);
|
_mailItemSource.InsertItem(groupKey, listComparer, item, listComparer);
|
||||||
|
|
||||||
var targetGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
|
var targetGroup = _mailItemSource.FirstGroupByKeyOrDefault(groupKey);
|
||||||
if (targetGroup != null)
|
if (targetGroup != null)
|
||||||
{
|
{
|
||||||
_itemToGroupMap[item] = targetGroup;
|
_itemToGroupMap[item] = targetGroup;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,23 +972,20 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
if (updates.Count == 0)
|
if (updates.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
foreach (var updateBatch in updates.Chunk(UiMutationBatchSize))
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
foreach (var update in updates)
|
||||||
{
|
{
|
||||||
foreach (var update in updateBatch)
|
var existingItem = update.ItemContainer.ItemViewModel;
|
||||||
{
|
var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged);
|
||||||
var existingItem = update.ItemContainer.ItemViewModel;
|
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
|
||||||
var appliedChanges = existingItem.ApplyStateChanges(update.UpdatedState.IsRead, update.UpdatedState.IsFlagged);
|
|
||||||
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
|
|
||||||
|
|
||||||
if (update.ItemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
|
if (update.ItemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
|
||||||
{
|
{
|
||||||
update.ItemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
|
update.ItemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task UpdateMailCopiesInternalAsync(IEnumerable<MailCopy> updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties)
|
private async Task UpdateMailCopiesInternalAsync(IEnumerable<MailCopy> updatedMailCopies, EntityUpdateSource mailUpdateSource, MailCopyChangeFlags changedProperties)
|
||||||
@@ -1029,25 +1018,22 @@ public class WinoMailCollection : ObservableRecipient, IRecipient<SelectedItemsC
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var updateBatch in updates.Chunk(UiMutationBatchSize))
|
await ExecuteUIThread(() =>
|
||||||
{
|
{
|
||||||
await ExecuteUIThread(() =>
|
foreach (var update in updates)
|
||||||
{
|
{
|
||||||
foreach (var update in updateBatch)
|
var updatedMail = update.UpdatedMail;
|
||||||
{
|
var itemContainer = update.ItemContainer;
|
||||||
var updatedMail = update.UpdatedMail;
|
var existingItem = itemContainer.ItemViewModel;
|
||||||
var itemContainer = update.ItemContainer;
|
var appliedChanges = existingItem.UpdateFrom(updatedMail, changedProperties);
|
||||||
var existingItem = itemContainer.ItemViewModel;
|
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
|
||||||
var appliedChanges = existingItem.UpdateFrom(updatedMail, changedProperties);
|
|
||||||
existingItem.IsBusy = mailUpdateSource == EntityUpdateSource.ClientUpdated;
|
|
||||||
|
|
||||||
if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
|
if (itemContainer.ThreadViewModel != null && appliedChanges != MailCopyChangeFlags.None)
|
||||||
{
|
{
|
||||||
itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
|
itemContainer.ThreadViewModel.NotifyMailItemUpdated(existingItem, appliedChanges);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0);
|
public MailItemViewModel GetFirst() => AllItems.ElementAtOrDefault(0);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Wino.Core.Domain;
|
||||||
|
using Wino.Core.Domain.Enums;
|
||||||
|
using Wino.Core.Domain.Models.Accounts;
|
||||||
|
|
||||||
|
namespace Wino.Mail.ViewModels.Data;
|
||||||
|
|
||||||
|
internal static class LegacyLocalMigrationFormatter
|
||||||
|
{
|
||||||
|
public static string BuildPreviewSummary(LegacyLocalMigrationPreview preview)
|
||||||
|
{
|
||||||
|
if (!preview.HasImportableData)
|
||||||
|
{
|
||||||
|
return Translator.LegacyLocalMigration_ImportEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerSummary = string.Join(", ", preview.ProviderCounts
|
||||||
|
.Where(a => a.ImportableAccountCount > 0)
|
||||||
|
.Select(a => $"{a.ImportableAccountCount} {GetProviderName(a.ProviderType)}"));
|
||||||
|
|
||||||
|
var parts = new List<string>
|
||||||
|
{
|
||||||
|
string.Format(Translator.LegacyLocalMigration_PreviewSummary, preview.ImportableAccountCount, providerSummary)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preview.DuplicateAccountCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(Translator.LegacyLocalMigration_PreviewDuplicateSummary, preview.DuplicateAccountCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.ImportableMergedInboxCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(Translator.LegacyLocalMigration_PreviewMergedSummary, preview.ImportableMergedInboxCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" ", parts.Where(a => !string.IsNullOrWhiteSpace(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildWarningSummary(LegacyLocalMigrationPreview preview)
|
||||||
|
=> string.Join(Environment.NewLine, preview.Warnings.Where(a => !string.IsNullOrWhiteSpace(a)).Distinct(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
public static string BuildImportMessage(LegacyLocalMigrationResult result)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
|
||||||
|
if (result.ImportedAccountCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportAccountsSucceeded, result.ImportedAccountCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.SkippedDuplicateAccountCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(Translator.WinoAccount_Management_ImportDuplicateAccountsSkipped, result.SkippedDuplicateAccountCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ImportedMergedInboxCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSucceeded, result.ImportedMergedInboxCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.SkippedMergedInboxCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportMergedInboxesSkipped, result.SkippedMergedInboxCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.FailedAccountCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(string.Format(Translator.LegacyLocalMigration_ImportFailedAccounts, result.FailedAccountCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.Count == 0)
|
||||||
|
{
|
||||||
|
parts.Add(Translator.LegacyLocalMigration_ImportEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ImportedAccountCount > 0)
|
||||||
|
{
|
||||||
|
parts.Add(Translator.WinoAccount_Management_ImportReloginReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildPromptMessage(LegacyLocalMigrationPreview preview)
|
||||||
|
{
|
||||||
|
var summary = BuildPreviewSummary(preview);
|
||||||
|
var warnings = BuildWarningSummary(preview);
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(warnings)
|
||||||
|
? summary
|
||||||
|
: $"{summary}{Environment.NewLine}{Environment.NewLine}{warnings}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetProviderName(MailProviderType providerType)
|
||||||
|
{
|
||||||
|
return providerType switch
|
||||||
|
{
|
||||||
|
MailProviderType.Outlook => Translator.LegacyLocalMigration_Provider_Outlook,
|
||||||
|
MailProviderType.Gmail => Translator.LegacyLocalMigration_Provider_Gmail,
|
||||||
|
MailProviderType.IMAP4 => Translator.LegacyLocalMigration_Provider_Imap,
|
||||||
|
_ => providerType.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,10 +90,12 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService;
|
private readonly IWebView2RuntimeValidatorService _webView2RuntimeValidatorService;
|
||||||
private readonly IStoreUpdateService _storeUpdateService;
|
private readonly IStoreUpdateService _storeUpdateService;
|
||||||
private readonly IShareActivationService _shareActivationService;
|
private readonly IShareActivationService _shareActivationService;
|
||||||
|
private readonly ILegacyLocalMigrationService _legacyLocalMigrationService;
|
||||||
|
|
||||||
private readonly INativeAppService _nativeAppService;
|
private readonly INativeAppService _nativeAppService;
|
||||||
private readonly IMailService _mailService;
|
private readonly IMailService _mailService;
|
||||||
private bool _hasRegisteredPersistentRecipients;
|
private bool _hasRegisteredPersistentRecipients;
|
||||||
|
private bool _hasHandledLegacyMigrationPrompt;
|
||||||
private readonly SemaphoreSlim _menuRefreshSemaphore = new(1, 1);
|
private readonly SemaphoreSlim _menuRefreshSemaphore = new(1, 1);
|
||||||
|
|
||||||
private readonly SemaphoreSlim accountInitFolderUpdateSlim = new SemaphoreSlim(1);
|
private readonly SemaphoreSlim accountInitFolderUpdateSlim = new SemaphoreSlim(1);
|
||||||
@@ -117,7 +119,8 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
IStartupBehaviorService startupBehaviorService,
|
IStartupBehaviorService startupBehaviorService,
|
||||||
IWebView2RuntimeValidatorService webView2RuntimeValidatorService,
|
IWebView2RuntimeValidatorService webView2RuntimeValidatorService,
|
||||||
IStoreUpdateService storeUpdateService,
|
IStoreUpdateService storeUpdateService,
|
||||||
IShareActivationService shareActivationService)
|
IShareActivationService shareActivationService,
|
||||||
|
ILegacyLocalMigrationService legacyLocalMigrationService)
|
||||||
{
|
{
|
||||||
StatePersistenceService = statePersistanceService;
|
StatePersistenceService = statePersistanceService;
|
||||||
|
|
||||||
@@ -141,6 +144,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
_webView2RuntimeValidatorService = webView2RuntimeValidatorService;
|
_webView2RuntimeValidatorService = webView2RuntimeValidatorService;
|
||||||
_storeUpdateService = storeUpdateService;
|
_storeUpdateService = storeUpdateService;
|
||||||
_shareActivationService = shareActivationService;
|
_shareActivationService = shareActivationService;
|
||||||
|
_legacyLocalMigrationService = legacyLocalMigrationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDispatcherAssigned()
|
protected override void OnDispatcherAssigned()
|
||||||
@@ -286,6 +290,7 @@ public partial class MailAppShellViewModel : MailBaseViewModel,
|
|||||||
await ProcessLaunchOptionsAsync();
|
await ProcessLaunchOptionsAsync();
|
||||||
await HandlePendingShareRequestAsync();
|
await HandlePendingShareRequestAsync();
|
||||||
await ValidateWebView2RuntimeAsync();
|
await ValidateWebView2RuntimeAsync();
|
||||||
|
await PromptLegacyMigrationIfNeededAsync(shouldRunStartupFlows);
|
||||||
|
|
||||||
if (shouldRunStartupFlows && !Debugger.IsAttached)
|
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()
|
private async Task ValidateWebView2RuntimeAsync()
|
||||||
{
|
{
|
||||||
var isRuntimeAvailable = await _webView2RuntimeValidatorService.IsRuntimeAvailableAsync();
|
var isRuntimeAvailable = await _webView2RuntimeValidatorService.IsRuntimeAvailableAsync();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
|
|||||||
private readonly IUpdateManager _updateManager;
|
private readonly IUpdateManager _updateManager;
|
||||||
private readonly IMailDialogService _dialogService;
|
private readonly IMailDialogService _dialogService;
|
||||||
private readonly IWinoAccountDataSyncService _syncService;
|
private readonly IWinoAccountDataSyncService _syncService;
|
||||||
|
private readonly ILegacyLocalMigrationService _legacyLocalMigrationService;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial List<UpdateNoteSection> UpdateSections { get; set; } = [];
|
public partial List<UpdateNoteSection> UpdateSections { get; set; } = [];
|
||||||
@@ -32,21 +33,40 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
|
|||||||
[NotifyCanExecuteChangedFor(nameof(GetStartedCommand))]
|
[NotifyCanExecuteChangedFor(nameof(GetStartedCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ImportFromWinoAccountCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ImportFromJsonCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ImportFromJsonCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ImportLegacyDatabaseCommand))]
|
||||||
public partial bool IsImportInProgress { get; set; }
|
public partial bool IsImportInProgress { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(HasImportStatus))]
|
[NotifyPropertyChangedFor(nameof(HasImportStatus))]
|
||||||
public partial string ImportStatusMessage { get; set; } = string.Empty;
|
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 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,
|
public WelcomePageV2ViewModel(IUpdateManager updateManager,
|
||||||
IMailDialogService dialogService,
|
IMailDialogService dialogService,
|
||||||
IWinoAccountDataSyncService syncService)
|
IWinoAccountDataSyncService syncService,
|
||||||
|
ILegacyLocalMigrationService legacyLocalMigrationService)
|
||||||
{
|
{
|
||||||
_updateManager = updateManager;
|
_updateManager = updateManager;
|
||||||
_dialogService = dialogService;
|
_dialogService = dialogService;
|
||||||
_syncService = syncService;
|
_syncService = syncService;
|
||||||
|
_legacyLocalMigrationService = legacyLocalMigrationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
public override async void OnNavigatedTo(NavigationMode mode, object parameters)
|
||||||
@@ -62,6 +82,15 @@ public partial class WelcomePageV2ViewModel : MailBaseViewModel
|
|||||||
{
|
{
|
||||||
UpdateSections = [];
|
UpdateSections = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LegacyMigrationPreview = await _legacyLocalMigrationService.DetectAsync();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
LegacyMigrationPreview = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanOpenWelcomeActions))]
|
[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 CanOpenWelcomeActions() => !IsImportInProgress;
|
||||||
|
private bool CanImportLegacyDatabase() => !IsImportInProgress && HasLegacyImportPreview;
|
||||||
|
|
||||||
private static string BuildInlineImportMessage(WinoAccountSyncImportResult result)
|
private static string BuildInlineImportMessage(WinoAccountSyncImportResult result)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -479,10 +479,8 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
EnsureAppNotificationRegistration();
|
EnsureAppNotificationRegistration();
|
||||||
|
|
||||||
await TranslationService.InitializeAsync();
|
await Services.GetRequiredService<ILegacyLocalMigrationService>()
|
||||||
|
.DetectAsync();
|
||||||
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
|
|
||||||
.RunIfNeededAsync();
|
|
||||||
|
|
||||||
await InitializeServicesAsync();
|
await InitializeServicesAsync();
|
||||||
|
|
||||||
@@ -492,6 +490,9 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
_hasConfiguredAccounts = (await _accountService.GetAccountsAsync()).Any();
|
_hasConfiguredAccounts = (await _accountService.GetAccountsAsync()).Any();
|
||||||
|
|
||||||
|
await Services.GetRequiredService<ReleaseLocalAccountDataCleanupService>()
|
||||||
|
.RunIfNeededAsync();
|
||||||
|
|
||||||
_activationInfrastructureInitialized = true;
|
_activationInfrastructureInitialized = true;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -1504,7 +1505,9 @@ public partial class App : WinoApplication,
|
|||||||
|
|
||||||
Services.GetRequiredService<IMailDialogService>().InfoBarMessage(
|
Services.GetRequiredService<IMailDialogService>().InfoBarMessage(
|
||||||
Translator.GeneralTitle_Info,
|
Translator.GeneralTitle_Info,
|
||||||
Translator.WinoAccount_Management_ImportReloginReminder,
|
string.IsNullOrWhiteSpace(message.CompletionMessage)
|
||||||
|
? Translator.WinoAccount_Management_ImportReloginReminder
|
||||||
|
: message.CompletionMessage,
|
||||||
InfoBarMessageType.Information);
|
InfoBarMessageType.Information);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<Identity
|
<Identity
|
||||||
Name="58272BurakKSE.WinoMailPreview"
|
Name="58272BurakKSE.WinoMailPreview"
|
||||||
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
Publisher="CN=51FBDAF3-E212-4149-89A2-A2636B3BC911"
|
||||||
Version="2.0.7.0" />
|
Version="2.0.6.0" />
|
||||||
|
|
||||||
<mp:PhoneIdentity PhoneProductId="7b7e90e9-cc55-4409-9769-99b4b5ed6e9b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
<mp:PhoneIdentity PhoneProductId="7b7e90e9-cc55-4409-9769-99b4b5ed6e9b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||||
|
|
||||||
|
|||||||
@@ -233,17 +233,6 @@ public class NotificationBuilder : INotificationBuilder
|
|||||||
ShowNotification(builder, "store-update-available");
|
ShowNotification(builder, "store-update-available");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CreateReleaseMigrationNotification()
|
|
||||||
{
|
|
||||||
var builder = CreateBuilder();
|
|
||||||
builder.AddText(Translator.Notifications_ReleaseMigrationTitle);
|
|
||||||
builder.AddText(Translator.Notifications_ReleaseMigrationMessage);
|
|
||||||
builder.AddArgument(Constants.ToastModeKey, Constants.ToastModeMail);
|
|
||||||
builder.AddButton(CreateDismissButton());
|
|
||||||
|
|
||||||
ShowNotification(builder, "release-migration-v2");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds)
|
public Task CreateCalendarReminderNotificationAsync(CalendarItem calendarItem, long reminderDurationInSeconds)
|
||||||
{
|
{
|
||||||
if (calendarItem == null)
|
if (calendarItem == null)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
@@ -11,20 +10,16 @@ namespace Wino.Mail.WinUI.Services;
|
|||||||
public sealed class ReleaseLocalAccountDataCleanupService
|
public sealed class ReleaseLocalAccountDataCleanupService
|
||||||
{
|
{
|
||||||
private const string CleanupCompletedSettingKey = "ReleaseLocalAccountDataCleanup_v1_Completed";
|
private const string CleanupCompletedSettingKey = "ReleaseLocalAccountDataCleanup_v1_Completed";
|
||||||
private const string LegacyDatabaseFileName = "Wino180.db";
|
|
||||||
|
|
||||||
private readonly IConfigurationService _configurationService;
|
private readonly IConfigurationService _configurationService;
|
||||||
private readonly IApplicationConfiguration _applicationConfiguration;
|
private readonly IApplicationConfiguration _applicationConfiguration;
|
||||||
private readonly INotificationBuilder _notificationBuilder;
|
|
||||||
private readonly ILogger _logger = Log.ForContext<ReleaseLocalAccountDataCleanupService>();
|
private readonly ILogger _logger = Log.ForContext<ReleaseLocalAccountDataCleanupService>();
|
||||||
|
|
||||||
public ReleaseLocalAccountDataCleanupService(IConfigurationService configurationService,
|
public ReleaseLocalAccountDataCleanupService(IConfigurationService configurationService,
|
||||||
IApplicationConfiguration applicationConfiguration,
|
IApplicationConfiguration applicationConfiguration)
|
||||||
INotificationBuilder notificationBuilder)
|
|
||||||
{
|
{
|
||||||
_configurationService = configurationService;
|
_configurationService = configurationService;
|
||||||
_applicationConfiguration = applicationConfiguration;
|
_applicationConfiguration = applicationConfiguration;
|
||||||
_notificationBuilder = notificationBuilder;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunIfNeededAsync()
|
public async Task RunIfNeededAsync()
|
||||||
@@ -33,7 +28,6 @@ public sealed class ReleaseLocalAccountDataCleanupService
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var localFolderPath = _applicationConfiguration.ApplicationDataFolderPath;
|
var localFolderPath = _applicationConfiguration.ApplicationDataFolderPath;
|
||||||
var publisherPath = _applicationConfiguration.PublisherSharedFolderPath;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(localFolderPath) || !Directory.Exists(localFolderPath))
|
if (string.IsNullOrWhiteSpace(localFolderPath) || !Directory.Exists(localFolderPath))
|
||||||
{
|
{
|
||||||
@@ -45,74 +39,47 @@ public sealed class ReleaseLocalAccountDataCleanupService
|
|||||||
{
|
{
|
||||||
Path.Combine(localFolderPath, "Mime"),
|
Path.Combine(localFolderPath, "Mime"),
|
||||||
Path.Combine(localFolderPath, "contacts"),
|
Path.Combine(localFolderPath, "contacts"),
|
||||||
Path.Combine(localFolderPath, "CalendarAttachments"),
|
Path.Combine(localFolderPath, "CalendarAttachments")
|
||||||
Path.Combine(publisherPath, LegacyDatabaseFileName)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var hadLegacyData = false;
|
|
||||||
|
|
||||||
foreach (var targetPath in cleanupTargets)
|
foreach (var targetPath in cleanupTargets)
|
||||||
{
|
{
|
||||||
hadLegacyData |= await DeletePathIfExistsAsync(targetPath, localFolderPath, publisherPath).ConfigureAwait(false);
|
await DeletePathIfExistsAsync(localFolderPath, targetPath).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_configurationService.Set(CleanupCompletedSettingKey, true);
|
_configurationService.Set(CleanupCompletedSettingKey, true);
|
||||||
|
|
||||||
if (hadLegacyData)
|
|
||||||
{
|
|
||||||
_notificationBuilder.CreateReleaseMigrationNotification();
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.Information("Completed one-time local account data cleanup for release migration.");
|
_logger.Information("Completed one-time local account data cleanup for release migration.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> DeletePathIfExistsAsync(string targetPath, params string[] allowedRootPaths)
|
private async Task DeletePathIfExistsAsync(string localFolderPath, string targetPath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fullTargetPath = Path.GetFullPath(targetPath);
|
var fullTargetPath = Path.GetFullPath(targetPath);
|
||||||
if (!allowedRootPaths.Any(rootPath => IsPathUnderAllowedRoot(fullTargetPath, rootPath)))
|
var fullLocalFolderPath = Path.GetFullPath(localFolderPath);
|
||||||
{
|
|
||||||
_logger.Warning("Skipped startup cleanup for path outside allowed roots: {TargetPath}", fullTargetPath);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var targetExists = Directory.Exists(fullTargetPath) || File.Exists(fullTargetPath);
|
if (!fullTargetPath.StartsWith(fullLocalFolderPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.Warning("Skipped startup cleanup for path outside local folder: {TargetPath}", fullTargetPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Directory.Exists(fullTargetPath))
|
if (Directory.Exists(fullTargetPath))
|
||||||
{
|
{
|
||||||
await Task.Run(() => Directory.Delete(fullTargetPath, recursive: true)).ConfigureAwait(false);
|
await Task.Run(() => Directory.Delete(fullTargetPath, recursive: true)).ConfigureAwait(false);
|
||||||
_logger.Information("Deleted legacy startup cleanup directory {TargetPath}", fullTargetPath);
|
_logger.Information("Deleted legacy startup cleanup directory {TargetPath}", fullTargetPath);
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File.Exists(fullTargetPath))
|
if (File.Exists(fullTargetPath))
|
||||||
{
|
{
|
||||||
File.Delete(fullTargetPath);
|
File.Delete(fullTargetPath);
|
||||||
_logger.Information("Deleted legacy startup cleanup file {TargetPath}", fullTargetPath);
|
_logger.Information("Deleted legacy startup cleanup file {TargetPath}", fullTargetPath);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return targetExists;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.Warning(ex, "Failed to delete legacy startup cleanup path {TargetPath}", targetPath);
|
_logger.Warning(ex, "Failed to delete legacy startup cleanup path {TargetPath}", targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsPathUnderAllowedRoot(string fullTargetPath, string rootPath)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(rootPath))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var fullRootPath = Path.GetFullPath(rootPath);
|
|
||||||
var relativePath = Path.GetRelativePath(fullRootPath, fullTargetPath);
|
|
||||||
|
|
||||||
return relativePath != "." &&
|
|
||||||
!relativePath.StartsWith("..", StringComparison.Ordinal) &&
|
|
||||||
!Path.IsPathRooted(relativePath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,25 @@
|
|||||||
<SymbolIcon Symbol="Account" />
|
<SymbolIcon Symbol="Account" />
|
||||||
</winuiControls:SettingsCard.HeaderIcon>
|
</winuiControls:SettingsCard.HeaderIcon>
|
||||||
</winuiControls:SettingsCard>
|
</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}">
|
<winuiControls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionTitle}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
<Button Command="{x:Bind ViewModel.ImportLocalDataCommand}" Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataImportAction}" />
|
<Button Command="{x:Bind ViewModel.ImportLocalDataCommand}" Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataImportAction}" />
|
||||||
|
|||||||
@@ -15,21 +15,43 @@
|
|||||||
MaxWidth="900"
|
MaxWidth="900"
|
||||||
Padding="20"
|
Padding="20"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch">
|
||||||
<winuiControls:SettingsCard
|
<StackPanel Spacing="12">
|
||||||
Description="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionDescription}"
|
<winuiControls:SettingsCard
|
||||||
Header="{x:Bind domain:Translator.WinoAccount_Management_LocalDataSectionTitle}">
|
Description="{x:Bind ViewModel.LegacyMigrationSummary, Mode=OneWay}"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
Header="{x:Bind domain:Translator.LegacyLocalMigration_SettingsSectionTitle, Mode=OneTime}"
|
||||||
<Button
|
Visibility="{x:Bind ViewModel.HasLegacyImportAvailable, Mode=OneWay}">
|
||||||
Command="{x:Bind ViewModel.ImportLocalDataCommand}"
|
<StackPanel Spacing="8">
|
||||||
Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataImportAction}" />
|
<TextBlock
|
||||||
<Button
|
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||||
Command="{x:Bind ViewModel.ExportLocalDataCommand}"
|
Style="{StaticResource CaptionTextBlockStyle}"
|
||||||
Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataExportAction}" />
|
Text="{x:Bind ViewModel.LegacyMigrationWarningSummary, Mode=OneWay}"
|
||||||
</StackPanel>
|
TextWrapping="WrapWholeWords"
|
||||||
<winuiControls:SettingsCard.HeaderIcon>
|
Visibility="{x:Bind ViewModel.HasLegacyImportWarnings, Mode=OneWay}" />
|
||||||
<SymbolIcon Symbol="Sync" />
|
<Button
|
||||||
</winuiControls:SettingsCard.HeaderIcon>
|
Command="{x:Bind ViewModel.ImportLegacyDatabaseCommand}"
|
||||||
</winuiControls:SettingsCard>
|
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}" />
|
||||||
|
<Button
|
||||||
|
Command="{x:Bind ViewModel.ExportLocalDataCommand}"
|
||||||
|
Content="{x:Bind domain:Translator.WinoAccount_Management_LocalDataExportAction}" />
|
||||||
|
</StackPanel>
|
||||||
|
<winuiControls:SettingsCard.HeaderIcon>
|
||||||
|
<SymbolIcon Symbol="Sync" />
|
||||||
|
</winuiControls:SettingsCard.HeaderIcon>
|
||||||
|
</winuiControls:SettingsCard>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</abstract:ManageAccountsPageAbstract>
|
</abstract:ManageAccountsPageAbstract>
|
||||||
|
|||||||
@@ -127,6 +127,40 @@
|
|||||||
Grid.Row="3"
|
Grid.Row="3"
|
||||||
MaxWidth="600"
|
MaxWidth="600"
|
||||||
HorizontalAlignment="Center">
|
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
|
<HyperlinkButton
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Command="{x:Bind ViewModel.ImportFromWinoAccountCommand}"
|
Command="{x:Bind ViewModel.ImportFromWinoAccountCommand}"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
namespace Wino.Messaging.UI;
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-334
@@ -727,70 +727,32 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
|
|
||||||
public async Task DeleteMailAsync(Guid accountId, string mailCopyId)
|
public async Task DeleteMailAsync(Guid accountId, string mailCopyId)
|
||||||
{
|
{
|
||||||
await DeleteMailsAsync(accountId, [mailCopyId]).ConfigureAwait(false);
|
var allMails = await GetMailCopiesByIdAsync([mailCopyId]).ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteMailsAsync(Guid accountId, IEnumerable<string> mailCopyIds)
|
foreach (var mailItem in allMails)
|
||||||
{
|
{
|
||||||
var targetMailIds = mailCopyIds?
|
// Delete mime file as well.
|
||||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
// Even though Gmail might have multiple copies for the same mail, we only have one MIME file for all.
|
||||||
.Distinct(StringComparer.Ordinal)
|
// Their FileId is inserted same.
|
||||||
.ToList() ?? [];
|
|
||||||
|
|
||||||
if (targetMailIds.Count == 0)
|
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false);
|
||||||
return;
|
}
|
||||||
|
|
||||||
var allMails = await GetMailCopiesByIdAsync(targetMailIds).ConfigureAwait(false);
|
|
||||||
await DeleteMailCopiesAsync(allMails, preserveMimeFile: false, reportUiChange: true).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReportAddedMails(IReadOnlyList<MailCopy> addedMails)
|
|
||||||
{
|
|
||||||
if (addedMails == null || addedMails.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (addedMails.Count == 1)
|
|
||||||
ReportUIChange(new MailAddedMessage(addedMails[0], EntityUpdateSource.Server));
|
|
||||||
else
|
|
||||||
ReportUIChange(new BulkMailAddedMessage(addedMails, EntityUpdateSource.Server));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReportUpdatedMails(IReadOnlyList<MailCopy> updatedMails, MailCopyChangeFlags changedProperties = MailCopyChangeFlags.None)
|
|
||||||
{
|
|
||||||
if (updatedMails == null || updatedMails.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (updatedMails.Count == 1)
|
|
||||||
ReportUIChange(new MailUpdatedMessage(updatedMails[0], EntityUpdateSource.Server, changedProperties));
|
|
||||||
else
|
|
||||||
ReportUIChange(new BulkMailUpdatedMessage(updatedMails, EntityUpdateSource.Server, changedProperties));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReportRemovedMails(IReadOnlyList<MailCopy> removedMails)
|
|
||||||
{
|
|
||||||
if (removedMails == null || removedMails.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (removedMails.Count == 1)
|
|
||||||
ReportUIChange(new MailRemovedMessage(removedMails[0], EntityUpdateSource.Server));
|
|
||||||
else
|
|
||||||
ReportUIChange(new BulkMailRemovedMessage(removedMails, EntityUpdateSource.Server));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Repository Calls
|
#region Repository Calls
|
||||||
|
|
||||||
private async Task<MailCopy> InsertMailAsync(MailCopy mailCopy, bool reportUiChange)
|
private async Task InsertMailAsync(MailCopy mailCopy)
|
||||||
{
|
{
|
||||||
if (mailCopy == null)
|
if (mailCopy == null)
|
||||||
{
|
{
|
||||||
_logger.Warning("Null mail passed to InsertMailAsync call.");
|
_logger.Warning("Null mail passed to InsertMailAsync call.");
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mailCopy.FolderId == Guid.Empty)
|
if (mailCopy.FolderId == Guid.Empty)
|
||||||
{
|
{
|
||||||
_logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id);
|
_logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id);
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
|
_logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
|
||||||
@@ -798,27 +760,21 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
||||||
|
|
||||||
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
||||||
if (reportUiChange)
|
ReportUIChange(new MailAddedMessage(hydratedMailCopy, EntityUpdateSource.Server));
|
||||||
ReportAddedMails([hydratedMailCopy]);
|
|
||||||
|
|
||||||
return hydratedMailCopy;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateMailAsync(MailCopy mailCopy)
|
public async Task UpdateMailAsync(MailCopy mailCopy)
|
||||||
=> await UpdateMailAsync(mailCopy, reportUiChange: true).ConfigureAwait(false);
|
|
||||||
|
|
||||||
private async Task<MailCopy> UpdateMailAsync(MailCopy mailCopy, bool reportUiChange, MailCopy existingMailCopy = null)
|
|
||||||
{
|
{
|
||||||
if (mailCopy == null)
|
if (mailCopy == null)
|
||||||
{
|
{
|
||||||
_logger.Warning("Null mail passed to UpdateMailAsync call.");
|
_logger.Warning("Null mail passed to UpdateMailAsync call.");
|
||||||
|
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
|
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
|
||||||
|
|
||||||
existingMailCopy ??= mailCopy.UniqueId != Guid.Empty
|
var existingMailCopy = mailCopy.UniqueId != Guid.Empty
|
||||||
? await Connection.FindAsync<MailCopy>(mailCopy.UniqueId).ConfigureAwait(false)
|
? await Connection.FindAsync<MailCopy>(mailCopy.UniqueId).ConfigureAwait(false)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -831,19 +787,16 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
|
||||||
|
|
||||||
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
var hydratedMailCopy = await HydrateMailCopyAsync(mailCopy).ConfigureAwait(false);
|
||||||
if (reportUiChange)
|
ReportUIChange(new MailUpdatedMessage(hydratedMailCopy, EntityUpdateSource.Server));
|
||||||
ReportUpdatedMails([hydratedMailCopy]);
|
|
||||||
|
|
||||||
return hydratedMailCopy;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<MailCopy> DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile, bool reportUiChange)
|
private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile)
|
||||||
{
|
{
|
||||||
if (mailCopy == null)
|
if (mailCopy == null)
|
||||||
{
|
{
|
||||||
_logger.Warning("Null mail passed to DeleteMailAsync call.");
|
_logger.Warning("Null mail passed to DeleteMailAsync call.");
|
||||||
|
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
|
_logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
|
||||||
@@ -859,31 +812,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
|
await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reportUiChange)
|
ReportUIChange(new MailRemovedMessage(mailCopy, EntityUpdateSource.Server));
|
||||||
ReportRemovedMails([mailCopy]);
|
|
||||||
|
|
||||||
return mailCopy;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<MailCopy>> DeleteMailCopiesAsync(IReadOnlyList<MailCopy> mailCopies, bool preserveMimeFile, bool reportUiChange)
|
|
||||||
{
|
|
||||||
if (mailCopies == null || mailCopies.Count == 0)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
var removedMails = new List<MailCopy>(mailCopies.Count);
|
|
||||||
|
|
||||||
foreach (var mailCopy in mailCopies)
|
|
||||||
{
|
|
||||||
var removedMail = await DeleteMailInternalAsync(mailCopy, preserveMimeFile, reportUiChange: false).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (removedMail != null)
|
|
||||||
removedMails.Add(removedMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reportUiChange)
|
|
||||||
ReportRemovedMails(removedMails);
|
|
||||||
|
|
||||||
return removedMails;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -1092,40 +1021,6 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
||||||
=> await CreateAssignmentsAsync(accountId, [new MailFolderAssignmentUpdate(mailCopyId, remoteFolderId)]).ConfigureAwait(false);
|
|
||||||
|
|
||||||
public async Task CreateAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
|
|
||||||
{
|
|
||||||
var targetAssignments = assignments?
|
|
||||||
.Where(x => x != null &&
|
|
||||||
!string.IsNullOrWhiteSpace(x.MailCopyId) &&
|
|
||||||
!string.IsNullOrWhiteSpace(x.RemoteFolderId))
|
|
||||||
.GroupBy(x => $"{x.MailCopyId}\u001f{x.RemoteFolderId}", StringComparer.Ordinal)
|
|
||||||
.Select(group => group.First())
|
|
||||||
.ToList() ?? [];
|
|
||||||
|
|
||||||
if (targetAssignments.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var addedMails = new List<MailCopy>(targetAssignments.Count);
|
|
||||||
var removedMails = new List<MailCopy>();
|
|
||||||
|
|
||||||
foreach (var assignment in targetAssignments)
|
|
||||||
{
|
|
||||||
var (addedMail, removedMail) = await CreateAssignmentInternalAsync(accountId, assignment.MailCopyId, assignment.RemoteFolderId).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (removedMail != null)
|
|
||||||
removedMails.Add(removedMail);
|
|
||||||
|
|
||||||
if (addedMail != null)
|
|
||||||
addedMails.Add(addedMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReportRemovedMails(removedMails);
|
|
||||||
ReportAddedMails(addedMails);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(MailCopy AddedMail, MailCopy RemovedMail)> CreateAssignmentInternalAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
|
||||||
{
|
{
|
||||||
// Note: Folder might not be available at the moment due to user not syncing folders before the delta processing.
|
// Note: Folder might not be available at the moment due to user not syncing folders before the delta processing.
|
||||||
// This is a problem, because assignments won't be created.
|
// This is a problem, because assignments won't be created.
|
||||||
@@ -1138,14 +1033,14 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
|
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
|
||||||
_logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId);
|
_logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId);
|
||||||
|
|
||||||
return (null, null);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await IsMailExistsAsync(mailCopyId, localFolder.Id).ConfigureAwait(false))
|
if (await IsMailExistsAsync(mailCopyId, localFolder.Id).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
_logger.Debug("Skipping assignment creation for {MailCopyId} because folder {FolderId} already has a local copy.",
|
_logger.Debug("Skipping assignment creation for {MailCopyId} because folder {FolderId} already has a local copy.",
|
||||||
mailCopyId, localFolder.Id);
|
mailCopyId, localFolder.Id);
|
||||||
return (null, null);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
|
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
|
||||||
@@ -1154,12 +1049,9 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
{
|
{
|
||||||
_logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId);
|
_logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId);
|
||||||
|
|
||||||
return (null, null);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MailCopy removedMail = null;
|
|
||||||
var mailCopyToInsert = mailCopy;
|
|
||||||
|
|
||||||
if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent &&
|
if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent &&
|
||||||
localFolder.SpecialFolderType == SpecialFolderType.Deleted)
|
localFolder.SpecialFolderType == SpecialFolderType.Deleted)
|
||||||
{
|
{
|
||||||
@@ -1170,52 +1062,21 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
// This way item will only be visible in Trash folder as in Gmail Web UI.
|
// This way item will only be visible in Trash folder as in Gmail Web UI.
|
||||||
// Don't delete MIME file since if exists.
|
// Don't delete MIME file since if exists.
|
||||||
|
|
||||||
mailCopyToInsert = CloneMailCopy(mailCopy);
|
await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true).ConfigureAwait(false);
|
||||||
removedMail = await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true, reportUiChange: false).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy one of the mail copy and assign it to the new folder.
|
// Copy one of the mail copy and assign it to the new folder.
|
||||||
// We don't need to create a new MIME pack.
|
// We don't need to create a new MIME pack.
|
||||||
// Therefore FileId is not changed for the new MailCopy.
|
// Therefore FileId is not changed for the new MailCopy.
|
||||||
|
|
||||||
mailCopyToInsert.UniqueId = Guid.NewGuid();
|
mailCopy.UniqueId = Guid.NewGuid();
|
||||||
mailCopyToInsert.FolderId = localFolder.Id;
|
mailCopy.FolderId = localFolder.Id;
|
||||||
mailCopyToInsert.AssignedFolder = localFolder;
|
mailCopy.AssignedFolder = localFolder;
|
||||||
|
|
||||||
var addedMail = await InsertMailAsync(mailCopyToInsert, reportUiChange: false).ConfigureAwait(false);
|
await InsertMailAsync(mailCopy).ConfigureAwait(false);
|
||||||
return (addedMail, removedMail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
||||||
=> await DeleteAssignmentsAsync(accountId, [new MailFolderAssignmentUpdate(mailCopyId, remoteFolderId)]).ConfigureAwait(false);
|
|
||||||
|
|
||||||
public async Task DeleteAssignmentsAsync(Guid accountId, IEnumerable<MailFolderAssignmentUpdate> assignments)
|
|
||||||
{
|
|
||||||
var targetAssignments = assignments?
|
|
||||||
.Where(x => x != null &&
|
|
||||||
!string.IsNullOrWhiteSpace(x.MailCopyId) &&
|
|
||||||
!string.IsNullOrWhiteSpace(x.RemoteFolderId))
|
|
||||||
.GroupBy(x => $"{x.MailCopyId}\u001f{x.RemoteFolderId}", StringComparer.Ordinal)
|
|
||||||
.Select(group => group.First())
|
|
||||||
.ToList() ?? [];
|
|
||||||
|
|
||||||
if (targetAssignments.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var removedMails = new List<MailCopy>(targetAssignments.Count);
|
|
||||||
|
|
||||||
foreach (var assignment in targetAssignments)
|
|
||||||
{
|
|
||||||
var removedMail = await DeleteAssignmentInternalAsync(accountId, assignment.MailCopyId, assignment.RemoteFolderId).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (removedMail != null)
|
|
||||||
removedMails.Add(removedMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReportRemovedMails(removedMails);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<MailCopy> DeleteAssignmentInternalAsync(Guid accountId, string mailCopyId, string remoteFolderId)
|
|
||||||
{
|
{
|
||||||
var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false);
|
var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -1223,7 +1084,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
{
|
{
|
||||||
_logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId);
|
_logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId);
|
||||||
|
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
|
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
|
||||||
@@ -1232,50 +1093,10 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
{
|
{
|
||||||
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
|
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
|
||||||
|
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await DeleteMailInternalAsync(mailItem, preserveMimeFile: false, reportUiChange: false).ConfigureAwait(false);
|
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false);
|
||||||
}
|
|
||||||
|
|
||||||
private static MailCopy CloneMailCopy(MailCopy source)
|
|
||||||
{
|
|
||||||
if (source == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new MailCopy
|
|
||||||
{
|
|
||||||
UniqueId = source.UniqueId,
|
|
||||||
Id = source.Id,
|
|
||||||
FolderId = source.FolderId,
|
|
||||||
ThreadId = source.ThreadId,
|
|
||||||
MessageId = source.MessageId,
|
|
||||||
References = source.References,
|
|
||||||
InReplyTo = source.InReplyTo,
|
|
||||||
FromName = source.FromName,
|
|
||||||
FromAddress = source.FromAddress,
|
|
||||||
Subject = source.Subject,
|
|
||||||
PreviewText = source.PreviewText,
|
|
||||||
CreationDate = source.CreationDate,
|
|
||||||
Importance = source.Importance,
|
|
||||||
IsRead = source.IsRead,
|
|
||||||
IsFlagged = source.IsFlagged,
|
|
||||||
IsPinned = source.IsPinned,
|
|
||||||
IsFocused = source.IsFocused,
|
|
||||||
HasAttachments = source.HasAttachments,
|
|
||||||
ItemType = source.ItemType,
|
|
||||||
DraftId = source.DraftId,
|
|
||||||
IsDraft = source.IsDraft,
|
|
||||||
FileId = source.FileId,
|
|
||||||
AssignedFolder = source.AssignedFolder,
|
|
||||||
AssignedAccount = source.AssignedAccount,
|
|
||||||
SenderContact = source.SenderContact,
|
|
||||||
IsReadReceiptRequested = source.IsReadReceiptRequested,
|
|
||||||
ReadReceiptStatus = source.ReadReceiptStatus,
|
|
||||||
ReadReceiptAcknowledgedAtUtc = source.ReadReceiptAcknowledgedAtUtc,
|
|
||||||
ReadReceiptMessageUniqueId = source.ReadReceiptMessageUniqueId,
|
|
||||||
Categories = source.Categories == null ? [] : [.. source.Categories]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
|
public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
|
||||||
@@ -1292,7 +1113,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
|
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
|
||||||
|
|
||||||
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
|
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
|
||||||
var insertMailTask = InsertMailAsync(mailCopy, reportUiChange: true);
|
var insertMailTask = InsertMailAsync(mailCopy);
|
||||||
|
|
||||||
await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false);
|
await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false);
|
||||||
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
||||||
@@ -1304,129 +1125,6 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateMailsAsync(Guid accountId, IReadOnlyList<NewMailItemPackage> packages)
|
|
||||||
{
|
|
||||||
var targetPackages = packages?
|
|
||||||
.Where(package => package != null)
|
|
||||||
.ToList() ?? [];
|
|
||||||
|
|
||||||
if (targetPackages.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (account == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (account.ProviderType != MailProviderType.Gmail)
|
|
||||||
{
|
|
||||||
foreach (var package in targetPackages)
|
|
||||||
await CreateMailAsync(accountId, package).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var pendingInserts = new List<(MailCopy MailCopy, NewMailItemPackage Package, MimeMessage MimeMessage)>();
|
|
||||||
var pendingUpdates = new List<(MailCopy MailCopy, MailCopy ExistingMailCopy, NewMailItemPackage Package, MimeMessage MimeMessage)>();
|
|
||||||
|
|
||||||
foreach (var package in targetPackages)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(package.AssignedRemoteFolderId))
|
|
||||||
{
|
|
||||||
_logger.Warning("Remote folder id is not set for {MailCopyId}.", package.Copy?.Id);
|
|
||||||
_logger.Warning("Ignoring creation of mail.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var assignedFolder = await _folderService.GetFolderAsync(accountId, package.AssignedRemoteFolderId).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (assignedFolder == null)
|
|
||||||
{
|
|
||||||
_logger.Warning("Assigned folder not found for {MailCopyId}.", package.Copy?.Id);
|
|
||||||
_logger.Warning("Ignoring creation of mail.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var mailCopy = package.Copy;
|
|
||||||
var mimeMessage = package.Mime;
|
|
||||||
|
|
||||||
mailCopy.UniqueId = Guid.NewGuid();
|
|
||||||
mailCopy.AssignedAccount = account;
|
|
||||||
mailCopy.AssignedFolder = assignedFolder;
|
|
||||||
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
|
|
||||||
mailCopy.FolderId = assignedFolder.Id;
|
|
||||||
|
|
||||||
if (mimeMessage != null)
|
|
||||||
{
|
|
||||||
var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId, mailCopy.FileId).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!isMimeExists)
|
|
||||||
{
|
|
||||||
bool isMimeSaved = await _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, accountId).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!isMimeSaved)
|
|
||||||
{
|
|
||||||
_logger.Warning("Failed to save mime file for {MailCopyId}.", mailCopy.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var existingCopyItem = await Connection.Table<MailCopy>()
|
|
||||||
.FirstOrDefaultAsync(a => a.Id == mailCopy.Id && a.FolderId == assignedFolder.Id)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (existingCopyItem != null)
|
|
||||||
{
|
|
||||||
mailCopy.UniqueId = existingCopyItem.UniqueId;
|
|
||||||
pendingUpdates.Add((mailCopy, existingCopyItem, package, mimeMessage));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
pendingInserts.Add((mailCopy, package, mimeMessage));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var insertedMails = new List<MailCopy>(pendingInserts.Count);
|
|
||||||
foreach (var pendingInsert in pendingInserts)
|
|
||||||
{
|
|
||||||
var insertedMail = await InsertMailAsync(pendingInsert.MailCopy, reportUiChange: false).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (insertedMail != null)
|
|
||||||
insertedMails.Add(insertedMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
var updatedMails = new List<MailCopy>(pendingUpdates.Count);
|
|
||||||
foreach (var pendingUpdate in pendingUpdates)
|
|
||||||
{
|
|
||||||
var updatedMail = await UpdateMailAsync(
|
|
||||||
pendingUpdate.MailCopy,
|
|
||||||
reportUiChange: false,
|
|
||||||
existingMailCopy: pendingUpdate.ExistingMailCopy).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (updatedMail != null)
|
|
||||||
updatedMails.Add(updatedMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReportAddedMails(insertedMails);
|
|
||||||
ReportUpdatedMails(updatedMails);
|
|
||||||
|
|
||||||
foreach (var pendingInsert in pendingInserts)
|
|
||||||
{
|
|
||||||
await ReplaceMailCategoriesForPackageAsync(accountId, pendingInsert.MailCopy, pendingInsert.Package).ConfigureAwait(false);
|
|
||||||
await _sentMailReceiptService.TrackSentMailAsync(pendingInsert.MailCopy, pendingInsert.MimeMessage).ConfigureAwait(false);
|
|
||||||
await _sentMailReceiptService.ProcessIncomingReceiptAsync(pendingInsert.MailCopy, pendingInsert.MimeMessage).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var pendingUpdate in pendingUpdates)
|
|
||||||
{
|
|
||||||
await ReplaceMailCategoriesForPackageAsync(accountId, pendingUpdate.MailCopy, pendingUpdate.Package).ConfigureAwait(false);
|
|
||||||
await _sentMailReceiptService.TrackSentMailAsync(pendingUpdate.MailCopy, pendingUpdate.MimeMessage).ConfigureAwait(false);
|
|
||||||
await _sentMailReceiptService.ProcessIncomingReceiptAsync(pendingUpdate.MailCopy, pendingUpdate.MimeMessage).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
|
public async Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
|
||||||
{
|
{
|
||||||
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
|
||||||
@@ -1495,7 +1193,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
{
|
{
|
||||||
mailCopy.UniqueId = existingCopyItem.UniqueId;
|
mailCopy.UniqueId = existingCopyItem.UniqueId;
|
||||||
|
|
||||||
await UpdateMailAsync(mailCopy, reportUiChange: true, existingMailCopy: existingCopyItem).ConfigureAwait(false);
|
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
|
||||||
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
|
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
|
||||||
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
||||||
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
||||||
@@ -1512,7 +1210,7 @@ public class MailService : BaseDatabaseService, IMailService
|
|||||||
await DeleteMailAsync(accountId, mailCopy.Id).ConfigureAwait(false);
|
await DeleteMailAsync(accountId, mailCopy.Id).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await InsertMailAsync(mailCopy, reportUiChange: true).ConfigureAwait(false);
|
await InsertMailAsync(mailCopy).ConfigureAwait(false);
|
||||||
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
|
await ReplaceMailCategoriesForPackageAsync(accountId, mailCopy, package).ConfigureAwait(false);
|
||||||
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
await _sentMailReceiptService.TrackSentMailAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
||||||
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
await _sentMailReceiptService.ProcessIncomingReceiptAsync(mailCopy, mimeMessage).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public static class ServicesContainerSetup
|
|||||||
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
|
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
|
||||||
services.AddTransient<ICalendarContextMenuItemService, CalendarContextMenuItemService>();
|
services.AddTransient<ICalendarContextMenuItemService, CalendarContextMenuItemService>();
|
||||||
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
|
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
|
||||||
|
services.AddTransient<ILegacyLocalMigrationService, LegacyLocalMigrationService>();
|
||||||
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
|
||||||
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
|
||||||
services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>();
|
services.AddSingleton<IWinoAccountProfileService, WinoAccountProfileService>();
|
||||||
|
|||||||
Reference in New Issue
Block a user