(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]
## [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.

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ public sealed class ImapMailSession : IMailSession {
private MailAuthMaterial? _material;
private CancellationTokenSource? _alive;
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 const MessageSummaryItems FlagItems =
@ -230,18 +231,21 @@ public sealed class ImapMailSession : IMailSession {
CancellationToken cancellationToken) {
List<UniqueId>? 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<MailFolderSync>.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<MailFolderSync>.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<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) {
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();
}

View File

@ -6,8 +6,9 @@ namespace MaksIT.PostClient.Shared.Archive;
/// <summary>
/// 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.
/// </summary>
public static class SharedMailboxStore {
private static readonly JsonSerializerOptions Json = new() {
@ -56,18 +57,35 @@ 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 (shared) {
if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) {
var moved = Move(privateRoot, central);
var moved = Move(privateRoot, central, replaceDest: true);
if (!moved.IsSuccess)
return moved;
}
account.ArchiveStoreId = "";
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;
}
DropLocalAccountFile(privateRoot);
RemovePublished(account.Id);
return Result.Ok();
}
public static Result Publish(MailboxAccount account) {
try {
var dir = SharedMailPaths.AccountDirectory(account.Id);
@ -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,22 +127,30 @@ 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);
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);
if (IsSharedRoot(dest))
ShareWithOtherUsers(dest);
return Result.Ok();
}
@ -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;

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>
/// 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.
/// </summary>
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);
/// <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) =>
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) {
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);
}

View File

@ -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)",

View File

@ -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)",

View File

@ -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)",

View File

@ -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)",

View File

@ -26,4 +26,32 @@ public static class MailListBudget {
var next = offset + take;
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 {
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);
}
}
}

View File

@ -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() {

View File

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

View File

@ -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<string>(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<string>(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<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) =>
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 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<FolderNodeViewModel> 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,

View File

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

View File

@ -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);
}
}