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
-
-
-
+
+
+

[](LICENSE.md)

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 @@
-
+
+
+
+
diff --git a/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs b/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs
index 580171c..ed6b4ec 100644
--- a/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs
+++ b/src/MaksIT.PostClient.UI/Windows/IdentityHubLoginWindow.axaml.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using Avalonia.Interactivity;
using Avalonia.Threading;
@@ -51,7 +52,7 @@ public partial class IdentityHubLoginWindow : Window {
_poll.Tick += (_, _) => _ = TryCompleteFromWebViewAsync();
HubWeb.EnvironmentRequested += (_, args) => WebViewSetup.Apply(args, identityHub: true);
HubWeb.AdapterCreated += (_, _) => LoadHub(force: false);
- HubWeb.NavigationCompleted += (_, _) => _ = TryCompleteFromWebViewAsync();
+ HubWeb.NavigationCompleted += (_, _) => OnNavigationCompleted();
HubWeb.WebMessageReceived += OnWebMessage;
HubWeb.NewWindowRequested += OnNewWindow;
Opened += OnOpened;
@@ -61,15 +62,55 @@ public partial class IdentityHubLoginWindow : Window {
private void OnOpened(object? sender, EventArgs e) {
LoadHub(force: false);
- StatusText.Text = "Waiting for Identity Hub… keep this window open until it closes itself.";
+ StatusText.Text = "Waiting for sign-in… keep this window open until it closes itself.";
+ UpdateBackButton();
_poll.Start();
}
+ private void OnNavigationCompleted() {
+ UpdateBackButton();
+ _ = TryCompleteFromWebViewAsync();
+ }
+
+ private void UpdateBackButton() {
+ try {
+ BackButton.IsEnabled = !_done && (HubWeb.CanGoBack || IsAwayFromSignIn());
+ }
+ catch {
+ BackButton.IsEnabled = !_done && IsAwayFromSignIn();
+ }
+ }
+
+ private bool IsAwayFromSignIn() {
+ if (HubWeb.Source is not { } current || IsEmptyDocument(current))
+ return false;
+ if (!Uri.TryCreate(_url, UriKind.Absolute, out var start))
+ return false;
+ return !string.Equals(current.GetLeftPart(UriPartial.Path), start.GetLeftPart(UriPartial.Path), StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void OnBackClick(object? sender, RoutedEventArgs e) {
+ if (_done)
+ return;
+ try {
+ if (HubWeb.CanGoBack) {
+ HubWeb.GoBack();
+ return;
+ }
+ }
+ catch {
+ // Fall through to reload the sign-in URL.
+ }
+
+ LoadHub(force: true);
+ UpdateBackButton();
+ }
+
private void LoadHub(bool force) {
if (_done)
return;
if (!Uri.TryCreate(_url, UriKind.Absolute, out var target)) {
- StatusText.Text = "Unable to open Identity Hub.";
+ StatusText.Text = "Unable to open sign-in.";
return;
}
@@ -78,10 +119,11 @@ public partial class IdentityHubLoginWindow : Window {
try {
HubWeb.Source = target;
_started = true;
+ UpdateBackButton();
}
catch {
if (!_started)
- StatusText.Text = "Unable to open Identity Hub.";
+ StatusText.Text = "Unable to open sign-in.";
}
}
@@ -121,6 +163,7 @@ public partial class IdentityHubLoginWindow : Window {
return;
_done = true;
_poll.Stop();
+ BackButton.IsEnabled = false;
Close(json);
}
}