Files
archivmail/cmd/archivmail/cmd_purge.go
T
sysopsandClaude Sonnet 4.6 76655f78a2 fix(PROJ-57): UTF-8-Encoding für Mails mit Nicht-UTF-8-Charset korrigieren
Der Mail-Parser ignorierte das charset-Parameter aus Content-Type und
interpretierte Bytes immer als UTF-8, wodurch iso-8859-1/windows-1252
kodierte Mails (z.B. mit Umlauten) als Mojibake gespeichert wurden.
Zusätzlich fehlte das Charset für die Manticore-MySQL-Verbindung und
der charset-Parameter im JSON-Response-Header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:47:00 +02:00

132 lines
3.7 KiB
Go

package main
import (
"context"
"flag"
"log/slog"
"os"
"archivmail/config"
"archivmail/internal/audit"
"archivmail/internal/index"
"archivmail/internal/storage"
)
// runPurge deletes mails that are BOTH past retain_until AND explicitly
// marked_for_deletion=TRUE by a user in the UI, removes them from the
// search index, and writes one audit log entry per deleted mail
// (GoBD-Nachvollziehbarkeit). Intended to be cron-driven (PROJ-56c).
//
// Deliberately NOT the same query as the manual /api/admin/purge endpoint
// (Store.Purge, deletes everything past retain_until regardless of marking):
// an unattended cron job must never delete mails on date alone — a human
// has to have explicitly flagged each one for deletion first.
//
// 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.ListExpiredMarkedMailIDs(ctx)
if err != nil {
logger.Error("purge: list expired+marked failed", "err", err)
os.Exit(1)
}
if len(ids) == 0 {
logger.Info("purge: nothing to do, no expired+marked 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)/?charset=utf8mb4"
}
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: "Cron-Purge: Aufbewahrungsfrist abgelaufen UND vom Nutzer zur Löschung markiert",
})
}
deleted++
}
logger.Info("purge: complete", "total", len(ids), "deleted", deleted, "failed", failed)
}