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
+61
-15
@@ -666,27 +666,57 @@ func (s *Store) Load(id string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// Delete removes a stored email by its ID, including its DB metadata row.
|
||||
// Delete removes a mail's metadata (emails row + dependent email_refs /
|
||||
// email_attachments rows, inside a transaction) and only deletes the file
|
||||
// from disk once that transaction has committed.
|
||||
//
|
||||
// This order matters: emails is referenced by email_refs and
|
||||
// email_attachments without ON DELETE CASCADE, so a plain
|
||||
// "DELETE FROM emails" fails with a FK violation whenever such rows exist
|
||||
// (e.g. any tenant-routed or multi-recipient mail). Removing the file
|
||||
// *before* the DB delete — as this function used to do — meant that
|
||||
// failure left an orphaned DB row pointing at a file that no longer
|
||||
// existed, silently, because the error was discarded. Doing the DB work
|
||||
// first and only then unlinking the file means a failed DB delete leaves
|
||||
// the file (and the mail) intact instead of corrupting metadata.
|
||||
func (s *Store) Delete(id string) error {
|
||||
// PROJ-34: Enforce retention lock before any disk or DB operation.
|
||||
ctx := context.Background()
|
||||
|
||||
if s.db != nil {
|
||||
ctx := context.Background()
|
||||
var until *time.Time
|
||||
_ = s.db.QueryRow(ctx, `SELECT retain_until FROM emails WHERE id=$1`, id).Scan(&until)
|
||||
if until != nil && time.Now().Before(*until) {
|
||||
return ErrRetentionLock
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: delete: begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM email_refs WHERE email_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("storage: delete: email_refs: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM email_attachments WHERE email_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("storage: delete: email_attachments: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM emails WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("storage: delete: emails: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("storage: delete: commit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
path := s.filePath(id)
|
||||
if err := os.Remove(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("storage: not found: %s", id)
|
||||
// DB metadata is already gone (or never existed) at this point;
|
||||
// a missing file is not an error worth failing the caller for.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("storage: delete: %w", err)
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
_, _ = s.db.Exec(context.Background(), `DELETE FROM emails WHERE id = $1`, id)
|
||||
return fmt.Errorf("storage: delete: file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -717,17 +747,18 @@ func (s *Store) GetReceivedAts(ctx context.Context, ids []string) map[string]tim
|
||||
return result
|
||||
}
|
||||
|
||||
// Purge deletes all mails whose retain_until has passed.
|
||||
// Returns the number of successfully deleted mails.
|
||||
// Mails that fail to delete (e.g. file missing) are skipped silently.
|
||||
func (s *Store) Purge(ctx context.Context) (int, error) {
|
||||
// ListExpiredMailIDs returns the IDs of all mails whose retain_until has
|
||||
// passed (PROJ-56c). Used by both Purge() and the cron-driven CLI purge
|
||||
// command, which additionally needs the IDs to clean up the search index
|
||||
// and write per-mail audit entries.
|
||||
func (s *Store) ListExpiredMailIDs(ctx context.Context) ([]string, error) {
|
||||
if s.db == nil {
|
||||
return 0, nil
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id FROM emails WHERE retain_until IS NOT NULL AND retain_until < NOW()`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("storage: purge query: %w", err)
|
||||
return nil, fmt.Errorf("storage: list expired query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
@@ -738,7 +769,22 @@ func (s *Store) Purge(ctx context.Context) (int, error) {
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, fmt.Errorf("storage: purge rows: %w", err)
|
||||
return nil, fmt.Errorf("storage: list expired rows: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Purge deletes all mails whose retain_until has passed.
|
||||
// Returns the number of successfully deleted mails.
|
||||
// Mails that fail to delete (e.g. file missing) are skipped silently.
|
||||
//
|
||||
// NOTE: this does not remove entries from the search index or write audit
|
||||
// log entries — callers that need that (e.g. the cron-driven CLI purge
|
||||
// command, PROJ-56c) should use ListExpiredMailIDs directly instead.
|
||||
func (s *Store) Purge(ctx context.Context) (int, error) {
|
||||
ids, err := s.ListExpiredMailIDs(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted := 0
|
||||
for _, id := range ids {
|
||||
|
||||
Reference in New Issue
Block a user