(feature): split folder columns and keep oauth across upgrades

This commit is contained in:
Maksym Sadovnychyy 2026-09-20 18:46:23 +02:00
parent adee0696ba
commit 06936e2bbd
21 changed files with 540 additions and 119 deletions

View File

@ -6,6 +6,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
## [Unreleased] ## [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 ## [0.3.5] - 2026-09-20
Index status covers every mailbox, not one folder or one account. Index status covers every mailbox, not one folder or one account.

View File

@ -30,6 +30,11 @@
<display_length compare="ge">768</display_length> <display_length compare="ge">768</display_length>
</requires> </requires>
<releases> <releases>
<release version="0.3.6" date="2026-09-20">
<description>
<p>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.</p>
</description>
</release>
<release version="0.3.5" date="2026-09-20"> <release version="0.3.5" date="2026-09-20">
<description> <description>
<p>Keyword and meaning index status shows one done / total across all mailboxes, not the current folder or a single account.</p> <p>Keyword and meaning index status shows one done / total across all mailboxes, not the current folder or a single account.</p>

View File

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

View File

@ -46,12 +46,30 @@ public sealed class MailAuthService : IMailAuthService {
"Sign in with Google or Microsoft in Account Settings (Identity Hub)."); "Sign in with Google or Microsoft in Account Settings (Identity Hub).");
var tokens = loaded.Value; var tokens = loaded.Value;
if (!HasHubSession(tokens)) if (HasHubSession(tokens)) {
try {
return await RefreshHubMailboxAsync(account.Id, tokens, cancellationToken).ConfigureAwait(false);
}
catch (IdentityHubApiException ex) {
var cached = CachedMailbox(tokens);
if (cached is not null)
return cached;
return Result<MailAuthMaterial>.UnprocessableEntity(null, ex.Message);
}
}
var leftover = CachedMailbox(tokens);
if (leftover is not null)
return leftover;
return Result<MailAuthMaterial>.UnprocessableEntity( return Result<MailAuthMaterial>.UnprocessableEntity(
null, null,
"Sign in again through Identity Hub."); "Sign in again through Identity Hub.");
}
try { private async Task<Result<MailAuthMaterial>> RefreshHubMailboxAsync(
string mailboxId,
OAuthTokenSet tokens,
CancellationToken cancellationToken) {
if (!string.IsNullOrWhiteSpace(tokens.HubRefreshToken)) { if (!string.IsNullOrWhiteSpace(tokens.HubRefreshToken)) {
var refreshed = await _hub.RefreshTokenAsync( var refreshed = await _hub.RefreshTokenAsync(
new RefreshTokenRequest { RefreshToken = tokens.HubRefreshToken }, new RefreshTokenRequest { RefreshToken = tokens.HubRefreshToken },
@ -62,10 +80,14 @@ public sealed class MailAuthService : IMailAuthService {
tokens.HubExpires = AsUtc(refreshed.ExpiresAt); tokens.HubExpires = AsUtc(refreshed.ExpiresAt);
if (!string.IsNullOrWhiteSpace(refreshed.Username)) if (!string.IsNullOrWhiteSpace(refreshed.Username))
tokens.Email = refreshed.Username; tokens.Email = refreshed.Username;
SaveTokens(account.Id, tokens); SaveTokens(mailboxId, tokens);
} }
else if (tokens.HubExpires <= DateTimeOffset.UtcNow.AddMinutes(2)) else if (tokens.HubExpires <= DateTimeOffset.UtcNow.AddMinutes(2)) {
var cached = CachedMailbox(tokens);
if (cached is not null)
return cached;
return Result<MailAuthMaterial>.UnprocessableEntity(null, "Sign in again. The Hub session expired."); return Result<MailAuthMaterial>.UnprocessableEntity(null, "Sign in again. The Hub session expired.");
}
_hub.AccessToken = tokens.HubToken; _hub.AccessToken = tokens.HubToken;
var mailbox = await _hub.GetMailboxAccessTokenAsync(cancellationToken).ConfigureAwait(false); var mailbox = await _hub.GetMailboxAccessTokenAsync(cancellationToken).ConfigureAwait(false);
@ -73,15 +95,22 @@ public sealed class MailAuthService : IMailAuthService {
return Result<MailAuthMaterial>.UnprocessableEntity(null, "Identity Hub did not return a mailbox token."); return Result<MailAuthMaterial>.UnprocessableEntity(null, "Identity Hub did not return a mailbox token.");
tokens.AccessToken = mailbox.AccessToken; tokens.AccessToken = mailbox.AccessToken;
tokens.AccessExpires = DateTimeOffset.UtcNow.AddMinutes(50); tokens.AccessExpires = DateTimeOffset.UtcNow.AddMinutes(50);
SaveTokens(account.Id, tokens); SaveTokens(mailboxId, tokens);
return Result<MailAuthMaterial>.Ok(new MailAuthMaterial { return Result<MailAuthMaterial>.Ok(new MailAuthMaterial {
UseOAuth = true, UseOAuth = true,
AccessToken = mailbox.AccessToken AccessToken = mailbox.AccessToken
}); });
} }
catch (IdentityHubApiException ex) {
return Result<MailAuthMaterial>.UnprocessableEntity(null, ex.Message); private static Result<MailAuthMaterial>? CachedMailbox(OAuthTokenSet tokens) {
} if (string.IsNullOrWhiteSpace(tokens.AccessToken))
return null;
if (tokens.AccessExpires <= DateTimeOffset.UtcNow.AddMinutes(2))
return null;
return Result<MailAuthMaterial>.Ok(new MailAuthMaterial {
UseOAuth = true,
AccessToken = tokens.AccessToken
});
} }
public Task<Result<OAuthTokenSet>> SignInAsync( public Task<Result<OAuthTokenSet>> SignInAsync(

View File

@ -113,15 +113,23 @@ public sealed class MailWorkerHost : IDisposable {
var configuration = _files.Current; var configuration = _files.Current;
configuration.EnsureDefaults(); configuration.EnsureDefaults();
foreach (var job in MailRetention.Jobs(configuration.Retention)) { 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 known = _archive.ListFolders(job.MailboxId);
var folder = MailRetention.BindFolder(job.Folder, known); var folder = MailRetention.BindFolder(job.Folder, known);
if (string.IsNullOrWhiteSpace(folder)) if (string.IsNullOrWhiteSpace(folder))
continue; continue;
var cutoff = DateTimeOffset.UtcNow.AddDays(-job.Days); 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) if (ids.Count == 0)
continue; continue;
if (MailRetention.IsTrash(folder)) { if (MailRetention.IsTrash(folder)) {
if (remote)
continue;
DeleteFiles(_archive.RemoveUids(job.MailboxId, folder, ids)); DeleteFiles(_archive.RemoveUids(job.MailboxId, folder, ids));
count += ids.Count; count += ids.Count;
continue; continue;

View File

@ -1,3 +1,6 @@
using System.Text.Json;
namespace MaksIT.PostClient.Shared; namespace MaksIT.PostClient.Shared;
@ -202,12 +205,67 @@ public static class AppPaths {
var oldRoam = Path.Combine( var oldRoam = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
ProductName); ProductName);
if (!Directory.Exists(dest) && Directory.Exists(oldLocal)) if (!Directory.Exists(dest) && Directory.Exists(oldLocal)) {
try {
Directory.Move(oldLocal, dest); Directory.Move(oldLocal, dest);
}
catch {
}
}
Directory.CreateDirectory(dest); Directory.CreateDirectory(dest);
CopyIfMissing(Path.Combine(oldRoam, "settings.json"), Path.Combine(dest, "settings.json")); AdoptSettingsIfEmpty(Path.Combine(oldRoam, "settings.json"), Path.Combine(dest, "settings.json"));
CopyIfMissing(Path.Combine(oldRoam, "secrets.bin"), Path.Combine(dest, "secrets.bin")); 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")); 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) { private static void CopyIfMissing(string source, string dest) {

View File

@ -10,7 +10,8 @@ public sealed class ConfigurationFileService {
private static readonly JsonSerializerOptions SerializerOptions = new() { private static readonly JsonSerializerOptions SerializerOptions = new() {
WriteIndented = true, WriteIndented = true,
PropertyNamingPolicy = null PropertyNamingPolicy = null,
PropertyNameCaseInsensitive = true
}; };
private readonly string? _seedPath; private readonly string? _seedPath;

View File

@ -64,11 +64,35 @@ public sealed class FileSecretStore : ISecretStore {
public static string OAuthClientSecretKey(string? authKind) => public static string OAuthClientSecretKey(string? authKind) =>
"oauth-client-secret:" + MailAuthKind.Normalize(authKind); "oauth-client-secret:" + MailAuthKind.Normalize(authKind);
private Dictionary<string, string> Load() { public static int MergeMissingKeys(string sourcePath, string destPath) {
if (!File.Exists(_path)) 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<string, string> Load() =>
ReadMap(_path);
private void Save(Dictionary<string, string> map) =>
WriteMap(_path, map);
private static Dictionary<string, string> ReadMap(string path) {
if (!File.Exists(path))
return new Dictionary<string, string>(StringComparer.Ordinal); return new Dictionary<string, string>(StringComparer.Ordinal);
try { try {
var json = File.ReadAllText(_path); var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) return JsonSerializer.Deserialize<Dictionary<string, string>>(json)
?? new Dictionary<string, string>(StringComparer.Ordinal); ?? new Dictionary<string, string>(StringComparer.Ordinal);
} }
@ -77,13 +101,13 @@ public sealed class FileSecretStore : ISecretStore {
} }
} }
private void Save(Dictionary<string, string> map) { private static void WriteMap(string path, Dictionary<string, string> map) {
var dir = Path.GetDirectoryName(_path); var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir)) if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
File.WriteAllText(_path, JsonSerializer.Serialize(map)); File.WriteAllText(path, JsonSerializer.Serialize(map));
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite); File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
} }
private static string Protect(string secret) { private static string Protect(string secret) {

View File

@ -387,8 +387,12 @@ public sealed class MailArchiveCatalog : IDisposable {
return dest.InsertCopy(destMailboxId, destFolder, copy); return dest.InsertCopy(destMailboxId, destFolder, copy);
}); });
public IReadOnlyList<uint> UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) => public IReadOnlyList<uint> UidsOlderThan(
OffUi(() => TryGet(mailboxId)?.UidsOlderThan(mailboxId, folder, cutoff) ?? []); string mailboxId,
string folder,
DateTimeOffset cutoff,
bool useReceivedDate = false) =>
OffUi(() => TryGet(mailboxId)?.UidsOlderThan(mailboxId, folder, cutoff, useReceivedDate) ?? []);
public int CopyDirectory(string source, string dest) => public int CopyDirectory(string source, string dest) =>
OffUi(() => { OffUi(() => {

View File

@ -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) { public MailArchiveCopy? ReadCopy(string mailboxId, string folder, uint uid) {
lock (_gate) { lock (_gate) {
using var cmd = _db.CreateCommand(); using var cmd = _db.CreateCommand();
@ -703,7 +723,12 @@ public sealed class MailArchiveStore : IDisposable {
EmlPath = copy.Header.EmlPath, EmlPath = copy.Header.EmlPath,
Labels = copy.Header.Labels 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()) { using (var body = _db.CreateCommand()) {
body.Transaction = tx; body.Transaction = tx;
body.CommandText = """ body.CommandText = """
@ -759,11 +784,15 @@ public sealed class MailArchiveStore : IDisposable {
} }
} }
public IReadOnlyList<uint> UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) { public IReadOnlyList<uint> UidsOlderThan(
var trash = MailRetention.IsTrash(folder); string mailboxId,
string folder,
DateTimeOffset cutoff,
bool useReceivedDate = false) {
var trashStamp = MailRetention.IsTrash(folder) && !useReceivedDate;
lock (_gate) { lock (_gate) {
using var cmd = _db.CreateCommand(); using var cmd = _db.CreateCommand();
cmd.CommandText = trash cmd.CommandText = trashStamp
? """ ? """
SELECT uid FROM messages SELECT uid FROM messages
WHERE mailbox_id = $m AND folder = $f WHERE mailbox_id = $m AND folder = $f
@ -869,7 +898,8 @@ public sealed class MailArchiveStore : IDisposable {
SqliteTransaction tx, SqliteTransaction tx,
string mailboxId, string mailboxId,
MailArchiveHeader header, MailArchiveHeader header,
bool updateFlags) { bool updateFlags,
bool stampTrashTime = false) {
using var cmd = _db.CreateCommand(); using var cmd = _db.CreateCommand();
cmd.Transaction = tx; cmd.Transaction = tx;
var flagAssign = updateFlags var flagAssign = updateFlags
@ -894,15 +924,11 @@ public sealed class MailArchiveStore : IDisposable {
in_reply_to = excluded.in_reply_to, in_reply_to = excluded.in_reply_to,
{flagAssign} {flagAssign}
has_attachments = excluded.has_attachments, has_attachments = excluded.has_attachments,
priority = excluded.priority, 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;
"""; """;
var trash = MailRetention.IsTrash(header.Folder); 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("$m", mailboxId);
cmd.Parameters.AddWithValue("$f", header.Folder); cmd.Parameters.AddWithValue("$f", header.Folder);
cmd.Parameters.AddWithValue("$u", header.Uid); 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("$att", header.HasAttachments ? 1 : 0);
cmd.Parameters.AddWithValue("$pri", header.Priority); cmd.Parameters.AddWithValue("$pri", header.Priority);
cmd.Parameters.AddWithValue("$trashed", trashed); cmd.Parameters.AddWithValue("$trashed", trashed);
cmd.Parameters.AddWithValue("$trash", trash ? 1 : 0);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
using var idCmd = _db.CreateCommand(); using var idCmd = _db.CreateCommand();
idCmd.Transaction = tx; idCmd.Transaction = tx;

View File

@ -123,6 +123,14 @@ public static class MailFolderRole {
public static bool CanHoldFolders(string? name, string? fullName) => public static bool CanHoldFolders(string? name, string? fullName) =>
IsNamespace(name, fullName) || IsCustom(name, 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( public static string? DisplayParent(
string? name, string? name,
string? fullName, string? fullName,

View File

@ -34,7 +34,24 @@ public static class MailRetention {
.Where(folder => !string.IsNullOrWhiteSpace(folder)) .Where(folder => !string.IsNullOrWhiteSpace(folder))
.Select(folder => (folder, folder)) .Select(folder => (folder, folder))
.ToList(); .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<FolderRetention>? 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<FolderRetention> Jobs(IEnumerable<FolderRetention>? retention) { public static IReadOnlyList<FolderRetention> Jobs(IEnumerable<FolderRetention>? retention) {

View File

@ -288,16 +288,79 @@ public class MailArchiveStoreTests {
[ [
new MailArchiveHeader { new MailArchiveHeader {
Uid = 1, 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, Folder = MailRetention.TrashFolder,
Subject = "Old", Subject = "Old",
From = "a@b.c", From = "a@b.c",
MessageId = "old@mail",
Date = DateTimeOffset.UtcNow.AddDays(-40) Date = DateTimeOffset.UtcNow.AddDays(-40)
} }
]); ]);
var yesterday = DateTimeOffset.UtcNow.AddDays(-1); var yesterday = DateTimeOffset.UtcNow.AddDays(-1);
Assert.Empty(store.UidsOlderThan("box", MailRetention.TrashFolder, yesterday)); Assert.Empty(store.UidsOlderThan("box", MailRetention.TrashFolder, yesterday));
var tomorrow = DateTimeOffset.UtcNow.AddDays(1); 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 { finally {
DeleteArchive(path); DeleteArchive(path);

View File

@ -193,6 +193,28 @@ public class MailRetentionTests {
Assert.Equal("Cestino", MailRetention.ResolveTrash(["INBOX", "Cestino"])); Assert.Equal("Cestino", MailRetention.ResolveTrash(["INBOX", "Cestino"]));
Assert.Equal(MailRetention.TrashFolder, MailRetention.ResolveTrash(["INBOX"])); Assert.Equal(MailRetention.TrashFolder, MailRetention.ResolveTrash(["INBOX"]));
Assert.True(MailRetention.IsTrash("Deleted Items")); 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"]));
} }
} }

View File

@ -102,6 +102,40 @@ public class AppPathsTests {
Environment.SetEnvironmentVariable(AppPaths.ConfigEnv, previous); 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")); 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] [Fact]
public void GmailTrash_IsTrash() { public void GmailTrash_IsTrash() {
Assert.Equal("trash", MailFolderRole.Kind("Trash", "[Gmail]/Trash")); Assert.Equal("trash", MailFolderRole.Kind("Trash", "[Gmail]/Trash"));
@ -677,6 +726,28 @@ public class FileSecretStoreTests {
File.Delete(path); 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; var reloaded = new ConfigurationFileService(path).Current;
Assert.Single(reloaded.Mailboxes); Assert.Single(reloaded.Mailboxes);
Assert.Equal("a@b.it", reloaded.Mailboxes[0].Address); 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("imap.example.it", reloaded.Mailboxes[0].ImapHost);
Assert.Equal(MailCertifiedKind.Pec, reloaded.Mailboxes[0].CertifiedKind); 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] [Fact]
public void SaveReload_KeepsLayout() { public void SaveReload_KeepsLayout() {
var dir = Path.Combine(Path.GetTempPath(), "postclient-cfg-" + Guid.NewGuid().ToString("N")); var dir = Path.Combine(Path.GetTempPath(), "postclient-cfg-" + Guid.NewGuid().ToString("N"));

View File

@ -147,6 +147,26 @@ public class MailAuthServiceHubTests {
Assert.Equal("imap-mailbox-token", result.Value?.AccessToken); Assert.Equal("imap-mailbox-token", result.Value?.AccessToken);
Assert.Equal("user@outlook.com", result.Value?.Email); 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 int RefreshCalls { get; private set; }
public bool FailRefresh { get; set; }
public Task CheckHealthLiveAsync(CancellationToken cancellationToken = default) => public Task CheckHealthLiveAsync(CancellationToken cancellationToken = default) =>
Task.CompletedTask; Task.CompletedTask;
@ -214,6 +236,8 @@ file sealed class FakeIdentityHub : IIdentityHubClient {
public Task<LoginResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) { public Task<LoginResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) {
RefreshCalls++; RefreshCalls++;
if (FailRefresh)
throw new IdentityHubApiException(401, "refresh failed", "", null);
return Task.FromResult(new LoginResponse( return Task.FromResult(new LoginResponse(
"Bearer", "Bearer",
"hub-jwt-refreshed", "hub-jwt-refreshed",

View File

@ -199,11 +199,27 @@
</TreeView.ContextFlyout> </TreeView.ContextFlyout>
<TreeView.ItemTemplate> <TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}"> <TreeDataTemplate ItemsSource="{Binding Children}">
<Grid ColumnDefinitions="20,*,Auto,Auto" Margin="0,1" HorizontalAlignment="Stretch"> <DockPanel LastChildFill="True" Margin="0,1" HorizontalAlignment="Stretch">
<TextBlock DockPanel.Dock="Right"
Width="44"
Text="{Binding RetentionLabel}"
Classes="hint folder-meta"
TextAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,4,0" />
<TextBlock DockPanel.Dock="Right"
Width="40"
Text="{Binding UnreadLabel}"
Classes="unread-count folder-meta"
TextAlignment="Right"
VerticalAlignment="Center"
Margin="8,0,4,0" />
<TextBlock Text="{Binding Glyph}" <TextBlock Text="{Binding Glyph}"
Width="20"
FontSize="12" FontSize="12"
VerticalAlignment="Center" /> VerticalAlignment="Center"
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center"> DockPanel.Dock="Left" />
<StackPanel Spacing="2" VerticalAlignment="Center" Margin="0,0,4,0">
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
FontWeight="{Binding Weight}" FontWeight="{Binding Weight}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
@ -214,19 +230,7 @@
Value="{Binding QuotaPercent}" Value="{Binding QuotaPercent}"
IsVisible="{Binding ShowQuota}" /> IsVisible="{Binding ShowQuota}" />
</StackPanel> </StackPanel>
<TextBlock Grid.Column="2" </DockPanel>
Text="{Binding UnreadLabel}"
Classes="unread-count"
Margin="8,0,4,0"
VerticalAlignment="Center"
IsVisible="{Binding HasUnread}" />
<TextBlock Grid.Column="3"
Text="{Binding RetentionLabel}"
Classes="hint"
Margin="0,0,4,0"
VerticalAlignment="Center"
IsVisible="{Binding HasRetention}" />
</Grid>
</TreeDataTemplate> </TreeDataTemplate>
</TreeView.ItemTemplate> </TreeView.ItemTemplate>
</TreeView> </TreeView>

View File

@ -358,6 +358,8 @@ public partial class MainWindow : Window {
if (current is Control { DataContext: FolderNodeViewModel node }) { if (current is Control { DataContext: FolderNodeViewModel node }) {
if (!vm.SelectedFolderNodes.Contains(node)) if (!vm.SelectedFolderNodes.Contains(node))
vm.SetSelectedFolders([node], node); vm.SetSelectedFolders([node], node);
else
vm.RefreshFolderMenu();
RestoreFolderTreeSelection(); RestoreFolderTreeSelection();
return; return;
} }

View File

@ -250,6 +250,9 @@
<Setter Property="FontSize" Value="12" /> <Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Style> </Style>
<Style Selector="TextBlock.folder-meta">
<Setter Property="MinHeight" Value="16" />
</Style>
<Style Selector="TreeView"> <Style Selector="TreeView">
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
@ -258,6 +261,10 @@
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" /> <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="Padding" Value="2,2" /> <Setter Property="Padding" Value="2,2" />
<Setter Property="MinHeight" Value="24" /> <Setter Property="MinHeight" Value="24" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
<Style Selector="TreeViewItem /template/ ContentPresenter#PART_HeaderPresenter">
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style> </Style>
<Style Selector="TreeViewItem:selected /template/ Border#PART_LayoutRoot"> <Style Selector="TreeViewItem:selected /template/ Border#PART_LayoutRoot">
<Setter Property="Background" Value="#006199" /> <Setter Property="Background" Value="#006199" />

View File

@ -1036,14 +1036,20 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (string.IsNullOrWhiteSpace(folder)) if (string.IsNullOrWhiteSpace(folder))
continue; continue;
var cutoff = DateTimeOffset.UtcNow.AddDays(-job.Days); var cutoff = DateTimeOffset.UtcNow.AddDays(-job.Days);
var ids = _archive.UidsOlderThan(box.Id, folder, cutoff); var ids = _archive.UidsOlderThan(
box.Id,
folder,
cutoff,
useReceivedDate: MailRetention.IsTrash(folder));
if (ids.Count == 0) if (ids.Count == 0)
continue; continue;
var session = SessionFor(box); var session = SessionFor(box);
if (session is not { IsConnected: true }) if (session is not { IsConnected: true })
continue; continue;
if (MailRetention.IsTrash(folder)) { if (MailRetention.IsTrash(folder)) {
await session.SetMessageFlagsAsync(folder, ids, new MailFlagUpdate { Deleted = true }); var purged = await session.SetMessageFlagsAsync(folder, ids, new MailFlagUpdate { Deleted = true });
if (purged.IsSuccess)
DropArchived(box.Id, folder, ids);
continue; continue;
} }
@ -1052,7 +1058,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var trash = MailRetention.ResolveTrash( var trash = MailRetention.ResolveTrash(
FoldersFor(box).Select(f => (f.Name, f.FullName)).ToList()); FoldersFor(box).Select(f => (f.Name, f.FullName)).ToList());
if (folder.Equals(trash, StringComparison.OrdinalIgnoreCase)) { if (folder.Equals(trash, StringComparison.OrdinalIgnoreCase)) {
await session.SetMessageFlagsAsync(folder, ids, new MailFlagUpdate { Deleted = true }); var purged = await session.SetMessageFlagsAsync(folder, ids, new MailFlagUpdate { Deleted = true });
if (purged.IsSuccess)
DropArchived(box.Id, folder, ids);
continue; continue;
} }
@ -1421,10 +1429,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
else if (SelectedFolderNodes.Count > 0 else if (SelectedFolderNodes.Count > 0
&& (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode))) && (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode)))
SelectedFolderNode = SelectedFolderNodes[0]; SelectedFolderNode = SelectedFolderNodes[0];
NotifyFolderCommands(); RefreshFolderMenu();
OnPropertyChanged(nameof(EmptyFolderLabel));
} }
public void RefreshFolderMenu() =>
NotifyFolderCommands();
public IReadOnlyList<uint> DragMessageIds(MessageRowViewModel source) { public IReadOnlyList<uint> DragMessageIds(MessageRowViewModel source) {
if (SelectedMessages.Contains(source)) if (SelectedMessages.Contains(source))
return SelectedMessages.Select(row => row.Header.Id).ToList(); return SelectedMessages.Select(row => row.Header.Id).ToList();
@ -1708,22 +1718,26 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
[RelayCommand(CanExecute = nameof(CanEmptyFolder))] [RelayCommand(CanExecute = nameof(CanEmptyFolder))]
private async Task EmptyFolderAsync() { private async Task EmptyFolderAsync() {
var session = ActiveSession; var node = TargetFolderNodes().FirstOrDefault();
var box = SelectedMailbox; var folder = node?.Folder ?? SelectedFolder;
var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder; var box = node?.Mailbox ?? SelectedMailbox;
if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null) if (folder is null
|| box is null
|| !MailFolderRole.CanEmpty(folder.Name, folder.FullName, box.IncomingProtocol))
return; return;
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None); var purge = MailRetention.IsTrash(folder.Name, folder.FullName);
var purge = string.IsNullOrWhiteSpace(trash)
|| trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase);
var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name); var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name);
if (!await ConfirmAsync(purge ? Copy.DeleteAllItems : Copy.EmptyFolder, confirm)) if (!await ConfirmAsync(purge ? Copy.DeleteAllItems : Copy.EmptyFolder, confirm))
return; return;
await RunAsync(Copy.EmptyingFolder, async token => { await RunAsync(Copy.EmptyingFolder, async token => {
var result = await session.EmptyFolderAsync( var session = await EnsureMailboxReadyAsync(box, token);
folder.FullName, if (session is not { IsConnected: true, SupportsFolders: true }) {
purge ? null : trash, Status = ArchiveFiles.Hint();
token); return;
}
var trash = purge ? null : await EnsureTrashFolderAsync(box, token);
var result = await session.EmptyFolderAsync(folder.FullName, trash, token);
if (!result.IsSuccess) { if (!result.IsSuccess) {
Status = string.Join(" ", result.Messages); Status = string.Join(" ", result.Messages);
return; return;
@ -1732,7 +1746,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
ForgetLocalFolder(box.Id, folder.FullName); ForgetLocalFolder(box.Id, folder.FullName);
folder.Total = 0; folder.Total = 0;
folder.Unread = 0; folder.Unread = 0;
if (SelectedFolder?.FullName.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase) == true) { if (SelectedMailbox?.Id.Equals(box.Id, StringComparison.OrdinalIgnoreCase) == true
&& SelectedFolder?.FullName.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase) == true) {
Messages.Clear(); Messages.Clear();
VisibleMessages.Clear(); VisibleMessages.Clear();
SelectedMessage = null; SelectedMessage = null;
@ -2010,12 +2025,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
CanManageFolders(); CanManageFolders();
private bool CanEmptyFolder() { private bool CanEmptyFolder() {
var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder; var node = TargetFolderNodes().FirstOrDefault();
if (folder is null || MailFolderRole.IsNamespace(folder.Name, folder.FullName)) var folder = node?.Folder ?? SelectedFolder;
var box = node?.Mailbox ?? SelectedMailbox;
if (folder is null || box is null)
return false; return false;
if (SelectedMailbox is { IsLocalStore: true } && !IsBusy) return MailFolderRole.CanEmpty(folder.Name, folder.FullName, box.IncomingProtocol);
return ActiveSession is { IsConnected: true, SupportsFolders: true };
return CanManageFolders();
} }
private bool CanDeleteCustomFolder() => private bool CanDeleteCustomFolder() =>
@ -2222,6 +2237,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
partial void OnIsBusyChanged(bool value) { partial void OnIsBusyChanged(bool value) {
OnPropertyChanged(nameof(ShowDetachPst)); OnPropertyChanged(nameof(ShowDetachPst));
DetachPstCommand.NotifyCanExecuteChanged(); DetachPstCommand.NotifyCanExecuteChanged();
NotifyFolderCommands();
} }
partial void OnSelectedFolderChanged(FolderRowViewModel? value) { partial void OnSelectedFolderChanged(FolderRowViewModel? value) {
@ -2602,7 +2618,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var expanded = previous.TryGetValue(folder.FullName, out var keep) var expanded = previous.TryGetValue(folder.FullName, out var keep)
? keep ? keep
: SavedFolderExpanded(box, folder.FullName); : SavedFolderExpanded(box, folder.FullName);
map[folder.FullName] = FolderNodeViewModel.ForFolder(box, folder, expanded); var childNode = FolderNodeViewModel.ForFolder(box, folder, expanded);
childNode.RetentionDays = MailRetention.DaysFor(box.Id, folder.FullName, _files.Current.Retention);
map[folder.FullName] = childNode;
} }
foreach (var folder in rows foreach (var folder in rows
.OrderBy(f => MailFolderRole.SortKey(f.Name, f.FullName)) .OrderBy(f => MailFolderRole.SortKey(f.Name, f.FullName))
@ -4389,12 +4407,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
f.FullName.Equals(fullName, StringComparison.OrdinalIgnoreCase)); f.FullName.Equals(fullName, StringComparison.OrdinalIgnoreCase));
private void NotifyFolderCounts() { private void NotifyFolderCounts() {
foreach (var account in FolderTree) { foreach (var account in FolderTree)
ApplyRetentionBadge(account); ApplyRetentionBadge(account);
foreach (var child in account.Children)
child.NotifyCounts();
account.NotifyCounts();
}
} }
public void RefreshRetentionBadges() => public void RefreshRetentionBadges() =>
@ -4402,23 +4416,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
private void ApplyRetentionBadge(FolderNodeViewModel node) { private void ApplyRetentionBadge(FolderNodeViewModel node) {
if (node.Mailbox is { } box && node.Folder is { } folder) if (node.Mailbox is { } box && node.Folder is { } folder)
node.RetentionDays = RetentionDaysFor(box.Id, folder.FullName); node.RetentionDays = MailRetention.DaysFor(box.Id, folder.FullName, _files.Current.Retention);
else else
node.RetentionDays = 0; node.RetentionDays = 0;
node.NotifyCounts();
foreach (var child in node.Children) foreach (var child in node.Children)
ApplyRetentionBadge(child); ApplyRetentionBadge(child);
} }
private int RetentionDaysFor(string mailboxId, string folder) {
foreach (var row in _files.Current.Retention ?? []) {
if (row.MailboxId.Equals(mailboxId, StringComparison.OrdinalIgnoreCase)
&& row.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase))
return row.Days;
}
return 0;
}
private void RefreshFolderStats() { private void RefreshFolderStats() {
if (SelectedFolder is null) { if (SelectedFolder is null) {
FolderStats = ""; FolderStats = "";

View File

@ -152,7 +152,10 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
public bool HasUnread => public bool HasUnread =>
Unread > 0; Unread > 0;
public int RetentionDays { get; set; } [ObservableProperty]
[NotifyPropertyChangedFor(nameof(RetentionLabel))]
[NotifyPropertyChangedFor(nameof(HasRetention))]
private int retentionDays;
public string RetentionLabel => public string RetentionLabel =>
UiLocale.Copy.RetentionBadge(RetentionDays); UiLocale.Copy.RetentionBadge(RetentionDays);