feat(PROJ-56c): GoBD-Retention-Purge als Cron-Job + Delete()-Konsistenzfix

archivmail purge ist ein neuer CLI-Befehl, der Mails mit abgelaufener
retain_until löscht, aus dem Suchindex entfernt und pro Mail einen
Audit-Eintrag (mail_purged) schreibt — analog zu Pilers purge.sh, nachts
03:40 Uhr über deploy/cron.d/archivmail. Nur Mails mit explizit gesetztem
und abgelaufenem retain_until werden angefasst; ohne retain_until bleibt
alles unberührt, die Löschsperre (PROJ-34) greift weiterhin.

Beim Testen aufgedeckt: Store.Delete() entfernte die Datei vor dem
DB-Delete und verschluckte den Fehler, wenn email_refs/email_attachments
per Fremdschlüssel die Löschung blockierten — Ergebnis war ein DB-Eintrag
ohne zugehörige Datei. Jetzt läuft die DB-Löschung (inkl. abhängiger
Zeilen) zuerst in einer Transaktion, die Datei wird erst nach
erfolgreichem Commit entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-06-24 16:00:41 +02:00
co-authored by Claude Sonnet 4.6
parent d826fe1da7
commit 586af2478c
5 changed files with 198 additions and 16 deletions
+1
View File
@@ -285,6 +285,7 @@ Commands:
import-piler Aus mailpiler migrieren (pilerexport oder direkte Store-Methode)
export E-Mails exportieren (EML, MBOX)
reindex Index neu aufbauen (alle oder pro Mandant)
purge Mails mit abgelaufener Aufbewahrungsfrist löschen (cron-fähig)
recompress Bestehende Mails nachträglich gzip-komprimieren
rethread Thread-IDs rückwirkend aus In-Reply-To/References befüllen
ocr-reprocess OCR für Anhänge nachholen (alle oder pro Mandant/Status)
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"context"
"flag"
"log/slog"
"os"
"archivmail/config"
"archivmail/internal/audit"
"archivmail/internal/index"
"archivmail/internal/storage"
)
// runPurge deletes all mails whose retain_until has passed, removes them
// from the search index, and writes one audit log entry per deleted mail
// (GoBD-Nachvollziehbarkeit). Intended to be cron-driven (PROJ-56c), mirrors
// the manual /api/admin/purge endpoint but adds index cleanup + audit trail,
// which the plain Store.Purge() helper intentionally does not do.
//
// Usage: archivmail purge [-config /path/to/config.yml] [-dry-run]
func runPurge(args []string) {
fs := flag.NewFlagSet("purge", flag.ExitOnError)
configPath := fs.String("config", "/etc/archivmail/config.yml", "path to config file")
dryRun := fs.Bool("dry-run", false, "list expired mails without deleting them")
fs.Parse(args)
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg, err := config.Load(*configPath)
if err != nil {
logger.Error("failed to load config", "err", err)
os.Exit(1)
}
storeCfg := storage.Config{
Dir: cfg.Storage.StorePath,
Keyfile: cfg.Storage.Keyfile,
DSN: cfg.Database.DSN(),
CompressEnabled: cfg.Storage.Compress,
}
mailStore, err := storage.New(storeCfg)
if err != nil {
logger.Error("storage init failed", "err", err)
os.Exit(1)
}
defer mailStore.Close()
ctx := context.Background()
ids, err := mailStore.ListExpiredMailIDs(ctx)
if err != nil {
logger.Error("purge: list expired failed", "err", err)
os.Exit(1)
}
if len(ids) == 0 {
logger.Info("purge: nothing to do, no expired mails")
return
}
if *dryRun {
logger.Info("purge: dry-run, would delete", "count", len(ids))
for _, id := range ids {
logger.Info("purge: dry-run candidate", "id", id)
}
return
}
// Index + audit log are best-effort extras (PROJ-56c); the OCR/index
// backends and audit DB can be unreachable without that blocking the
// actual deletion, which is what GoBD-Löschsperre/Retention requires.
var idxMgr index.TenantIndexer
indexBackend := cfg.Index.Backend
if indexBackend == "manticore" {
dsn := cfg.Index.ManticoreDSN
if dsn == "" {
dsn = "manticore@tcp(127.0.0.1:9306)/"
}
if m, err := index.NewManticoreTenantManager(dsn); err == nil {
idxMgr = m
defer m.Close()
} else {
logger.Warn("purge: index init failed, skipping index cleanup", "err", err)
}
}
var audlog *audit.Logger
if a, err := audit.New(cfg.Database.DSN(), cfg.Audit.ResolvedLogPath(), logger); err == nil {
audlog = a
defer audlog.Close()
} else {
logger.Warn("purge: audit log init failed, deletions will not be audited", "err", err)
}
deleted := 0
failed := 0
for _, id := range ids {
tenantID, _ := mailStore.GetTenantForMail(ctx, id)
if err := mailStore.Delete(id); err != nil {
logger.Warn("purge: delete failed", "id", id, "err", err)
failed++
continue
}
if idxMgr != nil {
if err := idxMgr.ForTenant(tenantID).Delete(id); err != nil {
logger.Warn("purge: index cleanup failed", "id", id, "err", err)
}
}
if audlog != nil {
audlog.Log(audit.Entry{
EventType: "mail_purged",
Username: "cron:purge",
TenantID: tenantID,
MailID: id,
Success: true,
Detail: "automatischer Purge nach Ablauf der Aufbewahrungsfrist (retain_until)",
})
}
deleted++
}
logger.Info("purge: complete", "total", len(ids), "deleted", deleted, "failed", failed)
}
+3
View File
@@ -58,6 +58,9 @@ func main() {
case "reindex":
runReindex(os.Args[2:])
return
case "purge":
runPurge(os.Args[2:])
return
case "recompress":
runRecompress(os.Args[2:])
return