using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Serilog; 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.Exceptions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Extensions; using Wino.Core.Services; using Wino.Core.ViewModels; using Wino.Core.ViewModels.Data; using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Navigation; using Wino.Messaging.UI; using Wino.Services; namespace Wino.Mail.ViewModels; public partial class AccountManagementViewModel : AccountManagementPageViewModelBase { private const string LocalExportFileName = "wino-data-export.json"; private const string SummaryUnavailable = "—"; private static readonly UTF8Encoding Utf8WithoutBom = new(false); private readonly IWinoAccountDataSyncService _syncService; private readonly IWinoLogger _winoLogger; private readonly ISpecialImapProviderConfigResolver _specialImapProviderConfigResolver; private readonly ICalDavClient _calDavClient; private readonly IMimeStorageService _mimeStorageService; private readonly ICalendarService _calendarService; private readonly IMailService _mailService; private readonly IFolderService _folderService; public IMailDialogService MailDialogService { get; } [ObservableProperty] public partial string SummaryAccountCountText { get; set; } = SummaryUnavailable; [ObservableProperty] public partial string SummaryEmailCountText { get; set; } = SummaryUnavailable; [ObservableProperty] public partial string SummaryStorageText { get; set; } = SummaryUnavailable; [ObservableProperty] public partial string SummaryCalendarCountText { get; set; } = SummaryUnavailable; [ObservableProperty] public partial string SummaryDefaultAccountText { get; set; } = string.Format(Translator.WinoAccount_Management_SummaryDefaultAccountLabel, SummaryUnavailable); public AccountManagementViewModel(IMailDialogService dialogService, INavigationService navigationService, IAccountService accountService, IProviderService providerService, IWinoAccountProfileService winoAccountProfileService, IWinoAccountDataSyncService syncService, IWinoLogger winoLogger, ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, ICalDavClient calDavClient, IMimeStorageService mimeStorageService, ICalendarService calendarService, IMailService mailService, IFolderService folderService, IAuthenticationProvider authenticationProvider, IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, winoAccountProfileService, authenticationProvider, preferencesService) { MailDialogService = dialogService; _syncService = syncService; _winoLogger = winoLogger; _specialImapProviderConfigResolver = specialImapProviderConfigResolver; _calDavClient = calDavClient; _mimeStorageService = mimeStorageService; _calendarService = calendarService; _mailService = mailService; _folderService = folderService; } [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ExportLocalDataCommand))] [NotifyCanExecuteChangedFor(nameof(ImportLocalDataCommand))] public partial bool IsDataTransferInProgress { get; set; } [RelayCommand] private async Task CreateMergedAccountAsync() { var linkName = await DialogService.ShowTextInputDialogAsync(string.Empty, Translator.DialogMessage_CreateLinkedAccountTitle, Translator.DialogMessage_CreateLinkedAccountMessage, Translator.Buttons_Create); if (string.IsNullOrEmpty(linkName)) return; // Create arbitary empty merged inbox with an empty Guid and go to edit page. var mergedInbox = new MergedInbox() { Id = Guid.Empty, Name = linkName }; var mergedAccountProviderDetailViewModel = new MergedAccountProviderDetailViewModel(mergedInbox, new List()); Messenger.Send(new BreadcrumbNavigationRequested(mergedAccountProviderDetailViewModel.MergedInbox.Name, WinoPage.MergedAccountDetailsPage, mergedAccountProviderDetailViewModel)); } [RelayCommand] private Task AddNewAccountAsync() { Messenger.Send(new BreadcrumbNavigationRequested( Translator.WelcomeWizard_Step2Title, WinoPage.ProviderSelectionPage, ProviderSelectionNavigationContext.CreateForSettingsAddAccount())); return Task.CompletedTask; } public Task StartAddNewAccountAsync() => AddNewAccountAsync(); private async Task ValidateSpecialImapConnectivityAsync(CustomServerInformation serverInformation) { var connectivityResult = await SynchronizationManager.Instance .TestImapConnectivityAsync(serverInformation, allowSSLHandshake: false) .ConfigureAwait(false); if (connectivityResult.IsCertificateUIRequired) { var certificateMessage = $"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row0}\n\n" + $"{Translator.IMAPSetupDialog_CertificateIssuer}: {connectivityResult.CertificateIssuer}\n" + $"{Translator.IMAPSetupDialog_CertificateValidFrom}: {connectivityResult.CertificateValidFromDateString}\n" + $"{Translator.IMAPSetupDialog_CertificateValidTo}: {connectivityResult.CertificateExpirationDateString}\n\n" + $"{Translator.IMAPSetupDialog_CertificateAllowanceRequired_Row1}"; var allowCertificate = await ExecuteUIThreadTaskAsync( () => MailDialogService.ShowConfirmationDialogAsync(certificateMessage, Translator.GeneralTitle_Warning, Translator.Buttons_Allow)) .ConfigureAwait(false); if (!allowCertificate) throw new InvalidOperationException(Translator.IMAPSetupDialog_CertificateDenied); connectivityResult = await SynchronizationManager.Instance .TestImapConnectivityAsync(serverInformation, allowSSLHandshake: true) .ConfigureAwait(false); } if (!connectivityResult.IsSuccess) throw new InvalidOperationException(connectivityResult.FailedReason ?? Translator.IMAPSetupDialog_ConnectionFailedMessage); if (serverInformation.CalendarSupportMode != ImapCalendarSupportMode.CalDav) return; if (string.IsNullOrWhiteSpace(serverInformation.CalDavServiceUrl)) throw new InvalidOperationException(Translator.ImapCalDavSettingsPage_CalDavUrlRequired); var settings = new CalDavConnectionSettings { ServiceUri = new Uri(serverInformation.CalDavServiceUrl, UriKind.Absolute), Username = serverInformation.CalDavUsername, Password = serverInformation.CalDavPassword }; await _calDavClient.DiscoverCalendarsAsync(settings).ConfigureAwait(false); } private async Task ExecuteUIThreadTaskAsync(Func action) { if (Dispatcher == null) { await action().ConfigureAwait(false); return; } var completionSource = new TaskCompletionSource(); await ExecuteUIThread(() => { _ = ExecuteAndCaptureAsync(); async Task ExecuteAndCaptureAsync() { try { await action().ConfigureAwait(false); completionSource.TrySetResult(null); } catch (Exception ex) { completionSource.TrySetException(ex); } } }); await completionSource.Task.ConfigureAwait(false); } private async Task ExecuteUIThreadTaskAsync(Func> action) { if (Dispatcher == null) return await action().ConfigureAwait(false); var completionSource = new TaskCompletionSource(); await ExecuteUIThread(() => { _ = ExecuteAndCaptureAsync(); async Task ExecuteAndCaptureAsync() { try { var result = await action().ConfigureAwait(false); completionSource.TrySetResult(result); } catch (Exception ex) { completionSource.TrySetException(ex); } } }); return await completionSource.Task.ConfigureAwait(false); } public void NavigateToAccount(AccountProviderDetailViewModel accountDetails) { if (accountDetails == null) { return; } Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAccountSettings_Title, WinoPage.ManageAccountsPage)); Messenger.Send(new BreadcrumbNavigationRequested(GetAccountDetailsTitle(accountDetails.Account), WinoPage.AccountDetailsPage, accountDetails.Account.Id)); } [RelayCommand] private void EditMergedAccounts(MergedAccountProviderDetailViewModel mergedAccountProviderDetailViewModel) { Messenger.Send(new BreadcrumbNavigationRequested(mergedAccountProviderDetailViewModel.MergedInbox.Name, WinoPage.MergedAccountDetailsPage, mergedAccountProviderDetailViewModel)); } [RelayCommand(CanExecute = nameof(CanReorderAccounts))] private Task ReorderAccountsAsync() => MailDialogService.ShowAccountReorderDialogAsync(availableAccounts: Accounts); [RelayCommand(CanExecute = nameof(CanTransferLocalData))] private async Task ExportLocalDataAsync() { try { var exportPath = await ExecuteUIThreadTaskAsync( () => MailDialogService.PickFilePathAsync(LocalExportFileName)) .ConfigureAwait(false); if (string.IsNullOrWhiteSpace(exportPath)) { return; } await ExecuteUIThread(() => IsDataTransferInProgress = true); var exportResult = await _syncService.ExportToJsonAsync(new()).ConfigureAwait(false); await File.WriteAllTextAsync(exportPath, exportResult.JsonContent, Utf8WithoutBom).ConfigureAwait(false); DialogService.InfoBarMessage( Translator.GeneralTitle_Info, $"{BuildExportSuccessMessage(exportResult.ExportResult)} {string.Format(Translator.WinoAccount_Management_LocalDataSaved, exportPath)}", InfoBarMessageType.Success); } catch (Exception ex) { DialogService.InfoBarMessage( Translator.GeneralTitle_Error, ex.Message, InfoBarMessageType.Error); } finally { await ExecuteUIThread(() => IsDataTransferInProgress = false); } } [RelayCommand(CanExecute = nameof(CanTransferLocalData))] private async Task ImportLocalDataAsync() { try { var fileContent = await ExecuteUIThreadTaskAsync( () => MailDialogService.PickWindowsFileContentAsync(".json")) .ConfigureAwait(false); if (fileContent.Length == 0) { return; } await ExecuteUIThread(() => IsDataTransferInProgress = true); var jsonContent = Encoding.UTF8.GetString(fileContent); var result = await _syncService.ImportFromJsonAsync(jsonContent).ConfigureAwait(false); await InitializeAccountsAsync().ConfigureAwait(false); var messageType = result.FailedPreferenceCount > 0 ? InfoBarMessageType.Warning : InfoBarMessageType.Success; DialogService.InfoBarMessage( result.FailedPreferenceCount > 0 ? Translator.GeneralTitle_Warning : Translator.GeneralTitle_Info, BuildImportMessage(result), messageType); } catch (JsonException) { DialogService.InfoBarMessage( Translator.GeneralTitle_Error, Translator.WinoAccount_Management_LocalDataInvalidFile, InfoBarMessageType.Error); } catch (Exception ex) { DialogService.InfoBarMessage( Translator.GeneralTitle_Error, ex.Message, InfoBarMessageType.Error); } finally { await ExecuteUIThread(() => IsDataTransferInProgress = false); } } private bool CanTransferLocalData() => !IsDataTransferInProgress; public override void OnNavigatedFrom(NavigationMode mode, object parameters) { base.OnNavigatedFrom(mode, parameters); Accounts.CollectionChanged -= AccountCollectionChanged; PropertyChanged -= PagePropertyChanged; } private void AccountCollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { OnPropertyChanged(nameof(HasAccountsDefined)); ReorderAccountsCommand.NotifyCanExecuteChanged(); } private void PagePropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { if (e.PropertyName == nameof(StartupAccount) && StartupAccount != null) { PreferencesService.StartupEntityId = StartupAccount.StartupEntityId; } } public override async void OnNavigatedTo(NavigationMode mode, object parameters) { base.OnNavigatedTo(mode, parameters); Accounts.CollectionChanged -= AccountCollectionChanged; Accounts.CollectionChanged += AccountCollectionChanged; await InitializeAccountsAsync(); PropertyChanged -= PagePropertyChanged; PropertyChanged += PagePropertyChanged; } public override async Task InitializeAccountsAsync() { StartupAccount = null; Accounts.Clear(); var accounts = await AccountService.GetAccountsAsync().ConfigureAwait(false); // Group accounts and display merged ones at the top. var groupedAccounts = accounts.GroupBy(a => a.MergedInboxId); await ExecuteUIThread(() => { foreach (var accountGroup in groupedAccounts) { var mergedInboxId = accountGroup.Key; if (mergedInboxId == null) { foreach (var account in accountGroup) { var accountDetails = GetAccountProviderDetails(account); Accounts.Add(accountDetails); } } else { var mergedInbox = accountGroup.First(a => a.MergedInboxId == mergedInboxId).MergedInbox; var holdingAccountProviderDetails = accountGroup.Select(a => GetAccountProviderDetails(a)).ToList(); var mergedAccountViewModel = new MergedAccountProviderDetailViewModel(mergedInbox, holdingAccountProviderDetails); Accounts.Add(mergedAccountViewModel); } } // Handle startup entity. if (PreferencesService.StartupEntityId != null) { StartupAccount = Accounts.FirstOrDefault(a => a.StartupEntityId == PreferencesService.StartupEntityId); } }); await RefreshAccountSummaryAsync(accounts).ConfigureAwait(false); } private async Task RefreshAccountSummaryAsync(List accounts) { await ExecuteUIThread(() => { SummaryAccountCountText = accounts.Count.ToString(CultureInfo.CurrentCulture); SummaryEmailCountText = SummaryUnavailable; SummaryStorageText = SummaryUnavailable; SummaryCalendarCountText = SummaryUnavailable; SummaryDefaultAccountText = string.Format( Translator.WinoAccount_Management_SummaryDefaultAccountLabel, GetDefaultAccountName(accounts)); }); try { var accountIds = accounts.Select(a => a.Id).ToList(); if (accountIds.Count > 0) { var storageSizes = await _mimeStorageService.GetAccountsMimeStorageSizesAsync(accountIds).ConfigureAwait(false); var totalStorageBytes = storageSizes.Values.DefaultIfEmpty(0L).Sum(); var totalCalendarCount = 0; var uniqueMessageIds = new HashSet(); foreach (var account in accounts) { try { var calendars = await _calendarService.GetAccountCalendarsAsync(account.Id).ConfigureAwait(false); totalCalendarCount += calendars?.Count ?? 0; } catch { // Keep partial summary data if a specific account calendar lookup fails. } try { var folders = await _folderService.GetFoldersAsync(account.Id).ConfigureAwait(false); foreach (var folder in folders) { var mails = await _mailService.GetMailsByFolderIdAsync(folder.Id).ConfigureAwait(false); foreach (var mail in mails) { uniqueMessageIds.Add(mail.UniqueId); } } } catch { // Keep partial summary data if a specific account mail lookup fails. } } await ExecuteUIThread(() => { SummaryEmailCountText = uniqueMessageIds.Count > 0 ? uniqueMessageIds.Count.ToString("N0", CultureInfo.CurrentCulture) : "0"; SummaryStorageText = totalStorageBytes > 0 ? totalStorageBytes.GetBytesReadable() : "0 B"; SummaryCalendarCountText = totalCalendarCount.ToString(CultureInfo.CurrentCulture); }); } } catch { // Leave graceful placeholders if summary data can't be gathered. } } private string GetDefaultAccountName(List accounts) { var startupEntityId = PreferencesService.StartupEntityId; if (startupEntityId != null) { var startupAccount = accounts.FirstOrDefault(a => a.Id == startupEntityId) ?? accounts.FirstOrDefault(a => a.MergedInboxId == startupEntityId) ?? accounts.FirstOrDefault(a => a.MergedInbox?.Id == startupEntityId); if (startupAccount?.MergedInbox != null) return startupAccount.MergedInbox.Name; if (!string.IsNullOrWhiteSpace(startupAccount?.Name)) return startupAccount.Name; if (!string.IsNullOrWhiteSpace(startupAccount?.Address)) return startupAccount.Address; } var firstAccount = accounts.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(firstAccount?.Name)) return firstAccount.Name; if (!string.IsNullOrWhiteSpace(firstAccount?.Address)) return firstAccount.Address; return SummaryUnavailable; } private static string BuildExportSuccessMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncExportResult result) { var parts = new Collection(); if (result.IncludedPreferences) { parts.Add(Translator.WinoAccount_Management_ExportPreferencesSucceeded); } if (result.IncludedAccounts) { parts.Add(string.Format(Translator.WinoAccount_Management_ExportAccountsSucceeded, result.ExportedMailboxCount)); } if (parts.Count == 0) { parts.Add(Translator.WinoAccount_Management_ExportSucceeded); } return string.Join(" ", parts); } private static string BuildImportMessage(Wino.Core.Domain.Models.Accounts.WinoAccountSyncImportResult result) { var parts = new Collection(); if (result.HadRemotePreferences) { parts.Add(result.FailedPreferenceCount > 0 ? string.Format(Translator.WinoAccount_Management_ImportPartial, result.AppliedPreferenceCount, result.FailedPreferenceCount) : string.Format(Translator.WinoAccount_Management_ImportPreferencesSucceeded, result.AppliedPreferenceCount)); } if (result.ImportedMailboxCount > 0) { parts.Add(string.Format(Translator.WinoAccount_Management_ImportAccountsSucceeded, result.ImportedMailboxCount)); } if (result.SkippedDuplicateMailboxCount > 0) { parts.Add(string.Format(Translator.WinoAccount_Management_ImportDuplicateAccountsSkipped, result.SkippedDuplicateMailboxCount)); } if (parts.Count == 0) { parts.Add(Translator.WinoAccount_Management_ImportEmpty); } if (result.ImportedMailboxCount > 0) { parts.Add(Translator.WinoAccount_Management_ImportReloginReminder); } return string.Join(" ", parts); } }