mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-09-30 03:18:09 +02:00
(feature): split folder columns and keep oauth across upgrades
This commit is contained in:
parent
adee0696ba
commit
06936e2bbd
14
CHANGELOG.md
14
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.
|
||||
|
||||
@ -30,6 +30,11 @@
|
||||
<display_length compare="ge">768</display_length>
|
||||
</requires>
|
||||
<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">
|
||||
<description>
|
||||
<p>Keyword and meaning index status shows one done / total across all mailboxes, not the current folder or a single account.</p>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.3.5</Version>
|
||||
<Version>0.3.6</Version>
|
||||
<Product>Postclient</Product>
|
||||
<AssemblyTitle>Postclient</AssemblyTitle>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
|
||||
@ -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<MailAuthMaterial>.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<MailAuthMaterial>.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<MailAuthMaterial>.UnprocessableEntity(null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
_hub.AccessToken = tokens.HubToken;
|
||||
var mailbox = await _hub.GetMailboxAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(mailbox.AccessToken))
|
||||
return Result<MailAuthMaterial>.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<MailAuthMaterial>.Ok(new MailAuthMaterial {
|
||||
UseOAuth = true,
|
||||
AccessToken = mailbox.AccessToken
|
||||
});
|
||||
var leftover = CachedMailbox(tokens);
|
||||
if (leftover is not null)
|
||||
return leftover;
|
||||
return Result<MailAuthMaterial>.UnprocessableEntity(
|
||||
null,
|
||||
"Sign in again through Identity Hub.");
|
||||
}
|
||||
|
||||
private async Task<Result<MailAuthMaterial>> 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<MailAuthMaterial>.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<MailAuthMaterial>.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<MailAuthMaterial>.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<MailAuthMaterial>.Ok(new MailAuthMaterial {
|
||||
UseOAuth = true,
|
||||
AccessToken = mailbox.AccessToken
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -64,11 +64,35 @@ public sealed class FileSecretStore : ISecretStore {
|
||||
public static string OAuthClientSecretKey(string? authKind) =>
|
||||
"oauth-client-secret:" + MailAuthKind.Normalize(authKind);
|
||||
|
||||
private Dictionary<string, string> 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<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);
|
||||
try {
|
||||
var json = File.ReadAllText(_path);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(json)
|
||||
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
@ -77,13 +101,13 @@ public sealed class FileSecretStore : ISecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(Dictionary<string, string> map) {
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
private static void WriteMap(string path, Dictionary<string, string> 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) {
|
||||
|
||||
@ -387,8 +387,12 @@ public sealed class MailArchiveCatalog : IDisposable {
|
||||
return dest.InsertCopy(destMailboxId, destFolder, copy);
|
||||
});
|
||||
|
||||
public IReadOnlyList<uint> UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) =>
|
||||
OffUi(() => TryGet(mailboxId)?.UidsOlderThan(mailboxId, folder, cutoff) ?? []);
|
||||
public IReadOnlyList<uint> 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(() => {
|
||||
|
||||
@ -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<uint> UidsOlderThan(string mailboxId, string folder, DateTimeOffset cutoff) {
|
||||
var trash = MailRetention.IsTrash(folder);
|
||||
public IReadOnlyList<uint> 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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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<LoginResponse> 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",
|
||||
|
||||
@ -199,11 +199,27 @@
|
||||
</TreeView.ContextFlyout>
|
||||
<TreeView.ItemTemplate>
|
||||
<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}"
|
||||
Width="20"
|
||||
FontSize="12"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
VerticalAlignment="Center"
|
||||
DockPanel.Dock="Left" />
|
||||
<StackPanel Spacing="2" VerticalAlignment="Center" Margin="0,0,4,0">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontWeight="{Binding Weight}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
@ -214,19 +230,7 @@
|
||||
Value="{Binding QuotaPercent}"
|
||||
IsVisible="{Binding ShowQuota}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2"
|
||||
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>
|
||||
</DockPanel>
|
||||
</TreeDataTemplate>
|
||||
</TreeView.ItemTemplate>
|
||||
</TreeView>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -250,6 +250,9 @@
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.folder-meta">
|
||||
<Setter Property="MinHeight" Value="16" />
|
||||
</Style>
|
||||
<Style Selector="TreeView">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
@ -258,6 +261,10 @@
|
||||
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
|
||||
<Setter Property="Padding" Value="2,2" />
|
||||
<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 Selector="TreeViewItem:selected /template/ Border#PART_LayoutRoot">
|
||||
<Setter Property="Background" Value="#006199" />
|
||||
|
||||
@ -1036,14 +1036,20 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
continue;
|
||||
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)
|
||||
continue;
|
||||
var session = SessionFor(box);
|
||||
if (session is not { IsConnected: true })
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1052,7 +1058,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
var trash = MailRetention.ResolveTrash(
|
||||
FoldersFor(box).Select(f => (f.Name, f.FullName)).ToList());
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1421,10 +1429,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
else if (SelectedFolderNodes.Count > 0
|
||||
&& (SelectedFolderNode is null || !SelectedFolderNodes.Contains(SelectedFolderNode)))
|
||||
SelectedFolderNode = SelectedFolderNodes[0];
|
||||
NotifyFolderCommands();
|
||||
OnPropertyChanged(nameof(EmptyFolderLabel));
|
||||
RefreshFolderMenu();
|
||||
}
|
||||
|
||||
public void RefreshFolderMenu() =>
|
||||
NotifyFolderCommands();
|
||||
|
||||
public IReadOnlyList<uint> DragMessageIds(MessageRowViewModel source) {
|
||||
if (SelectedMessages.Contains(source))
|
||||
return SelectedMessages.Select(row => row.Header.Id).ToList();
|
||||
@ -1708,22 +1718,26 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanEmptyFolder))]
|
||||
private async Task EmptyFolderAsync() {
|
||||
var session = ActiveSession;
|
||||
var box = SelectedMailbox;
|
||||
var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder;
|
||||
if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null)
|
||||
var node = TargetFolderNodes().FirstOrDefault();
|
||||
var folder = node?.Folder ?? SelectedFolder;
|
||||
var box = node?.Mailbox ?? SelectedMailbox;
|
||||
if (folder is null
|
||||
|| box is null
|
||||
|| !MailFolderRole.CanEmpty(folder.Name, folder.FullName, box.IncomingProtocol))
|
||||
return;
|
||||
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None);
|
||||
var purge = string.IsNullOrWhiteSpace(trash)
|
||||
|| trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase);
|
||||
var purge = MailRetention.IsTrash(folder.Name, folder.FullName);
|
||||
var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name);
|
||||
if (!await ConfirmAsync(purge ? Copy.DeleteAllItems : Copy.EmptyFolder, confirm))
|
||||
return;
|
||||
await RunAsync(Copy.EmptyingFolder, async token => {
|
||||
var result = await session.EmptyFolderAsync(
|
||||
folder.FullName,
|
||||
purge ? null : trash,
|
||||
token);
|
||||
var session = await EnsureMailboxReadyAsync(box, token);
|
||||
if (session is not { IsConnected: true, SupportsFolders: true }) {
|
||||
Status = ArchiveFiles.Hint();
|
||||
return;
|
||||
}
|
||||
|
||||
var trash = purge ? null : await EnsureTrashFolderAsync(box, token);
|
||||
var result = await session.EmptyFolderAsync(folder.FullName, trash, token);
|
||||
if (!result.IsSuccess) {
|
||||
Status = string.Join(" ", result.Messages);
|
||||
return;
|
||||
@ -1732,7 +1746,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
ForgetLocalFolder(box.Id, folder.FullName);
|
||||
folder.Total = 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();
|
||||
VisibleMessages.Clear();
|
||||
SelectedMessage = null;
|
||||
@ -2010,12 +2025,12 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
CanManageFolders();
|
||||
|
||||
private bool CanEmptyFolder() {
|
||||
var folder = TargetFolderNodes().FirstOrDefault()?.Folder ?? SelectedFolder;
|
||||
if (folder is null || MailFolderRole.IsNamespace(folder.Name, folder.FullName))
|
||||
var node = TargetFolderNodes().FirstOrDefault();
|
||||
var folder = node?.Folder ?? SelectedFolder;
|
||||
var box = node?.Mailbox ?? SelectedMailbox;
|
||||
if (folder is null || box is null)
|
||||
return false;
|
||||
if (SelectedMailbox is { IsLocalStore: true } && !IsBusy)
|
||||
return ActiveSession is { IsConnected: true, SupportsFolders: true };
|
||||
return CanManageFolders();
|
||||
return MailFolderRole.CanEmpty(folder.Name, folder.FullName, box.IncomingProtocol);
|
||||
}
|
||||
|
||||
private bool CanDeleteCustomFolder() =>
|
||||
@ -2222,6 +2237,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
partial void OnIsBusyChanged(bool value) {
|
||||
OnPropertyChanged(nameof(ShowDetachPst));
|
||||
DetachPstCommand.NotifyCanExecuteChanged();
|
||||
NotifyFolderCommands();
|
||||
}
|
||||
|
||||
partial void OnSelectedFolderChanged(FolderRowViewModel? value) {
|
||||
@ -2602,7 +2618,9 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
var expanded = previous.TryGetValue(folder.FullName, out var keep)
|
||||
? keep
|
||||
: 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
|
||||
.OrderBy(f => MailFolderRole.SortKey(f.Name, f.FullName))
|
||||
@ -4389,12 +4407,8 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
f.FullName.Equals(fullName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void NotifyFolderCounts() {
|
||||
foreach (var account in FolderTree) {
|
||||
foreach (var account in FolderTree)
|
||||
ApplyRetentionBadge(account);
|
||||
foreach (var child in account.Children)
|
||||
child.NotifyCounts();
|
||||
account.NotifyCounts();
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshRetentionBadges() =>
|
||||
@ -4402,23 +4416,14 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
|
||||
|
||||
private void ApplyRetentionBadge(FolderNodeViewModel node) {
|
||||
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
|
||||
node.RetentionDays = 0;
|
||||
node.NotifyCounts();
|
||||
foreach (var child in node.Children)
|
||||
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() {
|
||||
if (SelectedFolder is null) {
|
||||
FolderStats = "";
|
||||
|
||||
@ -152,7 +152,10 @@ public sealed partial class FolderNodeViewModel : ObservableObject {
|
||||
public bool HasUnread =>
|
||||
Unread > 0;
|
||||
|
||||
public int RetentionDays { get; set; }
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(RetentionLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(HasRetention))]
|
||||
private int retentionDays;
|
||||
|
||||
public string RetentionLabel =>
|
||||
UiLocale.Copy.RetentionBadge(RetentionDays);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user