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:
co-authored by
Claude Sonnet 4.6
parent
d826fe1da7
commit
586af2478c
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user