(feature): add All emails, relocate shared archives, and finish IMAP folder paging

This commit is contained in:
Maksym Sadovnychyy 2026-11-05 07:47:33 +01:00
parent 70a6894b80
commit be6fc8d550
20 changed files with 728 additions and 82 deletions

View File

@ -6,6 +6,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
## [Unreleased] ## [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 ## [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. Folder sync keeps paging older IMAP mail after the newest page is already archived. Identity Hub desktop login passes the covered app id.

View File

@ -1,8 +1,8 @@
# Postclient # Postclient
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-60.4%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.3%25-yellowgreen) ![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-46.7%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.1%25-green) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.9%25-green)
![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![.NET](https://img.shields.io/badge/.NET-10-512BD4)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue)](LICENSE.md) [![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) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS-0078D6)

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.4.6</Version> <Version>0.4.8</Version>
<Product>Postclient</Product> <Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle> <AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>

View File

@ -12,7 +12,9 @@ public static class ServiceCollectionExtensions {
public static IServiceCollection AddPostClient(this IServiceCollection services) { public static IServiceCollection AddPostClient(this IServiceCollection services) {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
services.AddIdentityHubIdpClient(IdentityHubAddress.Origin()); services.AddIdentityHubIdpClient(IdentityHubAddress.Origin());
services.AddSingleton<ISecretStore, FileSecretStore>(); services.AddSingleton<ISecretStore>(_ => new LayeredSecretStore(
new FileSecretStore(),
new SharedSecretStore()));
services.AddSingleton<ISentReceiptStore, FileSentReceiptStore>(); services.AddSingleton<ISentReceiptStore, FileSentReceiptStore>();
services.AddSingleton<IMailAuthService, MailAuthService>(); services.AddSingleton<IMailAuthService, MailAuthService>();
services.AddSingleton<IMailSessionFactory, MailSessionFactory>(); services.AddSingleton<IMailSessionFactory, MailSessionFactory>();

View File

@ -19,6 +19,7 @@ public sealed class ImapMailSession : IMailSession {
private MailAuthMaterial? _material; private MailAuthMaterial? _material;
private CancellationTokenSource? _alive; private CancellationTokenSource? _alive;
private readonly Dictionary<string, int> _listOffset = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, int> _listOffset = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, int> _listMisses = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<UniqueId>> _listUids = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<UniqueId>> _listUids = new(StringComparer.OrdinalIgnoreCase);
private const MessageSummaryItems FlagItems = private const MessageSummaryItems FlagItems =
@ -230,18 +231,21 @@ public sealed class ImapMailSession : IMailSession {
CancellationToken cancellationToken) { CancellationToken cancellationToken) {
List<UniqueId>? plan; List<UniqueId>? plan;
int offset; int offset;
int misses;
lock (_gate) { lock (_gate) {
_listUids.TryGetValue(folder, out plan); _listUids.TryGetValue(folder, out plan);
_listOffset.TryGetValue(folder, out offset); _listOffset.TryGetValue(folder, out offset);
_listMisses.TryGetValue(folder, out misses);
} }
if (plan is null) { if (plan is null) {
var searched = await imapFolder.SearchAsync(SearchQuery.All, cancellationToken).ConfigureAwait(false); plan = await BuildUidPlanAsync(imapFolder, cancellationToken).ConfigureAwait(false);
plan = searched.OrderByDescending(uid => uid.Id).ToList();
lock (_gate) { lock (_gate) {
_listUids[folder] = plan; _listUids[folder] = plan;
_listOffset[folder] = 0; _listOffset[folder] = 0;
_listMisses[folder] = 0;
offset = 0; offset = 0;
misses = 0;
} }
} }
@ -255,28 +259,71 @@ public sealed class ImapMailSession : IMailSession {
var page = plan.GetRange(range.Start, range.Take); var page = plan.GetRange(range.Start, range.Take);
var chunkRows = await FetchByUidsAsync(imapFolder, page, extraHeaders, cancellationToken) var chunkRows = await FetchByUidsAsync(imapFolder, page, extraHeaders, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
var fetched = IdsOf(chunkRows).ToHashSet();
// Empty FETCH on a non-empty UID page is usually transient - retry the same page. // Count only UIDs from this page so DistinctUids cannot inflate progress.
if (page.Count > 0 && chunkRows.Count == 0) var fetchedInPage = page.Count(uid => fetched.Contains(uid.Id));
return Result<MailFolderSync>.Ok(new MailFolderSync { Incomplete = true }); var (nextOffset, nextMisses, incomplete) = MailListBudget.AdvanceUidPage(
plan.Count,
offset,
range.Take,
fetchedInPage,
misses);
lock (_gate) { lock (_gate) {
if (range.Incomplete) if (incomplete) {
_listOffset[folder] = range.NextOffset; _listOffset[folder] = nextOffset;
_listMisses[folder] = nextMisses;
}
else else
ClearListCursor(folder); ClearListCursor(folder);
} }
return Result<MailFolderSync>.Ok(new MailFolderSync { return Result<MailFolderSync>.Ok(new MailFolderSync {
Headers = HeadersOf(folder, chunkRows), Headers = HeadersOf(folder, chunkRows),
Present = range.Incomplete ? null : plan.Select(uid => uid.Id).ToList(), Present = incomplete ? null : plan.Select(uid => uid.Id).ToList(),
Incomplete = range.Incomplete Incomplete = incomplete
}); });
} }
private static async Task<List<UniqueId>> 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) { private void ClearListCursor(string folder) {
lock (_gate) { lock (_gate) {
_listOffset.Remove(folder); _listOffset.Remove(folder);
_listMisses.Remove(folder);
_listUids.Remove(folder); _listUids.Remove(folder);
} }
} }
@ -915,6 +962,7 @@ public sealed class ImapMailSession : IMailSession {
client = _imap; client = _imap;
_imap = null; _imap = null;
_listOffset.Clear(); _listOffset.Clear();
_listMisses.Clear();
_listUids.Clear(); _listUids.Clear();
} }

View File

@ -6,8 +6,9 @@ namespace MaksIT.PostClient.Shared.Archive;
/// <summary> /// <summary>
/// Moves one opted-in mailbox between the user profile and the shared folder. /// Moves one mailbox between the user profile and the shared folder when Shared toggles.
/// The account file has no password. Each user keeps their own secret. /// 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.
/// </summary> /// </summary>
public static class SharedMailboxStore { public static class SharedMailboxStore {
private static readonly JsonSerializerOptions Json = new() { private static readonly JsonSerializerOptions Json = new() {
@ -56,16 +57,33 @@ public static class SharedMailboxStore {
ArgumentNullException.ThrowIfNull(account); ArgumentNullException.ThrowIfNull(account);
if (account.IsLocalStore) if (account.IsLocalStore)
return Result.BadRequest("A local store keeps the folder you chose."); 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.Shared = shared;
account.ArchiveStoreId = "";
var central = SharedMailPaths.AccountDirectory(account.Id); var central = SharedMailPaths.AccountDirectory(account.Id);
if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) { if (shared) {
var moved = Move(privateRoot, central); 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) if (!moved.IsSuccess)
return moved; return moved;
} }
account.ArchiveStoreId = ""; DropLocalAccountFile(privateRoot);
return Publish(account); RemovePublished(account.Id);
return Result.Ok();
} }
public static Result Publish(MailboxAccount account) { 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) { private static void RemovePublished(string mailboxId) {
var file = SharedMailPaths.AccountFile(mailboxId); var file = SharedMailPaths.AccountFile(mailboxId);
if (File.Exists(file)) if (File.Exists(file))
@ -99,23 +127,31 @@ public static class SharedMailboxStore {
Directory.Delete(dir); Directory.Delete(dir);
} }
private static Result Move(string source, string dest) { private static Result Move(string source, string dest, bool replaceDest) {
try { try {
if (source.Equals(dest, StringComparison.OrdinalIgnoreCase))
return Result.Ok();
if (!Directory.Exists(source)) { if (!Directory.Exists(source)) {
Directory.CreateDirectory(dest); Directory.CreateDirectory(dest);
ShareWithOtherUsers(dest); if (IsSharedRoot(dest))
ShareWithOtherUsers(dest);
return Result.Ok(); return Result.Ok();
} }
if (Directory.Exists(dest) && Directory.GetFileSystemEntries(dest).Length > 0) if (Directory.Exists(dest) && Directory.GetFileSystemEntries(dest).Length > 0) {
return Result.BadRequest("The shared folder already has mail for this account."); if (!replaceDest)
return Result.BadRequest("The destination folder already has mail for this account.");
Directory.Delete(dest, recursive: true);
}
var parent = Path.GetDirectoryName(dest); var parent = Path.GetDirectoryName(dest);
if (!string.IsNullOrWhiteSpace(parent)) if (!string.IsNullOrWhiteSpace(parent))
Directory.CreateDirectory(parent); Directory.CreateDirectory(parent);
if (Directory.Exists(dest)) if (Directory.Exists(dest))
Directory.Delete(dest); Directory.Delete(dest);
Directory.Move(source, dest); Directory.Move(source, dest);
ShareWithOtherUsers(dest); if (IsSharedRoot(dest))
ShareWithOtherUsers(dest);
return Result.Ok(); return Result.Ok();
} }
catch (Exception ex) { 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) { private static void ShareWithOtherUsers(string directory) {
if (!Directory.Exists(directory)) if (!Directory.Exists(directory))
return; return;

View File

@ -0,0 +1,50 @@
using MaksIT.Results;
namespace MaksIT.PostClient.Shared.Auth;
/// <summary>
/// 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.
/// </summary>
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<string?> 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();
}
/// <summary>Moves mailbox password and OAuth tokens into the shared store.</summary>
public Result PublishMailbox(string mailboxId) =>
SharedSecretStore.PublishMailbox(_user, mailboxId);
/// <summary>Moves mailbox password and OAuth tokens back into the user store.</summary>
public Result WithdrawMailbox(string mailboxId) =>
SharedSecretStore.WithdrawMailbox(_user, mailboxId);
}

View File

@ -8,9 +8,8 @@ namespace MaksIT.PostClient.Shared.Auth;
/// <summary> /// <summary>
/// Secrets for mailboxes the machine service syncs. /// Machine-scoped secrets for shared mailboxes and the sync service.
/// Windows protects them with the machine key so LocalService can read them after logoff. /// Windows protects them with the machine key so LocalService and other users on this PC can read them.
/// The file is not granted to every user.
/// </summary> /// </summary>
public sealed class SharedSecretStore : ISecretStore { public sealed class SharedSecretStore : ISecretStore {
public static string FilePath() => public static string FilePath() =>
@ -31,9 +30,57 @@ public sealed class SharedSecretStore : ISecretStore {
public Result Delete(string key) => public Result Delete(string key) =>
_inner.Delete(key); _inner.Delete(key);
/// <summary>
/// Moves mailbox password and OAuth tokens from the user store into the shared store.
/// </summary>
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();
}
/// <summary>
/// Moves mailbox password and OAuth tokens from the shared store back into the user store.
/// </summary>
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) => public static void Protect(string path) =>
Harden(path); Harden(path);
private static IEnumerable<string> MailboxKeys(string mailboxId) {
yield return FileSecretStore.MailboxKey(mailboxId);
yield return FileSecretStore.OAuthKey(mailboxId);
}
private static void Harden(string path) { private static void Harden(string path) {
try { try {
if (!File.Exists(path)) if (!File.Exists(path))
@ -48,6 +95,7 @@ public sealed class SharedSecretStore : ISecretStore {
var info = new FileInfo(path); var info = new FileInfo(path);
var security = info.GetAccessControl(); var security = info.GetAccessControl();
security.AddAccessRule(ServiceRule(FileSystemRights.Modify)); security.AddAccessRule(ServiceRule(FileSystemRights.Modify));
security.AddAccessRule(UsersRule(FileSystemRights.ReadAndExecute));
info.SetAccessControl(security); info.SetAccessControl(security);
} }
catch { catch {
@ -61,4 +109,12 @@ public sealed class SharedSecretStore : ISecretStore {
InheritanceFlags.None, InheritanceFlags.None,
PropagationFlags.None, PropagationFlags.None,
AccessControlType.Allow); AccessControlType.Allow);
private static FileSystemAccessRule UsersRule(FileSystemRights rights) =>
new(
new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null),
rights,
InheritanceFlags.None,
PropagationFlags.None,
AccessControlType.Allow);
} }

View File

@ -332,6 +332,8 @@ public sealed partial class UiCopy {
public string Folders { get; init; } = ""; public string Folders { get; init; } = "";
public string AllEmails { get; init; } = "";
public string SearchPlaceholder { get; init; } = ""; public string SearchPlaceholder { get; init; } = "";
public string ClearSearch { get; init; } = ""; public string ClearSearch { get; init; } = "";
@ -844,6 +846,7 @@ public sealed partial class UiCopy {
Delete = "Delete", Delete = "Delete",
MoveTo = "Move To", MoveTo = "Move To",
Folders = "Folders", Folders = "Folders",
AllEmails = "All emails",
SearchPlaceholder = "Search subject, body, attachments, labels, meaning", SearchPlaceholder = "Search subject, body, attachments, labels, meaning",
ClearSearch = "Clear search", ClearSearch = "Clear search",
Label = "Label", Label = "Label",
@ -883,7 +886,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Remove Account", 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).", 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", 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", DisplayName = "Display name",
Address = "Address", Address = "Address",
Username = "Username (if different)", Username = "Username (if different)",
@ -1150,6 +1153,7 @@ public sealed partial class UiCopy {
Delete = "Elimina", Delete = "Elimina",
MoveTo = "Sposta in", MoveTo = "Sposta in",
Folders = "Cartelle", Folders = "Cartelle",
AllEmails = "Tutte le email",
SearchPlaceholder = "Cerca oggetto, corpo, allegati, etichette, significato", SearchPlaceholder = "Cerca oggetto, corpo, allegati, etichette, significato",
ClearSearch = "Cancella ricerca", ClearSearch = "Cancella ricerca",
Label = "Etichetta", Label = "Etichetta",
@ -1189,7 +1193,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Rimuovi account", 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).", 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", 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", DisplayName = "Nome visualizzato",
Address = "Indirizzo", Address = "Indirizzo",
Username = "Nome utente (se diverso)", Username = "Nome utente (se diverso)",

View File

@ -170,6 +170,7 @@ public sealed partial class UiCopy {
Delete = "Löschen", Delete = "Löschen",
MoveTo = "Verschieben nach", MoveTo = "Verschieben nach",
Folders = "Ordner", Folders = "Ordner",
AllEmails = "Alle E-Mails",
SearchPlaceholder = "Betreff, Text, Anlagen, Kennzeichen, Bedeutung suchen", SearchPlaceholder = "Betreff, Text, Anlagen, Kennzeichen, Bedeutung suchen",
ClearSearch = "Suche leeren", ClearSearch = "Suche leeren",
Label = "Kennzeichen", Label = "Kennzeichen",
@ -209,7 +210,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Konto entfernen", 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).", 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", 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", DisplayName = "Anzeigename",
Address = "Adresse", Address = "Adresse",
Username = "Benutzername (falls abweichend)", Username = "Benutzername (falls abweichend)",

View File

@ -170,6 +170,7 @@ public sealed partial class UiCopy {
Delete = "Eliminar", Delete = "Eliminar",
MoveTo = "Mover a", MoveTo = "Mover a",
Folders = "Carpetas", Folders = "Carpetas",
AllEmails = "Todos los correos",
SearchPlaceholder = "Buscar asunto, cuerpo, adjuntos, etiquetas, significado", SearchPlaceholder = "Buscar asunto, cuerpo, adjuntos, etiquetas, significado",
ClearSearch = "Borrar búsqueda", ClearSearch = "Borrar búsqueda",
Label = "Etiqueta", Label = "Etiqueta",
@ -209,7 +210,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Quitar cuenta", 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).", 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", 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", DisplayName = "Nombre para mostrar",
Address = "Dirección", Address = "Dirección",
Username = "Nombre de usuario (si es distinto)", Username = "Nombre de usuario (si es distinto)",

View File

@ -170,6 +170,7 @@ public sealed partial class UiCopy {
Delete = "Supprimer", Delete = "Supprimer",
MoveTo = "Déplacer vers", MoveTo = "Déplacer vers",
Folders = "Dossiers", Folders = "Dossiers",
AllEmails = "Tous les courriers",
SearchPlaceholder = "Rechercher objet, corps, pièces, étiquettes, sens", SearchPlaceholder = "Rechercher objet, corps, pièces, étiquettes, sens",
ClearSearch = "Effacer la recherche", ClearSearch = "Effacer la recherche",
Label = "Étiquette", Label = "Étiquette",
@ -209,7 +210,7 @@ public sealed partial class UiCopy {
RemoveAccount = "Supprimer le compte", 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).", 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", 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é", DisplayName = "Nom affiché",
Address = "Adresse", Address = "Adresse",
Username = "Nom d’utilisateur (s’il est différent)", Username = "Nom d’utilisateur (s’il est différent)",

View File

@ -26,4 +26,32 @@ public static class MailListBudget {
var next = offset + take; var next = offset + take;
return new Slice(offset, offset + take - 1, take, next, next < count); return new Slice(offset, offset + take - 1, take, next, next < count);
} }
/// <summary>
/// Advance a UID page after FETCH. Partial or empty pages stay on the same offset until
/// <paramref name="maxMisses"/>, then the page is skipped so sync cannot stick forever.
/// </summary>
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);
}
} }

View File

@ -11,9 +11,14 @@ public class SharedMailboxTests {
try { try {
var box = new MailboxAccount { Id = "box1", Address = "shared@example.com" }; var box = new MailboxAccount { Id = "box1", Address = "shared@example.com" };
Assert.StartsWith(root, SharedMailPaths.AccountDirectory(box.Id), StringComparison.OrdinalIgnoreCase); 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(placed.IsSuccess);
Assert.True(File.Exists(SharedMailPaths.AccountFile(box.Id))); 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(); var configuration = new Configuration();
Assert.True(SharedMailboxStore.MergeMissing(configuration)); Assert.True(SharedMailboxStore.MergeMissing(configuration));
Assert.Equal("box1", configuration.Mailboxes[0].Id); Assert.Equal("box1", configuration.Mailboxes[0].Id);
@ -39,17 +44,24 @@ public class SharedMailboxTests {
} }
[Fact] [Fact]
public void EveryEnrolledAccountIsListed_OnlySharedOnesAreImported() { public void PrivatePlaceDoesNotPublish_OnlySharedOnesAreImported() {
var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env); var previous = Environment.GetEnvironmentVariable(SharedMailPaths.Env);
var root = Path.Combine(Path.GetTempPath(), "postclient-central-" + Guid.NewGuid().ToString("N")); var root = Path.Combine(Path.GetTempPath(), "postclient-central-" + Guid.NewGuid().ToString("N"));
Environment.SetEnvironmentVariable(SharedMailPaths.Env, root); Environment.SetEnvironmentVariable(SharedMailPaths.Env, root);
try { try {
var privateBox = new MailboxAccount { Id = "mine", Address = "mine@example.com" }; var privateBox = new MailboxAccount { Id = "mine", Address = "mine@example.com" };
var sharedBox = new MailboxAccount { Id = "ours", Address = "ours@example.com" }; var sharedBox = new MailboxAccount { Id = "ours", Address = "ours@example.com" };
Assert.True(SharedMailboxStore.Place(privateBox, shared: false, Path.Combine(root, "a")).IsSuccess); var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), privateBox.Id);
Assert.True(SharedMailboxStore.Place(sharedBox, shared: true, Path.Combine(root, "b")).IsSuccess); 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(); var listed = SharedMailboxStore.List();
Assert.Equal(2, listed.Count); Assert.Single(listed);
Assert.Equal("ours", listed[0].Id);
var configuration = new Configuration(); var configuration = new Configuration();
Assert.True(SharedMailboxStore.MergeMissing(configuration)); Assert.True(SharedMailboxStore.MergeMissing(configuration));
Assert.Single(configuration.Mailboxes); Assert.Single(configuration.Mailboxes);
@ -61,4 +73,35 @@ public class SharedMailboxTests {
Directory.Delete(root, recursive: true); 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);
}
}
} }

View File

@ -598,6 +598,24 @@ public class MailListBudgetTests {
Assert.Equal(999, last.End); Assert.Equal(999, last.End);
Assert.False(last.Incomplete); 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 { public class FileSecretStoreTests {
[Fact] [Fact]
public void PutGetRoundTrip() { public void PutGetRoundTrip() {

View File

@ -281,7 +281,7 @@ public partial class MainWindow : Window {
}; };
var json = await window.ShowDialog<string?>(_accountWindow ?? this); var json = await window.ShowDialog<string?>(_accountWindow ?? this);
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
vm.CancelIdentityHubSignIn("Identity Hub sign-in cancelled."); vm.CancelIdentityHubSignIn("Sign-in cancelled.");
else else
await vm.CompleteIdentityHubSignIn(kind, json); await vm.CompleteIdentityHubSignIn(kind, json);
} }

View File

@ -1041,7 +1041,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
if (!box.IsLocalStore) if (!box.IsLocalStore)
CopySecretsToService(box.Id); SyncMailboxSecrets(box.Id, box.Shared);
ReloadMailboxes(); ReloadMailboxes();
SelectedMailbox = Mailboxes.FirstOrDefault(m => m.Id == box.Id); SelectedMailbox = Mailboxes.FirstOrDefault(m => m.Id == box.Id);
Status = "Mailbox saved. Secrets stay in the OS secret file, not settings.json."; 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) { private bool ApplySharedLocation(Configuration configuration, MailboxAccount box) {
if (box.IsLocalStore) if (box.IsLocalStore)
return true; return true;
var privateRoot = File.Exists(SharedMailPaths.AccountFile(box.Id)) var central = SharedMailPaths.AccountDirectory(box.Id);
? 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); : MailArchiveLayout.MailRoot(box, configuration.Mailboxes);
var moving = !privateRoot.Equals( var desired = EditorShareAccount ? central : privateRoot;
SharedMailPaths.AccountDirectory(box.Id), var placeRoot = EditorShareAccount ? current : privateRoot;
StringComparison.OrdinalIgnoreCase); var moving = !current.Equals(desired, StringComparison.OrdinalIgnoreCase);
if (!moving) 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(() => { return HoldArchive(() => {
_archive.Close(box.Id); _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) { foreach (var box in configuration.Mailboxes) {
if (box.IsLocalStore) if (box.IsLocalStore)
continue; continue;
var root = File.Exists(SharedMailPaths.AccountFile(box.Id)) var central = SharedMailPaths.AccountDirectory(box.Id);
? 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); : 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; continue;
CopySecretsToService(box.Id); SyncMailboxSecrets(box.Id, box.Shared);
} }
} }
private void CopySecretsToService(string mailboxId) { private void SyncMailboxSecrets(string mailboxId, bool shared) {
var service = new SharedSecretStore(); var moved = shared
CopySecret(service, FileSecretStore.MailboxKey(mailboxId)); ? PublishMailboxSecrets(mailboxId)
CopySecret(service, FileSecretStore.OAuthKey(mailboxId)); : WithdrawMailboxSecrets(mailboxId);
if (!moved.IsSuccess)
Status = string.Join(" ", moved.Messages);
} }
private void CopySecret(SharedSecretStore service, string key) { private Result PublishMailboxSecrets(string mailboxId) =>
var stored = _secrets.Get(key); _secrets is LayeredSecretStore layered
if (stored.IsSuccess && !string.IsNullOrWhiteSpace(stored.Value)) ? layered.PublishMailbox(mailboxId)
service.Put(key, stored.Value); : SharedSecretStore.PublishMailbox(_secrets, mailboxId);
}
private Result WithdrawMailboxSecrets(string mailboxId) =>
_secrets is LayeredSecretStore layered
? layered.WithdrawMailbox(mailboxId)
: SharedSecretStore.WithdrawMailbox(_secrets, mailboxId);
[RelayCommand] [RelayCommand]
private async Task RemoveMailboxAsync() { private async Task RemoveMailboxAsync() {
@ -2845,6 +2856,15 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
partial void OnSelectedFolderNodeChanged(FolderNodeViewModel? value) { partial void OnSelectedFolderNodeChanged(FolderNodeViewModel? value) {
if (_syncingTree || value is null) if (_syncingTree || value is null)
return; 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) if (value.Mailbox is { } box && SelectedMailbox?.Id != box.Id)
SelectedMailbox = box; SelectedMailbox = box;
if (value.Folder is { } folder) if (value.Folder is { } folder)
@ -2857,7 +2877,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
public bool ShowFolderContext => public bool ShowFolderContext =>
TargetFolderNodes().Count > 0; SelectedFolderNode is not { IsAllEmails: true }
&& TargetFolderNodes().Count > 0;
public bool ShowDetachPst => public bool ShowDetachPst =>
!IsBusy !IsBusy
@ -2877,6 +2898,18 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
NotifyFolderCommands(); NotifyFolderCommands();
if (value is null || _syncingTree || _suppressFolderLoad) if (value is null || _syncingTree || _suppressFolderLoad)
return; 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)) { if (MailFolderRole.IsNamespace(value.Name, value.FullName)) {
_folderLoad?.Cancel(); _folderLoad?.Cancel();
_work?.Cancel(); _work?.Cancel();
@ -2965,7 +2998,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
IsBusy = true; IsBusy = true;
SignInGoogleCommand.NotifyCanExecuteChanged(); SignInGoogleCommand.NotifyCanExecuteChanged();
SignInMicrosoftCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged();
Status = "Waiting for Identity Hub…"; Status = "Waiting for sign-in…";
IdentityHubSignInRequested?.Invoke(kind); IdentityHubSignInRequested?.Invoke(kind);
await Task.CompletedTask; await Task.CompletedTask;
} }
@ -3188,11 +3221,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private void RebuildFolderTree() { private void RebuildFolderTree() {
_syncingTree = true; _syncingTree = true;
EnsureAllEmailsNode();
var keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var byId = FolderTree var byId = FolderTree
.Where(n => n.Mailbox is not null) .Where(n => n.Mailbox is not null)
.ToDictionary(n => n.Mailbox!.Id, StringComparer.OrdinalIgnoreCase); .ToDictionary(n => n.Mailbox!.Id, StringComparer.OrdinalIgnoreCase);
var index = 0; var index = 1;
foreach (var box in Mailboxes) { foreach (var box in Mailboxes) {
keep.Add(box.Id); keep.Add(box.Id);
if (!byId.TryGetValue(box.Id, out var node)) { 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--) { 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)) if (id is null || !keep.Contains(id))
FolderTree.RemoveAt(i); FolderTree.RemoveAt(i);
} }
RefreshAllEmailsCounts();
var selected = SnapshotFolderSelection(); var selected = SnapshotFolderSelection();
var selectedBoxId = SelectedFolderNode?.Mailbox?.Id ?? SelectedMailbox?.Id; var selectedBoxId = SelectedFolderNode?.IsAllEmails == true
var selectedFolder = SelectedFolderNode?.Folder?.FullName ?? SelectedFolder?.FullName; ? FolderNodeViewModel.AllEmailsId
: SelectedFolderNode?.Mailbox?.Id ?? SelectedMailbox?.Id;
var selectedFolder = SelectedFolderNode?.IsAllEmails == true
? FolderNodeViewModel.AllEmailsId
: SelectedFolderNode?.Folder?.FullName ?? SelectedFolder?.FullName;
SelectedFolderNode = FindTreeNode(selectedBoxId, selectedFolder) SelectedFolderNode = FindTreeNode(selectedBoxId, selectedFolder)
?? FolderNodeFor(SelectedMailbox, "inbox") ?? FolderNodeFor(SelectedMailbox, "inbox")
?? FolderTree.FirstOrDefault(n => n.Mailbox?.Id == SelectedMailbox?.Id)?.Children.FirstOrDefault() ?? 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); RemapFolderSelection(selected);
_syncingTree = false; _syncingTree = false;
RestoreFolderTreeSelection(); 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() { private List<(string MailboxId, string? Folder)> SnapshotFolderSelection() {
var nodes = SelectedFolderNodes.Count > 0 var nodes = SelectedFolderNodes.Count > 0
? SelectedFolderNodes ? SelectedFolderNodes
@ -3236,6 +3323,13 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var keys = new List<(string MailboxId, string? Folder)>(); var keys = new List<(string MailboxId, string? Folder)>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var node in nodes) { 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; var boxId = node.Mailbox?.Id;
if (string.IsNullOrWhiteSpace(boxId)) if (string.IsNullOrWhiteSpace(boxId))
continue; continue;
@ -3314,6 +3408,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
private FolderNodeViewModel? FindTreeNode(string? mailboxId, string? folder) { 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)) if (string.IsNullOrWhiteSpace(mailboxId))
return null; return null;
var account = FolderTree.FirstOrDefault(n => n.Mailbox?.Id == mailboxId); var account = FolderTree.FirstOrDefault(n => n.Mailbox?.Id == mailboxId);
@ -3326,6 +3423,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
?? account; ?? account;
} }
private static bool IsAllEmailsFolder(FolderRowViewModel? folder) =>
folder?.FullName.Equals(FolderNodeViewModel.AllEmailsId, StringComparison.OrdinalIgnoreCase) == true;
private static FolderNodeViewModel? FindFolderNode(FolderNodeViewModel node, string folder) { private static FolderNodeViewModel? FindFolderNode(FolderNodeViewModel node, string folder) {
if (node.Folder?.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase) == true) if (node.Folder?.FullName.Equals(folder, StringComparison.OrdinalIgnoreCase) == true)
return node; return node;
@ -5394,6 +5494,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private void NotifyFolderCounts() { private void NotifyFolderCounts() {
foreach (var account in FolderTree) foreach (var account in FolderTree)
ApplyRetentionBadge(account); ApplyRetentionBadge(account);
RefreshAllEmailsCounts();
} }
public void RefreshRetentionBadges() => public void RefreshRetentionBadges() =>
@ -5451,6 +5552,23 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
_ = OpenFolderAsync(folder, cts.Token); _ = 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) { private async Task OpenFolderAsync(string folder, CancellationToken token) {
try { try {
await LoadMessagesAsync(folder, token).ConfigureAwait(false); 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<MailArchiveHeader> CollectAllInboxHeaders() {
var rows = new List<MailArchiveHeader>();
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<MailArchiveHeader> 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) => private static void ReportFolderFailure(Exception ex) =>
Dispatcher.UIThread.Post(() => ErrorDialog.Report(ex)); Dispatcher.UIThread.Post(() => ErrorDialog.Report(ex));

View File

@ -114,8 +114,12 @@ public sealed partial class FolderRowViewModel : ObservableObject {
public sealed partial class FolderNodeViewModel : ObservableObject { public sealed partial class FolderNodeViewModel : ObservableObject {
public const string AllEmailsId = "__all_emails__";
public bool IsAccount { get; init; } public bool IsAccount { get; init; }
public bool IsAllEmails { get; init; }
public MailboxAccount? Mailbox { get; init; } public MailboxAccount? Mailbox { get; init; }
public FolderRowViewModel? Folder { get; init; } public FolderRowViewModel? Folder { get; init; }
@ -126,13 +130,25 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public ObservableCollection<FolderNodeViewModel> Children { get; } = []; public ObservableCollection<FolderNodeViewModel> Children { get; } = [];
public string Title => 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 => public string Glyph =>
IsAccount ? "✉" : MailFolderRole.Glyph(Folder?.Name, Folder?.FullName); IsAllEmails
? "📥"
: IsAccount
? "✉"
: MailFolderRole.Glyph(Folder?.Name, Folder?.FullName);
public int Unread => public int Unread =>
IsAccount ? ChildUnread() : Folder?.Unread ?? 0; IsAllEmails
? Folder?.Unread ?? 0
: IsAccount
? ChildUnread()
: Folder?.Unread ?? 0;
private int ChildUnread() { private int ChildUnread() {
var n = 0; var n = 0;
@ -192,6 +208,13 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
OnPropertyChanged(nameof(QuotaTip)); 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) => public static FolderNodeViewModel Account(MailboxAccount box, bool expanded = true) =>
new() { new() {
IsAccount = true, IsAccount = true,

View File

@ -1,7 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="MaksIT.PostClient.UI.Windows.IdentityHubLoginWindow" x:Class="MaksIT.PostClient.UI.Windows.IdentityHubLoginWindow"
Title="Identity Hub" Title="MaksIT Postclient"
Icon="/Assets/icon.ico" Icon="/Assets/icon.ico"
Width="920" Width="920"
Height="720" Height="720"
@ -9,12 +9,21 @@
MinHeight="480" MinHeight="480"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<DockPanel> <DockPanel>
<TextBlock x:Name="StatusText" <DockPanel DockPanel.Dock="Top" Margin="12,8">
DockPanel.Dock="Top" <Button x:Name="BackButton"
Classes="hint" DockPanel.Dock="Left"
Margin="12,8" Content="Back"
TextWrapping="Wrap" Classes="quiet"
Text="Waiting for Identity Hub… keep this window open until it closes itself." /> Margin="0,0,12,0"
VerticalAlignment="Top"
IsEnabled="False"
Click="OnBackClick" />
<TextBlock x:Name="StatusText"
Classes="hint"
TextWrapping="Wrap"
VerticalAlignment="Center"
Text="Waiting for sign-in… keep this window open until it closes itself." />
</DockPanel>
<NativeWebView x:Name="HubWeb" /> <NativeWebView x:Name="HubWeb" />
</DockPanel> </DockPanel>
</Window> </Window>

View File

@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
@ -51,7 +52,7 @@ public partial class IdentityHubLoginWindow : Window {
_poll.Tick += (_, _) => _ = TryCompleteFromWebViewAsync(); _poll.Tick += (_, _) => _ = TryCompleteFromWebViewAsync();
HubWeb.EnvironmentRequested += (_, args) => WebViewSetup.Apply(args, identityHub: true); HubWeb.EnvironmentRequested += (_, args) => WebViewSetup.Apply(args, identityHub: true);
HubWeb.AdapterCreated += (_, _) => LoadHub(force: false); HubWeb.AdapterCreated += (_, _) => LoadHub(force: false);
HubWeb.NavigationCompleted += (_, _) => _ = TryCompleteFromWebViewAsync(); HubWeb.NavigationCompleted += (_, _) => OnNavigationCompleted();
HubWeb.WebMessageReceived += OnWebMessage; HubWeb.WebMessageReceived += OnWebMessage;
HubWeb.NewWindowRequested += OnNewWindow; HubWeb.NewWindowRequested += OnNewWindow;
Opened += OnOpened; Opened += OnOpened;
@ -61,15 +62,55 @@ public partial class IdentityHubLoginWindow : Window {
private void OnOpened(object? sender, EventArgs e) { private void OnOpened(object? sender, EventArgs e) {
LoadHub(force: false); 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(); _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) { private void LoadHub(bool force) {
if (_done) if (_done)
return; return;
if (!Uri.TryCreate(_url, UriKind.Absolute, out var target)) { if (!Uri.TryCreate(_url, UriKind.Absolute, out var target)) {
StatusText.Text = "Unable to open Identity Hub."; StatusText.Text = "Unable to open sign-in.";
return; return;
} }
@ -78,10 +119,11 @@ public partial class IdentityHubLoginWindow : Window {
try { try {
HubWeb.Source = target; HubWeb.Source = target;
_started = true; _started = true;
UpdateBackButton();
} }
catch { catch {
if (!_started) 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; return;
_done = true; _done = true;
_poll.Stop(); _poll.Stop();
BackButton.IsEnabled = false;
Close(json); Close(json);
} }
} }