mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-11-13 21:56:11 +01:00
(feature): add All emails, relocate shared archives, and finish IMAP folder paging
This commit is contained in:
parent
70a6894b80
commit
be6fc8d550
17
CHANGELOG.md
17
CHANGELOG.md
@ -6,6 +6,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.8] - 2026-11-05
|
||||
|
||||
Sidebar **All emails** combines every Inbox. Sharing an account moves its archive and sign-in secrets with the setting. IMAP no longer stalls mid-folder after a partial FETCH.
|
||||
|
||||
### Changed
|
||||
- Account setting **Share this account with other users on this PC** moves the mailbox archive and its sign-in secrets: on -> `ProgramData` shared folder (+ `service-secrets.bin`), off -> this user's account folder and `secrets.bin` (replaces a stale archive copy at the destination).
|
||||
- Folder sidebar starts with **All emails** (combined Inboxes from every account) above the account list.
|
||||
|
||||
### Fixed
|
||||
- Identity Hub sign-in window: **Back** returns from Privacy/Terms (WebView history, or reload of the desktop-login URL).
|
||||
- IMAP folder sync no longer marks a label done after a partial FETCH: empty/partial pages retry (then skip after a few misses), and when SEARCH ALL returns fewer UIDs than EXISTS the plan is filled from sequence UIDs (Ebay-style 15-of-29 stalls).
|
||||
|
||||
## [0.4.7] - 2026-11-04
|
||||
|
||||
### Changed
|
||||
- Hub sign-in window title/status use **MaksIT Postclient** / “Waiting for sign-in…”, not “Identity Hub”.
|
||||
|
||||
## [0.4.6] - 2026-11-03
|
||||
|
||||
Folder sync keeps paging older IMAP mail after the newest page is already archived. Identity Hub desktop login passes the covered app id.
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# Postclient
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](LICENSE.md)
|
||||

|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
50
src/MaksIT.PostClient.Shared/Auth/LayeredSecretStore.cs
Normal file
50
src/MaksIT.PostClient.Shared/Auth/LayeredSecretStore.cs
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user