Compare commits

..

No commits in common. "main" and "v0.4.6" have entirely different histories.
main ... v0.4.6

21 changed files with 83 additions and 747 deletions

View File

@ -6,28 +6,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
## [Unreleased] ## [Unreleased]
## [0.4.9] - 2026-11-05
### Changed
- Sidebar **All emails** renamed to **All inboxes**, with a tooltip that it only combines each account's Inbox (not labels or other folders).
## [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.5%25-green) ![Line Coverage](https://img.shields.io/badge/Line%20Coverage-60.4%25-green)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-46.7%25-yellowgreen) ![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-46.3%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.9%25-green) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-63.1%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.9</Version> <Version>0.4.6</Version>
<Product>Postclient</Product> <Product>Postclient</Product>
<AssemblyTitle>Postclient</AssemblyTitle> <AssemblyTitle>Postclient</AssemblyTitle>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>

View File

@ -12,9 +12,7 @@ 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>(_ => new LayeredSecretStore( services.AddSingleton<ISecretStore, FileSecretStore>();
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,7 +19,6 @@ 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 =
@ -231,21 +230,18 @@ 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) {
plan = await BuildUidPlanAsync(imapFolder, cancellationToken).ConfigureAwait(false); var searched = await imapFolder.SearchAsync(SearchQuery.All, 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;
} }
} }
@ -259,71 +255,28 @@ 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();
// Count only UIDs from this page so DistinctUids cannot inflate progress. // Empty FETCH on a non-empty UID page is usually transient - retry the same page.
var fetchedInPage = page.Count(uid => fetched.Contains(uid.Id)); if (page.Count > 0 && chunkRows.Count == 0)
var (nextOffset, nextMisses, incomplete) = MailListBudget.AdvanceUidPage( return Result<MailFolderSync>.Ok(new MailFolderSync { Incomplete = true });
plan.Count,
offset,
range.Take,
fetchedInPage,
misses);
lock (_gate) { lock (_gate) {
if (incomplete) { if (range.Incomplete)
_listOffset[folder] = nextOffset; _listOffset[folder] = range.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 = incomplete ? null : plan.Select(uid => uid.Id).ToList(), Present = range.Incomplete ? null : plan.Select(uid => uid.Id).ToList(),
Incomplete = incomplete Incomplete = range.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);
} }
} }
@ -962,7 +915,6 @@ 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,9 +6,8 @@ namespace MaksIT.PostClient.Shared.Archive;
/// <summary> /// <summary>
/// Moves one mailbox between the user profile and the shared folder when Shared toggles. /// Moves one opted-in mailbox between the user profile and the shared folder.
/// Shared=true publishes under ProgramData; Shared=false keeps mail under the user profile. /// The account file has no password. Each user keeps their own secret.
/// 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() {
@ -57,33 +56,16 @@ 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 (shared) { if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) {
if (!privateRoot.Equals(central, StringComparison.OrdinalIgnoreCase)) { var moved = Move(privateRoot, central);
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;
} }
DropLocalAccountFile(privateRoot); account.ArchiveStoreId = "";
RemovePublished(account.Id); return Publish(account);
return Result.Ok();
} }
public static Result Publish(MailboxAccount account) { public static Result Publish(MailboxAccount account) {
@ -108,16 +90,6 @@ 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))
@ -127,31 +99,23 @@ public static class SharedMailboxStore {
Directory.Delete(dir); Directory.Delete(dir);
} }
private static Result Move(string source, string dest, bool replaceDest) { private static Result Move(string source, string dest) {
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);
if (IsSharedRoot(dest)) ShareWithOtherUsers(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)
if (!replaceDest) return Result.BadRequest("The shared folder already has mail for this account.");
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);
if (IsSharedRoot(dest)) ShareWithOtherUsers(dest);
ShareWithOtherUsers(dest);
return Result.Ok(); return Result.Ok();
} }
catch (Exception ex) { catch (Exception ex) {
@ -159,11 +123,6 @@ 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

@ -1,50 +0,0 @@
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,8 +8,9 @@ namespace MaksIT.PostClient.Shared.Auth;
/// <summary> /// <summary>
/// Machine-scoped secrets for shared mailboxes and the sync service. /// Secrets for mailboxes the machine service syncs.
/// Windows protects them with the machine key so LocalService and other users on this PC can read them. /// Windows protects them with the machine key so LocalService can read them after logoff.
/// 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() =>
@ -30,57 +31,9 @@ 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))
@ -95,7 +48,6 @@ 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 {
@ -109,12 +61,4 @@ 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,10 +332,6 @@ public sealed partial class UiCopy {
public string Folders { get; init; } = ""; public string Folders { get; init; } = "";
public string AllInboxes { get; init; } = "";
public string AllInboxesHint { get; init; } = "";
public string SearchPlaceholder { get; init; } = ""; public string SearchPlaceholder { get; init; } = "";
public string ClearSearch { get; init; } = ""; public string ClearSearch { get; init; } = "";
@ -848,8 +844,6 @@ public sealed partial class UiCopy {
Delete = "Delete", Delete = "Delete",
MoveTo = "Move To", MoveTo = "Move To",
Folders = "Folders", Folders = "Folders",
AllInboxes = "All inboxes",
AllInboxesHint = "Only each account's Inbox - not labels or other folders - so this list stays about what needs attention across mailboxes.",
SearchPlaceholder = "Search subject, body, attachments, labels, meaning", SearchPlaceholder = "Search subject, body, attachments, labels, meaning",
ClearSearch = "Clear search", ClearSearch = "Clear search",
Label = "Label", Label = "Label",
@ -889,7 +883,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 = "Mail and sign-in secrets move to {0}. Other people on this PC can open this mailbox without signing in again.", 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.",
DisplayName = "Display name", DisplayName = "Display name",
Address = "Address", Address = "Address",
Username = "Username (if different)", Username = "Username (if different)",
@ -1156,8 +1150,6 @@ public sealed partial class UiCopy {
Delete = "Elimina", Delete = "Elimina",
MoveTo = "Sposta in", MoveTo = "Sposta in",
Folders = "Cartelle", Folders = "Cartelle",
AllInboxes = "Tutte le caselle",
AllInboxesHint = "Solo la Posta in arrivo di ogni account - non etichette o altre cartelle - cosi l'elenco resta su cio che richiede attenzione tra le caselle.",
SearchPlaceholder = "Cerca oggetto, corpo, allegati, etichette, significato", SearchPlaceholder = "Cerca oggetto, corpo, allegati, etichette, significato",
ClearSearch = "Cancella ricerca", ClearSearch = "Cancella ricerca",
Label = "Etichetta", Label = "Etichetta",
@ -1197,7 +1189,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 = "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.", 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.",
DisplayName = "Nome visualizzato", DisplayName = "Nome visualizzato",
Address = "Indirizzo", Address = "Indirizzo",
Username = "Nome utente (se diverso)", Username = "Nome utente (se diverso)",

View File

@ -170,8 +170,6 @@ public sealed partial class UiCopy {
Delete = "Löschen", Delete = "Löschen",
MoveTo = "Verschieben nach", MoveTo = "Verschieben nach",
Folders = "Ordner", Folders = "Ordner",
AllInboxes = "Alle Posteingange",
AllInboxesHint = "Nur der Posteingang jedes Kontos - keine Labels oder anderen Ordner - damit die Liste bei dem bleibt, was uber Konten hinweg Aufmerksamkeit braucht.",
SearchPlaceholder = "Betreff, Text, Anlagen, Kennzeichen, Bedeutung suchen", SearchPlaceholder = "Betreff, Text, Anlagen, Kennzeichen, Bedeutung suchen",
ClearSearch = "Suche leeren", ClearSearch = "Suche leeren",
Label = "Kennzeichen", Label = "Kennzeichen",
@ -211,7 +209,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 = "Post und Anmeldegeheimnisse ziehen nach {0}. Andere Konten auf diesem PC können das Postfach ohne erneute Anmeldung öffnen.", 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.",
DisplayName = "Anzeigename", DisplayName = "Anzeigename",
Address = "Adresse", Address = "Adresse",
Username = "Benutzername (falls abweichend)", Username = "Benutzername (falls abweichend)",

View File

@ -170,8 +170,6 @@ public sealed partial class UiCopy {
Delete = "Eliminar", Delete = "Eliminar",
MoveTo = "Mover a", MoveTo = "Mover a",
Folders = "Carpetas", Folders = "Carpetas",
AllInboxes = "Todas las bandejas",
AllInboxesHint = "Solo la bandeja de entrada de cada cuenta - no etiquetas u otras carpetas - para que la lista se centre en lo que requiere atencion entre cuentas.",
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",
@ -211,7 +209,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 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.", 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.",
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,8 +170,6 @@ public sealed partial class UiCopy {
Delete = "Supprimer", Delete = "Supprimer",
MoveTo = "Déplacer vers", MoveTo = "Déplacer vers",
Folders = "Dossiers", Folders = "Dossiers",
AllInboxes = "Toutes les boites",
AllInboxesHint = "Uniquement la boite de reception de chaque compte - pas les libelles ni les autres dossiers - pour garder la liste sur ce qui demande de l'attention.",
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",
@ -211,7 +209,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 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.", 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.",
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,32 +26,4 @@ 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,14 +11,9 @@ 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 privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id); var placed = SharedMailboxStore.Place(box, shared: true, privateRoot: Path.Combine(root, "unused"));
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);
@ -44,24 +39,17 @@ public class SharedMailboxTests {
} }
[Fact] [Fact]
public void PrivatePlaceDoesNotPublish_OnlySharedOnesAreImported() { public void EveryEnrolledAccountIsListed_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" };
var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), privateBox.Id); Assert.True(SharedMailboxStore.Place(privateBox, shared: false, Path.Combine(root, "a")).IsSuccess);
Directory.CreateDirectory(privateRoot); Assert.True(SharedMailboxStore.Place(sharedBox, shared: true, Path.Combine(root, "b")).IsSuccess);
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.Single(listed); Assert.Equal(2, listed.Count);
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);
@ -73,35 +61,4 @@ 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,24 +598,6 @@ 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);
}
} }
@ -897,63 +879,6 @@ 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("Sign-in cancelled."); vm.CancelIdentityHubSignIn("Identity Hub 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)
SyncMailboxSecrets(box.Id, box.Shared); CopySecretsToService(box.Id);
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,19 +1057,17 @@ 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 central = SharedMailPaths.AccountDirectory(box.Id); var privateRoot = File.Exists(SharedMailPaths.AccountFile(box.Id))
var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id); ? SharedMailPaths.AccountDirectory(box.Id)
var current = File.Exists(SharedMailPaths.AccountFile(box.Id))
? central
: MailArchiveLayout.MailRoot(box, configuration.Mailboxes); : MailArchiveLayout.MailRoot(box, configuration.Mailboxes);
var desired = EditorShareAccount ? central : privateRoot; var moving = !privateRoot.Equals(
var placeRoot = EditorShareAccount ? current : privateRoot; SharedMailPaths.AccountDirectory(box.Id),
var moving = !current.Equals(desired, StringComparison.OrdinalIgnoreCase); StringComparison.OrdinalIgnoreCase);
if (!moving) if (!moving)
return PlaceShared(configuration, box, placeRoot, reopen: _archive.TryGet(box.Id) is null); return PlaceShared(configuration, box, privateRoot, reopen: _archive.TryGet(box.Id) is null);
return HoldArchive(() => { return HoldArchive(() => {
_archive.Close(box.Id); _archive.Close(box.Id);
return PlaceShared(configuration, box, placeRoot, reopen: true); return PlaceShared(configuration, box, privateRoot, reopen: true);
}); });
} }
@ -1113,35 +1111,26 @@ 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 central = SharedMailPaths.AccountDirectory(box.Id); var root = File.Exists(SharedMailPaths.AccountFile(box.Id))
var privateRoot = Path.Combine(AppPaths.AccountsDirectory(), box.Id); ? SharedMailPaths.AccountDirectory(box.Id)
var current = File.Exists(SharedMailPaths.AccountFile(box.Id))
? central
: MailArchiveLayout.MailRoot(box, configuration.Mailboxes); : MailArchiveLayout.MailRoot(box, configuration.Mailboxes);
var placeRoot = box.Shared ? current : privateRoot; if (!SharedMailboxStore.Place(box, box.Shared, root).IsSuccess)
if (!SharedMailboxStore.Place(box, box.Shared, placeRoot).IsSuccess)
continue; continue;
SyncMailboxSecrets(box.Id, box.Shared); CopySecretsToService(box.Id);
} }
} }
private void SyncMailboxSecrets(string mailboxId, bool shared) { private void CopySecretsToService(string mailboxId) {
var moved = shared var service = new SharedSecretStore();
? PublishMailboxSecrets(mailboxId) CopySecret(service, FileSecretStore.MailboxKey(mailboxId));
: WithdrawMailboxSecrets(mailboxId); CopySecret(service, FileSecretStore.OAuthKey(mailboxId));
if (!moved.IsSuccess)
Status = string.Join(" ", moved.Messages);
} }
private Result PublishMailboxSecrets(string mailboxId) => private void CopySecret(SharedSecretStore service, string key) {
_secrets is LayeredSecretStore layered var stored = _secrets.Get(key);
? layered.PublishMailbox(mailboxId) if (stored.IsSuccess && !string.IsNullOrWhiteSpace(stored.Value))
: SharedSecretStore.PublishMailbox(_secrets, mailboxId); service.Put(key, stored.Value);
}
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() {
@ -2856,15 +2845,6 @@ 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)
@ -2877,8 +2857,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
} }
public bool ShowFolderContext => public bool ShowFolderContext =>
SelectedFolderNode is not { IsAllEmails: true } TargetFolderNodes().Count > 0;
&& TargetFolderNodes().Count > 0;
public bool ShowDetachPst => public bool ShowDetachPst =>
!IsBusy !IsBusy
@ -2898,18 +2877,6 @@ 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();
@ -2998,7 +2965,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
IsBusy = true; IsBusy = true;
SignInGoogleCommand.NotifyCanExecuteChanged(); SignInGoogleCommand.NotifyCanExecuteChanged();
SignInMicrosoftCommand.NotifyCanExecuteChanged(); SignInMicrosoftCommand.NotifyCanExecuteChanged();
Status = "Waiting for sign-in…"; Status = "Waiting for Identity Hub…";
IdentityHubSignInRequested?.Invoke(kind); IdentityHubSignInRequested?.Invoke(kind);
await Task.CompletedTask; await Task.CompletedTask;
} }
@ -3221,12 +3188,11 @@ 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 = 1; var index = 0;
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)) {
@ -3246,76 +3212,23 @@ 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 node = FolderTree[i]; var id = FolderTree[i].Mailbox?.Id;
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?.IsAllEmails == true var selectedBoxId = SelectedFolderNode?.Mailbox?.Id ?? SelectedMailbox?.Id;
? FolderNodeViewModel.AllEmailsId var selectedFolder = SelectedFolderNode?.Folder?.FullName ?? SelectedFolder?.FullName;
: 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.AllInboxes
};
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.AllInboxes;
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.AllInboxes;
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
@ -3323,13 +3236,6 @@ 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;
@ -3408,9 +3314,6 @@ 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);
@ -3423,9 +3326,6 @@ 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;
@ -5494,7 +5394,6 @@ 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() =>
@ -5552,23 +5451,6 @@ 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);
@ -5580,92 +5462,6 @@ 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,12 +114,8 @@ 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; }
@ -130,30 +126,13 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public ObservableCollection<FolderNodeViewModel> Children { get; } = []; public ObservableCollection<FolderNodeViewModel> Children { get; } = [];
public string Title => public string Title =>
IsAllEmails IsAccount ? Mailbox?.Label ?? UiLocale.Copy.Mailbox : Folder?.Name ?? "";
? UiLocale.Copy.AllInboxes
: IsAccount
? Mailbox?.Label ?? UiLocale.Copy.Mailbox
: Folder?.Name ?? "";
public string Tip =>
IsAllEmails
? UiLocale.Copy.AllInboxesHint
: QuotaTip;
public string Glyph => public string Glyph =>
IsAllEmails IsAccount ? "✉" : MailFolderRole.Glyph(Folder?.Name, Folder?.FullName);
? "📥"
: IsAccount
? "✉"
: MailFolderRole.Glyph(Folder?.Name, Folder?.FullName);
public int Unread => public int Unread =>
IsAllEmails IsAccount ? ChildUnread() : Folder?.Unread ?? 0;
? Folder?.Unread ?? 0
: IsAccount
? ChildUnread()
: Folder?.Unread ?? 0;
private int ChildUnread() { private int ChildUnread() {
var n = 0; var n = 0;
@ -198,7 +177,6 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public void NotifyCounts() { public void NotifyCounts() {
OnPropertyChanged(nameof(Title)); OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Tip));
OnPropertyChanged(nameof(Unread)); OnPropertyChanged(nameof(Unread));
OnPropertyChanged(nameof(UnreadLabel)); OnPropertyChanged(nameof(UnreadLabel));
OnPropertyChanged(nameof(HasUnread)); OnPropertyChanged(nameof(HasUnread));
@ -214,13 +192,6 @@ 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

@ -72,7 +72,7 @@
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
FontWeight="{Binding Weight}" FontWeight="{Binding Weight}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Tip}" /> ToolTip.Tip="{Binding QuotaTip}" />
<ProgressBar Height="3" <ProgressBar Height="3"
Minimum="0" Minimum="0"
Maximum="100" Maximum="100"

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="MaksIT Postclient" Title="Identity Hub"
Icon="/Assets/icon.ico" Icon="/Assets/icon.ico"
Width="920" Width="920"
Height="720" Height="720"
@ -9,21 +9,12 @@
MinHeight="480" MinHeight="480"
WindowStartupLocation="CenterOwner"> WindowStartupLocation="CenterOwner">
<DockPanel> <DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="12,8"> <TextBlock x:Name="StatusText"
<Button x:Name="BackButton" DockPanel.Dock="Top"
DockPanel.Dock="Left" Classes="hint"
Content="Back" Margin="12,8"
Classes="quiet" TextWrapping="Wrap"
Margin="0,0,12,0" Text="Waiting for Identity Hub… keep this window open until it closes itself." />
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,5 +1,4 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
@ -52,7 +51,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 += (_, _) => OnNavigationCompleted(); HubWeb.NavigationCompleted += (_, _) => _ = TryCompleteFromWebViewAsync();
HubWeb.WebMessageReceived += OnWebMessage; HubWeb.WebMessageReceived += OnWebMessage;
HubWeb.NewWindowRequested += OnNewWindow; HubWeb.NewWindowRequested += OnNewWindow;
Opened += OnOpened; Opened += OnOpened;
@ -62,55 +61,15 @@ 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 sign-in… keep this window open until it closes itself."; StatusText.Text = "Waiting for Identity Hub… 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 sign-in."; StatusText.Text = "Unable to open Identity Hub.";
return; return;
} }
@ -119,11 +78,10 @@ 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 sign-in."; StatusText.Text = "Unable to open Identity Hub.";
} }
} }
@ -163,7 +121,6 @@ public partial class IdentityHubLoginWindow : Window {
return; return;
_done = true; _done = true;
_poll.Stop(); _poll.Stop();
BackButton.IsEnabled = false;
Close(json); Close(json);
} }
} }