From c622858d2dc6164bf94b2aabc7cb53ec8458fc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Tue, 14 Apr 2026 00:03:48 +0200 Subject: [PATCH] Add initial mail sync range selection --- .../Entities/Shared/MailAccount.cs | 10 +++ .../Enums/InitialSynchronizationRange.cs | 10 +++ .../InitialSynchronizationRangeExtensions.cs | 23 ++++++ .../Accounts/AccountCreationDialogResult.cs | 9 ++- .../InitialSynchronizationRangeOption.cs | 17 +++++ .../Translations/en_US/resources.json | 10 +++ Wino.Core/Synchronizers/GmailSynchronizer.cs | 25 +++---- .../ImapSync/UnifiedImapSynchronizer.cs | 72 +++++++++++++------ .../Synchronizers/OutlookSynchronizer.cs | 36 ++++++---- .../AccountDetailsPageViewModel.cs | 12 ++++ .../AccountSetupProgressPageViewModel.cs | 3 + .../Data/WelcomeWizardContext.cs | 7 +- .../ImapCalDavSettingsPageViewModel.cs | 7 +- .../ProviderSelectionPageViewModel.cs | 28 +++++++- Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml | 43 +++++++++++ .../Dialogs/NewAccountDialog.xaml.cs | 56 ++++++++++++--- .../Views/Account/AccountDetailsPage.xaml | 7 ++ .../Views/ProviderSelectionPage.xaml | 53 ++++++++++++-- Wino.Services/AccountService.cs | 5 ++ Wino.Services/DatabaseService.cs | 16 +++++ Wino.Services/WinoAccountDataSyncService.cs | 2 + 21 files changed, 378 insertions(+), 73 deletions(-) create mode 100644 Wino.Core.Domain/Enums/InitialSynchronizationRange.cs create mode 100644 Wino.Core.Domain/Extensions/InitialSynchronizationRangeExtensions.cs create mode 100644 Wino.Core.Domain/Models/Accounts/InitialSynchronizationRangeOption.cs diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs index 5ad3e46d..f549f65e 100644 --- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs +++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs @@ -112,6 +112,16 @@ public class MailAccount /// public DateTime? LastFolderStructureSyncDate { get; set; } + /// + /// Gets or sets when the account was created in Wino. + /// + public DateTime? CreatedAt { get; set; } + + /// + /// Gets or sets the timespan used for the account's initial mail synchronization. + /// + public InitialSynchronizationRange InitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths; + /// /// Gets whether the account can perform ProfileInformation sync type. /// diff --git a/Wino.Core.Domain/Enums/InitialSynchronizationRange.cs b/Wino.Core.Domain/Enums/InitialSynchronizationRange.cs new file mode 100644 index 00000000..b93a22ce --- /dev/null +++ b/Wino.Core.Domain/Enums/InitialSynchronizationRange.cs @@ -0,0 +1,10 @@ +namespace Wino.Core.Domain.Enums; + +public enum InitialSynchronizationRange +{ + SixMonths = 0, + ThreeMonths = 1, + NineMonths = 2, + OneYear = 3, + Everything = 4 +} diff --git a/Wino.Core.Domain/Extensions/InitialSynchronizationRangeExtensions.cs b/Wino.Core.Domain/Extensions/InitialSynchronizationRangeExtensions.cs new file mode 100644 index 00000000..643f1296 --- /dev/null +++ b/Wino.Core.Domain/Extensions/InitialSynchronizationRangeExtensions.cs @@ -0,0 +1,23 @@ +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Extensions; + +public static class InitialSynchronizationRangeExtensions +{ + public static DateTime? ToCutoffDateUtc(this InitialSynchronizationRange range, DateTime utcNow) + { + var normalizedUtcNow = utcNow.Kind == DateTimeKind.Utc + ? utcNow + : utcNow.ToUniversalTime(); + + return range switch + { + InitialSynchronizationRange.ThreeMonths => normalizedUtcNow.AddMonths(-3), + InitialSynchronizationRange.SixMonths => normalizedUtcNow.AddMonths(-6), + InitialSynchronizationRange.NineMonths => normalizedUtcNow.AddMonths(-9), + InitialSynchronizationRange.OneYear => normalizedUtcNow.AddYears(-1), + _ => null + }; + } +} diff --git a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs index 1d3f5cd9..3a3ca1e2 100644 --- a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs +++ b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs @@ -1,5 +1,10 @@ -using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Enums; namespace Wino.Core.Domain.Models.Accounts; -public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, SpecialImapProviderDetails SpecialImapProviderDetails, string AccountColorHex); +public record AccountCreationDialogResult( + MailProviderType ProviderType, + string AccountName, + SpecialImapProviderDetails SpecialImapProviderDetails, + string AccountColorHex, + InitialSynchronizationRange InitialSynchronizationRange); diff --git a/Wino.Core.Domain/Models/Accounts/InitialSynchronizationRangeOption.cs b/Wino.Core.Domain/Models/Accounts/InitialSynchronizationRangeOption.cs new file mode 100644 index 00000000..3b4f5489 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/InitialSynchronizationRangeOption.cs @@ -0,0 +1,17 @@ +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models.Accounts; + +public sealed class InitialSynchronizationRangeOption +{ + public InitialSynchronizationRange Range { get; } + public string DisplayText { get; } + + public bool IsEverything => Range == InitialSynchronizationRange.Everything; + + public InitialSynchronizationRangeOption(InitialSynchronizationRange range, string displayText) + { + Range = range; + DisplayText = displayText; + } +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 4844b0cc..6b62a911 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -23,6 +23,14 @@ "AccountCreationDialog_Initializing": "initializing", "AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.", "AccountCreationDialog_SigninIn": "Account information is being saved.", + "AccountCreation_InitialSynchronization_Title": "Mail synchronization range", + "AccountCreation_InitialSynchronization_Description": "Choose how far back Wino should download your mail during the first synchronization.", + "AccountCreation_InitialSynchronization_3Months": "3 Months", + "AccountCreation_InitialSynchronization_6Months": "6 Months", + "AccountCreation_InitialSynchronization_9Months": "9 Months", + "AccountCreation_InitialSynchronization_Year": "Year", + "AccountCreation_InitialSynchronization_Everything": "Everything", + "AccountCreation_InitialSynchronization_EverythingWarning": "This will synchronize all your mails to your computer. Extensive use of disk storage is needed. This is not recommended. For optimal performance use smaller synchronization timespan and use online search to access your mails.", "Purchased": "Purchased", "AccountEditDialog_Message": "Account Name", "AccountEditDialog_Title": "Edit Account", @@ -37,6 +45,8 @@ "AccountDetailsPage_TabMail": "Mail", "AccountDetailsPage_TabCalendar": "Calendar", "AccountDetailsPage_CalendarListDescription": "Select a calendar to configure its settings", + "AccountDetailsPage_InitialSynchronization_Title": "Initial synchronization", + "AccountDetailsPage_InitialSynchronization_Description": "Wino synchronized your mails until {0} going back.", "AddHyperlink": "Add", "AppCloseBackgroundSynchronizationWarningTitle": "Background Synchronization", "AppCloseStartupLaunchDisabledWarningMessageFirstLine": "Application has not been set to launch on Windows startup.", diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 5c7a915c..860e51dc 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -81,9 +81,8 @@ public class GmailSynchronizer : WinoSynchronizer 1000; /// - /// Maximum messages to fetch per folder during initial sync (1500). - /// All messages are downloaded with METADATA ONLY - no raw MIME content. - /// Uses Gmail API's Metadata format which includes headers, labels, and snippet but NOT full message body. + /// Legacy page size hint kept for compatibility with shared synchronizer contracts. + /// Gmail initial sync now downloads all messages inside the selected cutoff window. /// public override uint InitialMessageDownloadCountPerFolder => 1500; @@ -304,13 +303,18 @@ public class GmailSynchronizer : WinoSynchronizer /// Performs initial synchronization by downloading messages per-folder. - /// Each folder gets up to 1500 messages, but we track already downloaded message IDs globally - /// to avoid downloading the same message multiple times (Gmail messages can have multiple labels). + /// Messages are filtered by the account's configured initial synchronization cutoff date when present, + /// and duplicates are avoided globally because Gmail messages can have multiple labels. /// private async Task> PerformInitialSyncAsync(CancellationToken cancellationToken) { // Track all downloaded message IDs globally to avoid duplicate downloads var downloadedMessageIds = new HashSet(); + var referenceDateUtc = Account.CreatedAt ?? DateTime.UtcNow; + var initialSynchronizationCutoffDateUtc = Account.InitialSynchronizationRange.ToCutoffDateUtc(referenceDateUtc); + var queryText = initialSynchronizationCutoffDateUtc.HasValue + ? $"after:{initialSynchronizationCutoffDateUtc.Value.ToUniversalTime():yyyy/MM/dd}" + : null; _logger.Information("Performing initial sync for {Name} - downloading messages per folder", Account.Name); @@ -337,7 +341,6 @@ public class GmailSynchronizer : WinoSynchronizer(new[] { folder.RemoteFolderId }); - request.MaxResults = Math.Min(remainingToDownload, 500); // API max is 500 + request.MaxResults = 500; // API max is 500 request.PageToken = pageToken; + request.Q = queryText; var response = await request.ExecuteAsync(cancellationToken); @@ -373,19 +377,12 @@ public class GmailSynchronizer : WinoSynchronizer changedUids; + + if (folder.HighestModeSeq == 0) + { + changedUids = await remoteFolder + .SearchAsync(BuildInitialSyncQuery(synchronizer), cancellationToken) + .ConfigureAwait(false); + } + else + { + changedUids = await remoteFolder + .SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken) + .ConfigureAwait(false); + } downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false); @@ -308,25 +320,26 @@ public class UnifiedImapSynchronizer { IList changedUids; - if (client.Capabilities.HasFlag(ImapCapabilities.Sort)) + if (isInitialSync) { changedUids = await remoteFolder - .SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], cancellationToken) + .SearchAsync(BuildInitialSyncQuery(synchronizer), cancellationToken) .ConfigureAwait(false); } else { - changedUids = await remoteFolder - .SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken) - .ConfigureAwait(false); - } - - if (isInitialSync) - { - changedUids = changedUids - .OrderByDescending(a => a.Id) - .Take((int)synchronizer.InitialMessageDownloadCountPerFolder) - .ToList(); + if (client.Capabilities.HasFlag(ImapCapabilities.Sort)) + { + changedUids = await remoteFolder + .SortAsync(SearchQuery.ChangedSince(localHighestModSeq), [OrderBy.ReverseDate], cancellationToken) + .ConfigureAwait(false); + } + else + { + changedUids = await remoteFolder + .SearchAsync(SearchQuery.ChangedSince(localHighestModSeq), cancellationToken) + .ConfigureAwait(false); + } } downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, changedUids, synchronizer, cancellationToken).ConfigureAwait(false); @@ -367,15 +380,12 @@ public class UnifiedImapSynchronizer if (folder.HighestKnownUid == 0) { - var remoteUids = await remoteFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false); - - var initialUids = remoteUids - .OrderByDescending(a => a.Id) - .Take((int)synchronizer.InitialMessageDownloadCountPerFolder) - .ToList(); + var initialUids = await remoteFolder + .SearchAsync(BuildInitialSyncQuery(synchronizer), cancellationToken) + .ConfigureAwait(false); downloadedMessageIds = await DownloadMessagesByUidsAsync(client, remoteFolder, folder, initialUids, synchronizer, cancellationToken).ConfigureAwait(false); - UpdateHighestKnownUid(folder, remoteFolder, remoteUids.Select(a => a.Id)); + UpdateHighestKnownUid(folder, remoteFolder, initialUids.Select(a => a.Id)); } else { @@ -410,6 +420,22 @@ public class UnifiedImapSynchronizer #region Shared Helpers + private static SearchQuery BuildInitialSyncQuery(IImapSynchronizer synchronizer) + { + if (synchronizer is IBaseSynchronizer { Account: { } account }) + { + var referenceDateUtc = account.CreatedAt ?? DateTime.UtcNow; + var cutoffDateUtc = account.InitialSynchronizationRange.ToCutoffDateUtc(referenceDateUtc); + + if (cutoffDateUtc.HasValue) + { + return SearchQuery.DeliveredAfter(cutoffDateUtc.Value.ToUniversalTime().Date); + } + } + + return SearchQuery.All; + } + private async Task EnsureUidValidityStateAsync(MailItemFolder folder, IMailFolder remoteFolder) { if (folder.UidValidity != 0 && remoteFolder.UidValidity != folder.UidValidity) diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 59f685c4..c353411d 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -55,14 +55,14 @@ public partial class OutlookSynchronizerJsonContext : JsonSerializerContext; /// /// SYNCHRONIZATION STRATEGY: /// - Uses delta API for both initial and incremental sync -/// - Initial sync: Downloads last 30 days of emails with metadata only +/// - Initial sync: Downloads messages using the account's configured cutoff date with metadata only /// - Incremental sync: Uses delta token to get only changes since last sync /// - Messages are downloaded with metadata only (no MIME content during sync) /// - MIME files are downloaded on-demand when user explicitly reads a message /// /// Key implementation details: /// - SynchronizeFolderAsync: Main entry point for per-folder synchronization -/// - DownloadMailsForInitialSyncAsync: Downloads last 30 days using delta API with filter +/// - DownloadMailsForInitialSyncAsync: Downloads messages using delta API with an optional cutoff filter /// - ProcessDeltaChangesAsync: Processes incremental changes using delta token /// - DownloadMessageMetadataBatchAsync: Downloads metadata in batches using Graph batch API /// - CreateMailCopyFromMessageAsync: Creates MailCopy from Message metadata @@ -343,9 +343,9 @@ public class OutlookSynchronizer : WinoSynchronizer - /// Downloads mails for initial synchronization using Delta API with 30-day filter. - /// Downloads metadata only (no MIME content) for messages received in the last 30 days. + /// Downloads mails for initial synchronization using Delta API with the account's configured cutoff date. + /// Downloads metadata only (no MIME content) for messages received after that date. /// private async Task DownloadMailsForInitialSyncAsync(MailItemFolder folder, List downloadedMessageIds, CancellationToken cancellationToken) { - _logger.Debug("Starting initial mail download for folder {FolderName} (last 6 months)", folder.FolderName); + _logger.Debug("Starting initial mail download for folder {FolderName}", folder.FolderName); try { - // Calculate date 6 months ago - var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6); - var filterDate = sixMonthsAgo.ToString("yyyy-MM-ddTHH:mm:ssZ"); + var referenceDateUtc = Account.CreatedAt ?? DateTime.UtcNow; + var initialSynchronizationCutoffDateUtc = Account.InitialSynchronizationRange.ToCutoffDateUtc(referenceDateUtc); + var filterDate = initialSynchronizationCutoffDateUtc?.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"); - _logger.Information("Downloading messages received after {FilterDate} for folder {FolderName}", filterDate, folder.FolderName); + if (filterDate != null) + { + _logger.Information("Downloading messages received after {FilterDate} for folder {FolderName}", filterDate, folder.FolderName); + } + else + { + _logger.Information("Downloading all available messages for folder {FolderName}", folder.FolderName); + } - // Use Delta API with receivedDateTime filter for last 6 months var messageCollectionPage = await _graphClient.Me.MailFolders[folder.RemoteFolderId].Messages.Delta.GetAsDeltaGetResponseAsync((config) => { config.QueryParameters.Select = outlookMessageSelectParameters; config.QueryParameters.Orderby = ["receivedDateTime desc"]; - config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}"; + + if (filterDate != null) + { + config.QueryParameters.Filter = $"receivedDateTime ge {filterDate}"; + } }, cancellationToken).ConfigureAwait(false); var totalProcessed = 0; diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs index f2e11754..5ea89f98 100644 --- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -12,6 +13,7 @@ using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Folders; @@ -101,6 +103,14 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel ? $"ms-appx:///Assets/Providers/{Account.SpecialImapProvider}.png" : $"ms-appx:///Assets/Providers/{Account?.ProviderType}.png"; public string Address => Account?.Address ?? string.Empty; + public bool IsInitialSynchronizationSummaryVisible => Account?.CreatedAt.HasValue == true && Account.InitialSynchronizationRange != InitialSynchronizationRange.Everything; + public string InitialSynchronizationSummary => Account?.CreatedAt is not DateTime createdAtUtc + ? string.Empty + : Account.InitialSynchronizationRange.ToCutoffDateUtc(createdAtUtc) is not DateTime cutoffDateUtc + ? string.Empty + : string.Format( + Translator.AccountDetailsPage_InitialSynchronization_Description, + cutoffDateUtc.ToLocalTime().ToString("D", CultureInfo.CurrentUICulture)); public List AvailableAuthenticationMethods { get; } = [ @@ -363,6 +373,8 @@ public partial class AccountDetailsPageViewModel : MailBaseViewModel OnPropertyChanged(nameof(IsFocusedInboxSupportedForAccount)); OnPropertyChanged(nameof(ProviderIconPath)); OnPropertyChanged(nameof(Address)); + OnPropertyChanged(nameof(IsInitialSynchronizationSummaryVisible)); + OnPropertyChanged(nameof(InitialSynchronizationSummary)); } protected override async void OnPropertyChanged(PropertyChangedEventArgs e) diff --git a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs index 85423bab..8c218639 100644 --- a/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountSetupProgressPageViewModel.cs @@ -170,6 +170,7 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel try { CustomServerInformation customServerInformation = null; + var accountCreatedAt = DateTime.UtcNow; // Build account in memory _createdAccount = new MailAccount @@ -179,6 +180,8 @@ public partial class AccountSetupProgressPageViewModel : MailBaseViewModel Name = WizardContext.AccountName, SpecialImapProvider = WizardContext.SelectedProvider.SpecialImapProvider, AccountColorHex = WizardContext.AccountColorHex, + CreatedAt = accountCreatedAt, + InitialSynchronizationRange = WizardContext.SelectedInitialSynchronizationRange, IsCalendarAccessGranted = true }; diff --git a/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs b/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs index fe38bd23..61e2f2a9 100644 --- a/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs +++ b/Wino.Mail.ViewModels/Data/WelcomeWizardContext.cs @@ -18,6 +18,9 @@ public partial class WelcomeWizardContext : ObservableObject [ObservableProperty] public partial string AccountColorHex { get; set; } + [ObservableProperty] + public partial InitialSynchronizationRange SelectedInitialSynchronizationRange { get; set; } = InitialSynchronizationRange.SixMonths; + // Special IMAP fields (iCloud/Yahoo) [ObservableProperty] public partial string DisplayName { get; set; } @@ -62,7 +65,8 @@ public partial class WelcomeWizardContext : ObservableObject SelectedProvider.Type, AccountName, BuildSpecialImapProviderDetails(), - AccountColorHex); + AccountColorHex, + SelectedInitialSynchronizationRange); } public void Reset() @@ -70,6 +74,7 @@ public partial class WelcomeWizardContext : ObservableObject SelectedProvider = null; AccountName = null; AccountColorHex = null; + SelectedInitialSynchronizationRange = InitialSynchronizationRange.SixMonths; DisplayName = null; EmailAddress = null; AppSpecificPassword = null; diff --git a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs index 64a9c646..49e6ff2b 100644 --- a/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs +++ b/Wino.Mail.ViewModels/ImapCalDavSettingsPageViewModel.cs @@ -997,7 +997,12 @@ public partial class ImapCalDavSettingsPageViewModel : MailBaseViewModel SpecialImapProvider = _editingSpecialImapProvider, IsCalendarAccessGranted = mode != ImapCalendarSupportMode.Disabled }, - new AccountCreationDialogResult(MailProviderType.IMAP4, DisplayName.Trim(), providerDetails, string.Empty)); + new AccountCreationDialogResult( + MailProviderType.IMAP4, + DisplayName.Trim(), + providerDetails, + string.Empty, + _wizardContext.SelectedInitialSynchronizationRange)); if (serverInformation == null) return false; diff --git a/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs index c5480a68..71afb9c3 100644 --- a/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs +++ b/Wino.Mail.ViewModels/ProviderSelectionPageViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Navigation; using Wino.Core.ViewModels.Data; using Wino.Mail.ViewModels.Data; @@ -22,13 +23,26 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel public List Providers { get; private set; } = []; public List AvailableColors { get; private set; } = []; + public List InitialSynchronizationRanges { get; } = + [ + new(InitialSynchronizationRange.ThreeMonths, Translator.AccountCreation_InitialSynchronization_3Months), + new(InitialSynchronizationRange.SixMonths, Translator.AccountCreation_InitialSynchronization_6Months), + new(InitialSynchronizationRange.NineMonths, Translator.AccountCreation_InitialSynchronization_9Months), + new(InitialSynchronizationRange.OneYear, Translator.AccountCreation_InitialSynchronization_Year), + new(InitialSynchronizationRange.Everything, Translator.AccountCreation_InitialSynchronization_Everything) + ]; [ObservableProperty] public partial IProviderDetail SelectedProvider { get; set; } [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsColorSelected))] public partial AppColorViewModel SelectedColor { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsInitialSynchronizationWarningVisible))] + public partial InitialSynchronizationRangeOption SelectedInitialSynchronizationRange { get; set; } + [ObservableProperty] public partial string AccountName { get; set; } @@ -36,6 +50,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel public partial bool CanProceed { get; set; } public bool IsColorSelected => SelectedColor != null; + public bool IsInitialSynchronizationWarningVisible => SelectedInitialSynchronizationRange?.IsEverything == true; public ProviderSelectionPageViewModel( IProviderService providerService, @@ -45,6 +60,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel _providerService = providerService; _themeService = themeService; WizardContext = wizardContext; + SelectedInitialSynchronizationRange = InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths); } public override void OnNavigatedTo(NavigationMode mode, object parameters) @@ -56,6 +72,10 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel .Select(hex => new AppColorViewModel(hex)) .ToList(); + SelectedInitialSynchronizationRange = InitialSynchronizationRanges + .FirstOrDefault(option => option.Range == WizardContext.SelectedInitialSynchronizationRange) + ?? InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths); + // Restore from wizard context if navigating back if (WizardContext.SelectedProvider != null) { @@ -71,9 +91,12 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel Validate(); } - partial void OnSelectedProviderChanged(IProviderDetail value) => Validate(); + partial void OnSelectedProviderChanged(IProviderDetail value) + { + Validate(); + } + partial void OnAccountNameChanged(string value) => Validate(); - partial void OnSelectedColorChanged(AppColorViewModel value) => OnPropertyChanged(nameof(IsColorSelected)); [RelayCommand] private void ClearColor() => SelectedColor = null; @@ -92,6 +115,7 @@ public partial class ProviderSelectionPageViewModel : MailBaseViewModel WizardContext.SelectedProvider = SelectedProvider; WizardContext.AccountName = AccountName?.Trim(); WizardContext.AccountColorHex = SelectedColor?.Hex ?? string.Empty; + WizardContext.SelectedInitialSynchronizationRange = SelectedInitialSynchronizationRange?.Range ?? InitialSynchronizationRange.SixMonths; if (WizardContext.IsGenericImap) { diff --git a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml index 9eb668bb..7c03fb34 100644 --- a/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/NewAccountDialog.xaml @@ -55,6 +55,7 @@ + @@ -96,6 +97,48 @@ + + + + + + + + + + + + + + + + + + + helpingLinks = new Dictionary() + private readonly Dictionary helpingLinks = new() { { SpecialImapProvider.iCloud, "https://support.apple.com/en-us/102654" }, { SpecialImapProvider.Yahoo, "http://help.yahoo.com/kb/SLN15241.html" }, @@ -27,7 +27,6 @@ public sealed partial class NewAccountDialog : ContentDialog public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(AppColorViewModel), typeof(NewAccountDialog), new PropertyMetadata(null, new PropertyChangedCallback(OnSelectedColorChanged))); public static readonly DependencyProperty SelectedCalendarModeIndexProperty = DependencyProperty.Register(nameof(SelectedCalendarModeIndex), typeof(int), typeof(NewAccountDialog), new PropertyMetadata(0)); - public AppColorViewModel? SelectedColor { get { return (AppColorViewModel?)GetValue(SelectedColorProperty); } @@ -49,7 +48,6 @@ public sealed partial class NewAccountDialog : ContentDialog set { SetValue(SelectedMailProviderProperty, value); } } - public bool IsProviderSelectionVisible { get { return (bool)GetValue(IsProviderSelectionVisibleProperty); } @@ -63,10 +61,16 @@ public sealed partial class NewAccountDialog : ContentDialog } // List of available mail providers for now. - public List Providers { get; set; } = []; - public List AvailableColors { get; set; } = []; + public List InitialSynchronizationRanges { get; } = + [ + new(InitialSynchronizationRange.ThreeMonths, Translator.AccountCreation_InitialSynchronization_3Months), + new(InitialSynchronizationRange.SixMonths, Translator.AccountCreation_InitialSynchronization_6Months), + new(InitialSynchronizationRange.NineMonths, Translator.AccountCreation_InitialSynchronization_9Months), + new(InitialSynchronizationRange.OneYear, Translator.AccountCreation_InitialSynchronization_Year), + new(InitialSynchronizationRange.Everything, Translator.AccountCreation_InitialSynchronization_Everything) + ]; public List CalendarModeOptions { get; } = [ Translator.ImapCalDavSettingsPage_CalendarModeCalDav, @@ -74,7 +78,6 @@ public sealed partial class NewAccountDialog : ContentDialog Translator.ImapCalDavSettingsPage_CalendarModeDisabled ]; - public AccountCreationDialogResult? Result = null; public NewAccountDialog() @@ -85,6 +88,8 @@ public sealed partial class NewAccountDialog : ContentDialog AvailableColors = themeService.Select(a => new AppColorViewModel(a)).ToList(); UpdateSelectedColor(); + InitialSynchronizationComboBox.SelectedItem = InitialSynchronizationRanges.First(option => option.Range == InitialSynchronizationRange.SixMonths); + UpdateInitialSynchronizationState(); } private static void OnSelectedProviderChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) @@ -105,6 +110,19 @@ public sealed partial class NewAccountDialog : ContentDialog SelectedColorEllipse.Fill = SelectedColor == null ? null : XamlHelpers.GetSolidColorBrushFromHex(SelectedColor.Hex); } + private void UpdateInitialSynchronizationState() + { + InitialSynchronizationPanel.Visibility = SelectedMailProvider == null ? Visibility.Collapsed : Visibility.Visible; + var selectedOption = InitialSynchronizationComboBox.SelectedItem as InitialSynchronizationRangeOption; + InitialSynchronizationWarningBar.Visibility = selectedOption?.IsEverything == true ? Visibility.Visible : Visibility.Collapsed; + } + + private InitialSynchronizationRange GetInitialSynchronizationRange() + { + var selectedRange = (InitialSynchronizationComboBox.SelectedItem as InitialSynchronizationRangeOption)?.Range + ?? InitialSynchronizationRange.SixMonths; + return selectedRange; + } private void CancelClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args) { @@ -116,9 +134,11 @@ public sealed partial class NewAccountDialog : ContentDialog if (SelectedMailProvider == null) return; + var initialSynchronizationRange = GetInitialSynchronizationRange(); + if (IsSpecialImapServerPartVisible) { - // Special imap detail input. + // Special IMAP detail input. var calendarSupportMode = SelectedCalendarModeIndex switch { 1 => ImapCalendarSupportMode.LocalOnly, @@ -132,7 +152,12 @@ public sealed partial class NewAccountDialog : ContentDialog DisplayNameTextBox.Text.Trim(), SelectedMailProvider.SpecialImapProvider, calendarSupportMode); - Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim(), details, SelectedColor?.Hex ?? string.Empty); + Result = new AccountCreationDialogResult( + SelectedMailProvider.Type, + AccountNameTextbox.Text.Trim(), + details, + SelectedColor?.Hex ?? string.Empty, + initialSynchronizationRange); Hide(); return; @@ -140,11 +165,11 @@ public sealed partial class NewAccountDialog : ContentDialog Validate(); - if (IsSecondaryButtonEnabled) + if (IsPrimaryButtonEnabled) { if (SelectedMailProvider.SpecialImapProvider != SpecialImapProvider.None) { - // This step requires app-sepcific password login for some providers. + // This step requires app-specific password login for some providers. args.Cancel = true; IsProviderSelectionVisible = false; @@ -154,7 +179,12 @@ public sealed partial class NewAccountDialog : ContentDialog } else { - Result = new AccountCreationDialogResult(SelectedMailProvider.Type, AccountNameTextbox.Text.Trim(), null, SelectedColor?.Hex ?? string.Empty); + Result = new AccountCreationDialogResult( + SelectedMailProvider.Type, + AccountNameTextbox.Text.Trim(), + null, + SelectedColor?.Hex ?? string.Empty, + initialSynchronizationRange); Hide(); } } @@ -167,6 +197,7 @@ public sealed partial class NewAccountDialog : ContentDialog { ValidateCreateButton(); ValidateNames(); + UpdateInitialSynchronizationState(); } // Returns whether we can create account or not. @@ -199,6 +230,9 @@ public sealed partial class NewAccountDialog : ContentDialog private void ImapPasswordChanged(object sender, RoutedEventArgs e) => Validate(); + private void InitialSynchronizationSelectionChanged(object sender, SelectionChangedEventArgs e) + => UpdateInitialSynchronizationState(); + private async void AppSpecificHelpButtonClicked(object sender, RoutedEventArgs e) { if (SelectedMailProvider == null || diff --git a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml index ed123e9c..b0572554 100644 --- a/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml +++ b/Wino.Mail.WinUI/Views/Account/AccountDetailsPage.xaml @@ -210,6 +210,13 @@ + - + @@ -76,6 +73,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + ().CountAsync(); // If there are no accounts before this one, set it as startup account. diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index fc2a0080..216afc9f 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -79,6 +79,22 @@ public class DatabaseService : IDatabaseService { await EnsureKeyboardShortcutSchemaAsync().ConfigureAwait(false); + var accountColumns = await Connection.GetTableInfoAsync(nameof(MailAccount)).ConfigureAwait(false); + + if (!accountColumns.Any(c => c.Name == nameof(MailAccount.CreatedAt))) + { + await Connection + .ExecuteAsync($"ALTER TABLE {nameof(MailAccount)} ADD COLUMN {nameof(MailAccount.CreatedAt)} TEXT NULL") + .ConfigureAwait(false); + } + + if (!accountColumns.Any(c => c.Name == nameof(MailAccount.InitialSynchronizationRange))) + { + await Connection + .ExecuteAsync($"ALTER TABLE {nameof(MailAccount)} ADD COLUMN {nameof(MailAccount.InitialSynchronizationRange)} INTEGER NOT NULL DEFAULT {(int)InitialSynchronizationRange.SixMonths}") + .ConfigureAwait(false); + } + var folderColumns = await Connection.GetTableInfoAsync(nameof(MailItemFolder)).ConfigureAwait(false); if (!folderColumns.Any(c => c.Name == nameof(MailItemFolder.HighestKnownUid))) diff --git a/Wino.Services/WinoAccountDataSyncService.cs b/Wino.Services/WinoAccountDataSyncService.cs index 01f3e973..a46152e5 100644 --- a/Wino.Services/WinoAccountDataSyncService.cs +++ b/Wino.Services/WinoAccountDataSyncService.cs @@ -211,6 +211,8 @@ public sealed class WinoAccountDataSyncService : IWinoAccountDataSyncService SpecialImapProvider = (SpecialImapProvider)mailbox.SpecialImapProvider, AccountColorHex = mailbox.AccountColorHex?.Trim(), Base64ProfilePictureData = string.Empty, + CreatedAt = DateTime.UtcNow, + InitialSynchronizationRange = InitialSynchronizationRange.SixMonths, IsCalendarAccessGranted = mailbox.IsCalendarAccessGranted, SynchronizationDeltaIdentifier = string.Empty, CalendarSynchronizationDeltaIdentifier = string.Empty,