diff --git a/CHANGELOG.md b/CHANGELOG.md index 4888365..88d5a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic ## [Unreleased] +## [0.3.6] - 2026-09-20 + +Folder unread and retention days sit in separate columns. Sign-in and Gmail trash retention survive a rebuild. + +### Changed + +- Folder tree keeps unread count and retention days in two fixed columns on the right, instead of collapsing them next to the folder name. + +### Fixed + +- **Empty folder** stays enabled on folders that have mail, including while Get Messages is still running. +- Installing a new build keeps Gmail and Outlook sign-in: leftover secrets and the Identity Hub profile are merged from the previous data folder, and a still-valid mailbox token is used if Hub refresh fails. +- Gmail **[Gmail]/Trash** retention days show on the nested folder and permanently delete mail by received date after Get Messages or **Run now**. + ## [0.3.5] - 2026-09-20 Index status covers every mailbox, not one folder or one account. diff --git a/data/eu.postclient.desktop.metainfo.xml b/data/eu.postclient.desktop.metainfo.xml index 8953350..3d6cdc6 100644 --- a/data/eu.postclient.desktop.metainfo.xml +++ b/data/eu.postclient.desktop.metainfo.xml @@ -30,6 +30,11 @@ 768 + + +

Folder unread and retention days stay in separate columns. A new install keeps Gmail and Outlook sign-in. Gmail Trash retention shows on the nested folder and permanently deletes old trash.

+
+

Keyword and meaning index status shows one done / total across all mailboxes, not the current folder or a single account.

diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5109b0d..74fdebe 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.3.5 + 0.3.6 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/MailAuthService.cs b/src/MaksIT.PostClient.Client/MailAuthService.cs index a57c3b1..3bfb480 100644 --- a/src/MaksIT.PostClient.Client/MailAuthService.cs +++ b/src/MaksIT.PostClient.Client/MailAuthService.cs @@ -46,42 +46,71 @@ public sealed class MailAuthService : IMailAuthService { "Sign in with Google or Microsoft in Account Settings (Identity Hub)."); var tokens = loaded.Value; - if (!HasHubSession(tokens)) - return Result.UnprocessableEntity( - null, - "Sign in again through Identity Hub."); - - try { - if (!string.IsNullOrWhiteSpace(tokens.HubRefreshToken)) { - var refreshed = await _hub.RefreshTokenAsync( - new RefreshTokenRequest { RefreshToken = tokens.HubRefreshToken }, - cancellationToken).ConfigureAwait(false); - tokens.HubToken = refreshed.Token; - if (!string.IsNullOrWhiteSpace(refreshed.RefreshToken)) - tokens.HubRefreshToken = refreshed.RefreshToken; - tokens.HubExpires = AsUtc(refreshed.ExpiresAt); - if (!string.IsNullOrWhiteSpace(refreshed.Username)) - tokens.Email = refreshed.Username; - SaveTokens(account.Id, tokens); + if (HasHubSession(tokens)) { + try { + return await RefreshHubMailboxAsync(account.Id, tokens, cancellationToken).ConfigureAwait(false); } - else if (tokens.HubExpires <= DateTimeOffset.UtcNow.AddMinutes(2)) - return Result.UnprocessableEntity(null, "Sign in again. The Hub session expired."); + catch (IdentityHubApiException ex) { + var cached = CachedMailbox(tokens); + if (cached is not null) + return cached; + return Result.UnprocessableEntity(null, ex.Message); + } + } - _hub.AccessToken = tokens.HubToken; - var mailbox = await _hub.GetMailboxAccessTokenAsync(cancellationToken).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(mailbox.AccessToken)) - return Result.UnprocessableEntity(null, "Identity Hub did not return a mailbox token."); - tokens.AccessToken = mailbox.AccessToken; - tokens.AccessExpires = DateTimeOffset.UtcNow.AddMinutes(50); - SaveTokens(account.Id, tokens); - return Result.Ok(new MailAuthMaterial { - UseOAuth = true, - AccessToken = mailbox.AccessToken - }); + var leftover = CachedMailbox(tokens); + if (leftover is not null) + return leftover; + return Result.UnprocessableEntity( + null, + "Sign in again through Identity Hub."); + } + + private async Task> RefreshHubMailboxAsync( + string mailboxId, + OAuthTokenSet tokens, + CancellationToken cancellationToken) { + if (!string.IsNullOrWhiteSpace(tokens.HubRefreshToken)) { + var refreshed = await _hub.RefreshTokenAsync( + new RefreshTokenRequest { RefreshToken = tokens.HubRefreshToken }, + cancellationToken).ConfigureAwait(false); + tokens.HubToken = refreshed.Token; + if (!string.IsNullOrWhiteSpace(refreshed.RefreshToken)) + tokens.HubRefreshToken = refreshed.RefreshToken; + tokens.HubExpires = AsUtc(refreshed.ExpiresAt); + if (!string.IsNullOrWhiteSpace(refreshed.Username)) + tokens.Email = refreshed.Username; + SaveTokens(mailboxId, tokens); } - catch (IdentityHubApiException ex) { - return Result.UnprocessableEntity(null, ex.Message); + else if (tokens.HubExpires <= DateTimeOffset.UtcNow.AddMinutes(2)) { + var cached = CachedMailbox(tokens); + if (cached is not null) + return cached; + return Result.UnprocessableEntity(null, "Sign in again. The Hub session expired."); } + + _hub.AccessToken = tokens.HubToken; + var mailbox = await _hub.GetMailboxAccessTokenAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(mailbox.AccessToken)) + return Result.UnprocessableEntity(null, "Identity Hub did not return a mailbox token."); + tokens.AccessToken = mailbox.AccessToken; + tokens.AccessExpires = DateTimeOffset.UtcNow.AddMinutes(50); + SaveTokens(mailboxId, tokens); + return Result.Ok(new MailAuthMaterial { + UseOAuth = true, + AccessToken = mailbox.AccessToken + }); + } + + private static Result? CachedMailbox(OAuthTokenSet tokens) { + if (string.IsNullOrWhiteSpace(tokens.AccessToken)) + return null; + if (tokens.AccessExpires <= DateTimeOffset.UtcNow.AddMinutes(2)) + return null; + return Result.Ok(new MailAuthMaterial { + UseOAuth = true, + AccessToken = tokens.AccessToken + }); } public Task> SignInAsync( diff --git a/src/MaksIT.PostClient.Client/MailWorkerHost.cs b/src/MaksIT.PostClient.Client/MailWorkerHost.cs index 238e02a..fd9136e 100644 --- a/src/MaksIT.PostClient.Client/MailWorkerHost.cs +++ b/src/MaksIT.PostClient.Client/MailWorkerHost.cs @@ -113,15 +113,23 @@ public sealed class MailWorkerHost : IDisposable { var configuration = _files.Current; configuration.EnsureDefaults(); foreach (var job in MailRetention.Jobs(configuration.Retention)) { + var box = configuration.FindMailbox(job.MailboxId); + var remote = box is { IsLocalStore: false }; var known = _archive.ListFolders(job.MailboxId); var folder = MailRetention.BindFolder(job.Folder, known); if (string.IsNullOrWhiteSpace(folder)) continue; var cutoff = DateTimeOffset.UtcNow.AddDays(-job.Days); - var ids = _archive.UidsOlderThan(job.MailboxId, folder, cutoff); + var ids = _archive.UidsOlderThan( + job.MailboxId, + folder, + cutoff, + useReceivedDate: remote && MailRetention.IsTrash(folder)); if (ids.Count == 0) continue; if (MailRetention.IsTrash(folder)) { + if (remote) + continue; DeleteFiles(_archive.RemoveUids(job.MailboxId, folder, ids)); count += ids.Count; continue; diff --git a/src/MaksIT.PostClient.Shared/AppPaths.cs b/src/MaksIT.PostClient.Shared/AppPaths.cs index f3bd492..2889843 100644 --- a/src/MaksIT.PostClient.Shared/AppPaths.cs +++ b/src/MaksIT.PostClient.Shared/AppPaths.cs @@ -1,3 +1,6 @@ +using System.Text.Json; + + namespace MaksIT.PostClient.Shared; @@ -202,12 +205,67 @@ public static class AppPaths { var oldRoam = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ProductName); - if (!Directory.Exists(dest) && Directory.Exists(oldLocal)) - Directory.Move(oldLocal, dest); + if (!Directory.Exists(dest) && Directory.Exists(oldLocal)) { + try { + Directory.Move(oldLocal, dest); + } + catch { + } + } + Directory.CreateDirectory(dest); - CopyIfMissing(Path.Combine(oldRoam, "settings.json"), Path.Combine(dest, "settings.json")); - CopyIfMissing(Path.Combine(oldRoam, "secrets.bin"), Path.Combine(dest, "secrets.bin")); + AdoptSettingsIfEmpty(Path.Combine(oldRoam, "settings.json"), Path.Combine(dest, "settings.json")); + AdoptSettingsIfEmpty(Path.Combine(oldLocal, "settings.json"), Path.Combine(dest, "settings.json")); + FileSecretStore.MergeMissingKeys(Path.Combine(oldRoam, "secrets.bin"), Path.Combine(dest, "secrets.bin")); + FileSecretStore.MergeMissingKeys(Path.Combine(oldLocal, "secrets.bin"), Path.Combine(dest, "secrets.bin")); CopyIfMissing(Path.Combine(oldRoam, "receipts.json"), Path.Combine(dest, "receipts.json")); + MergeMissingDirectory(oldLocal, dest); + MergeMissingDirectory(Path.Combine(oldLocal, "webview"), Path.Combine(dest, "webview")); + } + + internal static void AdoptSettingsIfEmpty(string source, string dest) { + if (!File.Exists(source)) + return; + if (!File.Exists(dest)) { + CopyIfMissing(source, dest); + return; + } + + if (MailboxCount(dest) > 0 || MailboxCount(source) == 0) + return; + File.Copy(source, dest, overwrite: true); + } + + internal static void MergeMissingDirectory(string source, string dest) { + if (!Directory.Exists(source)) + return; + Directory.CreateDirectory(dest); + foreach (var file in Directory.GetFiles(source)) { + var name = Path.GetFileName(file); + if (name.Equals("settings.json", StringComparison.OrdinalIgnoreCase) + || name.Equals("secrets.bin", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".migrated", StringComparison.OrdinalIgnoreCase)) + continue; + CopyIfMissing(file, Path.Combine(dest, name)); + } + + foreach (var dir in Directory.GetDirectories(source)) + MergeMissingDirectory(dir, Path.Combine(dest, Path.GetFileName(dir))); + } + + internal static int MailboxCount(string settingsPath) { + try { + using var document = JsonDocument.Parse(File.ReadAllText(settingsPath)); + if (!document.RootElement.TryGetProperty("Configuration", out var configuration)) + return 0; + if (!configuration.TryGetProperty("Mailboxes", out var boxes) + && !configuration.TryGetProperty("mailboxes", out boxes)) + return 0; + return boxes.ValueKind == JsonValueKind.Array ? boxes.GetArrayLength() : 0; + } + catch { + return 0; + } } private static void CopyIfMissing(string source, string dest) { diff --git a/src/MaksIT.PostClient.Shared/ConfigurationFileService.cs b/src/MaksIT.PostClient.Shared/ConfigurationFileService.cs index f3a7cef..553f333 100644 --- a/src/MaksIT.PostClient.Shared/ConfigurationFileService.cs +++ b/src/MaksIT.PostClient.Shared/ConfigurationFileService.cs @@ -10,7 +10,8 @@ public sealed class ConfigurationFileService { private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, - PropertyNamingPolicy = null + PropertyNamingPolicy = null, + PropertyNameCaseInsensitive = true }; private readonly string? _seedPath; diff --git a/src/MaksIT.PostClient.Shared/FileSecretStore.cs b/src/MaksIT.PostClient.Shared/FileSecretStore.cs index d923154..29a8b1f 100644 --- a/src/MaksIT.PostClient.Shared/FileSecretStore.cs +++ b/src/MaksIT.PostClient.Shared/FileSecretStore.cs @@ -64,11 +64,35 @@ public sealed class FileSecretStore : ISecretStore { public static string OAuthClientSecretKey(string? authKind) => "oauth-client-secret:" + MailAuthKind.Normalize(authKind); - private Dictionary Load() { - if (!File.Exists(_path)) + public static int MergeMissingKeys(string sourcePath, string destPath) { + var source = ReadMap(sourcePath); + if (source.Count == 0) + return 0; + var dest = ReadMap(destPath); + var added = 0; + foreach (var pair in source) { + if (dest.ContainsKey(pair.Key)) + continue; + dest[pair.Key] = pair.Value; + added++; + } + + if (added > 0) + WriteMap(destPath, dest); + return added; + } + + private Dictionary Load() => + ReadMap(_path); + + private void Save(Dictionary map) => + WriteMap(_path, map); + + private static Dictionary ReadMap(string path) { + if (!File.Exists(path)) return new Dictionary(StringComparer.Ordinal); try { - var json = File.ReadAllText(_path); + var json = File.ReadAllText(path); return JsonSerializer.Deserialize>(json) ?? new Dictionary(StringComparer.Ordinal); } @@ -77,13 +101,13 @@ public sealed class FileSecretStore : ISecretStore { } } - private void Save(Dictionary map) { - var dir = Path.GetDirectoryName(_path); + private static void WriteMap(string path, Dictionary map) { + var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - File.WriteAllText(_path, JsonSerializer.Serialize(map)); + File.WriteAllText(path, JsonSerializer.Serialize(map)); if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } private static string Protect(string secret) { diff --git a/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs b/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs index c8fc464..49ac4c6 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs +++ b/src/MaksIT.PostClient.Shared/MailArchiveCatalog.cs @@ -387,8 +387,12 @@ public sealed class MailArchiveCatalog : IDisposable { return dest.InsertCopy(destMailboxId, destFolder, copy); }); - public IReadOnlyList UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) => - OffUi(() => TryGet(mailboxId)?.UidsOlderThan(mailboxId, folder, cutoff) ?? []); + public IReadOnlyList UidsOlderThan( + string mailboxId, + string folder, + DateTimeOffset cutoff, + bool useReceivedDate = false) => + OffUi(() => TryGet(mailboxId)?.UidsOlderThan(mailboxId, folder, cutoff, useReceivedDate) ?? []); public int CopyDirectory(string source, string dest) => OffUi(() => { diff --git a/src/MaksIT.PostClient.Shared/MailArchiveStore.cs b/src/MaksIT.PostClient.Shared/MailArchiveStore.cs index 781757e..a0236d7 100644 --- a/src/MaksIT.PostClient.Shared/MailArchiveStore.cs +++ b/src/MaksIT.PostClient.Shared/MailArchiveStore.cs @@ -638,6 +638,26 @@ public sealed class MailArchiveStore : IDisposable { } } + private bool HasMessageElsewhere( + SqliteTransaction tx, + string mailboxId, + string folder, + string? messageId) { + if (string.IsNullOrWhiteSpace(messageId)) + return false; + using var cmd = _db.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = """ + SELECT 1 FROM messages + WHERE mailbox_id = $m AND message_id = $mid AND folder <> $f + LIMIT 1; + """; + cmd.Parameters.AddWithValue("$m", mailboxId); + cmd.Parameters.AddWithValue("$mid", messageId.Trim()); + cmd.Parameters.AddWithValue("$f", folder); + return cmd.ExecuteScalar() is not null; + } + public MailArchiveCopy? ReadCopy(string mailboxId, string folder, uint uid) { lock (_gate) { using var cmd = _db.CreateCommand(); @@ -703,7 +723,12 @@ public sealed class MailArchiveStore : IDisposable { EmlPath = copy.Header.EmlPath, Labels = copy.Header.Labels }; - var id = UpsertHeader(tx, mailboxId, header, updateFlags: true); + var id = UpsertHeader( + tx, + mailboxId, + header, + updateFlags: true, + stampTrashTime: MailRetention.IsTrash(folder)); using (var body = _db.CreateCommand()) { body.Transaction = tx; body.CommandText = """ @@ -759,11 +784,15 @@ public sealed class MailArchiveStore : IDisposable { } } - public IReadOnlyList UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) { - var trash = MailRetention.IsTrash(folder); + public IReadOnlyList UidsOlderThan( + string mailboxId, + string folder, + DateTimeOffset cutoff, + bool useReceivedDate = false) { + var trashStamp = MailRetention.IsTrash(folder) && !useReceivedDate; lock (_gate) { using var cmd = _db.CreateCommand(); - cmd.CommandText = trash + cmd.CommandText = trashStamp ? """ SELECT uid FROM messages WHERE mailbox_id = $m AND folder = $f @@ -869,7 +898,8 @@ public sealed class MailArchiveStore : IDisposable { SqliteTransaction tx, string mailboxId, MailArchiveHeader header, - bool updateFlags) { + bool updateFlags, + bool stampTrashTime = false) { using var cmd = _db.CreateCommand(); cmd.Transaction = tx; var flagAssign = updateFlags @@ -894,15 +924,11 @@ public sealed class MailArchiveStore : IDisposable { in_reply_to = excluded.in_reply_to, {flagAssign} has_attachments = excluded.has_attachments, - priority = excluded.priority, - trashed_utc = CASE - WHEN messages.trashed_utc <> '' THEN messages.trashed_utc - WHEN $trash = 1 THEN excluded.trashed_utc - ELSE messages.trashed_utc - END; + priority = excluded.priority; """; var trash = MailRetention.IsTrash(header.Folder); - var trashed = trash ? DateTimeOffset.UtcNow.ToString("O") : ""; + var stampTrash = trash && (stampTrashTime || HasMessageElsewhere(tx, mailboxId, header.Folder, header.MessageId)); + var trashed = stampTrash ? DateTimeOffset.UtcNow.ToString("O") : ""; cmd.Parameters.AddWithValue("$m", mailboxId); cmd.Parameters.AddWithValue("$f", header.Folder); cmd.Parameters.AddWithValue("$u", header.Uid); @@ -919,7 +945,6 @@ public sealed class MailArchiveStore : IDisposable { cmd.Parameters.AddWithValue("$att", header.HasAttachments ? 1 : 0); cmd.Parameters.AddWithValue("$pri", header.Priority); cmd.Parameters.AddWithValue("$trashed", trashed); - cmd.Parameters.AddWithValue("$trash", trash ? 1 : 0); cmd.ExecuteNonQuery(); using var idCmd = _db.CreateCommand(); idCmd.Transaction = tx; diff --git a/src/MaksIT.PostClient.Shared/MailFolderRole.cs b/src/MaksIT.PostClient.Shared/MailFolderRole.cs index 51f1a67..8361dff 100644 --- a/src/MaksIT.PostClient.Shared/MailFolderRole.cs +++ b/src/MaksIT.PostClient.Shared/MailFolderRole.cs @@ -123,6 +123,14 @@ public static class MailFolderRole { public static bool CanHoldFolders(string? name, string? fullName) => IsNamespace(name, fullName) || IsCustom(name, fullName); + public static bool CanEmpty(string? name, string? fullName, string? incomingProtocol) { + if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(fullName)) + return false; + if (IsNamespace(name, fullName)) + return false; + return !MailProtocol.IsPop3(incomingProtocol); + } + public static string? DisplayParent( string? name, string? fullName, diff --git a/src/MaksIT.PostClient.Shared/MailRetention.cs b/src/MaksIT.PostClient.Shared/MailRetention.cs index 3a2843b..1d36f38 100644 --- a/src/MaksIT.PostClient.Shared/MailRetention.cs +++ b/src/MaksIT.PostClient.Shared/MailRetention.cs @@ -34,7 +34,24 @@ public static class MailRetention { .Where(folder => !string.IsNullOrWhiteSpace(folder)) .Select(folder => (folder, folder)) .ToList(); - return MailRuleEngine.ExactFolder(wanted, list) ?? (wanted ?? "").Trim(); + return MailRuleEngine.ExactFolder(wanted, list) + ?? MailRuleEngine.ResolveFolder(wanted, list) + ?? (wanted ?? "").Trim(); + } + + public static int DaysFor(string mailboxId, string folder, IEnumerable? retention) { + if (string.IsNullOrWhiteSpace(mailboxId) || string.IsNullOrWhiteSpace(folder)) + return 0; + foreach (var row in Jobs(retention)) { + if (!row.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)) + continue; + if (row.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase)) + return row.Days; + if (IsTrash(folder) && IsTrash(row.Folder)) + return row.Days; + } + + return 0; } public static IReadOnlyList Jobs(IEnumerable? retention) { diff --git a/src/MaksIT.PostClient.Tests/ArchiveTests.cs b/src/MaksIT.PostClient.Tests/ArchiveTests.cs index 2cad9e6..ee5e88a 100644 --- a/src/MaksIT.PostClient.Tests/ArchiveTests.cs +++ b/src/MaksIT.PostClient.Tests/ArchiveTests.cs @@ -288,16 +288,79 @@ public class MailArchiveStoreTests { [ new MailArchiveHeader { Uid = 1, + Folder = "INBOX", + Subject = "Old", + From = "a@b.c", + MessageId = "old@mail", + Date = DateTimeOffset.UtcNow.AddDays(-40) + } + ]); + store.UpsertHeaders( + "box", + [ + new MailArchiveHeader { + Uid = 2, Folder = MailRetention.TrashFolder, Subject = "Old", From = "a@b.c", + MessageId = "old@mail", Date = DateTimeOffset.UtcNow.AddDays(-40) } ]); var yesterday = DateTimeOffset.UtcNow.AddDays(-1); Assert.Empty(store.UidsOlderThan("box", MailRetention.TrashFolder, yesterday)); var tomorrow = DateTimeOffset.UtcNow.AddDays(1); - Assert.Equal([1u], store.UidsOlderThan("box", MailRetention.TrashFolder, tomorrow)); + Assert.Equal([2u], store.UidsOlderThan("box", MailRetention.TrashFolder, tomorrow)); + } + finally { + DeleteArchive(path); + } + } + + [Fact] + public void GmailTrashRetention_UsesReceivedDate() { + var path = Path.Combine(Path.GetTempPath(), "postclient-archive-" + Guid.NewGuid().ToString("N") + ".db"); + try { + using var store = new MailArchiveStore(path); + store.UpsertHeaders( + "gmail", + [ + new MailArchiveHeader { + Uid = 1, + Folder = "INBOX", + Subject = "Old trash", + From = "a@b.c", + MessageId = "old-trash@mail", + Date = DateTimeOffset.UtcNow.AddDays(-40) + } + ]); + store.UpsertHeaders( + "gmail", + [ + new MailArchiveHeader { + Uid = 9, + Folder = "[Gmail]/Trash", + Subject = "Old trash", + From = "a@b.c", + MessageId = "old-trash@mail", + Date = DateTimeOffset.UtcNow.AddDays(-40) + } + ]); + var yesterday = DateTimeOffset.UtcNow.AddDays(-1); + Assert.Empty(store.UidsOlderThan("gmail", "[Gmail]/Trash", yesterday)); + Assert.Equal([9u], store.UidsOlderThan("gmail", "[Gmail]/Trash", yesterday, useReceivedDate: true)); + store.UpsertHeaders( + "gmail", + [ + new MailArchiveHeader { + Uid = 3, + Folder = "[Gmail]/Trash", + Subject = "Already in trash", + From = "a@b.c", + Date = DateTimeOffset.UtcNow.AddDays(-40) + } + ]); + Assert.Equal([3u], store.UidsOlderThan("gmail", "[Gmail]/Trash", yesterday)); } finally { DeleteArchive(path); diff --git a/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs b/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs index db0c06d..fd5a4c6 100644 --- a/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs +++ b/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs @@ -193,6 +193,28 @@ public class MailRetentionTests { Assert.Equal("Cestino", MailRetention.ResolveTrash(["INBOX", "Cestino"])); Assert.Equal(MailRetention.TrashFolder, MailRetention.ResolveTrash(["INBOX"])); Assert.True(MailRetention.IsTrash("Deleted Items")); + Assert.True(MailRetention.IsTrash("Trash", "[Gmail]/Trash")); + } + + [Fact] + public void DaysFor_MatchesGmailTrashPath() { + var rows = new[] { + new FolderRetention { MailboxId = "gmail", Folder = "[Gmail]/Trash", Days = 7 } + }; + Assert.Equal(7, MailRetention.DaysFor("gmail", "[Gmail]/Trash", rows)); + Assert.Equal(7, MailRetention.DaysFor("gmail", "Trash", rows)); + Assert.Equal(0, MailRetention.DaysFor("gmail", "INBOX", rows)); + Assert.Equal(0, MailRetention.DaysFor("other", "[Gmail]/Trash", rows)); + } + + [Fact] + public void BindFolder_ResolvesGmailTrash() { + Assert.Equal( + "[Gmail]/Trash", + MailRetention.BindFolder("[Gmail]/Trash", ["INBOX", "[Gmail]/Trash", "[Gmail]/Spam"])); + Assert.Equal( + "[Gmail]/Trash", + MailRetention.BindFolder("Trash", ["INBOX", "[Gmail]/Trash"])); } } diff --git a/src/MaksIT.PostClient.Tests/SharedTests.cs b/src/MaksIT.PostClient.Tests/SharedTests.cs index bd65d7e..dd09587 100644 --- a/src/MaksIT.PostClient.Tests/SharedTests.cs +++ b/src/MaksIT.PostClient.Tests/SharedTests.cs @@ -102,6 +102,40 @@ public class AppPathsTests { Environment.SetEnvironmentVariable(AppPaths.ConfigEnv, previous); } } + + [Fact] + public void AdoptSettingsIfEmpty_ReplacesSeedWithoutMailboxes() { + var dir = Path.Combine(Path.GetTempPath(), "postclient-mig-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var source = Path.Combine(dir, "old.json"); + var dest = Path.Combine(dir, "new.json"); + try { + File.WriteAllText(source, """{"Configuration":{"Mailboxes":[{"Id":"keep"}]}}"""); + File.WriteAllText(dest, """{"Configuration":{"Mailboxes":[]}}"""); + AppPaths.AdoptSettingsIfEmpty(source, dest); + Assert.Equal(1, AppPaths.MailboxCount(dest)); + } + finally { + Directory.Delete(dir, true); + } + } + + [Fact] + public void MergeMissingDirectory_CopiesHubProfile() { + var dir = Path.Combine(Path.GetTempPath(), "postclient-wv-" + Guid.NewGuid().ToString("N")); + var source = Path.Combine(dir, "old", "webview", "hub"); + var dest = Path.Combine(dir, "new", "webview"); + Directory.CreateDirectory(source); + Directory.CreateDirectory(dest); + try { + File.WriteAllText(Path.Combine(source, "session"), "hub"); + AppPaths.MergeMissingDirectory(Path.Combine(dir, "old", "webview"), dest); + Assert.True(File.Exists(Path.Combine(dest, "hub", "session"))); + } + finally { + Directory.Delete(dir, true); + } + } } @@ -491,6 +525,21 @@ public class MailFolderRoleTests { Assert.False(MailFolderRole.IsCustom("All Mail", "[Gmail]/All Mail")); } + [Fact] + public void CanEmpty_AllowsImapFoldersWithMail() { + Assert.True(MailFolderRole.CanEmpty("INBOX", "INBOX", MailProtocol.Imap)); + Assert.True(MailFolderRole.CanEmpty("Clients", "INBOX.Clients", MailProtocol.Imap)); + Assert.True(MailFolderRole.CanEmpty("Trash", "[Gmail]/Trash", MailProtocol.Imap)); + Assert.True(MailFolderRole.CanEmpty("Inbox", "Inbox", MailProtocol.Store)); + } + + [Fact] + public void CanEmpty_RejectsNamespaceAndPop3() { + Assert.False(MailFolderRole.CanEmpty("[Gmail]", "[Gmail]", MailProtocol.Imap)); + Assert.False(MailFolderRole.CanEmpty("INBOX", "INBOX", MailProtocol.Pop3)); + Assert.False(MailFolderRole.CanEmpty(null, null, MailProtocol.Imap)); + } + [Fact] public void GmailTrash_IsTrash() { Assert.Equal("trash", MailFolderRole.Kind("Trash", "[Gmail]/Trash")); @@ -677,6 +726,28 @@ public class FileSecretStoreTests { File.Delete(path); } } + + [Fact] + public void MergeMissingKeys_KeepsDestAndFillsGaps() { + var dest = Path.Combine(Path.GetTempPath(), "postclient-secrets-d-" + Guid.NewGuid().ToString("N") + ".bin"); + var source = Path.Combine(Path.GetTempPath(), "postclient-secrets-s-" + Guid.NewGuid().ToString("N") + ".bin"); + try { + var destStore = new FileSecretStore(dest); + destStore.Put("oauth:keep", "new"); + var sourceStore = new FileSecretStore(source); + sourceStore.Put("oauth:keep", "old"); + sourceStore.Put("oauth:missing", "legacy"); + Assert.Equal(1, FileSecretStore.MergeMissingKeys(source, dest)); + Assert.Equal("new", destStore.Get("oauth:keep").Value); + Assert.Equal("legacy", destStore.Get("oauth:missing").Value); + } + finally { + if (File.Exists(dest)) + File.Delete(dest); + if (File.Exists(source)) + File.Delete(source); + } + } } @@ -765,6 +836,7 @@ public class ConfigurationFileServiceTests { var reloaded = new ConfigurationFileService(path).Current; Assert.Single(reloaded.Mailboxes); Assert.Equal("a@b.it", reloaded.Mailboxes[0].Address); + Assert.Equal(configuration.Mailboxes[0].Id, reloaded.Mailboxes[0].Id); Assert.Equal("imap.example.it", reloaded.Mailboxes[0].ImapHost); Assert.Equal(MailCertifiedKind.Pec, reloaded.Mailboxes[0].CertifiedKind); } @@ -773,6 +845,32 @@ public class ConfigurationFileServiceTests { } } + [Fact] + public void Load_KeepsCamelCaseMailboxId() { + var dir = Path.Combine(Path.GetTempPath(), "postclient-cfg-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "settings.json"); + try { + File.WriteAllText( + path, + """ + { + "Configuration": { + "Mailboxes": [ + { "id": "abc123", "Address": "a@b.it", "AuthKind": "google" } + ] + } + } + """); + var loaded = new ConfigurationFileService(path).Current; + Assert.Equal("abc123", loaded.Mailboxes[0].Id); + Assert.Equal(MailAuthKind.Google, loaded.Mailboxes[0].AuthKind); + } + finally { + Directory.Delete(dir, true); + } + } + [Fact] public void SaveReload_KeepsLayout() { var dir = Path.Combine(Path.GetTempPath(), "postclient-cfg-" + Guid.NewGuid().ToString("N")); diff --git a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs b/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs index 4da4001..6f2a2d7 100644 --- a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs +++ b/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs @@ -147,6 +147,26 @@ public class MailAuthServiceHubTests { Assert.Equal("imap-mailbox-token", result.Value?.AccessToken); Assert.Equal("user@outlook.com", result.Value?.Email); } + + [Fact] + public async Task ResolveAsync_UsesCachedMailboxTokenWhenHubRefreshFails() { + var secrets = new FileSecretStore(Path.Combine(Path.GetTempPath(), "postclient-hub-" + Guid.NewGuid().ToString("N") + ".bin")); + var hub = new FakeIdentityHub { FailRefresh = true }; + var auth = new MailAuthService(secrets, hub); + var box = new MailboxAccount { AuthKind = MailAuthKind.Google }; + auth.SaveTokens(box.Id, new OAuthTokenSet { + HubToken = "hub-jwt", + HubRefreshToken = "hub-refresh", + HubExpires = DateTimeOffset.UtcNow.AddHours(1), + AccessToken = "cached-imap", + AccessExpires = DateTimeOffset.UtcNow.AddMinutes(30), + Email = "user@gmail.com" + }); + var material = await auth.ResolveAsync(box, password: null, TestContext.Current.CancellationToken); + Assert.True(material.IsSuccess); + Assert.Equal("cached-imap", material.Value?.AccessToken); + Assert.Equal(1, hub.RefreshCalls); + } } @@ -180,6 +200,8 @@ file sealed class FakeIdentityHub : IIdentityHubClient { public int RefreshCalls { get; private set; } + public bool FailRefresh { get; set; } + public Task CheckHealthLiveAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -214,6 +236,8 @@ file sealed class FakeIdentityHub : IIdentityHubClient { public Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) { RefreshCalls++; + if (FailRefresh) + throw new IdentityHubApiException(401, "refresh failed", "", null); return Task.FromResult(new LoginResponse( "Bearer", "hub-jwt-refreshed", diff --git a/src/MaksIT.PostClient.UI/MainWindow.axaml b/src/MaksIT.PostClient.UI/MainWindow.axaml index 239ef69..86bfbfa 100644 --- a/src/MaksIT.PostClient.UI/MainWindow.axaml +++ b/src/MaksIT.PostClient.UI/MainWindow.axaml @@ -199,11 +199,27 @@ - + + + - + VerticalAlignment="Center" + DockPanel.Dock="Left" /> + - - - + diff --git a/src/MaksIT.PostClient.UI/MainWindow.axaml.cs b/src/MaksIT.PostClient.UI/MainWindow.axaml.cs index b519067..080ba79 100644 --- a/src/MaksIT.PostClient.UI/MainWindow.axaml.cs +++ b/src/MaksIT.PostClient.UI/MainWindow.axaml.cs @@ -358,6 +358,8 @@ public partial class MainWindow : Window { if (current is Control { DataContext: FolderNodeViewModel node }) { if (!vm.SelectedFolderNodes.Contains(node)) vm.SetSelectedFolders([node], node); + else + vm.RefreshFolderMenu(); RestoreFolderTreeSelection(); return; } diff --git a/src/MaksIT.PostClient.UI/Themes/Dark.axaml b/src/MaksIT.PostClient.UI/Themes/Dark.axaml index 38c634b..458a3bc 100644 --- a/src/MaksIT.PostClient.UI/Themes/Dark.axaml +++ b/src/MaksIT.PostClient.UI/Themes/Dark.axaml @@ -250,6 +250,9 @@ + +