diff --git a/CHANGELOG.md b/CHANGELOG.md index 9574093..259cc66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic ## [Unreleased] +## [0.4.8] - 2026-11-05 + +Sidebar **All emails** combines every Inbox. Sharing an account moves its archive and sign-in secrets with the setting. IMAP no longer stalls mid-folder after a partial FETCH. + +### Changed +- Account setting **Share this account with other users on this PC** moves the mailbox archive and its sign-in secrets: on -> `ProgramData` shared folder (+ `service-secrets.bin`), off -> this user's account folder and `secrets.bin` (replaces a stale archive copy at the destination). +- Folder sidebar starts with **All emails** (combined Inboxes from every account) above the account list. + +### Fixed +- Identity Hub sign-in window: **Back** returns from Privacy/Terms (WebView history, or reload of the desktop-login URL). +- IMAP folder sync no longer marks a label done after a partial FETCH: empty/partial pages retry (then skip after a few misses), and when SEARCH ALL returns fewer UIDs than EXISTS the plan is filled from sequence UIDs (Ebay-style 15-of-29 stalls). + +## [0.4.7] - 2026-11-04 + +### Changed +- Hub sign-in window title/status use **MaksIT Postclient** / “Waiting for sign-in…”, not “Identity Hub”. + ## [0.4.6] - 2026-11-03 Folder sync keeps paging older IMAP mail after the newest page is already archived. Identity Hub desktop login passes the covered app id. diff --git a/README.md b/README.md index d938263..28767c9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Postclient -![Line Coverage](https://img.shields.io/badge/Line%20Coverage-60.4%25-green) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-46.3%25-yellowgreen) -![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.1%25-green) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-60.5%25-green) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-46.7%25-yellowgreen) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.9%25-green) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) [![License](https://img.shields.io/badge/License-Apache%202.0-blue)](LICENSE.md) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-0078D6) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 16fb61b..5d883b5 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.4.6 + 0.4.8 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs index 94f4f3b..085148e 100644 --- a/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.PostClient.Client/Extensions/ServiceCollectionExtensions.cs @@ -12,7 +12,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddPostClient(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); services.AddIdentityHubIdpClient(IdentityHubAddress.Origin()); - services.AddSingleton(); + services.AddSingleton(_ => new LayeredSecretStore( + new FileSecretStore(), + new SharedSecretStore())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs b/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs index a27385c..460331a 100644 --- a/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs +++ b/src/MaksIT.PostClient.Client/Mail/ImapMailSession.cs @@ -19,6 +19,7 @@ public sealed class ImapMailSession : IMailSession { private MailAuthMaterial? _material; private CancellationTokenSource? _alive; private readonly Dictionary _listOffset = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _listMisses = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _listUids = new(StringComparer.OrdinalIgnoreCase); private const MessageSummaryItems FlagItems = @@ -230,18 +231,21 @@ public sealed class ImapMailSession : IMailSession { CancellationToken cancellationToken) { List? plan; int offset; + int misses; lock (_gate) { _listUids.TryGetValue(folder, out plan); _listOffset.TryGetValue(folder, out offset); + _listMisses.TryGetValue(folder, out misses); } if (plan is null) { - var searched = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false); - plan = searched.OrderByDescending(uid => uid.Id).ToList(); + plan = await BuildUidPlanAsync(imapFolder, cancellationToken).ConfigureAwait(false); lock (_gate) { _listUids[folder] = plan; _listOffset[folder] = 0; + _listMisses[folder] = 0; offset = 0; + misses = 0; } } @@ -255,28 +259,71 @@ public sealed class ImapMailSession : IMailSession { var page = plan.GetRange(range.Start, range.Take); var chunkRows = await FetchByUidsAsync(imapFolder, page, extraHeaders, cancellationToken) .ConfigureAwait(false); - - // Empty FETCH on a non-empty UID page is usually transient - retry the same page. - if (page.Count > 0 && chunkRows.Count == 0) - return Result.Ok(new MailFolderSync { Incomplete = true }); + var fetched = IdsOf(chunkRows).ToHashSet(); + // Count only UIDs from this page so DistinctUids cannot inflate progress. + var fetchedInPage = page.Count(uid => fetched.Contains(uid.Id)); + var (nextOffset, nextMisses, incomplete) = MailListBudget.AdvanceUidPage( + plan.Count, + offset, + range.Take, + fetchedInPage, + misses); lock (_gate) { - if (range.Incomplete) - _listOffset[folder] = range.NextOffset; + if (incomplete) { + _listOffset[folder] = nextOffset; + _listMisses[folder] = nextMisses; + } else ClearListCursor(folder); } return Result.Ok(new MailFolderSync { Headers = HeadersOf(folder, chunkRows), - Present = range.Incomplete ? null : plan.Select(uid => uid.Id).ToList(), - Incomplete = range.Incomplete + Present = incomplete ? null : plan.Select(uid => uid.Id).ToList(), + Incomplete = incomplete }); } + private static async Task> BuildUidPlanAsync( + IMailFolder imapFolder, + CancellationToken cancellationToken) { + var searched = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false); + var plan = searched.OrderByDescending(uid => uid.Id).ToList(); + // Gmail labels sometimes report EXISTS higher than SEARCH ALL. Fill gaps from sequence UIDs. + if (plan.Count >= imapFolder.Count || imapFolder.Count <= 0) + return plan; + + try { + var rows = await FetchRangeAsync( + imapFolder, + 0, + -1, + MessageSummaryItems.UniqueId, + extraHeaders: null, + cancellationToken).ConfigureAwait(false); + var seen = plan.Select(uid => uid.Id).ToHashSet(); + foreach (var row in rows) { + if (!row.UniqueId.IsValid || !seen.Add(row.UniqueId.Id)) + continue; + plan.Add(row.UniqueId); + } + + plan = plan.OrderByDescending(uid => uid.Id).ToList(); + } + catch (OperationCanceledException) { + throw; + } + catch { + } + + return plan; + } + private void ClearListCursor(string folder) { lock (_gate) { _listOffset.Remove(folder); + _listMisses.Remove(folder); _listUids.Remove(folder); } } @@ -915,6 +962,7 @@ public sealed class ImapMailSession : IMailSession { client = _imap; _imap = null; _listOffset.Clear(); + _listMisses.Clear(); _listUids.Clear(); } diff --git a/src/MaksIT.PostClient.Shared/Archive/SharedMailboxStore.cs b/src/MaksIT.PostClient.Shared/Archive/SharedMailboxStore.cs index 98047e5..ac92a3e 100644 --- a/src/MaksIT.PostClient.Shared/Archive/SharedMailboxStore.cs +++ b/src/MaksIT.PostClient.Shared/Archive/SharedMailboxStore.cs @@ -6,8 +6,9 @@ namespace MaksIT.PostClient.Shared.Archive; /// -/// Moves one opted-in mailbox between the user profile and the shared folder. -/// The account file has no password. Each user keeps their own secret. +/// Moves one mailbox between the user profile and the shared folder when Shared toggles. +/// Shared=true publishes under ProgramData; Shared=false keeps mail under the user profile. +/// Mailbox password and OAuth tokens move with the share flag via SharedSecretStore. /// public static class SharedMailboxStore { private static readonly JsonSerializerOptions Json = new() { @@ -56,16 +57,33 @@ public static class SharedMailboxStore { ArgumentNullException.ThrowIfNull(account); if (account.IsLocalStore) return Result.BadRequest("A local store keeps the folder you chose."); + if (string.IsNullOrWhiteSpace(privateRoot)) + return Result.BadRequest("A private account folder is required."); + account.Shared = shared; + account.ArchiveStoreId = ""; var central = SharedMailPaths.AccountDirectory(account.Id); - if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) { - var moved = Move(privateRoot, central); + if (shared) { + if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) { + var moved = Move(privateRoot, central, replaceDest: true); + if (!moved.IsSuccess) + return moved; + } + + return Publish(account); + } + + if (!central.Equals(privateRoot, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(central) + && Directory.GetFileSystemEntries(central).Length > 0) { + var moved = Move(central, privateRoot, replaceDest: true); if (!moved.IsSuccess) return moved; } - account.ArchiveStoreId = ""; - return Publish(account); + DropLocalAccountFile(privateRoot); + RemovePublished(account.Id); + return Result.Ok(); } public static Result Publish(MailboxAccount account) { @@ -90,6 +108,16 @@ public static class SharedMailboxStore { } } + private static void DropLocalAccountFile(string privateRoot) { + try { + var file = Path.Combine(privateRoot, "account.json"); + if (File.Exists(file)) + File.Delete(file); + } + catch { + } + } + private static void RemovePublished(string mailboxId) { var file = SharedMailPaths.AccountFile(mailboxId); if (File.Exists(file)) @@ -99,23 +127,31 @@ public static class SharedMailboxStore { Directory.Delete(dir); } - private static Result Move(string source, string dest) { + private static Result Move(string source, string dest, bool replaceDest) { try { + if (source.Equals(dest, StringComparison.OrdinalIgnoreCase)) + return Result.Ok(); if (!Directory.Exists(source)) { Directory.CreateDirectory(dest); - ShareWithOtherUsers(dest); + if (IsSharedRoot(dest)) + ShareWithOtherUsers(dest); return Result.Ok(); } - if (Directory.Exists(dest) && Directory.GetFileSystemEntries(dest).Length > 0) - return Result.BadRequest("The shared folder already has mail for this account."); + if (Directory.Exists(dest) && Directory.GetFileSystemEntries(dest).Length > 0) { + if (!replaceDest) + return Result.BadRequest("The destination folder already has mail for this account."); + Directory.Delete(dest, recursive: true); + } + var parent = Path.GetDirectoryName(dest); if (!string.IsNullOrWhiteSpace(parent)) Directory.CreateDirectory(parent); if (Directory.Exists(dest)) Directory.Delete(dest); Directory.Move(source, dest); - ShareWithOtherUsers(dest); + if (IsSharedRoot(dest)) + ShareWithOtherUsers(dest); return Result.Ok(); } catch (Exception ex) { @@ -123,6 +159,11 @@ public static class SharedMailboxStore { } } + private static bool IsSharedRoot(string directory) { + var root = SharedMailPaths.Root(); + return directory.StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + private static void ShareWithOtherUsers(string directory) { if (!Directory.Exists(directory)) return; diff --git a/src/MaksIT.PostClient.Shared/Auth/LayeredSecretStore.cs b/src/MaksIT.PostClient.Shared/Auth/LayeredSecretStore.cs new file mode 100644 index 0000000..98d2822 --- /dev/null +++ b/src/MaksIT.PostClient.Shared/Auth/LayeredSecretStore.cs @@ -0,0 +1,50 @@ +using MaksIT.Results; + + +namespace MaksIT.PostClient.Shared.Auth; + + +/// +/// User profile secrets first; falls back to the machine shared store for published mailboxes. +/// Writes go to the user store; when a key is already published, the shared copy is refreshed too. +/// +public sealed class LayeredSecretStore : ISecretStore { + private readonly ISecretStore _user; + private readonly ISecretStore _shared; + + public LayeredSecretStore(ISecretStore user, ISecretStore shared) { + _user = user ?? throw new ArgumentNullException(nameof(user)); + _shared = shared ?? throw new ArgumentNullException(nameof(shared)); + } + + public Result Put(string key, string secret) { + var saved = _user.Put(key, secret); + if (!saved.IsSuccess) + return saved; + var published = _shared.Get(key); + if (published.IsSuccess && !string.IsNullOrWhiteSpace(published.Value)) + return _shared.Put(key, secret); + return saved; + } + + public Result Get(string key) { + var user = _user.Get(key); + if (user.IsSuccess && !string.IsNullOrWhiteSpace(user.Value)) + return user; + return _shared.Get(key); + } + + public Result Delete(string key) { + _user.Delete(key); + _shared.Delete(key); + return Result.Ok(); + } + + /// Moves mailbox password and OAuth tokens into the shared store. + public Result PublishMailbox(string mailboxId) => + SharedSecretStore.PublishMailbox(_user, mailboxId); + + /// Moves mailbox password and OAuth tokens back into the user store. + public Result WithdrawMailbox(string mailboxId) => + SharedSecretStore.WithdrawMailbox(_user, mailboxId); +} diff --git a/src/MaksIT.PostClient.Shared/Auth/SharedSecretStore.cs b/src/MaksIT.PostClient.Shared/Auth/SharedSecretStore.cs index 2277b21..2a7f3e3 100644 --- a/src/MaksIT.PostClient.Shared/Auth/SharedSecretStore.cs +++ b/src/MaksIT.PostClient.Shared/Auth/SharedSecretStore.cs @@ -8,9 +8,8 @@ namespace MaksIT.PostClient.Shared.Auth; /// -/// Secrets for mailboxes the machine service syncs. -/// Windows protects them with the machine key so LocalService can read them after logoff. -/// The file is not granted to every user. +/// Machine-scoped secrets for shared mailboxes and the sync service. +/// Windows protects them with the machine key so LocalService and other users on this PC can read them. /// public sealed class SharedSecretStore : ISecretStore { public static string FilePath() => @@ -31,9 +30,57 @@ public sealed class SharedSecretStore : ISecretStore { public Result Delete(string key) => _inner.Delete(key); + /// + /// Moves mailbox password and OAuth tokens from the user store into the shared store. + /// + public static Result PublishMailbox(ISecretStore user, string mailboxId) { + ArgumentNullException.ThrowIfNull(user); + if (string.IsNullOrWhiteSpace(mailboxId)) + return Result.BadRequest("Mailbox id is required."); + var shared = new SharedSecretStore(); + foreach (var key in MailboxKeys(mailboxId)) { + var stored = user.Get(key); + if (!stored.IsSuccess || string.IsNullOrWhiteSpace(stored.Value)) + continue; + var saved = shared.Put(key, stored.Value); + if (!saved.IsSuccess) + return saved; + user.Delete(key); + } + + return Result.Ok(); + } + + /// + /// Moves mailbox password and OAuth tokens from the shared store back into the user store. + /// + public static Result WithdrawMailbox(ISecretStore user, string mailboxId) { + ArgumentNullException.ThrowIfNull(user); + if (string.IsNullOrWhiteSpace(mailboxId)) + return Result.BadRequest("Mailbox id is required."); + var shared = new SharedSecretStore(); + foreach (var key in MailboxKeys(mailboxId)) { + var stored = shared.Get(key); + if (stored.IsSuccess && !string.IsNullOrWhiteSpace(stored.Value)) { + var saved = user.Put(key, stored.Value); + if (!saved.IsSuccess) + return saved; + } + + shared.Delete(key); + } + + return Result.Ok(); + } + public static void Protect(string path) => Harden(path); + private static IEnumerable MailboxKeys(string mailboxId) { + yield return FileSecretStore.MailboxKey(mailboxId); + yield return FileSecretStore.OAuthKey(mailboxId); + } + private static void Harden(string path) { try { if (!File.Exists(path)) @@ -48,6 +95,7 @@ public sealed class SharedSecretStore : ISecretStore { var info = new FileInfo(path); var security = info.GetAccessControl(); security.AddAccessRule(ServiceRule(FileSystemRights.Modify)); + security.AddAccessRule(UsersRule(FileSystemRights.ReadAndExecute)); info.SetAccessControl(security); } catch { @@ -61,4 +109,12 @@ public sealed class SharedSecretStore : ISecretStore { InheritanceFlags.None, PropagationFlags.None, AccessControlType.Allow); + + private static FileSystemAccessRule UsersRule(FileSystemRights rights) => + new( + new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null), + rights, + InheritanceFlags.None, + PropagationFlags.None, + AccessControlType.Allow); } diff --git a/src/MaksIT.PostClient.Shared/Localization/UiCopy.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.cs index 0bfb792..06cbb73 100644 --- a/src/MaksIT.PostClient.Shared/Localization/UiCopy.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.cs @@ -332,6 +332,8 @@ public sealed partial class UiCopy { public string Folders { get; init; } = ""; + public string AllEmails { get; init; } = ""; + public string SearchPlaceholder { get; init; } = ""; public string ClearSearch { get; init; } = ""; @@ -844,6 +846,7 @@ public sealed partial class UiCopy { Delete = "Delete", MoveTo = "Move To", Folders = "Folders", + AllEmails = "All emails", SearchPlaceholder = "Search subject, body, attachments, labels, meaning", ClearSearch = "Clear search", Label = "Label", @@ -883,7 +886,7 @@ public sealed partial class UiCopy { RemoveAccount = "Remove Account", AccountHint = "Gmail and Outlook use OAuth when you Sign in. Italian PEC and EU QTSP mailboxes fill IMAP/SMTP. Other accounts use IMAP/POP3 and a password (DPAPI on Windows, mode 600 on Linux).", ShareAccount = "Share this account with other users on this PC", - ShareAccountHint = "Other people on this PC can open this mailbox. The sync service already keeps every mailbox in {0}, with or without this box checked.", + ShareAccountHint = "Mail and sign-in secrets move to {0}. Other people on this PC can open this mailbox without signing in again.", DisplayName = "Display name", Address = "Address", Username = "Username (if different)", @@ -1150,6 +1153,7 @@ public sealed partial class UiCopy { Delete = "Elimina", MoveTo = "Sposta in", Folders = "Cartelle", + AllEmails = "Tutte le email", SearchPlaceholder = "Cerca oggetto, corpo, allegati, etichette, significato", ClearSearch = "Cancella ricerca", Label = "Etichetta", @@ -1189,7 +1193,7 @@ public sealed partial class UiCopy { RemoveAccount = "Rimuovi account", AccountHint = "Gmail e Outlook usano OAuth con Accedi. PEC italiane e caselle QTSP UE compilano IMAP/SMTP. Gli altri account usano IMAP/POP3 e una password (DPAPI su Windows, mode 600 su Linux).", ShareAccount = "Condividi questo account con gli altri utenti di questo PC", - ShareAccountHint = "Le altre persone su questo PC possono aprire questa casella. Il servizio di sync tiene già ogni casella in {0}, con o senza questa opzione.", + ShareAccountHint = "La posta e i segreti di accesso si spostano in {0}. Le altre persone su questo PC possono aprire questa casella senza accedere di nuovo.", DisplayName = "Nome visualizzato", Address = "Indirizzo", Username = "Nome utente (se diverso)", diff --git a/src/MaksIT.PostClient.Shared/Localization/UiCopy.de.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.de.cs index 542d0d9..8059749 100644 --- a/src/MaksIT.PostClient.Shared/Localization/UiCopy.de.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.de.cs @@ -170,6 +170,7 @@ public sealed partial class UiCopy { Delete = "Löschen", MoveTo = "Verschieben nach", Folders = "Ordner", + AllEmails = "Alle E-Mails", SearchPlaceholder = "Betreff, Text, Anlagen, Kennzeichen, Bedeutung suchen", ClearSearch = "Suche leeren", Label = "Kennzeichen", @@ -209,7 +210,7 @@ public sealed partial class UiCopy { RemoveAccount = "Konto entfernen", AccountHint = "Gmail und Microsoft 365 nutzen OAuth bei der Anmeldung. Italienische PEC- und EU-QTSP-Postfächer füllen IMAP/SMTP. Andere Konten nutzen IMAP/POP3 und ein Passwort (DPAPI unter Windows, Modus 600 unter Linux).", ShareAccount = "Dieses Konto mit anderen Benutzern dieses PCs teilen", - ShareAccountHint = "Die Post zieht nach {0}. Andere Konten auf diesem PC können sie öffnen. Das Passwort bleibt in Ihrer eigenen Geheimdatei; jede Person meldet sich selbst an.", + ShareAccountHint = "Post und Anmeldegeheimnisse ziehen nach {0}. Andere Konten auf diesem PC können das Postfach ohne erneute Anmeldung öffnen.", DisplayName = "Anzeigename", Address = "Adresse", Username = "Benutzername (falls abweichend)", diff --git a/src/MaksIT.PostClient.Shared/Localization/UiCopy.es.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.es.cs index cddedc6..97df898 100644 --- a/src/MaksIT.PostClient.Shared/Localization/UiCopy.es.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.es.cs @@ -170,6 +170,7 @@ public sealed partial class UiCopy { Delete = "Eliminar", MoveTo = "Mover a", Folders = "Carpetas", + AllEmails = "Todos los correos", SearchPlaceholder = "Buscar asunto, cuerpo, adjuntos, etiquetas, significado", ClearSearch = "Borrar búsqueda", Label = "Etiqueta", @@ -209,7 +210,7 @@ public sealed partial class UiCopy { RemoveAccount = "Quitar cuenta", AccountHint = "Gmail y Microsoft 365 usan OAuth al iniciar sesión. Los buzones PEC italianos y QTSP de la UE rellenan IMAP/SMTP. Las demás cuentas usan IMAP/POP3 y una contraseña (DPAPI en Windows, modo 600 en Linux).", ShareAccount = "Compartir esta cuenta con los demás usuarios de este PC", - ShareAccountHint = "El correo se mueve a {0}. Las demás cuentas de este PC pueden abrirlo. La contraseña sigue en su archivo secreto; cada persona inicia sesión.", + ShareAccountHint = "El correo y los secretos de acceso se mueven a {0}. Las demás cuentas de este PC pueden abrir el buzón sin volver a iniciar sesión.", DisplayName = "Nombre para mostrar", Address = "Dirección", Username = "Nombre de usuario (si es distinto)", diff --git a/src/MaksIT.PostClient.Shared/Localization/UiCopy.fr.cs b/src/MaksIT.PostClient.Shared/Localization/UiCopy.fr.cs index 5458217..30389c4 100644 --- a/src/MaksIT.PostClient.Shared/Localization/UiCopy.fr.cs +++ b/src/MaksIT.PostClient.Shared/Localization/UiCopy.fr.cs @@ -170,6 +170,7 @@ public sealed partial class UiCopy { Delete = "Supprimer", MoveTo = "Déplacer vers", Folders = "Dossiers", + AllEmails = "Tous les courriers", SearchPlaceholder = "Rechercher objet, corps, pièces, étiquettes, sens", ClearSearch = "Effacer la recherche", Label = "Étiquette", @@ -209,7 +210,7 @@ public sealed partial class UiCopy { RemoveAccount = "Supprimer le compte", AccountHint = "Gmail et Microsoft 365 utilisent OAuth lorsque vous vous connectez. Les boîtes PEC italiennes et QTSP UE remplissent IMAP/SMTP. Les autres comptes utilisent IMAP/POP3 et un mot de passe (DPAPI sous Windows, mode 600 sous Linux).", ShareAccount = "Partager ce compte avec les autres utilisateurs de ce PC", - ShareAccountHint = "Le courrier est déplacé vers {0}. Les autres comptes de ce PC peuvent l’ouvrir. Le mot de passe reste dans votre fichier secret ; chacun se connecte.", + ShareAccountHint = "Le courrier et les secrets de connexion sont déplacés vers {0}. Les autres comptes de ce PC peuvent ouvrir la boîte sans se reconnecter.", DisplayName = "Nom affiché", Address = "Adresse", Username = "Nom d’utilisateur (s’il est différent)", diff --git a/src/MaksIT.PostClient.Shared/Mail/MailListBudget.cs b/src/MaksIT.PostClient.Shared/Mail/MailListBudget.cs index 1643356..1595f89 100644 --- a/src/MaksIT.PostClient.Shared/Mail/MailListBudget.cs +++ b/src/MaksIT.PostClient.Shared/Mail/MailListBudget.cs @@ -26,4 +26,32 @@ public static class MailListBudget { var next = offset + take; return new Slice(offset, offset + take - 1, take, next, next < count); } + + /// + /// Advance a UID page after FETCH. Partial or empty pages stay on the same offset until + /// , then the page is skipped so sync cannot stick forever. + /// + public static (int NextOffset, int NextMissStreak, bool Incomplete) AdvanceUidPage( + int planCount, + int offset, + int take, + int fetchedInPage, + int missStreak, + int maxMisses = 3) { + if (take <= 0 || offset >= planCount) + return (Math.Min(offset, Math.Max(0, planCount)), 0, false); + + if (fetchedInPage >= take) { + var next = offset + take; + return (next, 0, next < planCount); + } + + var streak = missStreak + 1; + if (streak >= Math.Max(1, maxMisses)) { + var next = offset + take; + return (next, 0, next < planCount); + } + + return (offset, streak, true); + } } diff --git a/src/MaksIT.PostClient.Tests/App/SharedMailboxTests.cs b/src/MaksIT.PostClient.Tests/App/SharedMailboxTests.cs index 8c7b52f..8a55123 100644 --- a/src/MaksIT.PostClient.Tests/App/SharedMailboxTests.cs +++ b/src/MaksIT.PostClient.Tests/App/SharedMailboxTests.cs @@ -11,9 +11,14 @@ public class SharedMailboxTests { try { var box = new MailboxAccount { Id = "box1", Address = "shared@example.com" }; Assert.StartsWith(root, SharedMailPaths.AccountDirectory(box.Id), StringComparison.OrdinalIgnoreCase); - var placed = SharedMailboxStore.Place(box, shared: true, privateRoot: Path.Combine(root, "unused")); + var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id); + Directory.CreateDirectory(privateRoot); + File.WriteAllText(Path.Combine(privateRoot, "mail.db"), "x"); + var placed = SharedMailboxStore.Place(box, shared: true, privateRoot); Assert.True(placed.IsSuccess); Assert.True(File.Exists(SharedMailPaths.AccountFile(box.Id))); + Assert.True(File.Exists(Path.Combine(SharedMailPaths.AccountDirectory(box.Id), "mail.db"))); + Assert.False(Directory.Exists(privateRoot)); var configuration = new Configuration(); Assert.True(SharedMailboxStore.MergeMissing(configuration)); Assert.Equal("box1", configuration.Mailboxes[0].Id); @@ -39,17 +44,24 @@ public class SharedMailboxTests { } [Fact] - public void EveryEnrolledAccountIsListed_OnlySharedOnesAreImported() { + public void PrivatePlaceDoesNotPublish_OnlySharedOnesAreImported() { var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env); var root = Path.Combine(Path.GetTempPath(), "postclient-central-" + Guid.NewGuid().ToString("N")); Environment.SetEnvironmentVariable(SharedMailPaths.Env, root); try { var privateBox = new MailboxAccount { Id = "mine", Address = "mine@example.com" }; var sharedBox = new MailboxAccount { Id = "ours", Address = "ours@example.com" }; - Assert.True(SharedMailboxStore.Place(privateBox, shared: false, Path.Combine(root, "a")).IsSuccess); - Assert.True(SharedMailboxStore.Place(sharedBox, shared: true, Path.Combine(root, "b")).IsSuccess); + var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), privateBox.Id); + Directory.CreateDirectory(privateRoot); + Assert.True(SharedMailboxStore.Place(privateBox, shared: false, privateRoot).IsSuccess); + Assert.False(File.Exists(SharedMailPaths.AccountFile(privateBox.Id))); + Assert.True(SharedMailboxStore.Place( + sharedBox, + shared: true, + Path.Combine(AppPaths.AccountsDirectory(), sharedBox.Id)).IsSuccess); var listed = SharedMailboxStore.List(); - Assert.Equal(2, listed.Count); + Assert.Single(listed); + Assert.Equal("ours", listed[0].Id); var configuration = new Configuration(); Assert.True(SharedMailboxStore.MergeMissing(configuration)); Assert.Single(configuration.Mailboxes); @@ -61,4 +73,35 @@ public class SharedMailboxTests { Directory.Delete(root, recursive: true); } } + + [Fact] + public void TogglingSharedMovesTheArchive() { + var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env); + var root = Path.Combine(Path.GetTempPath(), "postclient-toggle-" + Guid.NewGuid().ToString("N")); + Environment.SetEnvironmentVariable(SharedMailPaths.Env, root); + var box = new MailboxAccount { Id = "toggle", Address = "toggle@example.com" }; + var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id); + try { + if (Directory.Exists(privateRoot)) + Directory.Delete(privateRoot, recursive: true); + Directory.CreateDirectory(privateRoot); + File.WriteAllText(Path.Combine(privateRoot, "mail.db"), "live"); + Assert.True(SharedMailboxStore.Place(box, shared: true, privateRoot).IsSuccess); + var sharedDb = Path.Combine(SharedMailPaths.AccountDirectory(box.Id), "mail.db"); + Assert.Equal("live", File.ReadAllText(sharedDb)); + Assert.False(Directory.Exists(privateRoot)); + + Assert.True(SharedMailboxStore.Place(box, shared: false, privateRoot).IsSuccess); + Assert.False(File.Exists(SharedMailPaths.AccountFile(box.Id))); + Assert.Equal("live", File.ReadAllText(Path.Combine(privateRoot, "mail.db"))); + Assert.Equal(privateRoot, MailArchiveLayout.MailRoot(box, [box])); + } + finally { + Environment.SetEnvironmentVariable(SharedMailPaths.Env, previous); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + if (Directory.Exists(privateRoot)) + Directory.Delete(privateRoot, recursive: true); + } + } } diff --git a/src/MaksIT.PostClient.Tests/App/SharedTests.cs b/src/MaksIT.PostClient.Tests/App/SharedTests.cs index 0398bb1..f071e99 100644 --- a/src/MaksIT.PostClient.Tests/App/SharedTests.cs +++ b/src/MaksIT.PostClient.Tests/App/SharedTests.cs @@ -598,6 +598,24 @@ public class MailListBudgetTests { Assert.Equal(999, last.End); Assert.False(last.Incomplete); } + + [Fact] + public void AdvanceUidPage_RetriesPartialFetchUntilMissLimit() { + var retry = MailListBudget.AdvanceUidPage(29, 0, 29, fetchedInPage: 15, missStreak: 0); + Assert.Equal(0, retry.NextOffset); + Assert.Equal(1, retry.NextMissStreak); + Assert.True(retry.Incomplete); + + var skip = MailListBudget.AdvanceUidPage(29, 0, 29, fetchedInPage: 15, missStreak: 2); + Assert.Equal(29, skip.NextOffset); + Assert.Equal(0, skip.NextMissStreak); + Assert.False(skip.Incomplete); + + var full = MailListBudget.AdvanceUidPage(29, 0, 29, fetchedInPage: 29, missStreak: 1); + Assert.Equal(29, full.NextOffset); + Assert.Equal(0, full.NextMissStreak); + Assert.False(full.Incomplete); + } } @@ -879,6 +897,63 @@ public class MailPriorityTests { } +[Collection(ProcessEnvironment.Name)] +public class LayeredSecretStoreTests { + [Fact] + public void GetFallsBackToShared_AndPublishMovesMailboxKeys() { + var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env); + var root = Path.Combine(Path.GetTempPath(), "postclient-layered-" + Guid.NewGuid().ToString("N")); + Environment.SetEnvironmentVariable(SharedMailPaths.Env, root); + var userPath = Path.Combine(Path.GetTempPath(), "postclient-user-secrets-" + Guid.NewGuid().ToString("N") + ".bin"); + try { + var user = new FileSecretStore(userPath); + var shared = new SharedSecretStore(); + var layered = new LayeredSecretStore(user, shared); + Assert.True(user.Put(FileSecretStore.MailboxKey("box"), "pw").IsSuccess); + Assert.True(user.Put(FileSecretStore.OAuthKey("box"), "tok").IsSuccess); + Assert.True(layered.PublishMailbox("box").IsSuccess); + Assert.Null(user.Get(FileSecretStore.MailboxKey("box")).Value); + Assert.Equal("pw", shared.Get(FileSecretStore.MailboxKey("box")).Value); + Assert.Equal("pw", layered.Get(FileSecretStore.MailboxKey("box")).Value); + Assert.True(layered.WithdrawMailbox("box").IsSuccess); + Assert.Equal("pw", user.Get(FileSecretStore.MailboxKey("box")).Value); + Assert.Null(shared.Get(FileSecretStore.MailboxKey("box")).Value); + } + finally { + Environment.SetEnvironmentVariable(SharedMailPaths.Env, previous); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + if (File.Exists(userPath)) + File.Delete(userPath); + } + } + + [Fact] + public void PutRefreshesPublishedSharedCopy() { + var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env); + var root = Path.Combine(Path.GetTempPath(), "postclient-layered-put-" + Guid.NewGuid().ToString("N")); + Environment.SetEnvironmentVariable(SharedMailPaths.Env, root); + var userPath = Path.Combine(Path.GetTempPath(), "postclient-user-secrets-put-" + Guid.NewGuid().ToString("N") + ".bin"); + try { + var user = new FileSecretStore(userPath); + var shared = new SharedSecretStore(); + var layered = new LayeredSecretStore(user, shared); + Assert.True(shared.Put(FileSecretStore.MailboxKey("box"), "old").IsSuccess); + Assert.True(layered.Put(FileSecretStore.MailboxKey("box"), "new").IsSuccess); + Assert.Equal("new", shared.Get(FileSecretStore.MailboxKey("box")).Value); + Assert.Equal("new", user.Get(FileSecretStore.MailboxKey("box")).Value); + } + finally { + Environment.SetEnvironmentVariable(SharedMailPaths.Env, previous); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + if (File.Exists(userPath)) + File.Delete(userPath); + } + } +} + + public class FileSecretStoreTests { [Fact] public void PutGetRoundTrip() { diff --git a/src/MaksIT.PostClient.UI/MainWindow.axaml.cs b/src/MaksIT.PostClient.UI/MainWindow.axaml.cs index 1a96a57..811f1ae 100644 --- a/src/MaksIT.PostClient.UI/MainWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/MainWindow.axaml.cs @@ -281,7 +281,7 @@ public partial class MainWindow : Window { }; var json = await window.ShowDialog(_accountWindow ?? this); if (string.IsNullOrWhiteSpace(json)) - vm.CancelIdentityHubSignIn("Identity Hub sign-in cancelled."); + vm.CancelIdentityHubSignIn("Sign-in cancelled."); else await vm.CompleteIdentityHubSignIn(kind, json); } diff --git a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs index acd0610..859c3b4 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs @@ -1041,7 +1041,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } if (!box.IsLocalStore) - CopySecretsToService(box.Id); + SyncMailboxSecrets(box.Id, box.Shared); ReloadMailboxes(); SelectedMailbox = Mailboxes.FirstOrDefault(m => m.Id == box.Id); Status = "Mailbox saved. Secrets stay in the OS secret file, not settings.json."; @@ -1057,17 +1057,19 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private bool ApplySharedLocation(Configuration configuration, MailboxAccount box) { if (box.IsLocalStore) return true; - var privateRoot = File.Exists(SharedMailPaths.AccountFile(box.Id)) - ? SharedMailPaths.AccountDirectory(box.Id) + var central = SharedMailPaths.AccountDirectory(box.Id); + var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id); + var current = File.Exists(SharedMailPaths.AccountFile(box.Id)) + ? central : MailArchiveLayout.MailRoot(box, configuration.Mailboxes); - var moving = !privateRoot.Equals( - SharedMailPaths.AccountDirectory(box.Id), - StringComparison.OrdinalIgnoreCase); + var desired = EditorShareAccount ? central : privateRoot; + var placeRoot = EditorShareAccount ? current : privateRoot; + var moving = !current.Equals(desired, StringComparison.OrdinalIgnoreCase); if (!moving) - return PlaceShared(configuration, box, privateRoot, reopen: _archive.TryGet(box.Id) is null); + return PlaceShared(configuration, box, placeRoot, reopen: _archive.TryGet(box.Id) is null); return HoldArchive(() => { _archive.Close(box.Id); - return PlaceShared(configuration, box, privateRoot, reopen: true); + return PlaceShared(configuration, box, placeRoot, reopen: true); }); } @@ -1111,26 +1113,35 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { foreach (var box in configuration.Mailboxes) { if (box.IsLocalStore) continue; - var root = File.Exists(SharedMailPaths.AccountFile(box.Id)) - ? SharedMailPaths.AccountDirectory(box.Id) + var central = SharedMailPaths.AccountDirectory(box.Id); + var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id); + var current = File.Exists(SharedMailPaths.AccountFile(box.Id)) + ? central : MailArchiveLayout.MailRoot(box, configuration.Mailboxes); - if (!SharedMailboxStore.Place(box, box.Shared, root).IsSuccess) + var placeRoot = box.Shared ? current : privateRoot; + if (!SharedMailboxStore.Place(box, box.Shared, placeRoot).IsSuccess) continue; - CopySecretsToService(box.Id); + SyncMailboxSecrets(box.Id, box.Shared); } } - private void CopySecretsToService(string mailboxId) { - var service = new SharedSecretStore(); - CopySecret(service, FileSecretStore.MailboxKey(mailboxId)); - CopySecret(service, FileSecretStore.OAuthKey(mailboxId)); + private void SyncMailboxSecrets(string mailboxId, bool shared) { + var moved = shared + ? PublishMailboxSecrets(mailboxId) + : WithdrawMailboxSecrets(mailboxId); + if (!moved.IsSuccess) + Status = string.Join(" ", moved.Messages); } - private void CopySecret(SharedSecretStore service, string key) { - var stored = _secrets.Get(key); - if (stored.IsSuccess && !string.IsNullOrWhiteSpace(stored.Value)) - service.Put(key, stored.Value); - } + private Result PublishMailboxSecrets(string mailboxId) => + _secrets is LayeredSecretStore layered + ? layered.PublishMailbox(mailboxId) + : SharedSecretStore.PublishMailbox(_secrets, mailboxId); + + private Result WithdrawMailboxSecrets(string mailboxId) => + _secrets is LayeredSecretStore layered + ? layered.WithdrawMailbox(mailboxId) + : SharedSecretStore.WithdrawMailbox(_secrets, mailboxId); [RelayCommand] private async Task RemoveMailboxAsync() { @@ -2845,6 +2856,15 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { partial void OnSelectedFolderNodeChanged(FolderNodeViewModel? value) { if (_syncingTree || value is null) return; + if (value.IsAllEmails) { + if (value.Folder is { } all) + SelectedFolder = all; + OnPropertyChanged(nameof(ShowFolderContext)); + OnPropertyChanged(nameof(ShowDetachPst)); + DetachPstCommand.NotifyCanExecuteChanged(); + return; + } + if (value.Mailbox is { } box && SelectedMailbox?.Id != box.Id) SelectedMailbox = box; if (value.Folder is { } folder) @@ -2857,7 +2877,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } public bool ShowFolderContext => - TargetFolderNodes().Count > 0; + SelectedFolderNode is not { IsAllEmails: true } + && TargetFolderNodes().Count > 0; public bool ShowDetachPst => !IsBusy @@ -2877,6 +2898,18 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { NotifyFolderCommands(); if (value is null || _syncingTree || _suppressFolderLoad) return; + if (IsAllEmailsFolder(value)) { + if (SelectedMessage is not null) { + SelectedMessages.Clear(); + _selectedKeys.Clear(); + SelectedMessage = null; + } + + ClearReading(); + KickAllEmailsLoad(); + return; + } + if (MailFolderRole.IsNamespace(value.Name, value.FullName)) { _folderLoad?.Cancel(); _work?.Cancel(); @@ -2965,7 +2998,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { IsBusy = true; SignInGoogleCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged(); - Status = "Waiting for Identity Hub…"; + Status = "Waiting for sign-in…"; IdentityHubSignInRequested?.Invoke(kind); await Task.CompletedTask; } @@ -3188,11 +3221,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private void RebuildFolderTree() { _syncingTree = true; + EnsureAllEmailsNode(); 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; + var index = 1; foreach (var box in Mailboxes) { keep.Add(box.Id); if (!byId.TryGetValue(box.Id, out var node)) { @@ -3212,23 +3246,76 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } for (var i = FolderTree.Count - 1; i >= 0; i--) { - var id = FolderTree[i].Mailbox?.Id; + var node = FolderTree[i]; + if (node.IsAllEmails) + continue; + var id = node.Mailbox?.Id; if (id is null || !keep.Contains(id)) FolderTree.RemoveAt(i); } + RefreshAllEmailsCounts(); var selected = SnapshotFolderSelection(); - var selectedBoxId = SelectedFolderNode?.Mailbox?.Id ?? SelectedMailbox?.Id; - var selectedFolder = SelectedFolderNode?.Folder?.FullName ?? SelectedFolder?.FullName; + var selectedBoxId = SelectedFolderNode?.IsAllEmails == true + ? FolderNodeViewModel.AllEmailsId + : SelectedFolderNode?.Mailbox?.Id ?? SelectedMailbox?.Id; + var selectedFolder = SelectedFolderNode?.IsAllEmails == true + ? FolderNodeViewModel.AllEmailsId + : 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); + ?? FolderTree.FirstOrDefault(n => n.Mailbox?.Id == SelectedMailbox?.Id) + ?? AllEmailsNode(); RemapFolderSelection(selected); _syncingTree = false; RestoreFolderTreeSelection(); } + private void EnsureAllEmailsNode() { + var existing = AllEmailsNode(); + if (existing is null) { + var folder = new FolderRowViewModel { + FullName = FolderNodeViewModel.AllEmailsId, + Name = Copy.AllEmails + }; + FolderTree.Insert(0, FolderNodeViewModel.AllEmails(folder)); + return; + } + + if (!ReferenceEquals(FolderTree[0], existing)) { + FolderTree.Remove(existing); + FolderTree.Insert(0, existing); + } + + if (existing.Folder is { } row) + row.Name = Copy.AllEmails; + existing.NotifyCounts(); + } + + private FolderNodeViewModel? AllEmailsNode() => + FolderTree.FirstOrDefault(n => n.IsAllEmails); + + private void RefreshAllEmailsCounts() { + var node = AllEmailsNode(); + if (node?.Folder is null) + return; + var total = 0; + var unread = 0; + foreach (var box in Mailboxes) { + var inbox = InboxOf(box); + if (inbox is null) + continue; + total += inbox.Total; + unread += inbox.Unread; + } + + node.Folder.Total = total; + node.Folder.Unread = unread; + node.Folder.Name = Copy.AllEmails; + node.NotifyCounts(); + } + private List<(string MailboxId, string? Folder)> SnapshotFolderSelection() { var nodes = SelectedFolderNodes.Count > 0 ? SelectedFolderNodes @@ -3236,6 +3323,13 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { var keys = new List<(string MailboxId, string? Folder)>(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var node in nodes) { + if (node.IsAllEmails) { + if (!seen.Add(FolderNodeViewModel.AllEmailsId)) + continue; + keys.Add((FolderNodeViewModel.AllEmailsId, FolderNodeViewModel.AllEmailsId)); + continue; + } + var boxId = node.Mailbox?.Id; if (string.IsNullOrWhiteSpace(boxId)) continue; @@ -3314,6 +3408,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } private FolderNodeViewModel? FindTreeNode(string? mailboxId, string? folder) { + if (string.Equals(mailboxId, FolderNodeViewModel.AllEmailsId, StringComparison.OrdinalIgnoreCase) + || string.Equals(folder, FolderNodeViewModel.AllEmailsId, StringComparison.OrdinalIgnoreCase)) + return AllEmailsNode(); if (string.IsNullOrWhiteSpace(mailboxId)) return null; var account = FolderTree.FirstOrDefault(n => n.Mailbox?.Id == mailboxId); @@ -3326,6 +3423,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { ?? account; } + private static bool IsAllEmailsFolder(FolderRowViewModel? folder) => + folder?.FullName.Equals(FolderNodeViewModel.AllEmailsId, StringComparison.OrdinalIgnoreCase) == true; + private static FolderNodeViewModel? FindFolderNode(FolderNodeViewModel node, string folder) { if (node.Folder?.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase) == true) return node; @@ -5394,6 +5494,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { private void NotifyFolderCounts() { foreach (var account in FolderTree) ApplyRetentionBadge(account); + RefreshAllEmailsCounts(); } public void RefreshRetentionBadges() => @@ -5451,6 +5552,23 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { _ = OpenFolderAsync(folder, cts.Token); } + private void KickAllEmailsLoad() { + if (_screenshotSample) + return; + if (!Dispatcher.UIThread.CheckAccess()) { + Dispatcher.UIThread.Post(KickAllEmailsLoad); + return; + } + + _work?.Cancel(); + _folderLoad?.Cancel(); + _folderLoad?.Dispose(); + var cts = new CancellationTokenSource(); + _folderLoad = cts; + Status = Copy.OpeningFolder; + _ = OpenAllEmailsAsync(cts.Token); + } + private async Task OpenFolderAsync(string folder, CancellationToken token) { try { await LoadMessagesAsync(folder, token).ConfigureAwait(false); @@ -5462,6 +5580,92 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable { } } + private async Task OpenAllEmailsAsync(CancellationToken token) { + try { + await ShowAllEmailsArchiveAsync(token).ConfigureAwait(false); + foreach (var box in Mailboxes.ToList()) { + token.ThrowIfCancellationRequested(); + if (!IsAllEmailsFolder(SelectedFolder)) + return; + var inbox = InboxOf(box)?.FullName; + if (string.IsNullOrWhiteSpace(inbox)) + continue; + var session = SessionFor(box); + if (session is not { IsConnected: true }) + continue; + var known = await Task.Run(() => _archive.Uids(box.Id, inbox), token).ConfigureAwait(false); + var epoch = Volatile.Read(ref _mailEpoch); + var result = await session.ListMessagesAsync(inbox, known, token, IndexUiChunk).ConfigureAwait(false); + if (!result.IsSuccess) + continue; + token.ThrowIfCancellationRequested(); + var applyFlags = Volatile.Read(ref _mailEpoch) == epoch; + ApplyFolderSync(box.Id, inbox, result.Value ?? new MailFolderSync(), applyFlags); + await ShowAllEmailsArchiveAsync(token).ConfigureAwait(false); + } + + await UiAsync(() => { + if (IsAllEmailsFolder(SelectedFolder)) + Status = ArchiveFiles.Hint(); + }).ConfigureAwait(false); + } + catch (OperationCanceledException) { + } + catch (Exception ex) when (!token.IsCancellationRequested) { + ReportFolderFailure(ex); + } + } + + private async Task ShowAllEmailsArchiveAsync(CancellationToken token) { + var rows = await Task.Run(CollectAllInboxHeaders, token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + await UiAsync(() => { + if (!IsAllEmailsFolder(SelectedFolder)) + return; + BindArchiveAll(rows); + }).ConfigureAwait(false); + } + + private List CollectAllInboxHeaders() { + var rows = new List(); + foreach (var box in Mailboxes) { + var inbox = InboxOf(box)?.FullName; + if (string.IsNullOrWhiteSpace(inbox)) + continue; + foreach (var header in _archive.ListFolder(box.Id, inbox)) { + header.MailboxId = box.Id; + rows.Add(header); + } + } + + rows.Sort(static (a, b) => { + var byDate = b.Date.CompareTo(a.Date); + return byDate != 0 ? byDate : b.Uid.CompareTo(a.Uid); + }); + return rows; + } + + private void BindArchiveAll(IReadOnlyList rows) { + Messages.Clear(); + foreach (var row in rows) { + var header = MailArchiveMap.ToHeader(row); + ReconcileHeader(header, row.Folder); + Messages.Add(new MessageRowViewModel { + MailboxId = row.MailboxId, + Header = header + }); + } + + PaintSpam(Messages); + if (SelectedMessage is { } selected && !IsVisibleSelection(selected)) { + SelectedMessage = null; + ClearReading(); + } + + ApplyOpenedFolderCounts(); + RebuildVisible(); + } + private static void ReportFolderFailure(Exception ex) => Dispatcher.UIThread.Post(() => ErrorDialog.Report(ex)); diff --git a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs index afa788c..07e6834 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RowViewModels.cs @@ -114,8 +114,12 @@ public sealed partial class FolderRowViewModel : ObservableObject { public sealed partial class FolderNodeViewModel : ObservableObject { + public const string AllEmailsId = "__all_emails__"; + public bool IsAccount { get; init; } + public bool IsAllEmails { get; init; } + public MailboxAccount? Mailbox { get; init; } public FolderRowViewModel? Folder { get; init; } @@ -126,13 +130,25 @@ public sealed partial class FolderNodeViewModel : ObservableObject { public ObservableCollection Children { get; } = []; public string Title => - IsAccount ? Mailbox?.Label ?? UiLocale.Copy.Mailbox : Folder?.Name ?? ""; + IsAllEmails + ? UiLocale.Copy.AllEmails + : IsAccount + ? Mailbox?.Label ?? UiLocale.Copy.Mailbox + : Folder?.Name ?? ""; public string Glyph => - IsAccount ? "✉" : MailFolderRole.Glyph(Folder?.Name, Folder?.FullName); + IsAllEmails + ? "📥" + : IsAccount + ? "✉" + : MailFolderRole.Glyph(Folder?.Name, Folder?.FullName); public int Unread => - IsAccount ? ChildUnread() : Folder?.Unread ?? 0; + IsAllEmails + ? Folder?.Unread ?? 0 + : IsAccount + ? ChildUnread() + : Folder?.Unread ?? 0; private int ChildUnread() { var n = 0; @@ -192,6 +208,13 @@ public sealed partial class FolderNodeViewModel : ObservableObject { OnPropertyChanged(nameof(QuotaTip)); } + public static FolderNodeViewModel AllEmails(FolderRowViewModel folder) => + new() { + IsAllEmails = true, + Folder = folder, + IsExpanded = false + }; + public static FolderNodeViewModel Account(MailboxAccount box, bool expanded = true) => new() { IsAccount = true, diff --git a/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml b/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml index 41edcf1..873d175 100644 --- a/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml +++ b/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml @@ -1,7 +1,7 @@ - + +