(feature): move expired mail to trash and keep trash by days

This commit is contained in:
Maksym Sadovnychyy 2026-09-19 19:52:41 +02:00
parent 817836ab28
commit 863d9d949d
19 changed files with 391 additions and 56 deletions

View File

@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic
## [Unreleased]
## [0.3.2] - 2026-09-19
Expired folder mail goes to Trash; Trash can be kept forever or purged after a number of days.
### Changed
- **Settings → Retention…**: folder days (`0` = keep forever) now **move** older messages to Trash after Get Messages, instead of deleting them permanently. The Trash row uses the same days field: `0` keeps Trash forever, a number of days **permanently deletes** old Trash. The grid shows the action per row.
- Delete (toolbar, **Del**, empty folder, delete rules) moves messages to Trash. If Trash is missing, it is created. Deleting from Trash is still permanent. Local stores honor Trash when emptying a folder.
### Fixed
- Local folder stores no longer skip Trash when emptying a folder (messages go to **Deleted Items**).
## [0.3.1] - 2026-09-19
### Fixed

View File

@ -167,7 +167,7 @@ IMAP **QUOTA** from the gestore (when the server supports it) is a thin bar on t
| New store | File menu: empty bucket (`Inbox`, `Drafts`, `Sent Items`, `Deleted Items`) with `postclient.store.json` and `mail.db`. |
| Attach store | File menu: pick a folder that already has `postclient.store.json`. Recovers mail + search after an AppData wipe. |
| Move / Detach store | Copy the whole directory (or unregister it). The folder stays on disk when detached. |
| Retention | **Settings → Retention…**: days per folder (`0` = forever). Older messages are deleted permanently after Get Messages. |
| Retention | **Settings → Retention…**: days per folder (`0` = forever). Expired folder mail is moved to Trash after Get Messages. On Trash, `0` keeps it forever and a number of days permanently deletes older Trash. |
| Rules | **Settings → Rules**: **Import rules…** reads this app’s JSON export or a legacy `.rwz` file. **Export rules…** writes JSON. Each rule is bound to an **account**; a move folder can live on another mailbox (including a store). They run on Get Messages, on import, and from **Run all rules**. |
| Print | Readable HTML of the (optionally unwrapped) message |
| Save PDF | Same content as a PDF |

View File

@ -30,6 +30,11 @@
<display_length compare="ge">768</display_length>
</requires>
<releases>
<release version="0.3.2" date="2026-09-19">
<description>
<p>Retention moves expired folder mail to Trash. Trash can be kept forever or purged after a number of days. Delete sends messages to Trash.</p>
</description>
</release>
<release version="0.3.1" date="2026-09-19">
<description>
<p>The main window opens on launch from the Windows setup. Closing the window exits the app.</p>

View File

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

View File

@ -145,7 +145,7 @@ public sealed class LocalMailImport {
if (!MailRuleEngine.Matches(rule, from, to, subject, text, hasAttachment))
continue;
if (rule.Action == MailRuleAction.Delete)
folder = MailRuleEngine.ResolveFolder("Trash", catalog) ?? "Trash";
folder = MailRetention.ResolveTrash(catalog);
else if (rule.Action == MailRuleAction.Move)
folder = MailRuleEngine.ExactFolder(rule.Folder, catalog) ?? folder;
if (rule.Action == MailRuleAction.MarkRead)

View File

@ -247,12 +247,21 @@ public sealed class LocalStoreSession : IMailSession, IFileMailStore {
string? trashFolder,
CancellationToken cancellationToken = default) =>
_io.RunAsync(() => {
_ = trashFolder;
var dir = FolderDir(folder);
if (!Directory.Exists(dir))
return Task.FromResult(Result.Ok());
foreach (var file in Directory.EnumerateFiles(dir, "*.eml")) {
var files = Directory.EnumerateFiles(dir, "*.eml").ToList();
var toTrash = !string.IsNullOrWhiteSpace(trashFolder)
&& !folder.Equals(trashFolder, StringComparison.OrdinalIgnoreCase);
if (toTrash)
Directory.CreateDirectory(FolderDir(trashFolder!));
foreach (var file in files) {
cancellationToken.ThrowIfCancellationRequested();
if (toTrash) {
var dest = MessagePath(trashFolder!, NextUid(trashFolder!));
File.Copy(file, dest, overwrite: false);
}
File.Delete(file);
}

View File

@ -112,29 +112,89 @@ public sealed class MailWorkerHost : IDisposable {
var count = 0;
var configuration = _files.Current;
configuration.EnsureDefaults();
foreach (var rule in configuration.Retention) {
if (rule.Days <= 0 || string.IsNullOrWhiteSpace(rule.MailboxId) || string.IsNullOrWhiteSpace(rule.Folder))
foreach (var job in MailRetention.Jobs(configuration.Retention)) {
var known = _archive.ListFolders(job.MailboxId);
var folder = MailRetention.BindFolder(job.Folder, known);
if (string.IsNullOrWhiteSpace(folder))
continue;
var cutoff = DateTimeOffset.UtcNow.AddDays(-rule.Days);
var ids = _archive.UidsOlderThan(rule.MailboxId, rule.Folder, cutoff);
var cutoff = DateTimeOffset.UtcNow.AddDays(-job.Days);
var ids = _archive.UidsOlderThan(job.MailboxId, folder, cutoff);
if (ids.Count == 0)
continue;
var paths = _archive.RemoveUids(rule.MailboxId, rule.Folder, ids);
foreach (var path in paths) {
try {
if (File.Exists(path))
File.Delete(path);
}
catch {
}
if (MailRetention.IsTrash(folder)) {
DeleteFiles(_archive.RemoveUids(job.MailboxId, folder, ids));
count += ids.Count;
continue;
}
count += ids.Count;
var trash = MailRetention.ResolveTrash(known);
if (folder.Equals(trash, StringComparison.OrdinalIgnoreCase)) {
DeleteFiles(_archive.RemoveUids(job.MailboxId, folder, ids));
count += ids.Count;
continue;
}
count += MoveToTrash(configuration, job.MailboxId, folder, trash, ids);
}
return Ok(count.ToString());
}
private int MoveToTrash(
Configuration configuration,
string mailboxId,
string folder,
string trash,
IReadOnlyList<uint> ids) {
var moved = new List<uint>();
foreach (var uid in ids) {
try {
var source = _archive.EmlPath(mailboxId, folder, uid);
var destUid = _archive.NextUid(mailboxId, trash);
var dest = RetentionDestPath(configuration, mailboxId, trash, destUid);
if (!string.IsNullOrWhiteSpace(source)
&& File.Exists(source)
&& !source.Equals(dest, StringComparison.OrdinalIgnoreCase)) {
var dir = Path.GetDirectoryName(dest);
if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
File.Copy(source, dest, overwrite: false);
}
_archive.CopyIndexed(mailboxId, folder, uid, mailboxId, trash, dest, destUid);
moved.Add(uid);
}
catch {
}
}
if (moved.Count > 0)
DeleteFiles(_archive.RemoveUids(mailboxId, folder, moved));
return moved.Count;
}
private static string RetentionDestPath(
Configuration configuration,
string mailboxId,
string folder,
uint uid) {
var box = configuration.FindMailbox(mailboxId);
if (box is not null)
return MailArchiveLayout.EmlPath(box, configuration.Mailboxes, folder, uid);
return ArchiveFiles.EmlPath(mailboxId, folder, uid);
}
private static void DeleteFiles(IEnumerable<string> paths) {
foreach (var path in paths) {
try {
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
File.Delete(path);
}
catch {
}
}
}
public void Dispose() =>
_semantic?.Dispose();

View File

@ -68,6 +68,12 @@ public sealed class Configuration {
Semantic ??= new SemanticSearchSettings();
Semantic.Normalize();
Rules ??= [];
foreach (var rule in Rules) {
rule.MailboxId ??= "";
rule.FolderMailboxId ??= "";
rule.Folder ??= "";
}
Retention ??= [];
foreach (var row in Retention) {
row.MailboxId ??= "";

View File

@ -0,0 +1,68 @@
namespace MaksIT.PostClient.Shared;
public static class MailRetention {
public const string TrashFolder = "Deleted Items";
public static bool IsTrash(string? name, string? fullName = null) =>
MailFolderRole.Kind(name, fullName ?? name) == "trash";
public static string ResolveTrash(IEnumerable<string>? folders) {
foreach (var folder in folders ?? []) {
if (string.IsNullOrWhiteSpace(folder))
continue;
if (IsTrash(folder))
return folder;
}
return TrashFolder;
}
public static string ResolveTrash(IReadOnlyList<(string Name, string FullName)> folders) {
foreach (var folder in folders) {
if (string.IsNullOrWhiteSpace(folder.FullName))
continue;
if (MailFolderRole.Kind(folder.Name, folder.FullName) == "trash")
return folder.FullName;
}
return TrashFolder;
}
public static string BindFolder(string? wanted, IEnumerable<string>? folders) {
var list = (folders ?? [])
.Where(folder => !string.IsNullOrWhiteSpace(folder))
.Select(folder => (folder, folder))
.ToList();
return MailRuleEngine.ExactFolder(wanted, list) ?? (wanted ?? "").Trim();
}
public static IReadOnlyList<FolderRetention> Jobs(IEnumerable<FolderRetention>? retention) {
var map = new Dictionary<string, FolderRetention>(StringComparer.OrdinalIgnoreCase);
foreach (var row in retention ?? [])
Put(map, row);
return map.Values.Where(row => row.Days > 0).ToList();
}
private static void Put(Dictionary<string, FolderRetention> map, FolderRetention row) {
if (string.IsNullOrWhiteSpace(row.MailboxId) || string.IsNullOrWhiteSpace(row.Folder))
return;
var mailboxId = row.MailboxId.Trim();
var folder = row.Folder.Trim();
var days = Math.Max(0, row.Days);
if (days <= 0)
return;
var key = mailboxId + "\n" + folder;
if (map.TryGetValue(key, out var existing)) {
if (days < existing.Days)
existing.Days = days;
return;
}
map[key] = new FolderRetention {
MailboxId = mailboxId,
Folder = folder,
Days = days
};
}
}

View File

@ -118,6 +118,12 @@ public sealed partial class UiCopy {
public string RetentionDays { get; init; } = "";
public string RetentionAction { get; init; } = "";
public string RetentionToTrash { get; init; } = "";
public string RetentionPurge { get; init; } = "";
public string RetentionRun { get; init; } = "";
public string LocalStore { get; init; } = "";
@ -616,8 +622,11 @@ public sealed partial class UiCopy {
DetachStoreDone = "Detached {0}. The folder is still on disk.",
MoveStoreDone = "Moved store {0}.",
Retention = "_Retention…",
RetentionHint = "Per folder. 0 keeps mail forever. A number of days permanently deletes older messages (not Trash).",
RetentionHint = "Per folder. 0 keeps mail forever. Folder days move older messages to Trash. Trash days permanently delete, or 0 to keep Trash forever.",
RetentionDays = "Days",
RetentionAction = "When expired",
RetentionToTrash = "Move to Trash",
RetentionPurge = "Delete permanently",
RetentionRun = "Run now",
LocalStore = "Local store",
LocalStoreAppData = "App data (default)",
@ -880,8 +889,11 @@ public sealed partial class UiCopy {
DetachStoreDone = "Sganciato {0}. La cartella resta sul disco.",
MoveStoreDone = "Spostato l’archivio {0}.",
Retention = "_Conservazione…",
RetentionHint = "Per cartella. 0 conserva per sempre. Un numero di giorni elimina in modo permanente i messaggi più vecchi (non nel Cestino).",
RetentionHint = "Per cartella. 0 conserva per sempre. I giorni della cartella spostano i messaggi più vecchi nel Cestino. I giorni del Cestino eliminano in modo permanente, oppure 0 per tenere il Cestino per sempre.",
RetentionDays = "Giorni",
RetentionAction = "Alla scadenza",
RetentionToTrash = "Sposta nel Cestino",
RetentionPurge = "Elimina in modo permanente",
RetentionRun = "Esegui ora",
LocalStore = "Archivio locale",
LocalStoreAppData = "Dati app (predefinito)",

View File

@ -60,8 +60,11 @@ public sealed partial class UiCopy {
DetachStoreDone = "{0} gelöst. Der Ordner bleibt auf der Platte.",
MoveStoreDone = "Speicher {0} verschoben.",
Retention = "_Aufbewahrung…",
RetentionHint = "Pro Ordner. 0 behält dauerhaft. Eine Tageszahl löscht ältere Nachrichten endgültig (nicht in den Papierkorb).",
RetentionHint = "Pro Ordner. 0 behält dauerhaft. Ordner-Tage verschieben ältere Nachrichten in den Papierkorb. Papierkorb-Tage löschen endgültig, oder 0 behält den Papierkorb dauerhaft.",
RetentionDays = "Tage",
RetentionAction = "Nach Ablauf",
RetentionToTrash = "In den Papierkorb",
RetentionPurge = "Endgültig löschen",
RetentionRun = "Jetzt ausführen",
LocalStore = "Lokaler Speicher",
LocalStoreAppData = "App-Daten (Standard)",

View File

@ -60,8 +60,11 @@ public sealed partial class UiCopy {
DetachStoreDone = "Desadjuntado {0}. La carpeta sigue en el disco.",
MoveStoreDone = "Almacén {0} movido.",
Retention = "_Retención…",
RetentionHint = "Por carpeta. 0 conserva para siempre. Un número de días borra de forma permanente los mensajes más antiguos (no a la Papelera).",
RetentionHint = "Por carpeta. 0 conserva para siempre. Los días de la carpeta envían los mensajes antiguos a la Papelera. Los días de la Papelera borran de forma permanente, o 0 para conservarla siempre.",
RetentionDays = "Días",
RetentionAction = "Al caducar",
RetentionToTrash = "Mover a la Papelera",
RetentionPurge = "Eliminar de forma permanente",
RetentionRun = "Ejecutar ahora",
LocalStore = "Almacén local",
LocalStoreAppData = "Datos de la app (predeterminado)",

View File

@ -60,8 +60,11 @@ public sealed partial class UiCopy {
DetachStoreDone = "{0} détaché. Le dossier reste sur le disque.",
MoveStoreDone = "Magasin {0} déplacé.",
Retention = "_Rétention…",
RetentionHint = "Par dossier. 0 conserve pour toujours. Un nombre de jours supprime définitivement les messages plus anciens (pas la Corbeille).",
RetentionHint = "Par dossier. 0 conserve pour toujours. Les jours du dossier envoient les messages plus anciens vers la Corbeille. Les jours de la Corbeille suppriment définitivement, ou 0 pour la garder pour toujours.",
RetentionDays = "Jours",
RetentionAction = "À l’échéance",
RetentionToTrash = "Déplacer vers la Corbeille",
RetentionPurge = "Supprimer définitivement",
RetentionRun = "Exécuter maintenant",
LocalStore = "Magasin local",
LocalStoreAppData = "Données de l’app (défaut)",

View File

@ -174,6 +174,29 @@ public class MailRuleEngineTests {
}
public class MailRetentionTests {
[Fact]
public void Jobs_SkipsZeroDays() {
var jobs = MailRetention.Jobs(
[
new FolderRetention { MailboxId = "box", Folder = "INBOX", Days = 0 },
new FolderRetention { MailboxId = "box", Folder = "Pratiche", Days = 30 }
]);
var job = Assert.Single(jobs);
Assert.Equal("box", job.MailboxId);
Assert.Equal("Pratiche", job.Folder);
Assert.Equal(30, job.Days);
}
[Fact]
public void ResolveTrash_PrefersExistingTrashFolder() {
Assert.Equal("Cestino", MailRetention.ResolveTrash(["INBOX", "Cestino"]));
Assert.Equal(MailRetention.TrashFolder, MailRetention.ResolveTrash(["INBOX"]));
Assert.True(MailRetention.IsTrash("Deleted Items"));
}
}
public class MapiRestrictionTests {
[Fact]
public void Parse_SubjectContainsAndMarkRead() {

View File

@ -430,6 +430,53 @@ public class PstMailSessionTests {
}
public class LocalStoreSessionTests {
[Fact]
public async Task EmptyFolder_MovesToTrash() {
var dir = Path.Combine(Path.GetTempPath(), "postclient-store-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try {
var box = new MailboxAccount {
IncomingProtocol = MailProtocol.Store,
Provider = MailProvider.Store,
StorePath = dir
};
LocalStoreSidecar.Write(dir, box.Id, "Mail");
MailArchiveLayout.EnsureSystemFolders(dir);
await using var session = new LocalStoreSession();
var token = TestContext.Current.CancellationToken;
var connected = await session.ConnectAsync(box, "", token);
Assert.True(connected.IsSuccess, string.Join(" ", connected.Messages));
var appended = await session.AppendAsync("Inbox", StoreEml("keep"), token);
Assert.True(appended.IsSuccess, string.Join(" ", appended.Messages));
var listed = await session.ListMessagesAsync("Inbox", null, token);
Assert.True(listed.IsSuccess, string.Join(" ", listed.Messages));
Assert.Single(listed.Value!.Present!);
var emptied = await session.EmptyFolderAsync("Inbox", MailRetention.TrashFolder, token);
Assert.True(emptied.IsSuccess, string.Join(" ", emptied.Messages));
var inbox = await session.ListMessagesAsync("Inbox", null, token);
Assert.True(inbox.IsSuccess, string.Join(" ", inbox.Messages));
Assert.Empty(inbox.Value!.Present ?? []);
var trash = await session.ListMessagesAsync(MailRetention.TrashFolder, null, token);
Assert.True(trash.IsSuccess, string.Join(" ", trash.Messages));
Assert.Single(trash.Value!.Present ?? []);
}
finally {
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
private static byte[] StoreEml(string subject) =>
System.Text.Encoding.ASCII.GetBytes(
"From: test@example.com\r\nTo: you@example.com\r\nSubject: "
+ subject
+ "\r\nDate: Mon, 14 Sep 2026 08:00:00 +0000\r\nMessage-ID: <"
+ subject
+ "@example.com>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n");
}
public class MailSessionGateTests {
[Fact]
public async Task RunAsync_DoesNotUseCallerSynchronizationContext() {

View File

@ -439,7 +439,7 @@ public class LocalStoreTests {
}
[Fact]
public void Retention_ZeroKeeps_PositiveDeletesPermanently() {
public void Retention_ZeroKeeps_PositiveMovesToTrashThenPurgesTrash() {
var root = Path.Combine(Path.GetTempPath(), "postclient-ret-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var settings = Path.Combine(root, "settings.json");
@ -472,6 +472,15 @@ public class LocalStoreTests {
"");
File.WriteAllText(Path.Combine(root, "1.eml"), "old");
var files = new ConfigurationFileService(settings);
files.Current.Mailboxes =
[
new MailboxAccount {
Id = "box",
IncomingProtocol = MailProtocol.Store,
Provider = MailProvider.Store,
StorePath = root
}
];
files.Current.Retention =
[
new FolderRetention { MailboxId = "box", Folder = "INBOX", Days = 0 }
@ -490,7 +499,23 @@ public class LocalStoreTests {
var left = catalog.Search("box", "INBOX", "IMU");
Assert.Single(left);
Assert.Equal(2u, left[0].Uid);
var trashed = catalog.Search("box", MailRetention.TrashFolder, "IMU");
Assert.Single(trashed);
Assert.Equal("Old", trashed[0].Subject);
Assert.False(File.Exists(Path.Combine(root, "1.eml")));
Assert.True(File.Exists(MailArchiveLayout.EmlPath(
files.Current.Mailboxes[0],
files.Current.Mailboxes,
MailRetention.TrashFolder,
trashed[0].Uid)));
files.Current.Retention =
[
new FolderRetention { MailboxId = "box", Folder = MailRetention.TrashFolder, Days = 5 }
];
files.Save(files.Current);
using var purgeHost = new MailWorkerHost(catalog, files);
purgeHost.Retention(new WorkerRequest { Op = "retention" });
Assert.Empty(catalog.Search("box", MailRetention.TrashFolder, "IMU"));
}
finally {
Directory.Delete(root, recursive: true);

View File

@ -987,26 +987,47 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
public async Task RunRetentionAsync() {
var configuration = _files.Current;
configuration.EnsureDefaults();
foreach (var rule in configuration.Retention) {
if (rule.Days <= 0 || string.IsNullOrWhiteSpace(rule.MailboxId) || string.IsNullOrWhiteSpace(rule.Folder))
continue;
var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(rule.MailboxId, StringComparison.OrdinalIgnoreCase));
foreach (var job in MailRetention.Jobs(configuration.Retention)) {
var box = Mailboxes.FirstOrDefault(m => m.Id.Equals(job.MailboxId, StringComparison.OrdinalIgnoreCase));
if (box is null || box.IsLocalStore)
continue;
var cutoff = DateTimeOffset.UtcNow.AddDays(-rule.Days);
var ids = _archive.UidsOlderThan(rule.MailboxId, rule.Folder, cutoff);
var known = FoldersFor(box).Select(f => f.FullName).ToList();
if (known.Count == 0)
known = _archive.ListFolders(box.Id).ToList();
var folder = MailRetention.BindFolder(job.Folder, known);
if (string.IsNullOrWhiteSpace(folder))
continue;
var cutoff = DateTimeOffset.UtcNow.AddDays(-job.Days);
var ids = _archive.UidsOlderThan(box.Id, folder, cutoff);
if (ids.Count == 0)
continue;
var session = SessionFor(box);
if (session is { IsConnected: true })
await session.SetMessageFlagsAsync(rule.Folder, ids, new MailFlagUpdate { Deleted = true });
if (session is not { IsConnected: true })
continue;
if (MailRetention.IsTrash(folder)) {
await session.SetMessageFlagsAsync(folder, ids, new MailFlagUpdate { Deleted = true });
continue;
}
if (session is not { SupportsFolders: true })
continue;
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 });
continue;
}
var moved = await session.MoveMessagesAsync(folder, ids, trash, CancellationToken.None);
if (moved.IsSuccess)
DropArchived(box.Id, folder, ids);
}
var result = await Task.Run(() => _worker.Call(new WorkerRequest { Op = "retention" }))
.ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => {
Status = result.Ok
? "Retention deleted " + (result.Value ?? "0") + " messages."
? "Retention processed " + (result.Value ?? "0") + " messages."
: result.Error ?? "Retention failed.";
});
if (SelectedFolder is not null)
@ -1598,20 +1619,22 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var rows = TargetRows();
if (rows.Count == 0)
return;
var trash = Folders.Concat(FoldersFor(SelectedMailbox))
.FirstOrDefault(f => MailFolderRole.Kind(f.Name, f.FullName) == "trash");
if (trash is not null
&& SelectedFolder is not null
&& !trash.FullName.Equals(SelectedFolder.FullName, StringComparison.OrdinalIgnoreCase)) {
await MoveRowsAsync(rows, trash.FullName);
var box = SelectedMailbox;
var folder = SelectedFolder;
if (box is null || folder is null)
return;
if (!MailRetention.IsTrash(folder.Name, folder.FullName)) {
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None);
if (!string.IsNullOrWhiteSpace(trash)
&& !trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase)) {
await MoveRowsAsync(rows, trash);
return;
}
}
if (!await ApplyFlagsAsync(new MailFlagUpdate { Deleted = true }, null, rows))
return;
if (SelectedMailbox is null || SelectedFolder is null)
return;
DropArchived(SelectedMailbox.Id, SelectedFolder.FullName, TargetIds(rows));
DropArchived(box.Id, folder.FullName, TargetIds(rows));
RemoveRows(rows);
Status = "Deleted.";
}
@ -1647,17 +1670,16 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
var folder = SelectedFolder;
if (session is not { IsConnected: true, SupportsFolders: true } || box is null || folder is null)
return;
var trash = Folders.Concat(FoldersFor(SelectedMailbox))
.FirstOrDefault(f => MailFolderRole.Kind(f.Name, f.FullName) == "trash");
var purge = trash is null
|| trash.FullName.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase);
var trash = await EnsureTrashFolderAsync(box, CancellationToken.None);
var purge = string.IsNullOrWhiteSpace(trash)
|| trash.Equals(folder.FullName, StringComparison.OrdinalIgnoreCase);
var confirm = string.Format(purge ? Copy.EmptyTrashConfirm : Copy.EmptyFolderConfirm, folder.Name);
if (!await ConfirmAsync(Copy.EmptyFolder, confirm))
return;
await RunAsync(Copy.EmptyingFolder, async token => {
var result = await session.EmptyFolderAsync(
folder.FullName,
purge ? null : trash!.FullName,
purge ? null : trash,
token);
if (!result.IsSuccess) {
Status = string.Join(" ", result.Messages);
@ -2687,6 +2709,30 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
return _foldersByMailbox.TryGetValue(box.Id, out var rows) ? rows : [];
}
private FolderRowViewModel? TrashFolderOf(MailboxAccount? box) {
if (box is null)
return null;
return FoldersFor(box)
.Concat(Folders)
.FirstOrDefault(f => MailRetention.IsTrash(f.Name, f.FullName));
}
private async Task<string?> EnsureTrashFolderAsync(MailboxAccount box, CancellationToken token) {
var existing = TrashFolderOf(box);
if (existing is not null)
return existing.FullName;
var session = SessionFor(box);
if (session is not { IsConnected: true, SupportsFolders: true })
return null;
var created = await session.CreateFolderAsync(MailRetention.TrashFolder, null, token);
if (!created.IsSuccess)
created = await session.CreateFolderAsync("Trash", null, token);
if (created.IsSuccess)
await LoadFoldersAsync(box, session, token);
return TrashFolderOf(box)?.FullName
?? (created.IsSuccess ? MailRetention.TrashFolder : null);
}
private IReadOnlyList<MailRule> ActiveRules() =>
_files.Current.Rules ?? [];
@ -2794,7 +2840,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
continue;
touched.Add(header.Id);
if (rule.Action == MailRuleAction.Delete) {
var trash = MailRuleEngine.ResolveFolder("Trash", folders);
var trash = MailRetention.ResolveTrash(folders);
if (!string.IsNullOrWhiteSpace(trash))
AddUid(moves, trash, header.Id);
}
@ -2908,7 +2954,7 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
return;
var unique = copied.Distinct().ToList();
if (sourceSession is { IsConnected: true, SupportsFolders: true }) {
var trash = MailRuleEngine.ResolveFolder("Trash", sourceFolders);
var trash = MailRetention.ResolveTrash(sourceFolders);
if (!string.IsNullOrWhiteSpace(trash) && !trash.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase))
await sourceSession.MoveMessagesAsync(sourceFolder, unique, trash, token);
else
@ -4107,11 +4153,11 @@ public partial class MainViewModel : ObservableObject, IAsyncDisposable {
if (ids.Count == 0)
return;
if (session is { IsConnected: true, SupportsFolders: true }) {
var trash = FoldersFor(source)
.FirstOrDefault(f => MailFolderRole.Kind(f.Name, f.FullName) == "trash");
if (trash is not null
&& !trash.FullName.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase))
await session.MoveMessagesAsync(sourceFolder, ids, trash.FullName, token);
var trash = MailRetention.ResolveTrash(
FoldersFor(source).Select(f => (f.Name, f.FullName)).ToList());
if (!string.IsNullOrWhiteSpace(trash)
&& !trash.Equals(sourceFolder, StringComparison.OrdinalIgnoreCase))
await session.MoveMessagesAsync(sourceFolder, ids, trash, token);
else
await session.SetMessageFlagsAsync(sourceFolder, ids, new MailFlagUpdate { Deleted = true }, token);
}

View File

@ -14,6 +14,11 @@ public sealed class RetentionRowViewModel : ObservableObject {
public required string Folder { get; init; }
public required bool IsTrash { get; init; }
public string ActionLabel =>
IsTrash ? UiLocale.Copy.RetentionPurge : UiLocale.Copy.RetentionToTrash;
private int _days;
public int Days {
@ -38,8 +43,10 @@ public sealed partial class RetentionViewModel : ObservableObject {
foreach (var box in mailboxes) {
var listed = folders(box.Id);
var names = listed.Count > 0
? listed.Select(f => f.FullName)
: MailArchiveLayout.SystemFolders;
? listed.Select(f => f.FullName).ToList()
: MailArchiveLayout.SystemFolders.ToList();
if (!names.Any(folder => MailRetention.IsTrash(folder)))
names.Add(MailRetention.TrashFolder);
foreach (var folder in names.Distinct(StringComparer.OrdinalIgnoreCase)) {
var rule = saved.FirstOrDefault(r =>
r.MailboxId.Equals(box.Id, StringComparison.OrdinalIgnoreCase)
@ -48,6 +55,7 @@ public sealed partial class RetentionViewModel : ObservableObject {
MailboxId = box.Id,
MailboxLabel = box.Label,
Folder = folder,
IsTrash = MailRetention.IsTrash(folder),
Days = rule?.Days ?? 0
});
}

View File

@ -41,6 +41,10 @@
Binding="{Binding Folder}"
IsReadOnly="True"
Width="*" />
<DataGridTextColumn Header="{Binding Copy.RetentionAction, ElementName=Root}"
Binding="{Binding ActionLabel}"
IsReadOnly="True"
Width="140" />
<DataGridTextColumn Header="{Binding Copy.RetentionDays, ElementName=Root}"
Binding="{Binding Days}"
Width="90" />