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]
|
## [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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"));
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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 = "";
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user