feat(PROJ-56c): Purge-Cron löscht nur explizit markierte Mails
Der automatische Purge-Cron darf nicht allein anhand des abgelaufenen
retain_until löschen — eine Mail muss zusätzlich von einem Admin im UI
zur Löschung markiert worden sein. Dafür: neue Spalten
marked_for_deletion(_by/_at) auf emails, Store.ListExpiredMarkedMailIDs()
(retain_until abgelaufen UND markiert) für den Cron-Pfad, und
Store.SetMarkedForDeletion() zum Setzen/Löschen der Markierung.
Neue Endpoints (domain_admin+, tenant-scoped):
- GET /api/admin/retention/expired Metadaten abgelaufener Mails
(kein Body-Zugriff, SEC-29)
- PUT /api/admin/mails/{id}/mark-deletion Markierung setzen/entfernen,
mit Audit-Log-Eintrag
RetentionTab.tsx zeigt abgelaufene Mails mit Checkbox zum Markieren.
Der bestehende manuelle "Jetzt löschen"-Button (/api/admin/purge) bleibt
unverändert und löscht weiterhin alle abgelaufenen Mails auf einen Klick —
nur der unbeaufsichtigte Cron-Job ist jetzt auf markierte Mails beschränkt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
586af2478c
commit
b3ff8e6cf9
@@ -0,0 +1,148 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// MarkedForDeletionInfo reports the deletion-mark state of one mail.
|
||||
type MarkedForDeletionInfo struct {
|
||||
Marked bool
|
||||
By string
|
||||
At *time.Time
|
||||
}
|
||||
|
||||
// SetMarkedForDeletion sets or clears the marked_for_deletion flag for a
|
||||
// single mail (PROJ-56c). This is a deliberate, per-mail user action — the
|
||||
// cron-driven purge only ever deletes mails that are BOTH past retain_until
|
||||
// AND marked here; an expired retention date alone is never sufficient.
|
||||
func (s *Store) SetMarkedForDeletion(ctx context.Context, id string, marked bool, username string) error {
|
||||
if s.db == nil {
|
||||
return nil
|
||||
}
|
||||
if id == "" {
|
||||
return errors.New("storage: SetMarkedForDeletion: empty id")
|
||||
}
|
||||
if marked {
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE emails SET marked_for_deletion = TRUE, marked_for_deletion_by = $1, marked_for_deletion_at = NOW() WHERE id = $2`,
|
||||
username, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: set marked for deletion: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE emails SET marked_for_deletion = FALSE, marked_for_deletion_by = NULL, marked_for_deletion_at = NULL WHERE id = $1`,
|
||||
id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: clear marked for deletion: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarkedForDeletion returns the current deletion-mark state for one mail.
|
||||
func (s *Store) GetMarkedForDeletion(ctx context.Context, id string) (MarkedForDeletionInfo, error) {
|
||||
if s.db == nil {
|
||||
return MarkedForDeletionInfo{}, nil
|
||||
}
|
||||
var info MarkedForDeletionInfo
|
||||
var by *string
|
||||
row := s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(marked_for_deletion, FALSE), marked_for_deletion_by, marked_for_deletion_at
|
||||
FROM emails WHERE id = $1`, id)
|
||||
if err := row.Scan(&info.Marked, &by, &info.At); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return MarkedForDeletionInfo{}, nil
|
||||
}
|
||||
return MarkedForDeletionInfo{}, fmt.Errorf("storage: get marked for deletion: %w", err)
|
||||
}
|
||||
if by != nil {
|
||||
info.By = *by
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ExpiredMailMeta is metadata-only info about a mail past retain_until,
|
||||
// for the admin "mark for deletion" review list. Deliberately omits body
|
||||
// content — domain_admin/superadmin may manage retention without having
|
||||
// mail-content read access (SEC-29 separation of duties).
|
||||
type ExpiredMailMeta struct {
|
||||
ID string
|
||||
From string
|
||||
Subject string
|
||||
ReceivedAt time.Time
|
||||
RetainUntil time.Time
|
||||
Marked bool
|
||||
MarkedBy string
|
||||
}
|
||||
|
||||
// ListExpiredMails returns metadata for all mails whose retain_until has
|
||||
// passed, regardless of marked_for_deletion — this is what the admin UI
|
||||
// shows so a human can review and mark individual mails (PROJ-56c).
|
||||
// If tenantID is non-nil, results are restricted to that tenant.
|
||||
func (s *Store) ListExpiredMails(ctx context.Context, tenantID *int64) ([]ExpiredMailMeta, error) {
|
||||
if s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
query := `SELECT id, COALESCE(mail_from, ''), COALESCE(subject, ''), received_at,
|
||||
retain_until, COALESCE(marked_for_deletion, FALSE), COALESCE(marked_for_deletion_by, '')
|
||||
FROM emails
|
||||
WHERE retain_until IS NOT NULL AND retain_until < NOW()`
|
||||
args := []interface{}{}
|
||||
if tenantID != nil {
|
||||
args = append(args, *tenantID)
|
||||
query += fmt.Sprintf(" AND tenant_id = $%d", len(args))
|
||||
}
|
||||
query += " ORDER BY retain_until ASC LIMIT 500"
|
||||
|
||||
rows, err := s.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list expired mails: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ExpiredMailMeta
|
||||
for rows.Next() {
|
||||
var m ExpiredMailMeta
|
||||
if err := rows.Scan(&m.ID, &m.From, &m.Subject, &m.ReceivedAt, &m.RetainUntil, &m.Marked, &m.MarkedBy); err == nil {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("storage: list expired mails rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListExpiredMarkedMailIDs returns IDs of mails that are BOTH past
|
||||
// retain_until AND explicitly marked_for_deletion=TRUE (PROJ-56c). This is
|
||||
// the query the cron-driven purge uses — unlike ListExpiredMailIDs (used by
|
||||
// the manual admin "Jetzt löschen" button), it never deletes anything based
|
||||
// on the date alone.
|
||||
func (s *Store) ListExpiredMarkedMailIDs(ctx context.Context) ([]string, error) {
|
||||
if s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id FROM emails WHERE retain_until IS NOT NULL AND retain_until < NOW() AND marked_for_deletion = TRUE`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list expired marked query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("storage: list expired marked rows: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
Reference in New Issue
Block a user