diff --git a/CHANGELOG.md b/CHANGELOG.md index 435ac57..ed13d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0586323..80215c3 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/data/eu.postclient.desktop.metainfo.xml b/data/eu.postclient.desktop.metainfo.xml index e275289..1d4e1e5 100644 --- a/data/eu.postclient.desktop.metainfo.xml +++ b/data/eu.postclient.desktop.metainfo.xml @@ -30,6 +30,11 @@ 768 + + +

Retention moves expired folder mail to Trash. Trash can be kept forever or purged after a number of days. Delete sends messages to Trash.

+
+

The main window opens on launch from the Windows setup. Closing the window exits the app.

diff --git a/src/Directory.Build.props b/src/Directory.Build.props index afbfdf2..f50ee0b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.3.1 + 0.3.2 Postclient Postclient Maksym Sadovnychyy diff --git a/src/MaksIT.PostClient.Client/LocalMailImport.cs b/src/MaksIT.PostClient.Client/LocalMailImport.cs index bbcb553..2bb1549 100644 --- a/src/MaksIT.PostClient.Client/LocalMailImport.cs +++ b/src/MaksIT.PostClient.Client/LocalMailImport.cs @@ -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) diff --git a/src/MaksIT.PostClient.Client/LocalStoreSession.cs b/src/MaksIT.PostClient.Client/LocalStoreSession.cs index 50d96db..c7a4f5a 100644 --- a/src/MaksIT.PostClient.Client/LocalStoreSession.cs +++ b/src/MaksIT.PostClient.Client/LocalStoreSession.cs @@ -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); } diff --git a/src/MaksIT.PostClient.Client/MailWorkerHost.cs b/src/MaksIT.PostClient.Client/MailWorkerHost.cs index 1572499..238e02a 100644 --- a/src/MaksIT.PostClient.Client/MailWorkerHost.cs +++ b/src/MaksIT.PostClient.Client/MailWorkerHost.cs @@ -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 ids) { + var moved = new List(); + 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 paths) { + foreach (var path in paths) { + try { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + File.Delete(path); + } + catch { + } + } + } + public void Dispose() => _semantic?.Dispose(); diff --git a/src/MaksIT.PostClient.Shared/Configuration.cs b/src/MaksIT.PostClient.Shared/Configuration.cs index c150e03..0ebdc5a 100644 --- a/src/MaksIT.PostClient.Shared/Configuration.cs +++ b/src/MaksIT.PostClient.Shared/Configuration.cs @@ -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 ??= ""; diff --git a/src/MaksIT.PostClient.Shared/MailRetention.cs b/src/MaksIT.PostClient.Shared/MailRetention.cs new file mode 100644 index 0000000..3a2843b --- /dev/null +++ b/src/MaksIT.PostClient.Shared/MailRetention.cs @@ -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? 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? folders) { + var list = (folders ?? []) + .Where(folder => !string.IsNullOrWhiteSpace(folder)) + .Select(folder => (folder, folder)) + .ToList(); + return MailRuleEngine.ExactFolder(wanted, list) ?? (wanted ?? "").Trim(); + } + + public static IReadOnlyList Jobs(IEnumerable? retention) { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in retention ?? []) + Put(map, row); + return map.Values.Where(row => row.Days > 0).ToList(); + } + + private static void Put(Dictionary 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 + }; + } +} diff --git a/src/MaksIT.PostClient.Shared/UiCopy.cs b/src/MaksIT.PostClient.Shared/UiCopy.cs index 8539e7b..b89c5e0 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.cs @@ -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)", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.de.cs b/src/MaksIT.PostClient.Shared/UiCopy.de.cs index 79269cd..1f0c197 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.de.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.de.cs @@ -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)", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.es.cs b/src/MaksIT.PostClient.Shared/UiCopy.es.cs index d6894cb..2ed8a25 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.es.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.es.cs @@ -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)", diff --git a/src/MaksIT.PostClient.Shared/UiCopy.fr.cs b/src/MaksIT.PostClient.Shared/UiCopy.fr.cs index c3d40fa..fb91bbb 100644 --- a/src/MaksIT.PostClient.Shared/UiCopy.fr.cs +++ b/src/MaksIT.PostClient.Shared/UiCopy.fr.cs @@ -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)", diff --git a/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs b/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs index 09a7ba8..db0c06d 100644 --- a/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs +++ b/src/MaksIT.PostClient.Tests/OutlookRulesTests.cs @@ -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() { diff --git a/src/MaksIT.PostClient.Tests/ProtocolTests.cs b/src/MaksIT.PostClient.Tests/ProtocolTests.cs index 43199c7..db4da9b 100644 --- a/src/MaksIT.PostClient.Tests/ProtocolTests.cs +++ b/src/MaksIT.PostClient.Tests/ProtocolTests.cs @@ -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() { diff --git a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs b/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs index 33a9d66..84c4117 100644 --- a/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs +++ b/src/MaksIT.PostClient.Tests/StoreAndHubTests.cs @@ -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); diff --git a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs index cea0c2d..a9a5475 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/MainViewModel.cs @@ -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 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 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); } diff --git a/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs b/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs index 786d60c..10c746e 100644 --- a/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs +++ b/src/MaksIT.PostClient.UI/ViewModels/RetentionViewModel.cs @@ -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 }); } diff --git a/src/MaksIT.PostClient.UI/Windows/RetentionWindow.axaml b/src/MaksIT.PostClient.UI/Windows/RetentionWindow.axaml index f6e6d2a..7b07a88 100644 --- a/src/MaksIT.PostClient.UI/Windows/RetentionWindow.axaml +++ b/src/MaksIT.PostClient.UI/Windows/RetentionWindow.axaml @@ -41,6 +41,10 @@ Binding="{Binding Folder}" IsReadOnly="True" Width="*" /> +