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 @@
+
+