mirror of
https://github.com/MAKS-IT-COM/maksit-postclient.git
synced 2026-09-29 19:08:09 +02:00
(feature): move expired mail to trash and keep trash by days
This commit is contained in:
parent
817836ab28
commit
863d9d949d
13
CHANGELOG.md
13
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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 ??= "";
|
||||
|
||||
68
src/MaksIT.PostClient.Shared/MailRetention.cs
Normal file
68
src/MaksIT.PostClient.Shared/MailRetention.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user