using System.Collections.ObjectModel; using System.Diagnostics; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MaksIT.Results; using MaksIT.PostClient.UI; namespace MaksIT.PostClient.UI.ViewModels; public partial class MainViewModel : ObservableObject, IAsyncDisposable { private readonly ConfigurationFileService _files; private readonly ISecretStore _secrets; private readonly IMailSessionFactory _sessions; private readonly IMailAuthService _auth; private readonly ISentReceiptStore _receipts; private readonly MailArchiveCatalog _archive; private readonly MailWorkerClient _worker; private readonly ISemanticSearchService _semantic; private readonly IAppUpdateService _updates; private readonly Dictionary _connections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _foldersByMailbox = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _connecting = new(StringComparer.OrdinalIgnoreCase); private CancellationTokenSource? _work; private CancellationTokenSource? _readingLoad; private CancellationTokenSource? _folderLoad; private CancellationTokenSource? _backfill; private CancellationTokenSource? _search; private int _searchGen; private int _indexPause; private bool _syncingList; private readonly HashSet _selectedKeys = []; private readonly HashSet _indexMailboxes = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _catalogDone = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _catalogFolders = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _rulesDone = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _rulesOffset = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _indexSkip = []; private const int IndexCatalogChunk = 250; private const int IndexUiChunk = 250; private const int IndexBodyChunk = 16; private const int IndexBodyTurn = 4; private const int RulesSlice = 48; private const int IndexBodyLookahead = 512; private readonly StatusLineHold _indexHold; private readonly StatusLineHold _semanticHold; private bool _uiReady; private MailMessageBody? _reading; private MessageRowViewModel? _pendingWindow; private bool _syncingTree; private bool _suppressFolderLoad; private bool _applyingProvider; private bool _accountSettingsOpen; private bool _syncingBodyKind; private bool _updateBusy; private string _bodyPreference = MailBodyKind.Html; private string _listSortHeader = "Date"; private bool _listSortDescending = true; private OAuthTokenSet? _pendingOauth; public ISemanticSearchService SemanticSearch => _semantic; public bool AccountSettingsOpen { get => _accountSettingsOpen; set => _accountSettingsOpen = value; } [ObservableProperty] private MailboxAccount? selectedMailbox; [ObservableProperty] private FolderRowViewModel? selectedFolder; [ObservableProperty] private FolderNodeViewModel? selectedFolderNode; [ObservableProperty] private MessageRowViewModel? selectedMessage; [ObservableProperty] private string status = ""; [ObservableProperty] private UiCopy copy = UiCopy.For(UiLanguage.En); [ObservableProperty] private string folderStats = ""; [ObservableProperty] private bool isBusy; [ObservableProperty] private string editorName = ""; [ObservableProperty] private string editorAddress = ""; [ObservableProperty] private bool editorShareAccount; [ObservableProperty] private string editorUsername = ""; [ObservableProperty] private string editorPassword = ""; [ObservableProperty] private ChoiceRow editorProvider = ChoiceRow.Provider(MailProvider.Imap); [ObservableProperty] private ChoiceRow editorCertifiedKind = ChoiceRow.Certified(MailCertifiedKind.Ordinary); [ObservableProperty] private string editorGoogleClientId = ""; [ObservableProperty] private string editorGoogleClientSecret = ""; [ObservableProperty] private string editorMicrosoftClientId = ""; [ObservableProperty] private bool showPasswordFields = true; [ObservableProperty] private bool showOAuthButtons; [ObservableProperty] private bool showGoogleSignIn; [ObservableProperty] private bool showMicrosoftSignIn; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowAuthHint))] private string oauthHint = ""; public bool ShowAuthHint => !string.IsNullOrWhiteSpace(OauthHint); [ObservableProperty] private ChoiceRow editorIncomingProtocol = ChoiceRow.Incoming(MailProtocol.Imap); [ObservableProperty] private ChoiceRow editorIncomingSecurity = ChoiceRow.Security(MailSecurity.Ssl); [ObservableProperty] private string editorIncomingHost = ""; [ObservableProperty] private string editorStorePath = ""; [ObservableProperty] private decimal editorIncomingPort = 993; [ObservableProperty] private ChoiceRow editorSmtpSecurity = ChoiceRow.Security(MailSecurity.Ssl); [ObservableProperty] private string editorSmtpHost = ""; [ObservableProperty] private decimal editorSmtpPort = 465; [ObservableProperty] private string readingSubject = ""; [ObservableProperty] private string readingFrom = ""; [ObservableProperty] private string readingTo = ""; [ObservableProperty] private string readingCc = ""; [ObservableProperty] private string readingDate = ""; [ObservableProperty] private string readingBody = ""; [ObservableProperty] private string readingHtmlDocument = ""; [ObservableProperty] private string readingSource = ""; [ObservableProperty] private string readingEnvelope = ""; [ObservableProperty] private bool hasReading; [ObservableProperty] private bool hasReadingCc; [ObservableProperty] private bool hasReadingEnvelope; [ObservableProperty] private bool hasReadingFiles; [ObservableProperty] private bool unwrapEnvelope; [ObservableProperty] private bool groupConversations; [ObservableProperty] private string readingBodyKind = MailBodyKind.Html; [ObservableProperty] private string readingLayout = MailLayout.Stacked; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasMessageFilter))] private string messageFilter = ""; public bool HasMessageFilter => !string.IsNullOrEmpty(MessageFilter); [ObservableProperty] private string practiceLabel = ""; [ObservableProperty] private string backgroundSyncLabel = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowIndexLine))] private string indexLine = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSemanticLine))] private string semanticLine = ""; [ObservableProperty] private string readingFattura = ""; [ObservableProperty] private bool hasReadingFattura; public ObservableCollection Mailboxes { get; } = []; public ObservableCollection Folders { get; } = []; public ObservableCollection FolderTree { get; } = []; public ObservableCollection Messages { get; } = []; public ObservableCollection VisibleMessages { get; } = []; public ObservableCollection ReadingFiles { get; } = []; public List SelectedMessages { get; } = []; public bool IsSyncingMessageList => _syncingList; public List SelectedFolderNodes { get; } = []; public IReadOnlyList IncomingProtocols => ChoiceRow.IncomingProtocols; public IReadOnlyList SecurityModes => ChoiceRow.SecurityModes; public IReadOnlyList Providers => ChoiceRow.ProvidersFor(_files.Current.Features, EditorProvider.Id); public IReadOnlyList CertifiedKinds => ChoiceRow.CertifiedKinds; public ObservableCollection ArchiveStoreChoices { get; } = []; [ObservableProperty] private ChoiceRow editorArchiveStore = new() { Id = "", Title = "" }; public bool ShowPstFields => false; public bool ShowServerFields => !MailProvider.IsLocalBucket(EditorProvider.Id); public bool ShowArchiveStore => ShowServerFields; public bool ShowCertifiedKind => ShowServerFields && MailProvider.Normalize(EditorProvider.Id) == MailProvider.Imap; public IReadOnlyList Languages { get; } = UiLanguage.All.Select(id => new ChoiceRow { Id = id, Title = UiLanguage.Title(id) }).ToList(); public ChoiceRow SelectedLanguage { get => Languages.First(l => l.Id == UiLocale.Id); set { if (value is null || value.Id == UiLocale.Id) return; SetLanguage(value.Id); } } public bool IsEnglish => UiLocale.Id == UiLanguage.En; public bool IsItalian => UiLocale.Id == UiLanguage.It; public bool IsFrench => UiLocale.Id == UiLanguage.Fr; public bool IsGerman => UiLocale.Id == UiLanguage.De; public bool IsSpanish => UiLocale.Id == UiLanguage.Es; public bool HtmlEngineReady { get; private set; } = true; public bool ShowHtmlBody => HasReading && HtmlEngineReady && MailBodyKind.IsHtml(ReadingBodyKind); public bool ShowTextBody => HasReading && (MailBodyKind.IsText(ReadingBodyKind) || (!HtmlEngineReady && MailBodyKind.IsHtml(ReadingBodyKind))); public bool ShowSourceBody => HasReading && MailBodyKind.IsSource(ReadingBodyKind); public bool ShowFatturaBody => HasReading && UseFatturaPa && MailBodyKind.IsFattura(ReadingBodyKind); public bool UseFascicolo => FeatureGate.On(AppFeature.Fascicolo); public bool UseFatturaPa => FeatureGate.On(AppFeature.FatturaPa); public bool UseEnvelopeTools => FeatureGate.On(AppFeature.PecEnvelope) || FeatureGate.On(AppFeature.RemEvidence); public bool UseDeliveryColumn => UseEnvelopeTools || FeatureGate.On(AppFeature.FrEvidence) || FeatureGate.On(AppFeature.DeEvidence) || FeatureGate.On(AppFeature.EsEvidence) || FeatureGate.On(AppFeature.ChEvidence); public bool UseTypeColumn => UseEnvelopeTools; public bool ShowFatturaPaTools => HasReadingFattura && UseFatturaPa; public bool IsHtmlKind => MailBodyKind.IsHtml(ReadingBodyKind); public bool IsTextKind => MailBodyKind.IsText(ReadingBodyKind); public bool IsSourceKind => MailBodyKind.IsSource(ReadingBodyKind); public bool IsFatturaKind => MailBodyKind.IsFattura(ReadingBodyKind); public bool IsWideLayout { get => MailLayout.IsWide(ReadingLayout); set => ReadingLayout = value ? MailLayout.Wide : MailLayout.Stacked; } public bool IsStackedLayout { get => MailLayout.IsStacked(ReadingLayout); set { if (value) ReadingLayout = MailLayout.Stacked; else OnPropertyChanged(nameof(IsStackedLayout)); } } public string LayoutToggleLabel => IsWideLayout ? Copy.ThreeColumns : Copy.ListReading; public event Action? ComposeRequested; public event Action? MessageWindowRequested; public event Action? AccountSettingsRequested; public event Action? FeaturesRequested; public event Action? SemanticSearchRequested; public event Action? MessageListSelectionRestoreRequested; public event Action? FolderTreeSelectionRestoreRequested; public event Action? RulesRequested; public event Action? AccountSaved; public event Action? NoticeRequested; public event Action? AboutRequested; public event Action? LogsRequested; public bool ShowIndexLine => !string.IsNullOrWhiteSpace(IndexLine); public bool ShowSemanticLine => !string.IsNullOrWhiteSpace(SemanticLine); public string EmptyFolderLabel { get { foreach (var node in TargetFolderNodes()) { if (MailRetention.IsTrash(node.Folder?.Name, node.Folder?.FullName)) return Copy.DeleteAllItems; } return Copy.EmptyFolder; } } public event Func>? PromptRequested; public event Action? ExportArchiveRequested; public event Func>? PickBundleRequested; public event Action? ExportFascicoloRequested; public event Action? ImportEmlRequested; public event Action? ImportPstRequested; public event Action? AttachPstRequested; public event Action? CreatePstRequested; public event Action? IdentityHubSignInRequested; public event Action? RetentionRequested; public event Func>? MoveStorePathRequested; public event Func>? PickStorePathRequested; public event Action? ImportThunderbirdRequested; public event Action? PrintHtmlRequested; public event Action? SavePdfRequested; public event Action? SaveAttachmentsZipRequested; public MainViewModel( ConfigurationFileService files, ISecretStore secrets, IMailSessionFactory sessions, IMailAuthService auth, ISentReceiptStore receipts, MailArchiveCatalog archive, MailWorkerClient worker, ISemanticSearchService semantic, IAppUpdateService updates) { _files = files; _secrets = secrets; _sessions = sessions; _auth = auth; _receipts = receipts; _archive = archive; _worker = worker; _semantic = semantic; _updates = updates; _indexHold = new StatusLineHold(line => IndexLine = line); _semanticHold = new StatusLineHold(line => SemanticLine = line); files.Current.EnsureDefaults(); if (SharedMailboxStore.MergeMissing(files.Current)) files.Save(files.Current); EnrollAll(files.Current); PstStoreUpgrade.Apply(files, archive); LegacyArchiveUpgrade.Apply(files.Current.Mailboxes); archive.OpenAll(files.Current.Mailboxes); FeatureGate.Use(files.Current.Features); UiLocale.Apply(files.Current.Language); copy = UiLocale.Copy; status = copy.AddMailboxThenGet; backgroundSyncLabel = BackgroundSyncCaption(copy); unwrapEnvelope = files.Current.UnwrapEnvelope; groupConversations = files.Current.GroupConversations; _bodyPreference = MailBodyKind.Preference(files.Current.ReadingBodyKind); readingBodyKind = _bodyPreference; readingLayout = MailLayout.Normalize(files.Current.ReadingLayout); messageFilter = files.Current.Layout?.MessageFilter ?? ""; var savedSort = files.Current.Layout?.ColumnSort; if (savedSort is not null && !string.IsNullOrWhiteSpace(savedSort.Header)) { _listSortHeader = savedSort.Header; _listSortDescending = string.Equals( savedSort.Direction, nameof(System.ComponentModel.ListSortDirection.Descending), StringComparison.OrdinalIgnoreCase); } editorGoogleClientId = files.Current.GoogleClientId; editorGoogleClientSecret = StoredGoogleClientSecret(); editorMicrosoftClientId = files.Current.MicrosoftClientId; ReloadMailboxes(); MarkExistingSyncComplete(); semanticLine = semantic.StatusLine; semantic.Changed += OnSemanticChanged; semantic.Faulted += OnSemanticFaulted; } public void StartBackgroundWork() { if (_uiReady) return; _uiReady = true; _semantic.Start(); if (SelectedMailbox is not null && !AccountSettingsOpen) _ = ConnectAsync(); } public async ValueTask DisposeAsync() { StopBackfill(); _folderLoad?.Cancel(); _folderLoad?.Dispose(); _folderLoad = null; _search?.Cancel(); _search?.Dispose(); _search = null; _readingLoad?.Cancel(); _readingLoad?.Dispose(); _readingLoad = null; _work?.Cancel(); _semantic.Changed -= OnSemanticChanged; _semantic.Faulted -= OnSemanticFaulted; _semantic.Dispose(); _indexHold.Dispose(); _semanticHold.Dispose(); List sessions; lock (_connections) { sessions = [.. _connections.Values]; _connections.Clear(); } foreach (var session in sessions) await session.DisposeAsync(); _work?.Dispose(); _archive.Dispose(); _worker.Dispose(); } private void OnSemanticFaulted(Exception exception) => Dispatcher.UIThread.Post(() => ErrorDialog.Report(exception)); private void OnSemanticChanged() => Dispatcher.UIThread.Post(() => { var line = _semantic.StatusLine; if (line.Contains("ready", StringComparison.OrdinalIgnoreCase)) line = ""; _semanticHold.Set(line); }); [RelayCommand] private void NewMailbox() { _pendingOauth = null; SelectedMailbox = null; EditorName = ""; EditorAddress = ""; EditorShareAccount = false; EditorUsername = ""; EditorPassword = ""; EditorGoogleClientId = _files.Current.GoogleClientId; EditorGoogleClientSecret = ""; EditorMicrosoftClientId = _files.Current.MicrosoftClientId; _applyingProvider = true; EditorProvider = ChoiceRow.Provider(MailProvider.Imap); EditorCertifiedKind = ChoiceRow.Certified(MailCertifiedKind.Ordinary); _applyingProvider = false; EditorIncomingProtocol = ChoiceRow.Incoming(MailProtocol.Imap); EditorIncomingSecurity = ChoiceRow.Security(MailSecurity.Ssl); EditorIncomingHost = ""; EditorStorePath = ""; EditorIncomingPort = MailSecurity.DefaultIncomingPort(MailProtocol.Imap, MailSecurity.Ssl); EditorSmtpSecurity = ChoiceRow.Security(MailSecurity.Ssl); EditorSmtpHost = ""; EditorSmtpPort = MailSecurity.DefaultSmtpPort(MailSecurity.Ssl); UpdateAuthUi(); AccountSettingsRequested?.Invoke(); } [RelayCommand] private void OpenFeatures() => FeaturesRequested?.Invoke(); [RelayCommand] private void ToggleBackgroundSync() { var registrar = new MailSyncRegistrar(); var remove = registrar.IsInstalled(); var result = remove ? registrar.Remove() : registrar.Install(); if (!result.IsSuccess) Status = string.Join(" ", result.Messages); else if (remove) Status = Copy.BackgroundSyncRemoved; else Status = Copy.BackgroundSyncRegistered; BackgroundSyncLabel = BackgroundSyncCaption(Copy); } private static string BackgroundSyncCaption(UiCopy text) => new MailSyncRegistrar().IsInstalled() ? text.BackgroundSyncRemove : text.BackgroundSyncRegister; [RelayCommand] private void OpenSemanticSearch() => SemanticSearchRequested?.Invoke(); [RelayCommand] private void OpenRules() => RulesRequested?.Invoke(); [RelayCommand] private void ShowAbout() => AboutRequested?.Invoke(); [RelayCommand] private void ShowLogs() => LogsRequested?.Invoke(); [RelayCommand(CanExecute = nameof(CanCheckForUpdates))] private async Task CheckForUpdatesAsync() { _updateBusy = true; CheckForUpdatesCommand.NotifyCanExecuteChanged(); Status = Copy.UpdateChecking; try { var check = await _updates.CheckAsync(); if (!check.IsSuccess) { var msg = string.Join(" ", check.Messages); Status = msg.Contains("404", StringComparison.Ordinal) ? Copy.UpdateNone : string.Format(Copy.UpdateFailed, msg); return; } var info = check.Value; if (info is null || !info.IsNewer) { Status = string.Format(Copy.UpdateLatest, AppVersion.Display()); return; } var latest = info.Release.VersionText; var installed = AppVersion.Format(info.Installed); var hasAsset = info.Asset is not null; var accepted = PromptRequested is null ? null : await PromptRequested(new PromptRequest { Title = Copy.CheckUpdates.Replace("_", "", StringComparison.Ordinal), Message = hasAsset ? string.Format(Copy.UpdateAvailable, latest, installed) : string.Format(Copy.UpdateNoAsset, latest, installed), ConfirmOnly = true, ConfirmLabel = hasAsset ? Copy.UpdateDownload : Copy.UpdateOpenPage }); if (accepted is null) { Status = string.Format(Copy.UpdateAvailable, latest, installed); return; } if (hasAsset) { Status = Copy.UpdateDownloading; var downloaded = await _updates.DownloadAsync(info.Asset!); if (!downloaded.IsSuccess || string.IsNullOrWhiteSpace(downloaded.Value)) { Status = string.Join(" ", downloaded.Messages); OpenUrl(info.Release.HtmlUrl); return; } if (!_updates.TryLaunch(downloaded.Value)) OpenUrl(info.Release.HtmlUrl); else Status = ""; return; } OpenUrl(info.Release.HtmlUrl); } finally { _updateBusy = false; CheckForUpdatesCommand.NotifyCanExecuteChanged(); } } private bool CanCheckForUpdates() => !_updateBusy; private static void OpenUrl(string? url) { if (string.IsNullOrWhiteSpace(url)) return; try { Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); } catch { } } [RelayCommand] private void OpenAccountSettings() { if (_pendingOauth is not null) { AccountSettingsRequested?.Invoke(); return; } if (SelectedMailbox is null) { NewMailbox(); return; } FillEditor(SelectedMailbox); AccountSettingsRequested?.Invoke(); } [RelayCommand] private void SetLanguage(string? id) { var language = UiLanguage.Normalize(id); var configuration = _files.Current; configuration.Language = language; _files.Save(configuration); ApplyLanguage(); } private void ApplyLanguage() { var previous = Copy; UiLocale.Apply(_files.Current.Language); Copy = UiLocale.Copy; OnPropertyChanged(nameof(ShareAccountLocation)); BackgroundSyncLabel = BackgroundSyncCaption(Copy); if (string.IsNullOrWhiteSpace(Status) || Status == previous.AddMailboxThenGet) Status = Copy.AddMailboxThenGet; RelocalizeFolders(); RefreshFolderStats(); UpdateAuthUi(); EditorIncomingSecurity = ChoiceRow.Security(EditorIncomingSecurity.Id); EditorSmtpSecurity = ChoiceRow.Security(EditorSmtpSecurity.Id); EditorCertifiedKind = ChoiceRow.Certified(EditorCertifiedKind.Id); OnPropertyChanged(nameof(SecurityModes)); OnPropertyChanged(nameof(CertifiedKinds)); OnPropertyChanged(nameof(SelectedLanguage)); OnPropertyChanged(nameof(IsEnglish)); OnPropertyChanged(nameof(IsItalian)); OnPropertyChanged(nameof(IsFrench)); OnPropertyChanged(nameof(IsGerman)); OnPropertyChanged(nameof(IsSpanish)); OnPropertyChanged(nameof(LayoutToggleLabel)); NotifyFeatures(); foreach (var row in Messages) row.RefreshMarks(); } private void RelocalizeFolders() { foreach (var rows in _foldersByMailbox.Values) { foreach (var folder in rows) folder.Name = MailFolderRole.DisplayName(null, folder.FullName); } NotifyFolderCounts(); } [RelayCommand] private void EditMailbox() => OpenAccountSettings(); [RelayCommand] private void SaveMailbox() => WriteMailbox(closeSettings: true); private void WriteMailbox(bool closeSettings) { if (ShowPstFields) { if (string.IsNullOrWhiteSpace(EditorStorePath) || !File.Exists(EditorStorePath.Trim())) { Status = Copy.PstPathRequired; return; } } else if (string.IsNullOrWhiteSpace(EditorIncomingHost) || string.IsNullOrWhiteSpace(EditorAddress)) { Status = "Address and incoming host are required."; return; } var configuration = _files.Current; configuration.EnsureDefaults(); var box = MailboxToSave(); box.DisplayName = EditorName.Trim(); box.Address = EditorAddress.Trim(); box.Username = EditorUsername.Trim(); if (ShowPstFields) { var path = EditorStorePath.Trim(); box.Provider = MailProvider.Pst; box.IncomingProtocol = MailProtocol.Pst; box.StorePath = path; box.ImapHost = ""; box.AuthKind = MailAuthKind.Password; box.CertifiedKind = MailCertifiedKind.Ordinary; if (string.IsNullOrWhiteSpace(box.Address)) box.Address = Path.GetFileNameWithoutExtension(path); if (string.IsNullOrWhiteSpace(box.DisplayName)) box.DisplayName = Path.GetFileName(path); } else { box.IncomingProtocol = EditorIncomingProtocol.Id; box.IncomingSecurity = EditorIncomingSecurity.Id; box.ImapHost = EditorIncomingHost.Trim(); box.StorePath = ""; box.ImapPort = EditorIncomingPort <= 0 ? MailSecurity.DefaultIncomingPort(box.IncomingProtocol, box.IncomingSecurity) : (int)EditorIncomingPort; box.SmtpSecurity = EditorSmtpSecurity.Id; box.SmtpHost = string.IsNullOrWhiteSpace(EditorSmtpHost) ? EditorIncomingHost.Trim() : EditorSmtpHost.Trim(); box.SmtpPort = EditorSmtpPort <= 0 ? MailSecurity.DefaultSmtpPort(box.SmtpSecurity) : (int)EditorSmtpPort; box.Provider = MailProvider.Normalize(EditorProvider.Id); box.CertifiedKind = EditorCertifiedKind.Id; var nextStoreId = EditorArchiveStore?.Id ?? ""; if (!box.ArchiveStoreId.Equals(nextStoreId, StringComparison.OrdinalIgnoreCase)) { var oldRoot = MailArchiveLayout.MailRoot(box, configuration.Mailboxes); box.ArchiveStoreId = nextStoreId; var newRoot = MailArchiveLayout.MailRoot(box, configuration.Mailboxes); if (!oldRoot.Equals(newRoot, StringComparison.OrdinalIgnoreCase) && Directory.Exists(oldRoot)) { _worker.Call(new WorkerRequest { Op = "copy-store", Path = oldRoot, Dest = newRoot }); } _archive.Close(box.Id); _archive.Open(box.Id, MailArchiveLayout.DatabasePath(box, configuration.Mailboxes)); } else box.ArchiveStoreId = nextStoreId; if (_pendingOauth is not null) box.AuthKind = MailAuthKind.Normalize(_pendingOauth.Provider); else if (MailProvider.UsesOAuth(box.Provider) || _auth.HasTokens(box.Id)) box.AuthKind = MailAuthKind.Normalize( box.Provider == MailProvider.Outlook ? MailAuthKind.Microsoft : MailAuthKind.Google); else box.AuthKind = MailAuthKind.Password; MailProvider.Apply(box); } if (!ApplySharedLocation(configuration, box)) return; if (configuration.FindMailbox(box.Id) is null) configuration.Mailboxes.Add(box); configuration.SelectedMailboxId = box.Id; PersistClientIds(configuration); PersistGoogleClientSecret(); _files.Save(configuration); if (!string.IsNullOrWhiteSpace(EditorPassword)) _secrets.Put(FileSecretStore.MailboxKey(box.Id), EditorPassword); if (_pendingOauth is not null) { _auth.SaveTokens(box.Id, _pendingOauth); _pendingOauth = null; } if (!box.IsLocalStore) CopySecretsToService(box.Id); ReloadMailboxes(); SelectedMailbox = Mailboxes.FirstOrDefault(m => m.Id == box.Id); Status = "Mailbox saved. Secrets stay in the OS secret file, not settings.json."; if (!closeSettings) return; AccountSettingsOpen = false; AccountSaved?.Invoke(); } public string ShareAccountLocation => string.Format(Copy.ShareAccountHint, SharedMailPaths.Root()); private bool ApplySharedLocation(Configuration configuration, MailboxAccount box) { if (box.IsLocalStore) return true; _archive.Close(box.Id); var privateRoot = File.Exists(SharedMailPaths.AccountFile(box.Id)) ? SharedMailPaths.AccountDirectory(box.Id) : MailArchiveLayout.MailRoot(box, configuration.Mailboxes); var placed = SharedMailboxStore.Place(box, EditorShareAccount, privateRoot); if (!placed.IsSuccess) { Status = string.Join(" ", placed.Messages); _archive.Open(box.Id, MailArchiveLayout.DatabasePath(box, configuration.Mailboxes)); return false; } box.Shared = EditorShareAccount; box.ArchiveStoreId = ""; _archive.Open(box.Id, MailArchiveLayout.DatabasePath(box, configuration.Mailboxes)); return true; } private void EnrollAll(Configuration configuration) { foreach (var box in configuration.Mailboxes) { if (box.IsLocalStore) continue; var root = File.Exists(SharedMailPaths.AccountFile(box.Id)) ? SharedMailPaths.AccountDirectory(box.Id) : MailArchiveLayout.MailRoot(box, configuration.Mailboxes); if (!SharedMailboxStore.Place(box, box.Shared, root).IsSuccess) continue; CopySecretsToService(box.Id); } } private void CopySecretsToService(string mailboxId) { var service = new SharedSecretStore(); CopySecret(service, FileSecretStore.MailboxKey(mailboxId)); CopySecret(service, FileSecretStore.OAuthKey(mailboxId)); } private void CopySecret(SharedSecretStore service, string key) { var stored = _secrets.Get(key); if (stored.IsSuccess && !string.IsNullOrWhiteSpace(stored.Value)) service.Put(key, stored.Value); } [RelayCommand] private async Task RemoveMailboxAsync() { if (SelectedMailbox is null) return; await RemoveMailboxCoreAsync(SelectedMailbox.Id); Status = "Mailbox removed."; } [RelayCommand(CanExecute = nameof(CanDetachPst))] private async Task DetachPstAsync() { var box = SelectedFolderNode?.Mailbox; if (box is null || !box.IsLocalStore) return; var label = box.Label; await RemoveMailboxCoreAsync(box.Id); Status = string.Format(Copy.DetachStoreDone, label); } private bool CanDetachPst() => ShowDetachPst; private async Task RemoveMailboxCoreAsync(string id) { var configuration = _files.Current; configuration.Mailboxes.RemoveAll(m => m.Id == id); if (configuration.SelectedMailboxId == id) configuration.SelectedMailboxId = configuration.Mailboxes.FirstOrDefault()?.Id; foreach (var rule in configuration.Rules ?? []) { if (rule.MailboxId.Equals(id, StringComparison.OrdinalIgnoreCase)) rule.MailboxId = ""; if (rule.FolderMailboxId.Equals(id, StringComparison.OrdinalIgnoreCase)) rule.FolderMailboxId = ""; } _files.Save(configuration); _secrets.Delete(FileSecretStore.MailboxKey(id)); _auth.DeleteTokens(id); _archive.Close(id); await DisconnectMailboxAsync(id); ReloadMailboxes(); AccountSaved?.Invoke(); } [RelayCommand(CanExecute = nameof(CanGetMessages))] private async Task GetMessagesAsync() { if (SelectedMailbox is { IsLocalStore: true }) return; if (ActiveSession is { IsConnected: true }) await RefreshAsync(); else await ConnectAsync(); await RunRetentionAsync(); } [RelayCommand(CanExecute = nameof(CanConnect))] private async Task ConnectAsync() { if (SelectedMailbox is null) return; var box = SelectedMailbox; if (SessionFor(box) is not { IsConnected: true }) { var password = PasswordFor(box); var oauth = MailAuthKind.IsOAuth(box.AuthKind) || _auth.HasTokens(box.Id); if (!box.IsLocalStore && string.IsNullOrWhiteSpace(password) && !oauth) { Status = "Enter the password once in Account Settings, then Get Messages."; if (!AccountSettingsOpen) { FillEditor(box); AccountSettingsRequested?.Invoke(); } return; } await RunAsync(Copy.Connecting, async token => { var ok = await ConnectMailboxCoreAsync(box, password, token); if (!ok) return; if (!string.IsNullOrWhiteSpace(EditorPassword) && SelectedMailbox?.Id == box.Id) _secrets.Put(FileSecretStore.MailboxKey(box.Id), EditorPassword); if (_auth.HasTokens(box.Id) && !MailAuthKind.IsOAuth(box.AuthKind)) box.AuthKind = MailProvider.Normalize(box.Provider) == MailProvider.Outlook ? MailAuthKind.Microsoft : MailAuthKind.Google; var configuration = _files.Current; configuration.SelectedMailboxId = box.Id; _files.Save(configuration); Status = "Connected to " + box.Label + "."; }); } else { SyncFoldersCollection(box); RebuildFolderTree(); } _ = ConnectIdleMailboxesAsync(); } [RelayCommand] private async Task RefreshAsync() { if (SelectedFolder is null) return; await RunAsync( Copy.GettingMessages, token => LoadMessagesAsync(SelectedFolder.FullName, token, resumeBodies: true, yieldAfterFirst: true)); } [RelayCommand] private void OpenArchiveFolder() { AppPaths.EnsureDirectories(); try { Process.Start(new ProcessStartInfo { FileName = AppPaths.DataDirectory(), UseShellExecute = true }); } catch (Exception ex) { Status = ex.Message; } } [RelayCommand] private void ExportArchive() => ExportArchiveRequested?.Invoke(); public void ExportArchiveTo(string destDir) { try { _archive.Checkpoint(); ArchiveFiles.ExportTo(destDir); Status = "Exported archive to " + destDir + ". " + ArchiveFiles.Hint(); } catch (Exception ex) { Status = ex.Message; } } private bool CanMigrate() => !IsBusy; [RelayCommand(CanExecute = nameof(CanMigrate))] private async Task CreateBundleAsync() { if (PromptRequested is null || PickBundleRequested is null) return; var pass = await PromptRequested(new PromptRequest { Title = Copy.EasyMigration, Message = Copy.EasyMigrationPassphrase, Password = true, RepeatPassword = true, RepeatMessage = Copy.EasyMigrationConfirmPassphrase, RequiredMessage = Copy.EasyMigrationPassphraseRequired, MismatchMessage = Copy.EasyMigrationPassphraseMismatch }); if (string.IsNullOrWhiteSpace(pass)) return; var path = await PickBundleRequested(true); if (string.IsNullOrWhiteSpace(path)) return; await RunMigrationAsync(() => EasyMigration.Create(MigrationLayout.Local(), path, pass), restored: false, path); } [RelayCommand(CanExecute = nameof(CanMigrate))] private async Task OpenBundleAsync() { if (PromptRequested is null || PickBundleRequested is null) return; var path = await PickBundleRequested(false); if (string.IsNullOrWhiteSpace(path)) return; var pass = await PromptRequested(new PromptRequest { Title = Copy.EasyMigration, Message = Copy.EasyMigrationUnlock, Password = true }); if (pass is null) return; if (string.IsNullOrWhiteSpace(pass)) { Status = Copy.EasyMigrationPassphraseRequired; return; } if (EasyMigration.HasExistingData(MigrationLayout.Local())) { var accepted = await PromptRequested(new PromptRequest { Title = Copy.EasyMigration, Message = Copy.EasyMigrationReplace, ConfirmOnly = true, ConfirmLabel = Copy.Ok }); if (accepted is null) return; } await DropSessionsAsync(); await RunMigrationAsync(() => EasyMigration.Restore(path, pass, MigrationLayout.Local()), restored: true, path); } private async Task RunMigrationAsync(Func work, bool restored, string path) { IsBusy = true; Result result; try { result = await Task.Run(() => { ReleaseMigrationLocks(); return work(); }); } catch (Exception ex) { result = Result.UnprocessableEntity(ex.Message); } finally { if (restored) ReloadAfterRestore(); else ReopenArchives(); IsBusy = false; } if (!result.IsSuccess) { Status = MigrationFailure(result); return; } Status = restored ? Copy.EasyMigrationRestored : string.Format(Copy.EasyMigrationCreated, path); } private void ReleaseMigrationLocks() { _archive.Checkpoint(); _worker.Release(); _archive.Release(); _semantic.ReleaseModel(); } private void ReopenArchives() { _archive.OpenAll(_files.Current.Mailboxes); _semantic.ResumeModel(); } private void ReloadAfterRestore() { var configuration = _files.Reload(); if (SharedMailboxStore.MergeMissing(configuration)) _files.Save(configuration); FeatureGate.Use(configuration.Features); UiLocale.Apply(configuration.Language); Copy = UiLocale.Copy; EnrollAll(configuration); _archive.OpenAll(configuration.Mailboxes); _semantic.ResumeModel(); _suppressFolderLoad = true; Messages.Clear(); VisibleMessages.Clear(); SelectedMessages.Clear(); _selectedKeys.Clear(); SelectedMessage = null; ClearReading(); _suppressFolderLoad = false; ReloadMailboxes(); } private async Task DropSessionsAsync() { _folderLoad?.Cancel(); _search?.Cancel(); _readingLoad?.Cancel(); _work?.Cancel(); List sessions; lock (_connections) { sessions = [.. _connections.Values]; _connections.Clear(); } foreach (var session in sessions) await session.DisposeAsync(); } private string MigrationFailure(Result result) { var text = string.Join(" ", result.Messages); if (text.Contains(EasyMigration.WrongPassphrase, StringComparison.Ordinal)) return Copy.EasyMigrationWrongPassphrase; if (text.Contains(EasyMigration.Locked, StringComparison.Ordinal)) return Copy.EasyMigrationLocked; return text; } [RelayCommand] private void ExportFascicolo() => ExportFascicoloRequested?.Invoke(); [RelayCommand(CanExecute = nameof(HasOpenMessage))] private void PrintMessage() { if (_reading is null) return; var html = MessagePrint.Html(_reading, UnwrapEnvelope); var path = Path.Combine(Path.GetTempPath(), "postclient-print.html"); File.WriteAllText(path, html); PrintHtmlRequested?.Invoke(path); } [RelayCommand(CanExecute = nameof(HasOpenMessage))] private void SaveMessagePdf() { if (_reading is null) return; SavePdfRequested?.Invoke(MessagePrint.Pdf(_reading, UnwrapEnvelope), MessagePrint.EmlFileName(_reading.Header) + ".pdf"); } [RelayCommand(CanExecute = nameof(HasReadingAttachments))] private void SaveAttachmentsZip() { if (ReadingFiles.Count == 0) return; var name = AttachmentZip.SuggestedName(_reading?.Header.Subject); SaveAttachmentsZipRequested?.Invoke(AttachmentZip.FromFiles(ReadingFiles), name); Status = "Save the ZIP on this PC — originals stay in the message."; } private bool HasReadingAttachments() => HasReadingFiles; [RelayCommand] private void ImportEml() => ImportEmlRequested?.Invoke(); [RelayCommand] private void ImportPst() => ImportPstRequested?.Invoke(); [RelayCommand] private void AttachPst() => AttachPstRequested?.Invoke(); [RelayCommand] private void CreatePst() => CreatePstRequested?.Invoke(); [RelayCommand] private async Task MoveStoreAsync() { if (MoveStorePathRequested is null) return; var path = await MoveStorePathRequested(); if (!string.IsNullOrWhiteSpace(path)) await MoveStorePathAsync(path); } [RelayCommand] private void OpenRetention() => RetentionRequested?.Invoke(); public async Task RunRetentionAsync() { var configuration = _files.Current; configuration.EnsureDefaults(); foreach (var job in MailRetention.Jobs(configuration.Retention)) { var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(job.MailboxId, StringComparison.OrdinalIgnoreCase)); if (box is null || box.IsLocalStore) continue; var known = FoldersFor(box).Select(f => f.FullName).ToList(); if (known.Count == 0) known = _archive.ListFolders(box.Id).ToList(); var folder = MailRetention.BindFolder(job.Folder, known); if (string.IsNullOrWhiteSpace(folder)) continue; var cutoff = DateTimeOffset.UtcNow.AddDays(-job.Days); var ids = _archive.UidsOlderThan( box.Id, folder, cutoff, useReceivedDate: MailRetention.IsTrash(folder)); if (ids.Count == 0) continue; var session = SessionFor(box); if (session is not { IsConnected: true }) continue; if (MailRetention.IsTrash(folder)) { var purged = await session.SetMessageFlagsAsync(folder, ids, new MailFlagUpdate { Deleted = true }); if (purged.IsSuccess) DropArchived(box.Id, folder, ids); continue; } if (session is not { SupportsFolders: true }) continue; var trash = MailRetention.ResolveTrash( FoldersFor(box).Select(f => (f.Name, f.FullName)).ToList()); if (folder.Equals(trash, StringComparison.OrdinalIgnoreCase)) { var purged = await session.SetMessageFlagsAsync(folder, ids, new MailFlagUpdate { Deleted = true }); if (purged.IsSuccess) DropArchived(box.Id, folder, ids); continue; } var moved = await session.MoveMessagesAsync(folder, ids, trash, CancellationToken.None); if (moved.IsSuccess) DropArchived(box.Id, folder, ids); } var result = await Task.Run(() => _worker.Call(new WorkerRequest { Op = "retention" })) .ConfigureAwait(false); await Dispatcher.UIThread.InvokeAsync(() => { Status = result.Ok ? "Retention processed " + (result.Value ?? "0") + " messages." : result.Error ?? "Retention failed."; }); if (SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, CancellationToken.None); } [RelayCommand] private void ImportThunderbird() => ImportThunderbirdRequested?.Invoke(); [RelayCommand] private void ApplyPracticeLabel() { if (SelectedMailbox is null || SelectedFolder is null || PracticeLabel.Trim().Length == 0) return; var name = PracticeLabel.Trim(); foreach (var row in TargetRows()) { _archive.AddLabel(SelectedMailbox.Id, row.Header.Folder, row.Header.Id, name); var labels = string.IsNullOrWhiteSpace(row.Header.Labels) ? name : row.Header.Labels + ", " + name; row.Header.Labels = labels; row.RefreshMarks(); } Status = "Label “" + name + "” stays on this PC — not a shared studio table."; RebuildVisible(); } private bool HasOpenMessage() => _reading is not null; public async Task ExportFascicoloToAsync(string destDir) { if (SelectedMailbox is null) return; Directory.CreateDirectory(destDir); var rows = TargetRows(); if (rows.Count == 0 && SelectedMessage is not null) rows = [SelectedMessage]; var copied = 0; var index = new List(); foreach (var row in rows) { var eml = _archive.EmlPath(SelectedMailbox.Id, row.Header.Folder, row.Header.Id); if (string.IsNullOrWhiteSpace(eml) || !File.Exists(eml)) { if (_reading is { } body && body.Header.Id == row.Header.Id && body.RawEml.Length > 0) { eml = Path.Combine(destDir, MessagePrint.EmlFileName(row.Header)); await File.WriteAllBytesAsync(eml, body.RawEml); } else continue; } else { var dest = Path.Combine(destDir, MessagePrint.EmlFileName(row.Header)); File.Copy(eml, dest, overwrite: true); eml = dest; } copied++; index.Add(row.Header.Date.ToLocalTime().ToString("yyyy-MM-dd HH:mm") + " " + row.Header.Subject); } if (_reading is not null) await File.WriteAllBytesAsync(Path.Combine(destDir, "messaggio.pdf"), MessagePrint.Pdf(_reading, UnwrapEnvelope)); await File.WriteAllLinesAsync(Path.Combine(destDir, "index.txt"), index); Status = "Fascicolo: " + copied + " .eml in " + destDir + " (this PC only)."; } public async Task ImportEmlPathsAsync(IReadOnlyList paths) { if (SelectedMailbox is null) { Status = "Select a mailbox first."; return; } var folder = SelectedFolder?.FullName ?? "Imported"; var import = new LocalMailImport(_archive); var count = await import.ImportEmlFilesAsync( SelectedMailbox.Id, folder, paths, ActiveSession, UnwrapEnvelope, CancellationToken.None, ActiveRules(), FoldersForRules(SelectedMailbox.Id)); Status = "Imported " + count + " .eml into " + folder + "."; if (SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, CancellationToken.None); } public async Task ImportPstPathAsync(string path, string? storeDirectory = null) { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return; var dest = string.IsNullOrWhiteSpace(storeDirectory) ? AppPaths.ProposedStoreDirectory(path) : storeDirectory; Directory.CreateDirectory(dest); var sidecar = LocalStoreSidecar.TryRead(dest); var configuration = _files.Current; configuration.EnsureDefaults(); MailboxAccount box; if (sidecar is not null) { box = configuration.FindMailbox(sidecar.Id) ?? new MailboxAccount { Id = sidecar.Id, DisplayName = sidecar.Name, Address = sidecar.Name, StorePath = dest, IncomingProtocol = MailProtocol.Store, Provider = MailProvider.Store }; box.StorePath = dest; if (configuration.FindMailbox(box.Id) is null) configuration.Mailboxes.Add(box); } else { box = new MailboxAccount { DisplayName = Path.GetFileNameWithoutExtension(path), Address = Path.GetFileNameWithoutExtension(path), StorePath = dest, IncomingProtocol = MailProtocol.Store, Provider = MailProvider.Store }; LocalStoreSidecar.Write(dest, box.Id, box.Label); MailArchiveLayout.EnsureSystemFolders(dest); configuration.Mailboxes.Add(box); } configuration.SelectedMailboxId = box.Id; _files.Save(configuration); _archive.Open(box.Id, MailArchiveLayout.DatabasePath(box, configuration.Mailboxes)); ReloadMailboxes(); SelectedMailbox = Mailboxes.FirstOrDefault(m => m.Id == box.Id); Status = "Importing PST…"; var result = await Task.Run(() => _worker.Call(new WorkerRequest { Op = "import-pst", MailboxId = box.Id, Path = path })).ConfigureAwait(false); await Dispatcher.UIThread.InvokeAsync(() => { Status = result.Ok ? "Imported " + result.Value + " messages from PST into " + box.Label + "." : result.Error ?? "PST import failed."; }); if (SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, CancellationToken.None); } public async Task AttachStorePathAsync(string path) { if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path)) { Status = Copy.PstPathRequired; return; } var sidecar = LocalStoreSidecar.TryRead(path); if (sidecar is null) { Status = "Folder is not a Postclient store (missing postclient.store.json)."; return; } var configuration = _files.Current; configuration.EnsureDefaults(); var existing = configuration.FindMailbox(sidecar.Id); var box = existing ?? new MailboxAccount { Id = sidecar.Id, DisplayName = string.IsNullOrWhiteSpace(sidecar.Name) ? Path.GetFileName(path) : sidecar.Name, Address = sidecar.Name, StorePath = path, IncomingProtocol = MailProtocol.Store, Provider = MailProvider.Store }; box.StorePath = path; box.IncomingProtocol = MailProtocol.Store; box.Provider = MailProvider.Store; if (existing is null) configuration.Mailboxes.Add(box); configuration.SelectedMailboxId = box.Id; _files.Save(configuration); _archive.Open(box.Id, MailArchiveLayout.DatabasePath(box, configuration.Mailboxes)); ReloadMailboxes(); SelectedMailbox = Mailboxes.FirstOrDefault(m => m.Id == box.Id); Status = string.Format(Copy.AttachStoreDone, box.Label); await ConnectAsync(); } public Task AttachPstPathAsync(string path) => AttachStorePathAsync(path); public async Task CreateStorePathAsync(string path) { if (string.IsNullOrWhiteSpace(path)) return; Directory.CreateDirectory(path); var name = Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); if (string.IsNullOrWhiteSpace(name)) name = "Mail"; var box = new MailboxAccount { DisplayName = name, Address = name, StorePath = path, IncomingProtocol = MailProtocol.Store, Provider = MailProvider.Store }; LocalStoreSidecar.Write(path, box.Id, name); MailArchiveLayout.EnsureSystemFolders(path); var configuration = _files.Current; configuration.EnsureDefaults(); configuration.Mailboxes.Add(box); configuration.SelectedMailboxId = box.Id; _files.Save(configuration); _archive.Open(box.Id, MailArchiveLayout.DatabasePath(box, configuration.Mailboxes)); ReloadMailboxes(); SelectedMailbox = Mailboxes.FirstOrDefault(m => m.Id == box.Id); Status = string.Format(Copy.CreateStoreDone, box.Label); await ConnectAsync(); } public Task CreatePstPathAsync(string path) => CreateStorePathAsync(path); public async Task MoveStorePathAsync(string dest) { var box = SelectedFolderNode?.Mailbox ?? SelectedMailbox; if (box is null || !box.IsLocalStore || string.IsNullOrWhiteSpace(box.StorePath)) return; var source = box.StorePath; if (string.IsNullOrWhiteSpace(dest) || dest.Equals(source, StringComparison.OrdinalIgnoreCase)) return; Status = "Moving store…"; var result = await Task.Run(() => _worker.Call(new WorkerRequest { Op = "copy-store", Path = source, Dest = dest })).ConfigureAwait(false); if (!result.Ok) { Status = result.Error ?? "Move store failed."; return; } try { Directory.Delete(source, recursive: true); } catch { } box.StorePath = dest; _files.Save(_files.Current); _archive.Open(box.Id, MailArchiveLayout.DatabasePath(box, _files.Current.Mailboxes)); ReloadMailboxes(); Status = string.Format(Copy.MoveStoreDone, box.Label); } public async Task ImportThunderbirdAsync() { if (SelectedMailbox is null) { Status = "Select a mailbox first."; return; } var import = new LocalMailImport(_archive); var count = await import.ImportThunderbirdAsync( SelectedMailbox.Id, ActiveSession, UnwrapEnvelope, CancellationToken.None, ActiveRules(), FoldersForRules(SelectedMailbox.Id)); Status = count == 0 ? "No Thunderbird mbox files found." : "Imported " + count + " messages from Thunderbird."; if (SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, CancellationToken.None); } [RelayCommand(CanExecute = nameof(CanStartCompose))] private void StartCompose() => OpenCompose("Write: " + (SelectedMailbox?.Address ?? ""), null, null, "", ""); [RelayCommand(CanExecute = nameof(CanReply))] private void Reply() { OpenCompose( "Reply", Recipients(ReadingFrom), null, ReplySubject(), QuotedBody()); } [RelayCommand(CanExecute = nameof(CanReply))] private void ReplyAll() { var self = SelectedMailbox?.Address ?? ""; var to = DistinctRecipients(Recipients(ReadingFrom).Concat(Recipients(ReadingTo)), self); var toSet = new HashSet(to.Select(r => r.Address), StringComparer.OrdinalIgnoreCase); var cc = DistinctRecipients(Recipients(ReadingCc), self) .Where(r => !toSet.Contains(r.Address)) .ToList(); OpenCompose("Reply All", to, cc, ReplySubject(), QuotedBody()); } [RelayCommand(CanExecute = nameof(CanReply))] private void Forward() { var subject = SelectedMessage is null ? "" : (SelectedMessage.Header.Subject.StartsWith("Fwd:", StringComparison.OrdinalIgnoreCase) ? SelectedMessage.Header.Subject : "Fwd: " + SelectedMessage.Header.Subject); OpenCompose("Forward", null, null, subject, QuotedBody()); } [RelayCommand(CanExecute = nameof(CanReply))] private void OpenMessageWindow(MessageRowViewModel? row) { row ??= SelectedMessage; if (row is null) return; if (IsLoadedBody(row.Header) && _reading is not null) { if (!row.Header.IsSeen) _ = MarkOpenedReadAsync(row.Header, CancellationToken.None); ShowMessageWindow(_reading); return; } _pendingWindow = row; if (!ReferenceEquals(SelectedMessage, row)) SelectedMessage = row; } [RelayCommand(CanExecute = nameof(CanSignIn))] private async Task SignInGoogleAsync() => await SignInProviderAsync(MailAuthKind.Google); [RelayCommand(CanExecute = nameof(CanSignIn))] private async Task SignInMicrosoftAsync() => await SignInProviderAsync(MailAuthKind.Microsoft); public void SetSelectedMessages(IEnumerable rows) { var current = SelectedMessage; var live = MailMessageList.Resolve( rows.Select(row => row.Key), VisibleMessages, row => row.Key); SelectedMessages.Clear(); _selectedKeys.Clear(); foreach (var row in live) { SelectedMessages.Add(row); _selectedKeys.Add(row.Key); } SelectedMessage = MailMessageList.Live(SelectedMessages, current, row => row.Key); NotifyMessageCommands(); } public bool IsMessageSelected(MessageRowViewModel row) => VisibleMessages.Any(item => item.Key == row.Key) && (_selectedKeys.Contains(row.Key) || SelectedMessage?.Key == row.Key); public IReadOnlyList VisibleSelectedMessages() => MailMessageList.Resolve( _selectedKeys.Count > 0 ? _selectedKeys : SelectedMessage is { } one ? [one.Key] : [], VisibleMessages, row => row.Key); public void RestoreMessageGridSelection() => MessageListSelectionRestoreRequested?.Invoke(); public void RestoreFolderTreeSelection() => FolderTreeSelectionRestoreRequested?.Invoke(); public void SetSelectedFolders(IEnumerable nodes, FolderNodeViewModel? primary) { SelectedFolderNodes.Clear(); foreach (var node in nodes) SelectedFolderNodes.Add(node); if (primary is not null && !SelectedFolderNodes.Contains(primary)) SelectedFolderNodes.Insert(0, primary); if (primary is not null) SelectedFolderNode = primary; else if (SelectedFolderNodes.Count > 0 && (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode))) SelectedFolderNode = SelectedFolderNodes[0]; RefreshFolderMenu(); } public void RefreshFolderMenu() => NotifyFolderCommands(); public void RefreshMessageMenu() => NotifyMessageCommands(); public IReadOnlyList DragMessageIds(MessageRowViewModel source) { if (!IsMessageSelected(source)) return [source.Header.Id]; var rows = TargetRows(); if (rows.Count == 0) return [source.Header.Id]; return rows.Select(row => row.Header.Id).ToList(); } public IReadOnlyList DragFolderNames(FolderNodeViewModel source) { var boxId = source.Mailbox?.Id ?? ""; var names = SelectedFolderNodes .Where(node => node.Mailbox?.Id.Equals(boxId, StringComparison.OrdinalIgnoreCase) == true && node.Folder is not null && MailFolderRole.IsCustom(node.Folder.Name, node.Folder.FullName)) .Select(node => node.Folder!.FullName) .ToList(); if (source.Folder is { } folder && MailFolderRole.IsCustom(folder.Name, folder.FullName) && names.All(name => !name.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase))) names.Insert(0, folder.FullName); if (names.Count == 0) return []; return MailSelection.Roots(names.Select(name => (boxId, name))) .Select(item => item.Folder) .ToList(); } [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task MarkSpamAsync() { var rows = TargetRows(); if (rows.Count == 0 || SelectedMailbox is null) return; foreach (var row in rows) _archive.MarkSpam(row.MailboxId, row.Header.Folder, row.Header.Id, EmbeddingModelSpec.Id); var junk = JunkFolder(SelectedMailbox); if (string.IsNullOrWhiteSpace(junk)) { Status = Copy.SpamNoJunk; PaintSpam(rows); return; } await MoveRowsAsync(rows, junk); } [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task MarkNotSpamAsync() { var rows = TargetRows(); if (rows.Count == 0 || SelectedMailbox is null) return; foreach (var row in rows) _archive.DismissMessage(row.MailboxId, row.Header.Folder, row.Header.Id); var inbox = InboxOf(SelectedMailbox)?.FullName ?? ""; if (string.IsNullOrWhiteSpace(inbox)) { PaintSpam(rows); return; } await MoveRowsAsync(rows, inbox); } public IReadOnlyList SpamExamples() => _archive.ListSpam(EmbeddingModelSpec.Id) .Select(row => new SpamListItem { MailboxId = row.MailboxId, Fingerprint = row.Example.Fingerprint, From = row.Example.From, Subject = row.Example.Subject, When = MailWhen.Line(SpamListItem.ParseDate(row.Example.DateUtc)), SortDate = SpamListItem.ParseDate(row.Example.DateUtc), Gone = row.Example.Gone }) .ToList(); public void DeleteSpamExample(SpamListItem item) { if (item is null || string.IsNullOrWhiteSpace(item.Fingerprint)) return; _archive.DismissSpam(item.MailboxId, item.Fingerprint); } public async Task NotSpamExampleAsync(SpamListItem item) { if (item is null || string.IsNullOrWhiteSpace(item.Fingerprint)) return; var hit = _archive.LocateSpam(item.MailboxId, item.Fingerprint); _archive.DismissSpam(item.MailboxId, item.Fingerprint); if (hit is not { Found: true }) return; var box = MailboxById(item.MailboxId); var inbox = box is null ? "" : InboxOf(box)?.FullName ?? ""; if (string.IsNullOrWhiteSpace(inbox) || inbox.Equals(hit.Folder, StringComparison.OrdinalIgnoreCase)) return; await MoveIdsAsync([hit.Uid], inbox, hit.Folder, sourceMailboxId: item.MailboxId); } [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task MoveToFolderAsync(FolderRowViewModel? folder) { if (folder is null) return; await MoveRowsAsync(TargetRows(), folder.FullName); } public bool CanDropMessagesOn(FolderNodeViewModel? node, string sourceMailboxId, string fromFolder) { if (node?.Mailbox is null) return false; return MailDrop.CanDropMessages( node.Mailbox.IncomingProtocol, node.Mailbox.Id, node.Folder?.FullName, sourceMailboxId, fromFolder); } public bool CanDropFolderOn(FolderNodeViewModel? node, string sourceMailboxId, string sourceFolder) { if (node?.Mailbox is null || string.IsNullOrWhiteSpace(sourceFolder)) return false; var row = FoldersFor(MailboxById(sourceMailboxId)) .FirstOrDefault(f => f.FullName.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase)); var destParent = node.IsAccount || node.Folder is null ? null : node.Folder.FullName; return MailDrop.CanDropFolder( node.Mailbox.IncomingProtocol, node.Mailbox.Id, destParent, sourceMailboxId, sourceFolder, MailFolderRole.IsCustom(row?.Name ?? MailFolderPath.Leaf(sourceFolder), sourceFolder)); } public Task DropMessagesAsync( FolderNodeViewModel dest, string sourceMailboxId, string fromFolder, IReadOnlyList ids) { if (dest.Mailbox is null) return Task.CompletedTask; return MoveIdsAsync( ids, dest.Folder?.FullName ?? "", fromFolder, destMailboxId: dest.Mailbox.Id, sourceMailboxId: sourceMailboxId); } public Task DropFolderAsync( FolderNodeViewModel dest, string sourceMailboxId, string sourceFolder) => DropFoldersAsync(dest, [(sourceMailboxId, sourceFolder)]); public async Task DropFoldersAsync( FolderNodeViewModel dest, IReadOnlyList<(string MailboxId, string Folder)> sources) { if (dest.Mailbox is null) return; var roots = MailSelection.Roots( sources.Where(source => CanDropFolderOn(dest, source.MailboxId, source.Folder))); if (roots.Count == 0) return; if (roots.Count == 1) { await MoveDroppedFolderAsync(dest, roots[0].MailboxId, roots[0].Folder); return; } var moved = 0; foreach (var source in roots) { if (!CanDropFolderOn(dest, source.MailboxId, source.Folder)) continue; await MoveDroppedFolderAsync(dest, source.MailboxId, source.Folder); moved++; } if (moved > 1) Status = string.Format(Copy.FoldersMoved, moved); } private async Task MoveDroppedFolderAsync( FolderNodeViewModel dest, string sourceMailboxId, string sourceFolder) { if (dest.Mailbox is null || !CanDropFolderOn(dest, sourceMailboxId, sourceFolder)) return; var sourceBox = MailboxById(sourceMailboxId); var destBox = dest.Mailbox; if (sourceBox is null) return; var destParent = dest.IsAccount || dest.Folder is null ? null : dest.Folder.FullName; var openFolder = SelectedMailbox?.Id == sourceBox.Id ? SelectedFolder?.FullName : null; await RunAsync(Copy.Moving, async token => { var sourceSession = await EnsureMailboxReadyAsync(sourceBox, token); var destSession = destBox.Id.Equals(sourceBox.Id, StringComparison.OrdinalIgnoreCase) ? sourceSession : await EnsureMailboxReadyAsync(destBox, token); if (sourceSession is not { IsConnected: true, SupportsFolders: true } || destSession is not { IsConnected: true, SupportsFolders: true }) { Status = Copy.CannotMoveSystemFolder; return; } var leaf = MailFolderPath.UniqueLeaf( MailFolderPath.Leaf(sourceFolder), SiblingNames(destBox, destParent, sourceFolder)); if (destBox.Id.Equals(sourceBox.Id, StringComparison.OrdinalIgnoreCase)) { var renamed = await sourceSession.RenameFolderAsync(sourceFolder, destParent, leaf, token); if (!renamed.IsSuccess) { Status = string.Join(" ", renamed.Messages); return; } await LoadFoldersAsync(sourceBox, sourceSession, token); var destPath = FindMovedFolder(sourceBox, destParent, leaf) ?? MailFolderPath.Combine(destParent, leaf); RememberMovedFolder(sourceBox.Id, sourceFolder, destPath, null); await SelectAfterFolderMoveAsync(sourceBox, sourceFolder, destPath, openFolder, token); Status = Copy.FolderMoved; return; } var created = await destSession.CreateFolderAsync(leaf, destParent, token); if (!created.IsSuccess) { Status = string.Join(" ", created.Messages); return; } await LoadFoldersAsync(destBox, destSession, token); var destRoot = FindMovedFolder(destBox, destParent, leaf) ?? MailFolderPath.Combine(destParent, leaf); var slice = FolderSlice(sourceBox, sourceFolder); var failed = 0; var copied = 0; foreach (var folder in slice) { var destPath = MailFolderPath.Rewrite(folder.FullName, sourceFolder, destRoot) ?? destRoot; if (!folder.FullName.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase)) { var nested = await destSession.CreateFolderAsync( MailFolderPath.Leaf(destPath), MailFolderPath.Parent(destPath), token); if (!nested.IsSuccess) { failed++; continue; } } foreach (var id in await FolderUidsAsync(sourceBox, sourceSession, folder.FullName, token)) { if (await CopyUidToMailboxAsync(sourceBox, sourceSession, destBox, folder.FullName, destPath, id, token)) copied++; else failed++; } } if (failed > 0) { await LoadFoldersAsync(destBox, destSession, token); Status = string.Format(Copy.MovedMessages, copied); return; } foreach (var folder in slice.OrderByDescending(f => f.FullName.Length)) { var deleted = await sourceSession.DeleteFolderAsync(folder.FullName, token); if (deleted.IsSuccess) ForgetLocalFolder(sourceBox.Id, folder.FullName); } RememberMovedFolder(sourceBox.Id, sourceFolder, destRoot, destBox.Id); await LoadFoldersAsync(sourceBox, sourceSession, token); await LoadFoldersAsync(destBox, destSession, token); await SelectAfterFolderMoveAsync(sourceBox, sourceFolder, destRoot, openFolder, token, destBox); Status = Copy.FolderMoved; }); } [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task MarkReadAsync() => await ApplyFlagsAsync(new MailFlagUpdate { Seen = true }, row => row.Header.IsSeen = true); [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task MarkUnreadAsync() => await ApplyFlagsAsync(new MailFlagUpdate { Seen = false }, row => row.Header.IsSeen = false); [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task ToggleFlagAsync(MessageRowViewModel? row) { var rows = row is null ? TargetRows() : [row]; if (rows.Count == 0) return; var flag = !rows[0].Header.IsFlagged; await ApplyFlagsAsync( new MailFlagUpdate { Flagged = flag }, r => r.Header.IsFlagged = flag, rows); } [RelayCommand(CanExecute = nameof(CanOrganize))] private async Task SetPriorityAsync(string? priority) { if (string.IsNullOrWhiteSpace(priority)) return; await ApplyFlagsAsync( new MailFlagUpdate { Priority = priority }, row => row.Header.Priority = priority); } [RelayCommand(CanExecute = nameof(CanOrganize), AllowConcurrentExecutions = true)] private async Task DeleteMessagesAsync() { var rows = TargetRows(); if (rows.Count == 0) return; if (MailMessageList.CoversAll(rows, VisibleMessages, row => row.Key)) rows = VisibleMessages.ToList(); foreach (var group in rows.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox; var folderName = group.Key.Folder; var groupRows = group.ToList(); if (box is null || string.IsNullOrWhiteSpace(folderName)) continue; if (!MailRetention.IsTrash(null, folderName)) { var trash = await EnsureTrashFolderAsync(box, CancellationToken.None); if (!string.IsNullOrWhiteSpace(trash) && !trash.Equals(folderName, StringComparison.OrdinalIgnoreCase)) { await MoveIdsAsync( TargetIds(groupRows), trash, fromFolder: folderName, rows: groupRows, sourceMailboxId: box.Id); continue; } } if (!await ApplyFlagsAsync(new MailFlagUpdate { Deleted = true }, null, groupRows)) continue; DropArchived(box.Id, folderName, TargetIds(groupRows)); RemoveRows(groupRows); } Status = "Deleted."; } [RelayCommand(CanExecute = nameof(CanCreateFolder))] private async Task CreateFolderAsync() { var session = ActiveSession; var box = SelectedMailbox; if (session is not { IsConnected: true, SupportsFolders: true } || box is null) return; var name = await PromptAsync(Copy.NewFolder, Copy.FolderName, confirmOnly: false, Copy.FolderName); if (string.IsNullOrWhiteSpace(name)) return; var parent = SelectedFolder is { } folder && MailFolderRole.CanHoldFolders(folder.Name, folder.FullName) ? folder.FullName : null; await RunAsync(Copy.CreatingFolder, async token => { var result = await session.CreateFolderAsync(name.Trim(), parent, token); if (!result.IsSuccess) { Status = string.Join(" ", result.Messages); return; } await LoadFoldersAsync(box, session, token); Status = Copy.FolderCreated; }); } [RelayCommand(CanExecute = nameof(CanEmptyFolder))] private async Task EmptyFolderAsync() { var node = TargetFolderNodes().FirstOrDefault(); var folder = node?.Folder ?? SelectedFolder; var box = node?.Mailbox ?? SelectedMailbox; if (folder is null || box is null || !MailFolderRole.CanEmpty(folder.Name, folder.FullName, box.IncomingProtocol)) return; var purge = MailRetention.IsTrash(folder.Name, folder.FullName); var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name); if (!await ConfirmAsync(purge ? Copy.DeleteAllItems : Copy.EmptyFolder, confirm)) return; await RunAsync(Copy.EmptyingFolder, async token => { var session = await EnsureMailboxReadyAsync(box, token); if (session is not { IsConnected: true, SupportsFolders: true }) { Status = ArchiveFiles.Hint(); return; } var trash = purge ? null : await EnsureTrashFolderAsync(box, token); var result = await session.EmptyFolderAsync(folder.FullName, trash, token); if (!result.IsSuccess) { Status = string.Join(" ", result.Messages); return; } ForgetLocalFolder(box.Id, folder.FullName); folder.Total = 0; folder.Unread = 0; if (SelectedMailbox?.Id.Equals(box.Id, StringComparison.OrdinalIgnoreCase) == true && SelectedFolder?.FullName.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase) == true) { Messages.Clear(); VisibleMessages.Clear(); SelectedMessages.Clear(); _selectedKeys.Clear(); SelectedMessage = null; ClearReading(); RefreshFolderStats(); } NotifyFolderCounts(); Status = Copy.FolderEmptied; }); } [RelayCommand(CanExecute = nameof(CanEmptyFolder))] private async Task MarkFolderReadAsync() => await MarkFolderSeenAsync(true); [RelayCommand(CanExecute = nameof(CanEmptyFolder))] private async Task MarkFolderUnreadAsync() => await MarkFolderSeenAsync(false); [RelayCommand(CanExecute = nameof(CanDeleteCustomFolder))] private async Task DeleteCustomFolderAsync() { var targets = TargetCustomFolderRoots(); if (targets.Count == 0) return; if (targets.Any(target => !MailFolderRole.IsCustom(target.Folder.Name, target.Folder.FullName))) { Status = Copy.CannotDeleteSystemFolder; return; } var confirm = targets.Count == 1 ? string.Format(Copy.DeleteFolderConfirm, targets[0].Folder.Name) : string.Format(Copy.DeleteFoldersConfirm, targets.Count); if (!await ConfirmAsync(Copy.DeleteFolder, confirm)) return; var open = SelectedMailbox is { } openBox && SelectedFolder is { } current && targets.Any(target => target.Box.Id.Equals(openBox.Id, StringComparison.OrdinalIgnoreCase) && MailFolderPath.IsSelfOrUnder(current.FullName, target.Folder.FullName)); await RunAsync(Copy.DeletingFolder, async token => { var deleted = 0; foreach (var group in targets.GroupBy(target => target.Box.Id, StringComparer.OrdinalIgnoreCase)) { var box = group.First().Box; var session = await EnsureMailboxReadyAsync(box, token); if (session is not { IsConnected: true, SupportsFolders: true }) { Status = Copy.CannotDeleteSystemFolder; return; } foreach (var target in group.OrderByDescending(item => item.Folder.FullName.Length)) { foreach (var folder in FolderSlice(box, target.Folder.FullName) .OrderByDescending(item => item.FullName.Length)) { var result = await session.DeleteFolderAsync(folder.FullName, token); if (!result.IsSuccess) { Status = string.Join(" ", result.Messages); return; } ForgetLocalFolder(box.Id, folder.FullName); } deleted++; } await LoadFoldersAsync(box, session, token); } if (open) { Messages.Clear(); VisibleMessages.Clear(); SelectedMessage = null; ClearReading(); } Status = deleted <= 1 ? Copy.FolderDeleted : string.Format(Copy.FoldersDeleted, deleted); }); } private async Task MarkFolderSeenAsync(bool seen) { var session = ActiveSession; var box = SelectedMailbox; var folder = SelectedFolder; if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null) return; await RunAsync(Copy.UpdatingFlags, async token => { var result = await session.SetFolderSeenAsync(folder.FullName, seen, token); if (!result.IsSuccess) { Status = string.Join(" ", result.Messages); return; } _archive.SetSeen(box.Id, folder.FullName, seen); foreach (var row in Messages) { row.Header.IsSeen = seen; row.RefreshMarks(); } folder.Unread = seen ? 0 : folder.Total; NotifyFolderCounts(); RefreshFolderStats(); Status = seen ? Copy.FolderMarkedRead : Copy.FolderMarkedUnread; }); } private async Task ConfirmAsync(string title, string message) { var result = await PromptAsync(title, message, confirmOnly: true); return result is not null; } private async Task PromptAsync(string title, string message, bool confirmOnly, string? placeholder = null) { if (PromptRequested is null) return confirmOnly ? "" : null; return await PromptRequested(new PromptRequest { Title = title, Message = message, ConfirmOnly = confirmOnly, Placeholder = placeholder ?? "" }); } private void ForgetLocalFolder(string mailboxId, string folder) => DropArchived(mailboxId, folder); private void DropArchived( string mailboxId, string folder, IReadOnlyCollection? uids = null) { DeleteEmlFiles( uids is null ? _archive.RemoveFolder(mailboxId, folder) : _archive.RemoveUids(mailboxId, folder, uids)); if (IsInitialSyncComplete(mailboxId)) _semantic.Wake(); } private static void DeleteEmlFiles(IEnumerable paths) { foreach (var path in paths) { try { if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) File.Delete(path); } catch { } } } private string ReplySubject() { if (SelectedMessage is null) return ""; return SelectedMessage.Header.Subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) ? SelectedMessage.Header.Subject : "Re: " + SelectedMessage.Header.Subject; } private string QuotedBody() => string.IsNullOrWhiteSpace(ReadingBody) ? "" : "\n\n--- Original message ---\n" + ReadingBody; private void OpenCompose( string title, IReadOnlyList? to, IReadOnlyList? cc, string subject, string body) { if (!TryComposeIdentities(out var identities, out var selected) || selected is null) { Status = "Add a mailbox to send From."; return; } var compose = new ComposeViewModel(identities, selected, title, EnsureComposeSessionAsync); if (to is { Count: > 0 }) compose.ToField.AddFromText(JoinRecipients(to)); if (cc is { Count: > 0 }) compose.CcField.AddFromText(JoinRecipients(cc)); compose.Subject = subject; compose.Body = body; if (_reading is not null && title.StartsWith("Reply", StringComparison.OrdinalIgnoreCase)) { compose.InReplyTo = _reading.Header.MessageId; compose.References = MailId.ThreadLine(_reading.Header.InReplyTo, _reading.Header.MessageId); } compose.Sent += RememberSent; ComposeRequested?.Invoke(compose); } private bool TryComposeIdentities(out List identities, out ComposeIdentity? selected) { identities = []; selected = null; foreach (var box in Mailboxes) { var row = new ComposeIdentity { Account = box, Session = SessionFor(box) }; identities.Add(row); if (SelectedMailbox is not null && box.Id == SelectedMailbox.Id) selected = row; } selected ??= identities.FirstOrDefault(); return identities.Count > 0; } private async Task EnsureComposeSessionAsync(ComposeIdentity identity, CancellationToken token) { var box = identity.Account; if (SessionFor(box) is { IsConnected: true } live) return live; var password = PasswordFor(box); var oauth = MailAuthKind.IsOAuth(box.AuthKind) || _auth.HasTokens(box.Id); if (string.IsNullOrWhiteSpace(password) && !oauth) return null; var ok = await ConnectMailboxCoreAsync(box, password, token); return ok ? SessionFor(box) : null; } private void RememberSent(MailSendRequest request, string messageId, string mailboxId) { var box = _files.Current.FindMailbox(mailboxId) ?? SelectedMailbox; if (box is null) return; if (TracksCertifiedReceipts(box)) { _receipts.Remember(new SentDispatch { MailboxId = box.Id, MessageId = messageId, Subject = request.Subject ?? "", To = request.To.Select(r => r.Address).ToList(), SentAt = DateTimeOffset.UtcNow, Status = ReceiptStatus.Submitted }); Status = string.Format(Copy.SentFromCertified, box.Label); return; } Status = string.Format(Copy.SentFrom, box.Label); } private static bool TracksCertifiedReceipts(MailboxAccount? box) => box is not null && box.TracksCertifiedReceipts; private bool TracksCertifiedReceipts(string mailboxId) { var box = _files.Current.FindMailbox(mailboxId) ?? Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)); return TracksCertifiedReceipts(box); } private void DropOrdinaryReceiptWaits() { foreach (var box in Mailboxes) { if (!TracksCertifiedReceipts(box)) _receipts.ForgetMailbox(box.Id); } } private bool CanConnect() => !IsBusy && SelectedMailbox is not null; private bool CanGetMessages() => !IsBusy && SelectedMailbox is not null && !SelectedMailbox.IsLocalStore; private bool CanStartCompose() => !IsBusy && SelectedMailbox is { IsLocalStore: false } && ActiveSession is { IsConnected: true }; private bool CanReply() => !IsBusy && SelectedMessage is not null && (ActiveSession is { IsConnected: true } || SelectedMailbox is { IsLocalStore: true }); private bool HasMessageSelection() => SelectedMessages.Count > 0 || SelectedMessage is not null; private bool CanSessionFolders() => !IsBusy && ActiveSession is { IsConnected: true, SupportsFolders: true }; private bool CanOrganize() => !IsBusy && HasMessageSelection() && SelectedMailbox is not null && (ActiveSession is { IsConnected: true, SupportsFolders: true } || SelectedMailbox.IsLocalStore); private bool CanManageFolders() => CanSessionFolders() && SelectedMailbox is not null; private bool CanCreateFolder() => CanManageFolders(); private bool CanEmptyFolder() { var node = TargetFolderNodes().FirstOrDefault(); var folder = node?.Folder ?? SelectedFolder; var box = node?.Mailbox ?? SelectedMailbox; if (folder is null || box is null) return false; return MailFolderRole.CanEmpty(folder.Name, folder.FullName, box.IncomingProtocol); } private bool CanDeleteCustomFolder() => !IsBusy && TargetCustomFolderRoots().Count > 0; partial void OnEditorIncomingProtocolChanged(ChoiceRow value) { ApplyIncomingPortDefault(); if (!_applyingProvider && MailProvider.HasHostPreset(EditorProvider.Id)) ApplyProviderPreset(); } partial void OnEditorIncomingSecurityChanged(ChoiceRow value) => ApplyIncomingPortDefault(); partial void OnEditorSmtpSecurityChanged(ChoiceRow value) { if (MailSecurity.IsDefaultSmtpPort((int)EditorSmtpPort)) EditorSmtpPort = MailSecurity.DefaultSmtpPort(value.Id); } private void ApplyIncomingPortDefault() { if (MailSecurity.IsDefaultIncomingPort((int)EditorIncomingPort)) EditorIncomingPort = MailSecurity.DefaultIncomingPort(EditorIncomingProtocol.Id, EditorIncomingSecurity.Id); } partial void OnEditorProviderChanged(ChoiceRow value) { if (_applyingProvider) return; ApplyProviderPreset(); UpdateAuthUi(); } partial void OnEditorCertifiedKindChanged(ChoiceRow value) { if (_applyingProvider) return; UpdateAuthUi(); } public void ApplyListSort(string header, bool descending) { _listSortHeader = string.IsNullOrWhiteSpace(header) ? "Date" : header; _listSortDescending = descending; RebuildVisible(); } partial void OnUnwrapEnvelopeChanged(bool value) { var configuration = _files.Current; configuration.UnwrapEnvelope = value; _files.Save(configuration); if (_reading is not null) ApplyReading(_reading); } partial void OnGroupConversationsChanged(bool value) { var configuration = _files.Current; configuration.GroupConversations = value; _files.Save(configuration); RebuildVisible(); } partial void OnReadingBodyKindChanged(string value) { if (_syncingBodyKind) return; var kind = MailBodyKind.Normalize(value); if (!MailBodyKind.IsSource(kind)) { _bodyPreference = MailBodyKind.Preference(kind); var configuration = _files.Current; configuration.ReadingBodyKind = _bodyPreference; _files.Save(configuration); } NotifyBodyKind(); if (_reading is not null) ApplyReading(_reading, keepKind: true); } [RelayCommand] private void SetReadingKind(string? kind) { var next = MailBodyKind.Normalize(kind); if (MailBodyKind.IsFattura(next) && !UseFatturaPa) next = MailBodyKind.Html; ReadingBodyKind = next; } [RelayCommand] private void ViewFatturaPa() { if (UseFatturaPa) SetReadingKind(MailBodyKind.Fattura); } [RelayCommand] private void SetReadingLayout(string? layout) { ReadingLayout = MailLayout.Normalize(layout); } private void ApplyBodyKind(string kind) { _syncingBodyKind = true; ReadingBodyKind = kind; _syncingBodyKind = false; NotifyBodyKind(); } partial void OnReadingLayoutChanged(string value) { var layout = MailLayout.Normalize(value); if (layout != value) { ReadingLayout = layout; return; } var configuration = _files.Current; configuration.ReadingLayout = layout; _files.Save(configuration); OnPropertyChanged(nameof(IsWideLayout)); OnPropertyChanged(nameof(IsStackedLayout)); OnPropertyChanged(nameof(LayoutToggleLabel)); } partial void OnHasReadingChanged(bool value) => NotifyBodyKind(); private void NotifyBodyKind() { OnPropertyChanged(nameof(ShowHtmlBody)); OnPropertyChanged(nameof(ShowTextBody)); OnPropertyChanged(nameof(ShowSourceBody)); OnPropertyChanged(nameof(ShowFatturaBody)); OnPropertyChanged(nameof(IsHtmlKind)); OnPropertyChanged(nameof(IsTextKind)); OnPropertyChanged(nameof(IsSourceKind)); OnPropertyChanged(nameof(IsFatturaKind)); OnPropertyChanged(nameof(HtmlEngineReady)); OnPropertyChanged(nameof(ShowFatturaPaTools)); } public void NotifyFeatures() { FeatureGate.Use(_files.Current.Features); OnPropertyChanged(nameof(Providers)); OnPropertyChanged(nameof(UseFascicolo)); OnPropertyChanged(nameof(UseFatturaPa)); OnPropertyChanged(nameof(UseEnvelopeTools)); OnPropertyChanged(nameof(UseDeliveryColumn)); OnPropertyChanged(nameof(UseTypeColumn)); OnPropertyChanged(nameof(ShowFatturaPaTools)); NotifyBodyKind(); if (MailBodyKind.IsFattura(ReadingBodyKind) && !UseFatturaPa) SetReadingKind(MailBodyKind.Html); } partial void OnHasReadingFatturaChanged(bool value) => OnPropertyChanged(nameof(ShowFatturaPaTools)); public void DisableHtmlEngine(string? reason = null) { if (!HtmlEngineReady) return; HtmlEngineReady = false; NotifyBodyKind(); Status = string.IsNullOrWhiteSpace(reason) ? WebViewSetup.MissingEngineHint() : reason; } partial void OnMessageFilterChanged(string value) { if (string.IsNullOrWhiteSpace(value)) RebuildVisible(); else ScheduleSearch(immediate: false); } [RelayCommand] private void ClearMessageFilter() => MessageFilter = ""; private bool CanSignIn() => !IsBusy; partial void OnSelectedMailboxChanged(MailboxAccount? value) { ConnectCommand.NotifyCanExecuteChanged(); GetMessagesCommand.NotifyCanExecuteChanged(); NotifyMessageCommands(); if (value is null) return; SyncFoldersCollection(value); if (SessionFor(value) is { IsConnected: true }) return; if (!_uiReady) return; if (!AccountSettingsOpen) _ = ConnectAsync(); } partial void OnSelectedFolderNodeChanged(FolderNodeViewModel? value) { if (_syncingTree || value is null) return; if (value.Mailbox is { } box && SelectedMailbox?.Id != box.Id) SelectedMailbox = box; if (value.Folder is { } folder) SelectedFolder = folder; else if (value.IsAccount && value.Mailbox is { } account) SelectedFolder = InboxOf(account); OnPropertyChanged(nameof(ShowFolderContext)); OnPropertyChanged(nameof(ShowDetachPst)); DetachPstCommand.NotifyCanExecuteChanged(); } public bool ShowFolderContext => TargetFolderNodes().Count > 0; public bool ShowDetachPst => !IsBusy && SelectedFolderNode is { IsAccount: true, Mailbox: { } box } && box.IsLocalStore; partial void OnIsBusyChanged(bool value) { OnPropertyChanged(nameof(ShowDetachPst)); DetachPstCommand.NotifyCanExecuteChanged(); CreateBundleCommand.NotifyCanExecuteChanged(); OpenBundleCommand.NotifyCanExecuteChanged(); NotifyFolderCommands(); } partial void OnSelectedFolderChanged(FolderRowViewModel? value) { RefreshFolderStats(); NotifyFolderCommands(); if (value is null || _syncingTree || _suppressFolderLoad) return; if (MailFolderRole.IsNamespace(value.Name, value.FullName)) { _folderLoad?.Cancel(); _work?.Cancel(); Messages.Clear(); VisibleMessages.Clear(); SelectedMessages.Clear(); _selectedKeys.Clear(); SelectedMessage = null; ClearReading(); Status = ""; return; } if (SelectedMessage is not null && !SelectedMessage.Header.Folder.Equals(value.FullName, StringComparison.OrdinalIgnoreCase)) { SelectedMessages.Clear(); _selectedKeys.Clear(); SelectedMessage = null; } if (_reading is not null && !_reading.Header.Folder.Equals(value.FullName, StringComparison.OrdinalIgnoreCase)) ClearReading(); KickFolderLoad(value.FullName); } partial void OnSelectedMessageChanged(MessageRowViewModel? value) { NotifyMessageCommands(); if (value is null) { if (!_syncingList && SelectedMessages.Count == 0) _selectedKeys.Clear(); return; } _selectedKeys.Add(value.Key); if (IsLoadedBody(value.Header)) return; _ = OpenMessageAsync(value); } private async Task OpenMessageAsync(MessageRowViewModel row) { _readingLoad?.Cancel(); _readingLoad?.Dispose(); var cts = new CancellationTokenSource(); _readingLoad = cts; try { await LoadBodyAsync(row.Header, cts.Token, row.MailboxId); } catch (OperationCanceledException) { } } private void FillEditor(MailboxAccount box) { _pendingOauth = null; EditorName = box.DisplayName; EditorAddress = box.Address; EditorShareAccount = box.Shared; EditorUsername = box.Username; EditorPassword = ""; EditorGoogleClientId = _files.Current.GoogleClientId; EditorGoogleClientSecret = StoredGoogleClientSecret(); EditorMicrosoftClientId = _files.Current.MicrosoftClientId; _applyingProvider = true; EditorIncomingProtocol = ChoiceRow.Incoming(box.IncomingProtocol); EditorIncomingSecurity = ChoiceRow.Security(box.IncomingSecurity, box.ImapSsl); EditorIncomingHost = box.ImapHost; EditorStorePath = box.DataFile; EditorIncomingPort = box.ImapPort; EditorSmtpSecurity = ChoiceRow.Security(box.SmtpSecurity, box.SmtpSsl); EditorSmtpHost = box.SmtpHost; EditorSmtpPort = box.SmtpPort; EditorProvider = ChoiceRow.Provider(box.IsLocalStore ? MailProvider.Store : box.Provider); EditorCertifiedKind = ChoiceRow.Certified( MailCertifiedKind.ForProvider(box.Provider, box.CertifiedKind)); RebuildArchiveStoreChoices(); EditorArchiveStore = ArchiveStoreChoices.FirstOrDefault(c => c.Id.Equals(box.ArchiveStoreId, StringComparison.OrdinalIgnoreCase)) ?? ArchiveStoreChoices[0]; _applyingProvider = false; UpdateAuthUi(); } private async Task SignInProviderAsync(string kind) { IsBusy = true; SignInGoogleCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged(); Status = "Waiting for Identity Hub…"; IdentityHubSignInRequested?.Invoke(kind); await Task.CompletedTask; } public async Task CompleteIdentityHubSignIn(string kind, string json) { IsBusy = false; SignInGoogleCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged(); var result = await _auth.CompleteHubSignInAsync(json, kind); CompleteSignIn(kind, result); } public void CancelIdentityHubSignIn(string? message) { IsBusy = false; SignInGoogleCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged(); if (!string.IsNullOrWhiteSpace(message)) Status = message; } public string HubLoginUrl(string kind) => _auth.DesktopLoginUrl(kind); private void CompleteSignIn(string kind, Result result) { if (!result.IsSuccess || result.Value is null) { var text = string.Join(" ", result.Messages); Status = string.IsNullOrWhiteSpace(text) ? "Sign-in failed." : text; OauthHint = Status; return; } _pendingOauth = result.Value; if (!string.IsNullOrWhiteSpace(result.Value.Email)) { EditorAddress = result.Value.Email; EditorUsername = result.Value.Email; } if (string.IsNullOrWhiteSpace(EditorName)) EditorName = EditorAddress; _applyingProvider = true; EditorProvider = ChoiceRow.Provider( kind == MailAuthKind.Microsoft ? MailProvider.Outlook : MailProvider.Gmail); _applyingProvider = false; ApplyProviderPreset(); UpdateAuthUi(); if (string.IsNullOrWhiteSpace(EditorAddress) || string.IsNullOrWhiteSpace(EditorIncomingHost)) { Status = "Signed in as " + (string.IsNullOrWhiteSpace(result.Value.Email) ? "the account" : result.Value.Email) + ". Fill Address and incoming host, then Save Account."; return; } WriteMailbox(closeSettings: false); Status = "Signed in as " + (string.IsNullOrWhiteSpace(result.Value.Email) ? "the account" : result.Value.Email) + ". Connecting…"; _ = ConnectAsync(); } private MailboxAccount MailboxToSave() { var byAddress = _files.Current.FindMailboxByAddress(EditorAddress); if (byAddress is not null) return byAddress; if (SelectedMailbox is null) return new MailboxAccount(); if (_pendingOauth is not null && !string.IsNullOrWhiteSpace(SelectedMailbox.Address) && !SelectedMailbox.Address.Equals(EditorAddress.Trim(), StringComparison.OrdinalIgnoreCase)) return new MailboxAccount(); return SelectedMailbox; } private void ApplyProviderPreset() { if (MailProvider.IsPst(EditorProvider.Id)) { EditorCertifiedKind = ChoiceRow.Certified(MailCertifiedKind.Ordinary); NotifyStoreUi(); UpdateAuthUi(); return; } var box = new MailboxAccount { Provider = EditorProvider.Id, IncomingProtocol = EditorIncomingProtocol.Id, CertifiedKind = EditorCertifiedKind.Id }; MailProvider.Apply(box); EditorIncomingHost = box.ImapHost; EditorIncomingPort = box.ImapPort; EditorIncomingSecurity = ChoiceRow.Security(box.IncomingSecurity); EditorSmtpHost = box.SmtpHost; EditorSmtpPort = box.SmtpPort; EditorSmtpSecurity = ChoiceRow.Security(box.SmtpSecurity); EditorCertifiedKind = ChoiceRow.Certified(box.CertifiedKind); NotifyStoreUi(); } private void UpdateAuthUi() { var provider = MailProvider.Normalize(EditorProvider.Id); ShowOAuthButtons = MailProvider.UsesOAuth(provider); ShowGoogleSignIn = provider == MailProvider.Gmail; ShowMicrosoftSignIn = provider == MailProvider.Outlook; var oauth = _pendingOauth is not null || (SelectedMailbox is not null && (MailAuthKind.IsOAuth(SelectedMailbox.AuthKind) || _auth.HasTokens(SelectedMailbox.Id))); ShowPasswordFields = ShowServerFields && (provider != MailProvider.Outlook || !oauth); OauthHint = provider == MailProvider.Gmail || provider == MailProvider.Outlook ? Copy.IdentityHubHint : MailCertifiedKind.TracksReceipts(EditorCertifiedKind.Id) || MailProvider.IsPec(provider) ? Copy.PecHint : MailProvider.IsLocalBucket(provider) ? Copy.IdentityHubHint : ""; NotifyStoreUi(); } private void NotifyStoreUi() { OnPropertyChanged(nameof(ShowPstFields)); OnPropertyChanged(nameof(ShowServerFields)); OnPropertyChanged(nameof(ShowArchiveStore)); OnPropertyChanged(nameof(ShowCertifiedKind)); } [RelayCommand] private async Task BrowseStorePathAsync() { var pick = PickStorePathRequested; if (pick is null) return; var path = await pick(); if (!string.IsNullOrWhiteSpace(path)) EditorStorePath = path; } private string GmailHint() { var stored = _secrets.Get(FileSecretStore.OAuthClientSecretKey(MailAuthKind.Google)); var hasSecret = stored.IsSuccess && !string.IsNullOrWhiteSpace(stored.Value); return Copy.GmailHintPrefix + (hasSecret ? Copy.GmailHintStored : Copy.GmailHintPaste) + Copy.GmailHintImap; } private string OutlookHint() { var preview = OAuthClientId.Preview(EditorMicrosoftClientId); var usingId = string.IsNullOrEmpty(preview) ? Copy.OutlookHintGuid : string.Format(Copy.OutlookHintNamed, preview); return usingId + Copy.OutlookHintPasswords; } partial void OnEditorGoogleClientIdChanged(string value) { if (ShowGoogleSignIn) UpdateAuthUi(); } partial void OnEditorMicrosoftClientIdChanged(string value) { if (ShowMicrosoftSignIn) UpdateAuthUi(); } private void PersistClientIds(Configuration configuration) { configuration.GoogleClientId = OAuthClientId.Normalize(EditorGoogleClientId); configuration.MicrosoftClientId = OAuthClientId.Normalize(EditorMicrosoftClientId); } private void PersistPstStorePath(MailboxAccount box, string path) { if (string.IsNullOrWhiteSpace(path) || box.StorePath.Equals(path, StringComparison.OrdinalIgnoreCase)) return; box.StorePath = path; var configuration = _files.Current; var stored = configuration.FindMailbox(box.Id); if (stored is not null) stored.StorePath = path; configuration.EnsureDefaults(); _files.Save(configuration); var notice = (SessionFor(box) as PstMailSession)?.LastNotice; Dispatcher.UIThread.Post(() => { if (SelectedMailbox?.Id == box.Id) EditorStorePath = path; if (!string.IsNullOrWhiteSpace(notice)) Status = notice; }); } private void PersistGoogleClientSecret() { var secret = OAuthClientSecret.Normalize(EditorGoogleClientSecret); if (string.IsNullOrWhiteSpace(secret) && EditorGoogleClientId.TrimStart().StartsWith('{')) secret = OAuthClientSecret.Normalize(EditorGoogleClientId); if (string.IsNullOrWhiteSpace(secret)) return; _secrets.Put(FileSecretStore.OAuthClientSecretKey(MailAuthKind.Google), secret); } private string StoredGoogleClientSecret() { var stored = _secrets.Get(FileSecretStore.OAuthClientSecretKey(MailAuthKind.Google)); return stored.IsSuccess ? stored.Value ?? "" : ""; } private void ReloadMailboxes() { Mailboxes.Clear(); foreach (var box in _files.Current.Mailboxes) Mailboxes.Add(box); DropOrdinaryReceiptWaits(); var selected = _files.Current.FindMailbox(_files.Current.SelectedMailboxId); SelectedMailbox = selected is null ? Mailboxes.FirstOrDefault() : Mailboxes.FirstOrDefault(m => m.Id == selected.Id); RebuildArchiveStoreChoices(); RebuildFolderTree(); } private void RebuildArchiveStoreChoices() { var keep = EditorArchiveStore?.Id ?? ""; ArchiveStoreChoices.Clear(); ArchiveStoreChoices.Add(new ChoiceRow { Id = "", Title = Copy.LocalStoreAppData }); foreach (var box in Mailboxes.Where(m => m.IsLocalStore)) ArchiveStoreChoices.Add(new ChoiceRow { Id = box.Id, Title = box.Label }); EditorArchiveStore = ArchiveStoreChoices.FirstOrDefault(c => c.Id.Equals(keep, StringComparison.OrdinalIgnoreCase)) ?? ArchiveStoreChoices[0]; } private void RebuildFolderTree() { _syncingTree = true; var keep = new HashSet(StringComparer.OrdinalIgnoreCase); var byId = FolderTree .Where(n => n.Mailbox is not null) .ToDictionary(n => n.Mailbox!.Id, StringComparer.OrdinalIgnoreCase); var index = 0; foreach (var box in Mailboxes) { keep.Add(box.Id); if (!byId.TryGetValue(box.Id, out var node)) { node = FolderNodeViewModel.Account(box, SavedFolderExpanded(box, null)); FolderTree.Insert(index, node); } else { var current = index < FolderTree.Count ? FolderTree[index] : null; if (!ReferenceEquals(current, node)) { FolderTree.Remove(node); FolderTree.Insert(Math.Min(index, FolderTree.Count), node); } } SyncFolderChildren(node, box); index++; } for (var i = FolderTree.Count - 1; i >= 0; i--) { var id = FolderTree[i].Mailbox?.Id; if (id is null || !keep.Contains(id)) FolderTree.RemoveAt(i); } var selected = SnapshotFolderSelection(); var selectedBoxId = SelectedFolderNode?.Mailbox?.Id ?? SelectedMailbox?.Id; var selectedFolder = SelectedFolderNode?.Folder?.FullName ?? SelectedFolder?.FullName; SelectedFolderNode = FindTreeNode(selectedBoxId, selectedFolder) ?? FolderNodeFor(SelectedMailbox, "inbox") ?? FolderTree.FirstOrDefault(n => n.Mailbox?.Id == SelectedMailbox?.Id)?.Children.FirstOrDefault() ?? FolderTree.FirstOrDefault(n => n.Mailbox?.Id == SelectedMailbox?.Id); RemapFolderSelection(selected); _syncingTree = false; RestoreFolderTreeSelection(); } private List<(string MailboxId, string? Folder)> SnapshotFolderSelection() { var nodes = SelectedFolderNodes.Count > 0 ? SelectedFolderNodes : SelectedFolderNode is { } one ? [one] : []; var keys = new List<(string MailboxId, string? Folder)>(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var node in nodes) { var boxId = node.Mailbox?.Id; if (string.IsNullOrWhiteSpace(boxId)) continue; var folder = node.Folder?.FullName; var key = boxId + "\n" + (folder ?? ""); if (!seen.Add(key)) continue; keys.Add((boxId, folder)); } return keys; } private void RemapFolderSelection(IReadOnlyList<(string MailboxId, string? Folder)> keys) { SelectedFolderNodes.Clear(); foreach (var (mailboxId, folder) in keys) { var node = FindTreeNode(mailboxId, folder); if (node is not null && !SelectedFolderNodes.Contains(node)) SelectedFolderNodes.Add(node); } if (SelectedFolderNode is not null && !SelectedFolderNodes.Contains(SelectedFolderNode)) SelectedFolderNodes.Insert(0, SelectedFolderNode); else if (SelectedFolderNode is null && SelectedFolderNodes.Count > 0) SelectedFolderNode = SelectedFolderNodes[0]; } private void SyncFolderChildren(FolderNodeViewModel node, MailboxAccount box) { var previous = SnapshotFolderExpand(node.Children); node.Children.Clear(); var rows = FoldersFor(box); var known = rows.Select(r => r.FullName).ToHashSet(StringComparer.OrdinalIgnoreCase); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var folder in rows) { var expanded = previous.TryGetValue(folder.FullName, out var keep) ? keep : SavedFolderExpanded(box, folder.FullName); var childNode = FolderNodeViewModel.ForFolder(box, folder, expanded); childNode.RetentionDays = MailRetention.DaysFor(box.Id, folder.FullName, _files.Current.Retention); map[folder.FullName] = childNode; } foreach (var folder in rows .OrderBy(f => MailFolderRole.SortKey(f.Name, f.FullName)) .ThenBy(f => f.Name, StringComparer.OrdinalIgnoreCase)) { var child = map[folder.FullName]; var parentPath = MailFolderRole.DisplayParent( folder.Name, folder.FullName, known, MailFolderLayout.For(box), folder.Delimiter); if (parentPath is not null && map.TryGetValue(parentPath, out var parent)) parent.Children.Add(child); else node.Children.Add(child); } node.NotifyCounts(); } private bool SavedFolderExpanded(MailboxAccount box, string? folder) => _files.Current.Layout.IsFolderExpanded(box.Id, folder); private static Dictionary SnapshotFolderExpand(IEnumerable nodes) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); Walk(nodes); return map; void Walk(IEnumerable list) { foreach (var node in list) { if (node.Folder?.FullName is { Length: > 0 } name) map[name] = node.IsExpanded; Walk(node.Children); } } } private FolderNodeViewModel? FindTreeNode(string? mailboxId, string? folder) { if (string.IsNullOrWhiteSpace(mailboxId)) return null; var account = FolderTree.FirstOrDefault(n => n.Mailbox?.Id == mailboxId); if (account is null) return null; if (string.IsNullOrWhiteSpace(folder)) return account.Children.FirstOrDefault() ?? account; return FindFolderNode(account, folder) ?? account.Children.FirstOrDefault() ?? account; } private static FolderNodeViewModel? FindFolderNode(FolderNodeViewModel node, string folder) { if (node.Folder?.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase) == true) return node; foreach (var child in node.Children) { var hit = FindFolderNode(child, folder); if (hit is not null) return hit; } return null; } private FolderNodeViewModel? FolderNodeFor(MailboxAccount? box, string kind) { if (box is null) return null; var account = FolderTree.FirstOrDefault(n => n.Mailbox?.Id == box.Id); if (account is null) return null; return FindKindNode(account, kind); } private static FolderNodeViewModel? FindKindNode(FolderNodeViewModel node, string kind) { if (node.Folder is { } folder && MailFolderRole.Kind(folder.Name, folder.FullName) == kind) return node; foreach (var child in node.Children) { var hit = FindKindNode(child, kind); if (hit is not null) return hit; } return null; } private async Task LoadFoldersAsync(MailboxAccount box, IMailSession session, CancellationToken token) { PauseIndexing(); try { var result = await session.ListFoldersAsync(token).ConfigureAwait(false); if (!result.IsSuccess) { await UiAsync(() => Status = string.Join(" ", result.Messages)).ConfigureAwait(false); return; } var rows = (result.Value ?? []) .Select(folder => new FolderRowViewModel { FullName = folder.FullName, Name = folder.Name, Unread = folder.Unread, Total = folder.Total, Delimiter = folder.Delimiter }) .ToList(); var loadFolder = ""; await UiAsync(() => { _foldersByMailbox[box.Id] = rows; RebuildFolderTree(); if (SelectedMailbox?.Id != box.Id) return; SyncFoldersCollection(box); var wanted = SelectedFolderNode?.Mailbox?.Id == box.Id ? SelectedFolderNode.Folder?.FullName : null; _suppressFolderLoad = true; try { SelectedFolder = rows.FirstOrDefault(f => f.FullName.Equals(wanted, StringComparison.OrdinalIgnoreCase)) ?? InboxOf(box) ?? rows.FirstOrDefault(); } finally { _suppressFolderLoad = false; } if (SelectedFolder is not null) loadFolder = SelectedFolder.FullName; }).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(loadFolder)) KickFolderLoad(loadFolder); var quota = await session.GetQuotaAsync(token).ConfigureAwait(false); if (quota.IsSuccess && quota.Value is { } info) { await UiAsync(() => { var node = FolderTree.FirstOrDefault(n => n.Mailbox?.Id == box.Id); if (node is null) return; node.Quota = info; node.NotifyQuota(); }).ConfigureAwait(false); } } finally { ResumeIndexing(); } } private void SyncFoldersCollection(MailboxAccount box) { Folders.Clear(); foreach (var folder in FoldersFor(box)) Folders.Add(folder); } private List FoldersFor(MailboxAccount? box) { if (box is null) return []; return _foldersByMailbox.TryGetValue(box.Id, out var rows) ? rows : []; } private FolderRowViewModel? TrashFolderOf(MailboxAccount? box) { if (box is null) return null; return FoldersFor(box) .Concat(Folders) .FirstOrDefault(f => MailRetention.IsTrash(f.Name, f.FullName)); } private async Task EnsureTrashFolderAsync(MailboxAccount box, CancellationToken token) { var existing = TrashFolderOf(box); if (existing is not null) return existing.FullName; var session = SessionFor(box); if (session is not { IsConnected: true, SupportsFolders: true }) return null; var created = await session.CreateFolderAsync(MailRetention.TrashFolder, null, token); if (!created.IsSuccess) created = await session.CreateFolderAsync("Trash", null, token); if (created.IsSuccess) await LoadFoldersAsync(box, session, token); return TrashFolderOf(box)?.FullName ?? (created.IsSuccess ? MailRetention.TrashFolder : null); } private IReadOnlyList ActiveRules() => _files.Current.Rules ?? []; public IReadOnlyList<(string Name, string FullName)> FoldersForRules(string mailboxId) { if (string.IsNullOrWhiteSpace(mailboxId)) return []; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var rows = new List<(string Name, string FullName)>(); void Add(string name, string full) { if (string.IsNullOrWhiteSpace(full) || !seen.Add(full.Trim())) return; rows.Add((name.Trim(), full.Trim())); } var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)); foreach (var folder in FoldersFor(box)) Add(folder.Name, folder.FullName); return rows; } public async Task EnsureFoldersForRulesAsync(string mailboxId) { var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)); if (box is null) return; await ConnectMailboxCoreAsync(box, PasswordFor(box), CancellationToken.None); if (FoldersFor(box).Count > 0) return; if (SessionFor(box) is { IsConnected: true } live) await LoadFoldersAsync(box, live, CancellationToken.None); } public async Task RunAllRulesAsync() { var rules = MailRuleEngine.Ready(ActiveRules()).ToList(); if (rules.Count == 0) return Copy.RulesNone; var matched = 0; foreach (var box in Mailboxes.ToList()) { if (!rules.Any(r => MailRuleEngine.TargetsMailbox(r, box.Id))) continue; await EnsureFoldersForRulesAsync(box.Id); foreach (var rule in rules.Where(r => MailRuleEngine.TargetsMailbox(r, box.Id))) await EnsureFoldersForRulesAsync(MailRuleEngine.FolderMailbox(rule)); var session = SessionFor(box); var folders = FoldersForRules(box.Id); var names = folders.Select(f => f.FullName).ToList(); foreach (var extra in _archive.ListFolders(box.Id)) { if (!names.Any(n => n.Equals(extra, StringComparison.OrdinalIgnoreCase))) names.Add(extra); } foreach (var folder in names) { var headers = _archive.ListFolder(box.Id, folder).Select(MailArchiveMap.ToHeader).ToList(); if (headers.Count == 0) continue; matched += await ApplyRulesToFolderAsync(box, session, folder, headers, folders, CancellationToken.None); } } if (SelectedMailbox is not null && SelectedFolder is not null) ShowArchive(SelectedMailbox.Id, SelectedFolder.FullName); var line = string.Format(Copy.RulesRan, matched); Status = line; return line; } private async Task ApplyIncomingRulesAsync( string folder, IReadOnlyList headers, CancellationToken token) { var box = SelectedMailbox; if (box is null) return; foreach (var rule in MailRuleEngine.Ready(ActiveRules()).Where(r => MailRuleEngine.TargetsMailbox(r, box.Id))) await EnsureFoldersForRulesAsync(MailRuleEngine.FolderMailbox(rule)); await ApplyRulesToFolderAsync(box, ActiveSession, folder, headers, FoldersForRules(box.Id), token); } private async Task ApplyRulesToFolderAsync( MailboxAccount box, IMailSession? session, string folder, IReadOnlyList headers, IReadOnlyList<(string Name, string FullName)> folders, CancellationToken token) { var rules = MailRuleEngine.Ready(ActiveRules()).ToList(); if (rules.Count == 0 || headers.Count == 0) return 0; var moves = new Dictionary>(StringComparer.OrdinalIgnoreCase); var copies = new Dictionary<(string MailboxId, string Folder), List>(); var read = new List(); var flagged = new List(); var touched = new HashSet(); foreach (var header in headers) { foreach (var rule in rules) { var destFolders = DestFoldersFor(rule, box.Id, folders); if (!MailRuleEngine.CanApply(rule, box.Id, destFolders)) continue; if (!MailRuleEngine.Matches(rule, header.From, "", header.Subject, "", header.HasAttachments)) continue; touched.Add(header.Id); if (rule.Action == MailRuleAction.Delete) { var trash = MailRetention.ResolveTrash(folders); if (!string.IsNullOrWhiteSpace(trash)) AddUid(moves, trash, header.Id); } else if (rule.Action == MailRuleAction.Move) { var dest = MailRuleEngine.ExactFolder(rule.Folder, destFolders); var destBox = MailRuleEngine.FolderMailbox(rule); if (!string.IsNullOrWhiteSpace(dest)) { if (destBox.Equals(box.Id, StringComparison.OrdinalIgnoreCase)) { if (!dest.Equals(folder, StringComparison.OrdinalIgnoreCase)) AddUid(moves, dest, header.Id); } else AddCopy(copies, destBox, dest, header.Id); } } if (rule.Action == MailRuleAction.MarkRead) read.Add(header.Id); if (rule.Action == MailRuleAction.Flag) flagged.Add(header.Id); if (rule.Action == MailRuleAction.Label && !string.IsNullOrWhiteSpace(rule.Label)) _archive.AddLabel(box.Id, folder, header.Id, rule.Label); if (rule.Stop) break; } } var movedIds = moves.SelectMany(p => p.Value).ToHashSet(); foreach (var pair in copies) foreach (var id in pair.Value) movedIds.Add(id); read.RemoveAll(movedIds.Contains); flagged.RemoveAll(movedIds.Contains); if (session is { IsConnected: true, SupportsFolders: true }) { foreach (var pair in moves) { var moved = await session.MoveMessagesAsync(folder, pair.Value, pair.Key, token); if (moved.IsSuccess) DropArchived(box.Id, folder, pair.Value); } if (read.Count > 0) { await session.SetMessageFlagsAsync(folder, read, new MailFlagUpdate { Seen = true }, token); _archive.UpdateFlags( box.Id, folder, headers.Where(h => read.Contains(h.Id)) .Select(h => (h.Id, true, h.IsFlagged || flagged.Contains(h.Id)))); } if (flagged.Count > 0) { await session.SetMessageFlagsAsync(folder, flagged, new MailFlagUpdate { Flagged = true }, token); _archive.UpdateFlags( box.Id, folder, headers.Where(h => flagged.Contains(h.Id)) .Select(h => (h.Id, h.IsSeen || read.Contains(h.Id), true))); } } if (copies.Count > 0) await CopyRuleMovesAsync(box, session, folder, folders, copies, token); return touched.Count; } private IReadOnlyList<(string Name, string FullName)> DestFoldersFor( MailRule rule, string sourceId, IReadOnlyList<(string Name, string FullName)> sourceFolders) { var destId = MailRuleEngine.FolderMailbox(rule); if (string.IsNullOrWhiteSpace(destId) || destId.Equals(sourceId, StringComparison.OrdinalIgnoreCase)) return sourceFolders; return FoldersForRules(destId); } private static void AddCopy( Dictionary<(string MailboxId, string Folder), List> map, string mailboxId, string folder, uint id) { var key = (mailboxId, folder); if (!map.TryGetValue(key, out var ids)) { ids = []; map[key] = ids; } if (!ids.Contains(id)) ids.Add(id); } private async Task CopyRuleMovesAsync( MailboxAccount source, IMailSession? sourceSession, string sourceFolder, IReadOnlyList<(string Name, string FullName)> sourceFolders, Dictionary<(string MailboxId, string Folder), List> copies, CancellationToken token) { var copied = new List(); foreach (var pair in copies) { var destBox = Mailboxes.FirstOrDefault(m => m.Id.Equals(pair.Key.MailboxId, StringComparison.OrdinalIgnoreCase)); if (destBox is null) continue; await EnsureFoldersForRulesAsync(destBox.Id); foreach (var id in pair.Value) { if (await CopyUidToMailboxAsync(source, sourceSession, destBox, sourceFolder, pair.Key.Folder, id, token)) copied.Add(id); } } if (copied.Count == 0) return; var unique = copied.Distinct().ToList(); if (sourceSession is { IsConnected: true, SupportsFolders: true }) { var trash = MailRetention.ResolveTrash(sourceFolders); if (!string.IsNullOrWhiteSpace(trash) && !trash.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase)) await sourceSession.MoveMessagesAsync(sourceFolder, unique, trash, token); else await sourceSession.SetMessageFlagsAsync(sourceFolder, unique, new MailFlagUpdate { Deleted = true }, token); } DropArchived(source.Id, sourceFolder, unique); } private async Task CopyUidToMailboxAsync( MailboxAccount source, IMailSession? sourceSession, MailboxAccount dest, string sourceFolder, string destFolder, uint id, CancellationToken token) { if (SessionFor(dest) is not { IsConnected: true }) await ConnectMailboxCoreAsync(dest, PasswordFor(dest), token).ConfigureAwait(false); var destSession = SessionFor(dest); if (destSession is not { IsConnected: true }) return false; byte[]? eml = null; var path = _archive.EmlPath(source.Id, sourceFolder, id) ?? MailArchiveLayout.EmlPath(source, Mailboxes, sourceFolder, id); if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) eml = await File.ReadAllBytesAsync(path, token); else if (sourceSession is { IsConnected: true }) { var body = await sourceSession.GetMessageAsync(sourceFolder, id, token); if (body.IsSuccess && body.Value?.RawEml is { Length: > 0 } raw) eml = raw; } if (eml is null || eml.Length == 0) return false; var appended = await destSession.AppendAsync(destFolder, eml, token); if (!appended.IsSuccess) return false; var destUid = appended.Value ?? 0; var destPath = destSession is IFileMailStore files ? files.MessagePath(destFolder, destUid) : MailArchiveLayout.EmlPath(dest, Mailboxes, destFolder, destUid); if (destUid > 0 && !string.IsNullOrWhiteSpace(destPath)) { Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); if (!File.Exists(destPath)) await File.WriteAllBytesAsync(destPath, eml, token); try { _archive.Open(dest.Id, MailArchiveLayout.DatabasePath(dest, Mailboxes)); _archive.CopyIndexed(source.Id, sourceFolder, id, dest.Id, destFolder, destPath, destUid); } catch { } } return true; } private static void AddUid(Dictionary> map, string folder, uint id) { if (!map.TryGetValue(folder, out var ids)) { ids = []; map[folder] = ids; } if (!ids.Contains(id)) ids.Add(id); } private FolderRowViewModel? InboxOf(MailboxAccount box) => FoldersFor(box).FirstOrDefault(f => MailFolderRole.Kind(f.Name, f.FullName) == "inbox") ?? FoldersFor(box).FirstOrDefault(); private IMailSession? ActiveSession => SessionFor(SelectedMailbox); private IMailSession? SessionFor(MailboxAccount? box) { if (box is null) return null; lock (_connections) return _connections.TryGetValue(box.Id, out var session) ? session : null; } private string PasswordFor(MailboxAccount box) { if (SelectedMailbox?.Id == box.Id && !string.IsNullOrWhiteSpace(EditorPassword)) return EditorPassword; var stored = _secrets.Get(FileSecretStore.MailboxKey(box.Id)); return stored.Value ?? ""; } private async Task ConnectMailboxCoreAsync(MailboxAccount box, string password, CancellationToken token) { while (true) { token.ThrowIfCancellationRequested(); lock (_connections) { if (_connecting.Add(box.Id)) break; } await Task.Delay(50, token).ConfigureAwait(false); } PauseIndexing(); try { if (SessionFor(box) is { IsConnected: true } live) { var missingFolders = false; await UiAsync(() => missingFolders = !_foldersByMailbox.ContainsKey(box.Id)).ConfigureAwait(false); if (missingFolders) await LoadFoldersAsync(box, live, token).ConfigureAwait(false); return true; } var session = _sessions.Create(box); var connected = await session.ConnectAsync(box, password, token).ConfigureAwait(false); if (!connected.IsSuccess) { await session.DisposeAsync().ConfigureAwait(false); await UiAsync(() => { if (SelectedMailbox?.Id == box.Id) Status = string.Join(" ", connected.Messages); }).ConfigureAwait(false); return false; } IMailSession? previous; lock (_connections) { _connections.TryGetValue(box.Id, out previous); _connections[box.Id] = session; } if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false); if (session is PstMailSession pst) pst.StorePathChanged += path => PersistPstStorePath(box, path); await LoadFoldersAsync(box, session, token).ConfigureAwait(false); await UiAsync(() => { EnsureIndexing(box.Id); NotifyFolderCommands(); }).ConfigureAwait(false); return true; } finally { ResumeIndexing(); lock (_connections) _connecting.Remove(box.Id); } } private async Task ConnectIdleMailboxesAsync() { foreach (var box in Mailboxes.ToList()) { if (SessionFor(box) is { IsConnected: true }) continue; lock (_connections) { if (_connecting.Contains(box.Id)) continue; } var oauth = MailAuthKind.IsOAuth(box.AuthKind) || _auth.HasTokens(box.Id); var password = PasswordFor(box); if (!box.IsLocalStore && string.IsNullOrWhiteSpace(password) && !oauth) continue; try { await ConnectMailboxCoreAsync(box, password, CancellationToken.None).ConfigureAwait(false); } catch { } } } private void ApplyFolderSync(string mailboxId, string folder, MailFolderSync sync) { var ready = IsInitialSyncComplete(mailboxId); _archive.SetKeywordIndexEnabled(mailboxId, ready); if (sync.Flags.Count > 0) _archive.UpdateFlags(mailboxId, folder, sync.Flags.Select(f => (f.Id, f.IsSeen, f.IsFlagged))); _archive.UpsertHeaders(mailboxId, sync.Headers.Select(MailArchiveMap.FromHeader)); if (sync.Present is not null) { var keep = sync.Present.ToHashSet(); var gone = _archive.Uids(mailboxId, folder).Where(uid => !keep.Contains(uid)).ToList(); if (gone.Count > 0) DropArchived(mailboxId, folder, gone); } } private async Task LoadMessagesAsync( string folder, CancellationToken token, bool resumeBodies = false, bool yieldAfterFirst = false, MailboxAccount? mailbox = null) { var box = mailbox ?? SelectedMailbox; if (box is null) return; await ShowArchiveAsync(box.Id, folder, token).ConfigureAwait(false); var session = SessionFor(box); if (session is not { IsConnected: true }) { await UiAsync(() => Status = ArchiveFiles.Hint()).ConfigureAwait(false); return; } PauseIndexing(); try { var painted = false; while (true) { var known = await Task.Run(() => _archive.Uids(box.Id, folder), token).ConfigureAwait(false); var result = await session.ListMessagesAsync(folder, known, token, IndexUiChunk).ConfigureAwait(false); if (!result.IsSuccess) { await UiAsync(() => Status = string.Join(" ", result.Messages) + " Showing local archive. " + ArchiveFiles.Hint()) .ConfigureAwait(false); return; } token.ThrowIfCancellationRequested(); var sync = result.Value ?? new MailFolderSync(); ApplyFolderSync(box.Id, folder, sync); var fresh = sync.Headers.Where(h => !known.Contains(h.Id)).ToList(); if (known.Count > 0) { await UiAsync(() => { if (SelectedMailbox?.Id == box.Id) NoticeNewMail(fresh); }).ConfigureAwait(false); } await ApplyIncomingRulesAsync(folder, fresh, token).ConfigureAwait(false); await ShowArchiveAsync(box.Id, folder, token).ConfigureAwait(false); if (!painted) { painted = true; await UiAsync(() => { IsBusy = false; NotifyFolderCommands(); }).ConfigureAwait(false); if (yieldAfterFirst && sync.Incomplete) { await UiAsync(() => Status = ArchiveFiles.Hint()).ConfigureAwait(false); _ = FinishFolderListAsync(box, folder, token, resumeBodies); return; } } if (!sync.Incomplete) break; } await UiAsync(() => MarkFolderCataloged(box, folder, session)).ConfigureAwait(false); if (TracksCertifiedReceipts(box) && MailFolderRole.Kind(null, folder) == "sent") { await IngestReceiptFoldersAsync(token).ConfigureAwait(false); await UiAsync(OverlaySentMarks).ConfigureAwait(false); } await UiAsync(() => Status = ArchiveFiles.Hint()).ConfigureAwait(false); if (resumeBodies && IsInitialSyncComplete(box.Id)) await UiAsync(() => EnsureBodyBackfill(box.Id)).ConfigureAwait(false); } finally { ResumeIndexing(); } } private async Task FinishFolderListAsync( MailboxAccount box, string folder, CancellationToken token, bool resumeBodies) { try { await LoadMessagesAsync(folder, token, resumeBodies, mailbox: box).ConfigureAwait(false); } catch (OperationCanceledException) { } } private async Task IngestReceiptFoldersAsync(CancellationToken token) { var session = ActiveSession; if (session is null || SelectedMailbox is null || !TracksCertifiedReceipts(SelectedMailbox)) return; foreach (var folder in FoldersFor(SelectedMailbox)) { var kind = MailFolderRole.Kind(folder.Name, folder.FullName); if (kind is not "inbox" and not "receipts") continue; var listed = await session.ListMessagesAsync( folder.FullName, _archive.Uids(SelectedMailbox.Id, folder.FullName), token); if (!listed.IsSuccess) continue; var sync = listed.Value ?? new MailFolderSync(); ApplyFolderSync(SelectedMailbox.Id, folder.FullName, sync); foreach (var header in sync.Headers) ReconcileHeader(header, folder.FullName); } } private void OverlaySentMarks() { if (SelectedMailbox is null || !TracksCertifiedReceipts(SelectedMailbox)) return; foreach (var row in Messages) { row.Header.DeliveryStatus = _receipts.StatusFor( SelectedMailbox.Id, row.Header.MessageId, row.Header.Subject); row.RefreshMarks(); } } private void ReconcileHeader(MailMessageHeader header, string folder) { if (SelectedMailbox is null) return; if (TracksCertifiedReceipts(SelectedMailbox) && header.EnvelopeKind is EnvelopeKind.PecReceipt or EnvelopeKind.EidasRem) _receipts.ApplyEvidence( SelectedMailbox.Id, header.InReplyTo, null, header.Subject, header.EnvelopeTipo); if (TracksCertifiedReceipts(SelectedMailbox) && MailFolderRole.Kind(null, folder) == "sent") header.DeliveryStatus = _receipts.StatusFor(SelectedMailbox.Id, header.MessageId, header.Subject); else if (string.IsNullOrWhiteSpace(header.DeliveryStatus)) header.DeliveryStatus = ReceiptStatus.FromTipo(header.EnvelopeTipo); } private void RebuildVisible() { if (SelectedMailbox is null || SelectedFolder is null) { VisibleMessages.Clear(); SelectedMessages.Clear(); _selectedKeys.Clear(); SelectedMessage = null; NotifyMessageCommands(); RestoreMessageGridSelection(); RefreshFolderStats(); return; } if (MessageFilter.Trim().Length == 0) ApplyVisibleRows([.. Messages]); else ScheduleSearch(immediate: true); } private void ScheduleSearch(bool immediate) { _search?.Cancel(); _search?.Dispose(); var cts = new CancellationTokenSource(); _search = cts; var gen = Interlocked.Increment(ref _searchGen); var query = MessageFilter ?? ""; var mailboxId = SelectedMailbox?.Id; var folder = SelectedFolder?.FullName; _ = RunSearchAsync(query, mailboxId, folder, gen, immediate, cts.Token); } private async Task RunSearchAsync( string query, string? mailboxId, string? folder, int gen, bool immediate, CancellationToken token) { try { var trimmed = query.Trim(); if (!immediate && trimmed.Length > 0) await Task.Delay(280, token).ConfigureAwait(false); if (token.IsCancellationRequested || gen != _searchGen) return; if (trimmed.Length == 0) { if (string.IsNullOrWhiteSpace(mailboxId) || string.IsNullOrWhiteSpace(folder)) { await Dispatcher.UIThread.InvokeAsync(() => { if (gen != _searchGen) return; VisibleMessages.Clear(); RefreshFolderStats(); }); return; } await Dispatcher.UIThread.InvokeAsync(() => { if (gen != _searchGen) return; ApplyVisibleRows([.. Messages]); }); return; } var hits = await Task.Run(() => { float[]? vector = null; if (_semantic.IsReady && trimmed.Length >= 2) vector = _semantic.EmbedQuery(trimmed); return _archive.SearchAll(trimmed, vector); }, token).ConfigureAwait(false); if (token.IsCancellationRequested || gen != _searchGen) return; await Dispatcher.UIThread.InvokeAsync(() => { if (gen != _searchGen) return; if (!string.Equals(MessageFilter.Trim(), trimmed, StringComparison.Ordinal)) return; var rows = hits .Select(hit => new MessageRowViewModel { MailboxId = hit.MailboxId, Header = MailArchiveMap.ToHeader(hit) }) .ToList(); PaintSpam(rows); ApplyVisibleRows(rows); }); } catch (OperationCanceledException) { } } private void ApplyVisibleRows(List rows) { var keepKeys = _selectedKeys.Count > 0 ? _selectedKeys.ToHashSet() : SelectedMessages.Select(row => row.Key).ToHashSet(); var keepOneKey = SelectedMessage?.Key; _syncingList = true; VisibleMessages.Clear(); try { var searching = MessageFilter.Trim().Length > 0; if (!searching && (SelectedMailbox is null || SelectedFolder is null)) return; if (!GroupConversations || searching) { rows.Sort(CompareRows); foreach (var row in rows) { row.SetChain(0, 1); VisibleMessages.Add(row); } } else { var map = rows .GroupBy(r => r.Header.Id) .ToDictionary(g => g.Key, g => g.First()); var links = MailChain.Order(rows.Select(ChainItem).ToList(), true); if (IsDateSort) { if (!_listSortDescending) links = MailListOrder.ReverseThreads(links); } else links = MailListOrder.ReorderThreads(links, (a, b) => CompareRows(map[a], map[b])); foreach (var link in links) { if (!map.TryGetValue(link.Uid, out var row)) continue; row.SetChain(link.Depth, link.Size); VisibleMessages.Add(row); } } var keep = MailMessageList.Resolve(keepKeys, VisibleMessages, row => row.Key); if (keepOneKey is { } key && keep.All(row => row.Key != key)) { var primary = VisibleMessages.FirstOrDefault(row => row.Key == key); if (primary is not null) keep = [primary, .. keep]; } SetSelectedMessages(keep); if (SelectedMessage is { } selected && !VisibleMessages.Any(row => row.Key == selected.Key)) SelectedMessage = null; if (SelectedMessage is { } open && !IsLoadedBody(open.Header)) _ = OpenMessageAsync(open); } finally { try { RefreshFolderStats(); NotifyMessageCommands(); RestoreMessageGridSelection(); } finally { _syncingList = false; } } } private static MailChainItem ChainItem(MessageRowViewModel row) => new() { Uid = row.Header.Id, MessageId = row.Header.MessageId, InReplyTo = row.Header.InReplyTo, Date = row.Header.Date }; private bool IsDateSort => string.IsNullOrWhiteSpace(_listSortHeader) || _listSortHeader.Equals("Date", StringComparison.OrdinalIgnoreCase); private int CompareRows(MessageRowViewModel a, MessageRowViewModel b) { var cmp = _listSortHeader switch { "Unread" => a.UnreadSort.CompareTo(b.UnreadSort), "Flag" => a.FlagSort.CompareTo(b.FlagSort), "Priority" => a.PrioritySort.CompareTo(b.PrioritySort), "Attachments" => a.AttachmentSort.CompareTo(b.AttachmentSort), "Delivery" => a.DeliverySort.CompareTo(b.DeliverySort), "Type" => string.Compare(a.Badge, b.Badge, StringComparison.OrdinalIgnoreCase), "From" => string.Compare(a.From, b.From, StringComparison.OrdinalIgnoreCase), "Label" => string.Compare(a.Labels, b.Labels, StringComparison.OrdinalIgnoreCase), "Subject" => string.Compare(a.Subject, b.Subject, StringComparison.OrdinalIgnoreCase), "Size" => a.Size.CompareTo(b.Size), _ => a.SortDate.CompareTo(b.SortDate) }; if (cmp == 0) cmp = a.Header.Id.CompareTo(b.Header.Id); return _listSortDescending ? -cmp : cmp; } private async Task LoadBodyAsync(MailMessageHeader header, CancellationToken token, string? mailboxId = null) { var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)) ?? SelectedMailbox; if (box is null) return; var eml = _archive.EmlPath(box.Id, header.Folder, header.Id) ?? MailArchiveLayout.EmlPath(box, Mailboxes, header.Folder, header.Id); var body = await MimeBody.TryFromEmlAsync(header.Folder, header.Id, eml, token).ConfigureAwait(false); if (body is null && SessionFor(box) is { IsConnected: true } session) { await UiAsync(() => Status = Copy.OpeningMessage).ConfigureAwait(false); PauseIndexing(); try { var result = await session.GetMessageAsync(header.Folder, header.Id, token).ConfigureAwait(false); if (!result.IsSuccess || result.Value is null) { await UiAsync(() => Status = string.Join(" ", result.Messages)).ConfigureAwait(false); return; } body = result.Value; } finally { ResumeIndexing(); } } if (body is null) { await UiAsync(() => Status = "Message is not in the local archive yet. Connect and Get Messages.") .ConfigureAwait(false); return; } if (body.Header.HasAttachments) header.HasAttachments = true; StoreArchivedBody(box.Id, header, body); await Dispatcher.UIThread.InvokeAsync(async () => { MarkRowAttachments(header); if (SelectedMessage is null || SelectedMessage.Header.Id != header.Id || !SelectedMessage.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) { if (Status == Copy.OpeningMessage) Status = ""; return; } _reading = body; ApplyReading(body); if (Status == Copy.OpeningMessage) Status = ""; if (TracksCertifiedReceipts(box) && (body.Envelope.Kind == EnvelopeKind.PecReceipt || body.Envelope.Kind == EnvelopeKind.EidasRem)) { _receipts.ApplyEvidence( box.Id, body.Envelope.Msgid, body.Envelope.Identificativo, body.Envelope.Oggetto, body.Envelope.Tipo); if (SelectedFolder is not null && MailFolderRole.Kind(SelectedFolder.Name, SelectedFolder.FullName) == "sent") OverlaySentMarks(); var explain = ReceiptStatus.Explain(body.Envelope.Tipo); if (!string.IsNullOrWhiteSpace(explain)) Status = explain; } await MarkOpenedReadAsync(header, token).ConfigureAwait(true); if (_pendingWindow is { } pending && pending.Header.Id == header.Id && pending.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) { _pendingWindow = null; ShowMessageWindow(body); } }); } private bool IsLoadedBody(MailMessageHeader header) => _reading is { } body && body.Header.Id == header.Id && body.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase); private void ShowMessageWindow(MailMessageBody body) { if (!TryComposeIdentities(out var identities, out var selected) || selected is null) return; var vm = new MessageWindowViewModel( body, identities, selected, UnwrapEnvelope, _bodyPreference, HtmlEngineReady, EnsureComposeSessionAsync); vm.ComposeRequested += compose => { compose.Sent += RememberSent; ComposeRequested?.Invoke(compose); }; MessageWindowRequested?.Invoke(vm); } private async Task MarkOpenedReadAsync(MailMessageHeader header, CancellationToken token) { var row = Messages.FirstOrDefault(m => m.Header.Id == header.Id && m.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)); if (row is null || row.Header.IsSeen || SelectedFolder is null) return; var session = ActiveSession; if (session is not { IsConnected: true }) return; if (session.SupportsFolders) { var result = await session.SetMessageFlagsAsync( SelectedFolder.FullName, [row.Header.Id], new MailFlagUpdate { Seen = true }, token); if (!result.IsSuccess) return; } ApplyUnreadDelta(SelectedFolder, [row], true); row.Header.IsSeen = true; row.RefreshMarks(); if (SelectedMailbox is not null) _archive.UpsertHeaders(SelectedMailbox.Id, [MailArchiveMap.FromHeader(row.Header)]); NotifyFolderCounts(); RefreshFolderStats(); } private void ApplyReading(MailMessageBody body, bool keepKind = false) { var inner = UnwrapEnvelope && body.Envelope.HasInnerMessage; ReadingSubject = inner && !string.IsNullOrWhiteSpace(body.InnerSubject) ? body.InnerSubject : body.Header.Subject; ReadingFrom = inner && !string.IsNullOrWhiteSpace(body.InnerFrom) ? body.InnerFrom : body.Header.From; ReadingTo = inner && !string.IsNullOrWhiteSpace(body.InnerTo) ? body.InnerTo : body.To; ReadingCc = inner && !string.IsNullOrWhiteSpace(body.InnerCc) ? body.InnerCc : body.Cc; HasReadingCc = !string.IsNullOrWhiteSpace(ReadingCc); ReadingDate = body.Header.Date == DateTimeOffset.MinValue ? "" : body.Header.Date.ToLocalTime().ToString("f"); var files = inner ? body.InnerAttachmentFiles : body.AttachmentFiles; var html = inner ? body.InnerHtml : body.Html; var text = inner ? body.InnerText : body.Text; var markup = MessageHtml.HasMarkup(html); if (string.IsNullOrWhiteSpace(text)) text = MessageText.StripHtml(html); if (markup) { var cid = MessageHtml.DataUris(files.Select(f => (f.ContentId, f.ContentType, f.Bytes))); ReadingHtmlDocument = MessageHtml.Document(MessageHtml.InlineCid(html, cid)); } else if (keepKind && MailBodyKind.IsHtml(ReadingBodyKind)) ReadingHtmlDocument = MessageHtml.Document(MessageHtml.FromPlain(text)); else ReadingHtmlDocument = ""; ReadingBody = text ?? ""; ReadingSource = body.RawEml.Length == 0 ? "" : System.Text.Encoding.UTF8.GetString(body.RawEml); ReadingEnvelope = body.Envelope.Line; HasReadingEnvelope = !string.IsNullOrWhiteSpace(ReadingEnvelope); ReadingFiles.Clear(); foreach (var file in files) { if (IsInlineImage(file, html ?? "")) continue; ReadingFiles.Add(file); } HasReadingFiles = ReadingFiles.Count > 0; HasReading = true; var fattura = FatturaPaDocument.FromAttachments(files); ReadingFattura = fattura?.Text ?? ""; HasReadingFattura = fattura is not null; if (!keepKind) ApplyBodyKind(fattura is not null && UseFatturaPa ? MailBodyKind.Fattura : MailBodyKind.DefaultView(html, inner ? body.InnerText : body.Text, _bodyPreference)); } private static bool IsInlineImage(MailFileAttachment file, string html) { if (string.IsNullOrWhiteSpace(file.ContentId)) return false; if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return false; return html.Contains("cid:" + file.ContentId, StringComparison.OrdinalIgnoreCase); } private void ClearReading() { _reading = null; _pendingWindow = null; HasReading = false; HasReadingCc = false; ReadingSubject = ""; ReadingFrom = ""; ReadingTo = ""; ReadingCc = ""; ReadingDate = ""; ReadingBody = ""; ReadingHtmlDocument = ""; ReadingSource = ""; ReadingEnvelope = ""; HasReadingEnvelope = false; HasReadingFiles = false; HasReadingFattura = false; ReadingFattura = ""; ReadingFiles.Clear(); } private void MarkRowAttachments(MailMessageHeader header) { if (!header.HasAttachments) return; foreach (var row in Messages) { if (row.Header.Id != header.Id) continue; if (!row.Header.Folder.Equals(header.Folder, StringComparison.OrdinalIgnoreCase)) continue; row.Header.HasAttachments = true; row.RefreshMarks(); } } private void StoreArchivedBody(string mailboxId, MailMessageHeader header, MailMessageBody body) { if (body.RawEml.Length == 0) return; if (body.Header.HasAttachments) header.HasAttachments = true; try { var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)); var path = box is not null ? MailArchiveLayout.EmlPath(box, Mailboxes, header.Folder, header.Id) : ArchiveFiles.EmlPath(mailboxId, header.Folder, header.Id); File.WriteAllBytes(path, body.RawEml); _archive.UpsertBody( mailboxId, MailArchiveMap.FromHeader(header), path, MailArchiveMap.BodyText(body, UnwrapEnvelope), MailArchiveMap.AttachmentIndex(body, UnwrapEnvelope)); if (IsInitialSyncComplete(mailboxId)) _semantic.Wake(); } catch { } } private static void ShowArchiveHint() { } private async Task ShowArchiveAsync(string mailboxId, string folder, CancellationToken token) { var rows = await Task.Run(() => _archive.ListFolder(mailboxId, folder), token).ConfigureAwait(false); token.ThrowIfCancellationRequested(); await UiAsync(() => { if (SelectedMailbox?.Id != mailboxId || SelectedFolder is null || !SelectedFolder.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) return; BindArchive(mailboxId, folder, rows); }).ConfigureAwait(false); } private void ShowArchive(string mailboxId, string folder) => BindArchive(mailboxId, folder, _archive.ListFolder(mailboxId, folder)); private void PaintSpam(IEnumerable rows) { foreach (var box in rows.GroupBy(row => row.MailboxId)) { foreach (var folder in box.GroupBy(row => row.Header.Folder)) { var flags = _archive.SpamFlags(box.Key, folder.Key, EmbeddingModelSpec.Id); var junk = MailFolderRole.Kind(null, folder.Key) == "junk"; foreach (var row in folder) { flags.TryGetValue(row.Header.Id, out var flag); row.SetSpam(flag.Explicit, !junk && !flag.Explicit && flag.Score >= SpamScore.ShowAt); } } } } private string? JunkFolder(MailboxAccount box) => FoldersFor(box).FirstOrDefault(folder => MailFolderRole.Kind(folder.Name, folder.FullName) == "junk")?.FullName; private void BindArchive(string mailboxId, string folder, IReadOnlyList rows) { if (Messages.Count > 0 && (Messages[0].MailboxId != mailboxId || !Messages[0].Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) { Messages.Clear(); if (SelectedMessage is not null && !IsVisibleSelection(SelectedMessage) && (!SelectedMessage.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase) || !SelectedMessage.Header.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))) { SelectedMessage = null; ClearReading(); } } var incoming = new List(rows.Count); foreach (var row in rows) { var header = MailArchiveMap.ToHeader(row); ReconcileHeader(header, folder); incoming.Add(new MessageRowViewModel { MailboxId = mailboxId, Header = header }); } MailMessageList.Merge( Messages, incoming, row => row.Header.Id, (dest, src) => dest.ApplyHeader(src.Header)); PaintSpam(Messages); if (SelectedMessage is { } selected && !IsVisibleSelection(selected)) { SelectedMessage = null; ClearReading(); } if (TracksCertifiedReceipts(mailboxId) && MailFolderRole.Kind(null, folder) == "sent") OverlaySentMarks(); ApplyOpenedFolderCounts(); RebuildVisible(); } private void NoticeNewMail(IReadOnlyList newcomers) { if (newcomers.Count == 0) return; RaiseNotice(newcomers.Where(h => h.EnvelopeKind == EnvelopeKind.PecTransport).ToList(), "PEC arrived"); RaiseNotice(newcomers.Where(h => h.EnvelopeKind == EnvelopeKind.PecReceipt).ToList(), "Ricevuta arrived"); RaiseNotice(newcomers.Where(h => h.EnvelopeKind == EnvelopeKind.EidasRem).ToList(), "REM arrived"); } private void RaiseNotice(IReadOnlyList rows, string title) { if (rows.Count == 0) return; var body = rows.Count == 1 ? rows[0].Subject : rows.Count + " new messages"; NoticeRequested?.Invoke(title, body); } private void StopBackfill() { _indexMailboxes.Clear(); _catalogDone.Clear(); _catalogFolders.Clear(); _rulesDone.Clear(); _rulesOffset.Clear(); _indexSkip.Clear(); _backfill?.Cancel(); _backfill?.Dispose(); _backfill = null; _indexHold.ClearNow(); } private void EnsureIndexing(string mailboxId) { if (!string.IsNullOrWhiteSpace(mailboxId)) _indexMailboxes.Add(mailboxId); TrackConnectedMailboxes(); if (_backfill is not null || _indexMailboxes.Count == 0) return; StartIndexing(); } private void EnsureBodyBackfill(string mailboxId) { if (string.IsNullOrWhiteSpace(mailboxId) || _backfill is not null) return; if (NextMissingBodies([mailboxId]).Count == 0) return; EnsureIndexing(mailboxId); } private void StopIndexingMailbox(string mailboxId) { _indexMailboxes.Remove(mailboxId); _catalogDone.Remove(mailboxId); _catalogFolders.RemoveWhere(key => key.StartsWith(mailboxId + "\n", StringComparison.OrdinalIgnoreCase)); _rulesDone.RemoveWhere(key => key.StartsWith(mailboxId + "\n", StringComparison.OrdinalIgnoreCase)); foreach (var key in _rulesOffset.Keys.Where(key => key.StartsWith(mailboxId + "\n", StringComparison.OrdinalIgnoreCase)).ToList()) _rulesOffset.Remove(key); if (_indexMailboxes.Count > 0) return; _backfill?.Cancel(); _backfill?.Dispose(); _backfill = null; _indexHold.ClearNow(); } private void StartIndexing() { if (_backfill is not null || _indexMailboxes.Count == 0) return; var cts = new CancellationTokenSource(); _backfill = cts; _ = IndexAllAsync(cts); } private async Task IndexAllAsync(CancellationTokenSource cts) { var token = cts.Token; await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); try { while (!token.IsCancellationRequested) { await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); var (snapshot, readyIds) = await Dispatcher.UIThread.InvokeAsync(() => { TrackConnectedMailboxes(); return (CaptureIndexSnapshot(), ReadyMailboxIds()); }); await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); if (snapshot.Count == 0) break; await ApplyNextArchiveRulesAsync(snapshot, token).ConfigureAwait(false); var sessions = snapshot.ToDictionary( s => s.Id, s => s.Session, StringComparer.OrdinalIgnoreCase); var cataloged = false; foreach (var box in snapshot) { if (box.CatalogDone || box.Session is not { IsConnected: true } || !box.Session.SupportsFolders) continue; var pendingFolders = false; foreach (var folder in box.Folders) { token.ThrowIfCancellationRequested(); var open = box.OpenFolder is not null && folder.FullName.Equals(box.OpenFolder, StringComparison.OrdinalIgnoreCase); if (open || box.CatalogedFolders.Contains(folder.FullName)) continue; pendingFolders = true; cataloged = true; await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); var known = _archive.Uids(box.Id, folder.FullName); var listed = await box.Session .ListMessagesAsync(folder.FullName, known, token, IndexCatalogChunk) .ConfigureAwait(false); if (!listed.IsSuccess) continue; var sync = listed.Value ?? new MailFolderSync(); ApplyFolderSync(box.Id, folder.FullName, sync); if (sync.Headers.Any(header => !known.Contains(header.Id))) { var key = CatalogKey(box.Id, folder.FullName); _rulesDone.Remove(key); _rulesOffset.Remove(key); } await SetIndexLineAsync(GlobalIndexLine(), token) .ConfigureAwait(false); var counts = _archive.FolderCounts(box.Id, folder.FullName); await UiAsync(() => RefreshIndexedFolderCounts(box.Id, folder.FullName, counts)) .ConfigureAwait(false); if (!sync.Incomplete) { await UiAsync(() => { _catalogFolders.Add(CatalogKey(box.Id, folder.FullName)); TryCompleteInitialSync(MailboxById(box.Id)); }).ConfigureAwait(false); } await Task.Delay(15, token).ConfigureAwait(false); break; } if (pendingFolders) break; await UiAsync(() => TryCompleteInitialSync(MailboxById(box.Id))).ConfigureAwait(false); } await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); var pending = NextMissingBodies(readyIds).Take(IndexBodyTurn).ToList(); if (pending.Count == 0) { await SetIndexLineAsync("", token).ConfigureAwait(false); if (!cataloged && !RulesRemain(snapshot)) break; await Task.Delay(400, token).ConfigureAwait(false); continue; } await SetIndexLineAsync(GlobalIndexLine(), token) .ConfigureAwait(false); var fetched = 0; foreach (var item in pending) { token.ThrowIfCancellationRequested(); await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); if (!sessions.TryGetValue(item.MailboxId, out var session) || session is null) continue; var key = MailMessageKey.Of(item.MailboxId, item.Folder, item.Uid); try { var result = await session .GetMessageAsync(item.Folder, item.Uid, token, interactive: false) .ConfigureAwait(false); if (result.IsSuccess && result.Value is not null) { StoreArchivedBody(item.MailboxId, result.Value.Header, result.Value); _indexSkip.Remove(key); fetched++; continue; } if (MailFetch.IsGone(result.Messages)) { _archive.RemoveUids(item.MailboxId, item.Folder, [item.Uid]); _indexSkip.Remove(key); fetched++; continue; } _indexSkip.Add(key); } catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } catch (OperationCanceledException) { _indexSkip.Add(key); } catch { _indexSkip.Add(key); } } if (fetched == 0 && !cataloged) { if (NextMissingBodies(readyIds).Count == 0) break; await Task.Delay(500, token).ConfigureAwait(false); continue; } } } catch (OperationCanceledException) { return; } finally { await Dispatcher.UIThread.InvokeAsync(() => { if (_backfill != cts) return; _backfill.Dispose(); _backfill = null; if (token.IsCancellationRequested) return; foreach (var id in _indexMailboxes.ToList()) { if (NextMissingBodies([id]).Count > 0) continue; var box = MailboxById(id); if (_catalogDone.Contains(id) || (box is not null && AllFoldersCataloged(box))) _indexMailboxes.Remove(id); } var again = _indexMailboxes.Any(id => SessionFor(MailboxById(id)) is { IsConnected: true } && (NextMissingBodies([id]).Count > 0 || !_catalogDone.Contains(id) || RulesRemainFor(id))); if (!again) { _indexHold.Set(""); return; } StartIndexing(); }); } } private async Task ApplyNextArchiveRulesAsync( IReadOnlyList snapshot, CancellationToken token) { foreach (var box in snapshot) { if (box.Session is not { IsConnected: true, SupportsFolders: true }) continue; var account = MailboxById(box.Id); if (account is null) continue; var names = box.Folders.Select(folder => (folder.Name, folder.FullName)).ToList(); foreach (var folder in box.Folders) { var key = CatalogKey(box.Id, folder.FullName); if (_rulesDone.Contains(key)) continue; await WaitIfIndexingPausedAsync(token).ConfigureAwait(false); var headers = _archive.ListFolder(box.Id, folder.FullName).Select(MailArchiveMap.ToHeader).ToList(); _rulesOffset.TryGetValue(key, out var offset); if (offset > headers.Count) offset = 0; var slice = headers.Skip(offset).Take(RulesSlice).ToList(); if (slice.Count > 0) await ApplyRulesToFolderAsync(account, box.Session, folder.FullName, slice, names, token) .ConfigureAwait(false); if (offset + slice.Count >= headers.Count) { _rulesDone.Add(key); _rulesOffset.Remove(key); } else _rulesOffset[key] = offset + slice.Count; return true; } } return false; } private bool RulesRemain(IReadOnlyList snapshot) { foreach (var box in snapshot) { if (RulesRemainFor(box.Id)) return true; } return false; } private bool RulesRemainFor(string mailboxId) { var box = MailboxById(mailboxId); foreach (var folder in FoldersFor(box)) { if (!_rulesDone.Contains(CatalogKey(mailboxId, folder.FullName))) return true; } return false; } private List CaptureIndexSnapshot() { var rows = new List(); foreach (var id in _indexMailboxes.ToList()) { var box = MailboxById(id); var session = SessionFor(box); var cataloged = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var folder in FoldersFor(box)) { if (_catalogFolders.Contains(CatalogKey(id, folder.FullName))) cataloged.Add(folder.FullName); } rows.Add(new IndexMailboxSnap( id, session, FoldersFor(box).Select(f => (f.FullName, f.Name)).ToList(), SelectedMailbox?.Id == id ? SelectedFolder?.FullName : null, _catalogDone.Contains(id), cataloged)); } return rows; } private readonly record struct IndexMailboxSnap( string Id, IMailSession? Session, IReadOnlyList<(string FullName, string Name)> Folders, string? OpenFolder, bool CatalogDone, IReadOnlySet CatalogedFolders); private async Task SetIndexLineAsync(string line, CancellationToken token) => await UiAsync(() => { if (!token.IsCancellationRequested) _indexHold.Set(line); }).ConfigureAwait(false); private void TrackConnectedMailboxes() { foreach (var box in Mailboxes) { if (SessionFor(box) is { IsConnected: true }) _indexMailboxes.Add(box.Id); } } private List ReadyMailboxIds() { var ids = new List(); foreach (var box in Mailboxes) { if (IsInitialSyncComplete(box.Id)) ids.Add(box.Id); } return ids; } private string GlobalIndexLine() { var total = _archive.TotalMessageCount(); var missing = _archive.MissingBodyCount(); var skip = Math.Min(_indexSkip.Count, Math.Max(0, missing)); return MailIndexProgress.Line(Math.Max(0, total - missing + skip), total); } private List NextMissingBodies(IReadOnlyCollection readyIds) { if (readyIds.Count == 0) return []; var rows = new List(); foreach (var item in _archive.MissingBodies(readyIds, IndexBodyLookahead)) { var key = MailMessageKey.Of(item.MailboxId, item.Folder, item.Uid); if (_indexSkip.Contains(key)) continue; if (item.Size > MailFetch.MaxBackfillBytes) { _indexSkip.Add(key); continue; } rows.Add(item); if (rows.Count >= IndexBodyChunk) break; } return rows; } private MailboxAccount? MailboxById(string mailboxId) => Mailboxes.FirstOrDefault(m => m.Id.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)) ?? _files.Current.FindMailbox(mailboxId); private void RefreshIndexedFolderCounts(string mailboxId, string folder, (int Total, int Unread) counts) { var view = FoldersFor(MailboxById(mailboxId)) .FirstOrDefault(f => f.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)); if (view is null) return; view.Total = counts.Total; view.Unread = counts.Unread; NotifyFolderCounts(); } private async Task DisconnectMailboxAsync(string mailboxId) { StopIndexingMailbox(mailboxId); IMailSession? session; lock (_connections) _connections.Remove(mailboxId, out session); if (session is not null) await session.DisposeAsync(); _foldersByMailbox.Remove(mailboxId); if (SelectedMailbox?.Id == mailboxId) { Messages.Clear(); RebuildVisible(); ClearReading(); Folders.Clear(); FolderStats = ""; } RebuildFolderTree(); } private void NotifyMessageCommands() { StartComposeCommand.NotifyCanExecuteChanged(); GetMessagesCommand.NotifyCanExecuteChanged(); ReplyCommand.NotifyCanExecuteChanged(); ReplyAllCommand.NotifyCanExecuteChanged(); ForwardCommand.NotifyCanExecuteChanged(); MoveToFolderCommand.NotifyCanExecuteChanged(); MarkSpamCommand.NotifyCanExecuteChanged(); MarkNotSpamCommand.NotifyCanExecuteChanged(); MarkReadCommand.NotifyCanExecuteChanged(); MarkUnreadCommand.NotifyCanExecuteChanged(); ToggleFlagCommand.NotifyCanExecuteChanged(); SetPriorityCommand.NotifyCanExecuteChanged(); DeleteMessagesCommand.NotifyCanExecuteChanged(); OpenMessageWindowCommand.NotifyCanExecuteChanged(); PrintMessageCommand.NotifyCanExecuteChanged(); SaveMessagePdfCommand.NotifyCanExecuteChanged(); SaveAttachmentsZipCommand.NotifyCanExecuteChanged(); NotifyFolderCommands(); } private void NotifyFolderCommands() { CreateFolderCommand.NotifyCanExecuteChanged(); EmptyFolderCommand.NotifyCanExecuteChanged(); MarkFolderReadCommand.NotifyCanExecuteChanged(); MarkFolderUnreadCommand.NotifyCanExecuteChanged(); DeleteCustomFolderCommand.NotifyCanExecuteChanged(); DetachPstCommand.NotifyCanExecuteChanged(); OnPropertyChanged(nameof(ShowFolderContext)); OnPropertyChanged(nameof(ShowDetachPst)); OnPropertyChanged(nameof(EmptyFolderLabel)); } partial void OnHasReadingFilesChanged(bool value) => SaveAttachmentsZipCommand.NotifyCanExecuteChanged(); private IReadOnlyList TargetRows() { var keys = _selectedKeys.Count > 0 ? _selectedKeys : SelectedMessage is { } one ? [one.Key] : []; return MailMessageList.Resolve(keys, VisibleMessages.Concat(Messages), row => row.Key); } private bool IsVisibleSelection(MessageRowViewModel row) { if (MessageFilter.Trim().Length > 0) return VisibleMessages.Any(item => item.Key == row.Key); return Messages.Any(item => item.Key == row.Key); } private IReadOnlyList TargetFolderNodes() { if (SelectedFolderNodes.Count > 0) return SelectedFolderNodes.Where(node => node is { IsAccount: false, Folder: not null }).ToList(); return SelectedFolderNode is { IsAccount: false, Folder: not null } node ? [node] : []; } private IReadOnlyList<(MailboxAccount Box, FolderRowViewModel Folder)> TargetCustomFolderRoots() { var picks = new List<(MailboxAccount Box, FolderRowViewModel Folder)>(); foreach (var node in TargetFolderNodes()) { if (node.Mailbox is not { } box || node.Folder is not { } folder) continue; if (!MailFolderRole.IsCustom(folder.Name, folder.FullName)) continue; picks.Add((box, folder)); } var roots = MailSelection.Roots(picks.Select(pick => (pick.Box.Id, pick.Folder.FullName))); return picks .Where(pick => roots.Any(root => root.MailboxId.Equals(pick.Box.Id, StringComparison.OrdinalIgnoreCase) && root.Folder.Equals(pick.Folder.FullName, StringComparison.OrdinalIgnoreCase))) .ToList(); } private IReadOnlyList TargetIds(IReadOnlyList? rows = null) => (rows ?? TargetRows()).Select(r => r.Header.Id).ToList(); private async Task MoveRowsAsync(IReadOnlyList rows, string destFolder) { if (rows.Count == 0) return; foreach (var group in rows.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { var groupRows = group.ToList(); await MoveIdsAsync( TargetIds(groupRows), destFolder, fromFolder: group.Key.Folder, rows: groupRows, sourceMailboxId: group.Key.MailboxId); } } private async Task MoveIdsAsync( IReadOnlyList ids, string destFolder, string? fromFolder = null, IReadOnlyList? rows = null, string? destMailboxId = null, string? sourceMailboxId = null) { var sourceBox = MailboxById(sourceMailboxId ?? SelectedMailbox?.Id ?? "") ?? SelectedMailbox; var destBox = MailboxById(destMailboxId ?? sourceBox?.Id ?? "") ?? sourceBox; if (sourceBox is null || destBox is null || ids.Count == 0) return; var source = fromFolder ?? SelectedFolder?.FullName; if (string.IsNullOrWhiteSpace(source)) return; await RunAsync(Copy.Moving, async token => { var sourceSession = await EnsureMailboxReadyAsync(sourceBox, token); var destSession = destBox.Id.Equals(sourceBox.Id, StringComparison.OrdinalIgnoreCase) ? sourceSession : await EnsureMailboxReadyAsync(destBox, token); var dest = destFolder; if (string.IsNullOrWhiteSpace(dest)) dest = InboxOf(destBox)?.FullName ?? ""; if (string.IsNullOrWhiteSpace(dest) || destSession is not { IsConnected: true, SupportsFolders: true }) { Status = Copy.CannotMoveSystemFolder; return; } if (sourceBox.Id.Equals(destBox.Id, StringComparison.OrdinalIgnoreCase) && source.Equals(dest, StringComparison.OrdinalIgnoreCase)) { Status = Copy.AlreadyInFolder; return; } if (sourceBox.Id.Equals(destBox.Id, StringComparison.OrdinalIgnoreCase)) { if (sourceSession is not { IsConnected: true, SupportsFolders: true }) { Status = Copy.CannotMoveSystemFolder; return; } var result = await sourceSession.MoveMessagesAsync(source, ids, dest, token); if (!result.IsSuccess) { Status = string.Join(" ", result.Messages); return; } ApplyFolderMoveCounts(sourceBox, destBox, source, dest, ids.Count, rows); DropArchived(sourceBox.Id, source, ids.ToList()); RemoveDroppedRows(sourceBox.Id, source, ids); if (SelectedMailbox?.Id == destBox.Id && SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, token); Status = string.Format(Copy.MovedMessages, ids.Count); return; } if (sourceSession is null) { Status = Copy.CannotMoveSystemFolder; return; } var copied = new List(); foreach (var id in ids) { if (await CopyUidToMailboxAsync(sourceBox, sourceSession, destBox, source, dest, id, token)) copied.Add(id); } if (copied.Count == 0) { Status = string.Format(Copy.MovedMessages, 0); return; } await RemoveCopiedUidsAsync(sourceBox, sourceSession, source, copied, token); ApplyFolderMoveCounts(sourceBox, destBox, source, dest, copied.Count, rows); RemoveDroppedRows(sourceBox.Id, source, copied); await LoadFoldersAsync(destBox, destSession, token); if (SelectedMailbox?.Id == destBox.Id && SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, token); else if (SelectedMailbox?.Id == sourceBox.Id && SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, token); Status = string.Format(Copy.MovedMessages, copied.Count); }); } private async Task EnsureMailboxReadyAsync(MailboxAccount box, CancellationToken token) { await ConnectMailboxCoreAsync(box, PasswordFor(box), token); var session = SessionFor(box); if (session is not { IsConnected: true }) return session; if (FoldersFor(box).Count == 0) await LoadFoldersAsync(box, session, token); return session; } private async Task> FolderUidsAsync( MailboxAccount box, IMailSession session, string folder, CancellationToken token) { var ids = new HashSet(_archive.Uids(box.Id, folder)); var listed = await session.ListMessagesAsync(folder, ids.Count == 0 ? null : ids, token); if (listed.IsSuccess && listed.Value?.Present is { } present) { foreach (var id in present) ids.Add(id); } return ids.ToList(); } private IReadOnlyList FolderSlice(MailboxAccount box, string root) => FoldersFor(box) .Where(folder => MailFolderPath.IsSelfOrUnder(folder.FullName, root)) .OrderBy(folder => folder.FullName.Length) .ThenBy(folder => folder.FullName, StringComparer.OrdinalIgnoreCase) .ToList(); private IEnumerable SiblingNames(MailboxAccount box, string? destParent, string? exceptFolder) { foreach (var folder in FoldersFor(box)) { if (exceptFolder is not null && folder.FullName.Equals(exceptFolder, StringComparison.OrdinalIgnoreCase)) continue; if (MailFolderPath.SameParent(folder.FullName, destParent)) yield return folder.Name; } } private string? FindMovedFolder(MailboxAccount box, string? destParent, string leaf) => FoldersFor(box).FirstOrDefault(folder => MailFolderPath.Leaf(folder.FullName).Equals(leaf, StringComparison.OrdinalIgnoreCase) && MailFolderPath.SameParent(folder.FullName, destParent))?.FullName; private void RememberMovedFolder(string sourceMailboxId, string from, string to, string? destMailboxId) { _archive.RewriteFolderPrefix(sourceMailboxId, from, to); if (MailRuleEngine.RetargetFolders(ActiveRules(), sourceMailboxId, from, to, destMailboxId) > 0) _files.Save(_files.Current); } private async Task SelectAfterFolderMoveAsync( MailboxAccount source, string from, string to, string? openFolder, CancellationToken token, MailboxAccount? destBox = null) { if (SelectedMailbox?.Id != source.Id) return; if (string.IsNullOrWhiteSpace(openFolder) || !MailFolderPath.IsSelfOrUnder(openFolder, from)) return; FolderRowViewModel? next = null; if (destBox is null || destBox.Id.Equals(source.Id, StringComparison.OrdinalIgnoreCase)) { var path = MailFolderPath.Rewrite(openFolder, from, to); next = FoldersFor(source).FirstOrDefault(f => path is not null && f.FullName.Equals(path, StringComparison.OrdinalIgnoreCase)) ?? FoldersFor(source).FirstOrDefault(f => f.FullName.Equals(to, StringComparison.OrdinalIgnoreCase)); } SelectedFolder = next ?? InboxOf(source) ?? FoldersFor(source).FirstOrDefault(); if (SelectedFolder is not null) await LoadMessagesAsync(SelectedFolder.FullName, token); } private async Task RemoveCopiedUidsAsync( MailboxAccount source, IMailSession? session, string sourceFolder, IReadOnlyList ids, CancellationToken token) { if (ids.Count == 0) return; if (session is { IsConnected: true, SupportsFolders: true }) { var trash = MailRetention.ResolveTrash( FoldersFor(source).Select(f => (f.Name, f.FullName)).ToList()); if (!string.IsNullOrWhiteSpace(trash) && !trash.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase)) await session.MoveMessagesAsync(sourceFolder, ids, trash, token); else await session.SetMessageFlagsAsync(sourceFolder, ids, new MailFlagUpdate { Deleted = true }, token); } DropArchived(source.Id, sourceFolder, ids.ToList()); } private void RemoveDroppedRows(string mailboxId, string folder, IReadOnlyList ids) { var keys = ids.Select(id => MailMessageKey.Of(mailboxId, folder, id)); var rows = MailMessageList.Resolve(keys, VisibleMessages.Concat(Messages), row => row.Key); if (rows.Count > 0) RemoveRows(rows); } private async Task ApplyFlagsAsync( MailFlagUpdate update, Action? apply, IReadOnlyList? rows = null) { var targets = rows ?? TargetRows(); if (targets.Count == 0) return false; var ok = false; await RunAsync(Copy.UpdatingFlags, async token => { foreach (var group in targets.GroupBy(row => MailMessageKey.Of(row.MailboxId, row.Header.Folder, 0))) { var box = MailboxById(group.Key.MailboxId) ?? SelectedMailbox; var session = SessionFor(box); if (session is not { IsConnected: true, SupportsFolders: true }) { Status = Copy.Connecting; return; } var folder = group.Key.Folder; var groupRows = group.ToList(); var result = await session.SetMessageFlagsAsync( folder, TargetIds(groupRows), update, token); if (!result.IsSuccess) { Status = string.Join(" ", result.Messages); return; } if (update.Seen is { } seen && SelectedFolder is { } current && current.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase)) ApplyUnreadDelta(current, groupRows, seen); if (apply is not null) { foreach (var row in groupRows) { apply(row); row.RefreshMarks(); } if (box is not null) _archive.UpdateFlags( box.Id, folder, groupRows.Select(r => (r.Header.Id, r.Header.IsSeen, r.Header.IsFlagged))); } } NotifyFolderCounts(); RefreshFolderStats(); Status = "Updated."; ok = true; }); return ok; } private void RemoveRows(IReadOnlyList rows) { ApplyRemovedCounts(rows); var drop = rows.Select(row => row.Key).ToHashSet(); for (var i = Messages.Count - 1; i >= 0; i--) { if (drop.Contains(Messages[i].Key)) Messages.RemoveAt(i); } for (var i = VisibleMessages.Count - 1; i >= 0; i--) { if (drop.Contains(VisibleMessages[i].Key)) VisibleMessages.RemoveAt(i); } SelectedMessages.Clear(); _selectedKeys.Clear(); SelectedMessage = null; NotifyMessageCommands(); RestoreMessageGridSelection(); RefreshFolderStats(); } private void ApplyOpenedFolderCounts() { if (SelectedFolder is null) return; var unread = Messages.Count(m => !m.Header.IsSeen); SelectedFolder.Total = Messages.Count; SelectedFolder.Unread = unread; NotifyFolderCounts(); } private void ApplyUnreadDelta(FolderRowViewModel folder, IReadOnlyList rows, bool seen) { var change = seen ? rows.Count(r => !r.Header.IsSeen) : rows.Count(r => r.Header.IsSeen); folder.Unread = Math.Max(0, folder.Unread + (seen ? -change : change)); } private void ApplyRemovedCounts(IReadOnlyList rows) { if (SelectedFolder is not { } folder) return; folder.Total = Math.Max(0, folder.Total - rows.Count); folder.Unread = Math.Max(0, folder.Unread - rows.Count(r => !r.Header.IsSeen)); NotifyFolderCounts(); } private void ApplyFolderMoveCounts( MailboxAccount sourceBox, MailboxAccount destBox, string source, string destFolder, int count, IReadOnlyList? rows) { var unread = rows?.Count(r => !r.Header.IsSeen) ?? 0; var from = FolderRow(sourceBox, source); var dest = FolderRow(destBox, destFolder); if (from is not null) { from.Total = Math.Max(0, from.Total - count); from.Unread = Math.Max(0, from.Unread - unread); } if (dest is not null) { dest.Total += count; dest.Unread += unread; } NotifyFolderCounts(); } private FolderRowViewModel? FolderRow(MailboxAccount? box, string fullName) => FoldersFor(box).FirstOrDefault(f => f.FullName.Equals(fullName, StringComparison.OrdinalIgnoreCase)); private void NotifyFolderCounts() { foreach (var account in FolderTree) ApplyRetentionBadge(account); } public void RefreshRetentionBadges() => NotifyFolderCounts(); private void ApplyRetentionBadge(FolderNodeViewModel node) { if (node.Mailbox is { } box && node.Folder is { } folder) node.RetentionDays = MailRetention.DaysFor(box.Id, folder.FullName, _files.Current.Retention); else node.RetentionDays = 0; node.NotifyCounts(); foreach (var child in node.Children) ApplyRetentionBadge(child); } private void RefreshFolderStats() { if (SelectedFolder is null) { FolderStats = ""; return; } FolderStats = MailFolderStats.Line( SelectedFolder.Name, SelectedFolder.Total, SelectedFolder.Unread, VisibleMessages.Count); } private static IReadOnlyList Recipients(string? text) => RecipientParser.Parse(text); private static List DistinctRecipients(IEnumerable rows, string self) => rows .Where(r => r.IsValid && !r.Address.Equals(self, StringComparison.OrdinalIgnoreCase)) .DistinctBy(r => r.Address, StringComparer.OrdinalIgnoreCase) .ToList(); private static string JoinRecipients(IEnumerable rows) => string.Join(", ", rows.Select(r => r.Tooltip)); private void KickFolderLoad(string folder) { if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(() => KickFolderLoad(folder)); return; } _work?.Cancel(); _folderLoad?.Cancel(); _folderLoad?.Dispose(); var cts = new CancellationTokenSource(); _folderLoad = cts; Status = Copy.OpeningFolder; _ = OpenFolderAsync(folder, cts.Token); } private async Task OpenFolderAsync(string folder, CancellationToken token) { try { await LoadMessagesAsync(folder, token).ConfigureAwait(false); } catch (OperationCanceledException) { } } private void PauseIndexing() => Interlocked.Increment(ref _indexPause); private void ResumeIndexing() => Interlocked.Decrement(ref _indexPause); private async Task WaitIfIndexingPausedAsync(CancellationToken token) { while (Volatile.Read(ref _indexPause) > 0) { token.ThrowIfCancellationRequested(); await Task.Delay(40, token).ConfigureAwait(false); } } private static string CatalogKey(string mailboxId, string folder) => mailboxId + "\n" + folder; private bool IsInitialSyncComplete(string mailboxId) { var box = MailboxById(mailboxId); return box?.InitialSyncCompleted == true || _catalogDone.Contains(mailboxId); } private void MarkExistingSyncComplete() { var changed = false; foreach (var box in _files.Current.Mailboxes) { if (box.InitialSyncCompleted) continue; if (_archive.MessageCount(box.Id) <= 0) continue; box.InitialSyncCompleted = true; changed = true; } foreach (var box in _files.Current.Mailboxes) _archive.SetKeywordIndexEnabled(box.Id, box.InitialSyncCompleted); if (changed) _files.Save(_files.Current); } private void MarkFolderCataloged(MailboxAccount box, string folder, IMailSession? session) { _catalogFolders.Add(CatalogKey(box.Id, folder)); if (session is not { SupportsFolders: true } || AllFoldersCataloged(box)) TryCompleteInitialSync(box); } private bool AllFoldersCataloged(MailboxAccount box) { var folders = FoldersFor(box); if (folders.Count == 0) return false; return folders.All(folder => _catalogFolders.Contains(CatalogKey(box.Id, folder.FullName))); } private void TryCompleteInitialSync(MailboxAccount? box) { if (box is null || box.InitialSyncCompleted) return; if (FoldersFor(box).Count > 0 && !AllFoldersCataloged(box)) return; box.InitialSyncCompleted = true; _catalogDone.Add(box.Id); var stored = _files.Current.FindMailbox(box.Id); if (stored is not null) stored.InitialSyncCompleted = true; _files.Save(_files.Current); _archive.SetKeywordIndexEnabled(box.Id, true); _archive.RebuildKeywordIndex(box.Id); _semantic.NotifySettingsChanged(); } private async Task UiAsync(Action work) { if (Dispatcher.UIThread.CheckAccess()) work(); else await Dispatcher.UIThread.InvokeAsync(work); await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); } private async Task RunAsync(string busy, Func work) { _work?.Cancel(); _work?.Dispose(); _work = new CancellationTokenSource(); IsBusy = true; ConnectCommand.NotifyCanExecuteChanged(); GetMessagesCommand.NotifyCanExecuteChanged(); SignInGoogleCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged(); NotifyMessageCommands(); Status = busy; try { await work(_work.Token); } catch (OperationCanceledException) { Status = Copy.Cancelled; } finally { IsBusy = false; ConnectCommand.NotifyCanExecuteChanged(); GetMessagesCommand.NotifyCanExecuteChanged(); SignInGoogleCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged(); NotifyMessageCommands(); } } } public sealed class PromptRequest { public required string Title { get; init; } public required string Message { get; init; } public string Placeholder { get; init; } = ""; public bool Password { get; init; } public bool RepeatPassword { get; init; } public string RepeatMessage { get; init; } = ""; public string RequiredMessage { get; init; } = ""; public string MismatchMessage { get; init; } = ""; public bool ConfirmOnly { get; init; } public string? ConfirmLabel { get; init; } }