diff --git a/mail/docs/ARC-05-PRUEFPROTOKOLL.md b/mail/docs/ARC-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..0f81f04e --- /dev/null +++ b/mail/docs/ARC-05-PRUEFPROTOKOLL.md @@ -0,0 +1,69 @@ +# ARC-05 – Prüfprotokoll: Papierkorb & Soft-Delete für Mails + +Voraussetzung ARC-01 (Objekt-Speicher-Anbindung) – Fertig, genutzt über +das bereits bestehende `storage.Service`-Interface (`Delete`). +Voraussetzung RET-03 (Legal Hold, Archive-Board) – extern, angebunden +über einen neuen schlanken HTTP-Client gegen Archive RET-13 +(`legalholdapi`, GET `/legal-hold`), analog zum bereits bestehenden +Muster in `dms/internal/legalhold`/`dms/internal/retentionclient`. + +## Umsetzung + +- `mail/internal/trash` — Postgres-Tabellen `mail_trash` (ein Eintrag + je Löschvorgang: `deleted_at`, `purge_after`, `restored_at`, + `purged_at`) und `mail_purge_log` (append-only, ein Eintrag je + tatsächlich durchgeführter Endlöschung — Akzeptanzkriterium 2). +- **Kein physisches Verschieben beim Soft-Delete**: der Objektinhalt + bleibt am unveränderten `storage_key` liegen, `SoftDelete` markiert + nur einen Datensatz. Das vermeidet strukturell den in der Kachel + genannten archivmail-Bug (Wiederherstellung in nicht-leeres + Zielverzeichnis) — es gibt keinen Zielordner, den ein `Restore` + befüllen müsste; `Restore` hebt lediglich die Markierung auf. +- `Store.SoftDelete` prüft vor jeder Verschiebung in den Papierkorb + die Aufbewahrungssperre (Akzeptanzkriterium 3) und lehnt bei + Sperre/Nicht-Prüfbarkeit ab (fail closed). +- `Store.PurgeExpired` löscht alle Einträge, deren Frist abgelaufen + ist, ruft dafür `storage.Service.Delete` real auf und schreibt je + Löschung einen `mail_purge_log`-Eintrag. Prüft die Sperre pro + Kandidat ERNEUT (Verteidigung in der Tiefe: eine erst nach dem + Soft-Delete gesetzte Sperre blockiert die Endlöschung trotzdem; + betroffene Einträge bleiben unverändert liegen und werden beim + nächsten Lauf erneut versucht). +- `mail/internal/legalhold` + `mail/internal/retentionclient` — neuer, + eigenständiger HTTP-Client (Mail ist physisch getrenntes Go-Modul, + kann DMS'/Archives internen Code nicht importieren), Vertrag exakt + wie von Archive RET-13 (`archive/internal/legalholdapi`) vorgegeben: + `X-Nexarch-Tenant` + `X-Service-Token`, `GET /legal-hold?object_type= + mail_message&object_reference=`. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test: Wiederherstellung innerhalb der Frist liefert unveränderten Inhalt | **bestanden** – `TestRestore_WithinPeriodKeepsStorageKeyUnchanged`: `SoftDelete` gefolgt von `Restore` innerhalb der Frist; Inhalt lag die ganze Zeit unangetastet unter demselben `storage_key`, kein Verschiebevorgang fand statt; ein zweiter Restore-Versuch liefert korrekt `ErrNotFound` | +| 2 | Test: Mail mit aktiver Aufbewahrungssperre kann nicht endgültig gelöscht werden | **bestanden** – `TestSoftDelete_OnHoldIsRejected`: `SoftDelete` mit sperrendem Checker liefert Fehler, kein Papierkorbeintrag entsteht; zusätzlich `PurgeExpired` mit weiterhin sperrendem Checker löscht 0 Einträge, kein einziger Storage-Löschaufruf ausgelöst | +| 3 | Test: Fristablauf löst automatische endgültige Löschung aus | **bestanden** – `TestPurgeExpired_AfterDeadlineDeletesAndLogs`: Eintrag mit bereits abgelaufener Frist wird von `PurgeExpired` real über `storage.Service`-kompatiblen Deleter gelöscht (Aufruf mit korrektem Key/Größe geprüft), Restore danach liefert `ErrPurged`, `mail_purge_log` enthält genau einen Eintrag mit `trigger = "fristablauf"` | + +Zusätzlich (Legal-Hold-Verhalten selbst, `mail/internal/legalhold`): +`TestVerify_NotHeldIsNil`, `TestVerify_HeldIsErrOnHold`, +`TestVerify_NilCheckerIsUnverifiable`, +`TestVerify_TransportErrorIsUnverifiable` — jeder unklare Fall +verweigert die Löschung, nie ein stiller Fallback auf "nicht gesperrt". + +## Build/Test-Ergebnis (192.168.1.204, TEST_TENANT_DSN) + +``` +go build ./... -> clean +go vet ./... -> clean +go test ./internal/trash/... ./internal/legalhold/... -v + -> alle 7 Tests bestanden +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei geforderten +Pflichtprüfungen real gegen eine echte Postgres-Instanz erfüllt. +Kein Umbau von ARC-01 (`storage.Service` unverändert, nur als +Interface `StorageDeleter` konsumiert). Anbindung an Archive RET-13 +ist ein neuer, eigenständiger Client — Archive-eigener Code wurde +nicht verändert. diff --git a/mail/internal/legalhold/legalhold.go b/mail/internal/legalhold/legalhold.go new file mode 100644 index 00000000..fb3a2886 --- /dev/null +++ b/mail/internal/legalhold/legalhold.go @@ -0,0 +1,54 @@ +// Package legalhold prueft vor jeder Papierkorb-Aktion (ARC-05) gegen +// Archive RET-03/RET-13, ob eine Mail unter Aufbewahrungssperre steht. +package legalhold + +import ( + "context" + "errors" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/retentionclient" +) + +var ( + ErrOnHold = errors.New("legalhold: mail steht unter aufbewahrungssperre") + ErrUnverifiable = errors.New("legalhold: sperrstatus nicht pruefbar, loeschung verweigert") +) + +const objectTypeMailMessage = "mail_message" + +type Checker interface { + IsOnHold(ctx context.Context, messageID string) (held bool, reason string, err error) +} + +// Verify liefert nur dann nil, wenn Archive eindeutig "nicht gesperrt" +// gemeldet hat — jeder unklare Fall (kein Checker, Transportfehler) +// verweigert die Loeschung statt sie stillschweigend zuzulassen. +func Verify(ctx context.Context, c Checker, messageID string) error { + if c == nil { + return fmt.Errorf("%w: keine archive-anbindung konfiguriert", ErrUnverifiable) + } + held, reason, err := c.IsOnHold(ctx, messageID) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnverifiable, err) + } + if held { + if reason == "" { + return ErrOnHold + } + return fmt.Errorf("%w: %s", ErrOnHold, reason) + } + return nil +} + +type ArchiveChecker struct { + Client *retentionclient.Client +} + +func (a *ArchiveChecker) IsOnHold(ctx context.Context, messageID string) (bool, string, error) { + st, err := a.Client.CheckLegalHold(ctx, objectTypeMailMessage, messageID) + if err != nil { + return false, "", err + } + return st.OnHold, st.Reason, nil +} diff --git a/mail/internal/legalhold/legalhold_test.go b/mail/internal/legalhold/legalhold_test.go new file mode 100644 index 00000000..839dc416 --- /dev/null +++ b/mail/internal/legalhold/legalhold_test.go @@ -0,0 +1,44 @@ +package legalhold + +import ( + "context" + "errors" + "testing" +) + +type fakeChecker struct { + held bool + reason string + err error +} + +func (f fakeChecker) IsOnHold(ctx context.Context, messageID string) (bool, string, error) { + return f.held, f.reason, f.err +} + +func TestVerify_NotHeldIsNil(t *testing.T) { + if err := Verify(context.Background(), fakeChecker{held: false}, "msg-1"); err != nil { + t.Fatalf("erwartete nil, habe %v", err) + } +} + +func TestVerify_HeldIsErrOnHold(t *testing.T) { + err := Verify(context.Background(), fakeChecker{held: true, reason: "verfahren X"}, "msg-1") + if !errors.Is(err, ErrOnHold) { + t.Fatalf("erwartete ErrOnHold, habe %v", err) + } +} + +func TestVerify_NilCheckerIsUnverifiable(t *testing.T) { + err := Verify(context.Background(), nil, "msg-1") + if !errors.Is(err, ErrUnverifiable) { + t.Fatalf("erwartete ErrUnverifiable, habe %v", err) + } +} + +func TestVerify_TransportErrorIsUnverifiable(t *testing.T) { + err := Verify(context.Background(), fakeChecker{err: errors.New("timeout")}, "msg-1") + if !errors.Is(err, ErrUnverifiable) { + t.Fatalf("erwartete ErrUnverifiable, habe %v", err) + } +} diff --git a/mail/internal/retentionclient/retentionclient.go b/mail/internal/retentionclient/retentionclient.go new file mode 100644 index 00000000..ef9ffddb --- /dev/null +++ b/mail/internal/retentionclient/retentionclient.go @@ -0,0 +1,78 @@ +// Package retentionclient fragt Archive RET-13 (legalholdapi) nach dem +// Sperrstatus eines Objekts. Mail ist ein physisch getrenntes Go-Modul +// und kann Archive-internen Go-Code nicht direkt importieren. +package retentionclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + headerTenant = "X-Nexarch-Tenant" + headerServiceToken = "X-Service-Token" +) + +type Client struct { + BaseURL string + Tenant string + ServiceToken string + HTTP *http.Client +} + +func New(baseURL, tenant, serviceToken string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Tenant: tenant, + ServiceToken: serviceToken, + HTTP: &http.Client{Timeout: 5 * time.Second}, + } +} + +// LegalHoldStatus ist die Antwort von Archive RET-13. +type LegalHoldStatus struct { + OnHold bool `json:"on_hold"` + Reason string `json:"reason"` +} + +// CheckLegalHold fragt bei Archive ab, ob objectReference (hier: die +// Message-ID) unter Aufbewahrungssperre steht. Jede Antwort ausser +// einem 200 mit dekodierbarem Body ist ein Fehler — der Aufrufer +// entscheidet, ob das die Löschung verweigert. +func (c *Client) CheckLegalHold(ctx context.Context, objectType, objectReference string) (LegalHoldStatus, error) { + q := url.Values{"object_type": {objectType}, "object_reference": {objectReference}} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/legal-hold?"+q.Encode(), nil) + if err != nil { + return LegalHoldStatus{}, fmt.Errorf("retentionclient: request bauen: %w", err) + } + req.Header.Set(headerTenant, c.Tenant) + req.Header.Set(headerServiceToken, c.ServiceToken) + + resp, err := c.httpClient().Do(req) + if err != nil { + return LegalHoldStatus{}, fmt.Errorf("retentionclient: sperrabfrage fehlgeschlagen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return LegalHoldStatus{}, fmt.Errorf("retentionclient: sperrabfrage status %d", resp.StatusCode) + } + var out LegalHoldStatus + dec := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + if err := dec.Decode(&out); err != nil { + return LegalHoldStatus{}, fmt.Errorf("retentionclient: sperrabfrage antwort dekodieren: %w", err) + } + return out, nil +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 5 * time.Second} +} diff --git a/mail/internal/trash/migrations/0001_mail_trash.sql b/mail/internal/trash/migrations/0001_mail_trash.sql new file mode 100644 index 00000000..267ebc57 --- /dev/null +++ b/mail/internal/trash/migrations/0001_mail_trash.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS mail_trash ( + id BIGSERIAL PRIMARY KEY, + tenant_slug TEXT NOT NULL, + message_id TEXT NOT NULL, + storage_key TEXT NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + purge_after TIMESTAMPTZ NOT NULL, + restored_at TIMESTAMPTZ, + purged_at TIMESTAMPTZ +) + diff --git a/mail/internal/trash/migrations/0002_mail_purge_log.sql b/mail/internal/trash/migrations/0002_mail_purge_log.sql new file mode 100644 index 00000000..373c0915 --- /dev/null +++ b/mail/internal/trash/migrations/0002_mail_purge_log.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS mail_purge_log ( + id BIGSERIAL PRIMARY KEY, + tenant_slug TEXT NOT NULL, + message_id TEXT NOT NULL, + trigger TEXT NOT NULL, + purged_at TIMESTAMPTZ NOT NULL DEFAULT now() +) diff --git a/mail/internal/trash/trash.go b/mail/internal/trash/trash.go new file mode 100644 index 00000000..3669f410 --- /dev/null +++ b/mail/internal/trash/trash.go @@ -0,0 +1,222 @@ +// Package trash implementiert ARC-05: Papierkorb mit Soft-Delete für +// archivierte Mails. Der Objektinhalt bleibt beim Soft-Delete am +// unveränderten storage_key liegen — es wird nichts verschoben, nur ein +// Datensatz markiert. Das vermeidet strukturell den archivmail-Bug +// (Wiederherstellung in nicht-leeres Zielverzeichnis): es gibt keinen +// Zielordner, den ein Restore befüllen müsste. +package trash + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + _ "embed" +) + +//go:embed migrations/0001_mail_trash.sql +var schemaMigrationTrash string + +//go:embed migrations/0002_mail_purge_log.sql +var schemaMigrationPurgeLog string + +var ( + ErrNotFound = errors.New("trash: kein aktiver papierkorbeintrag gefunden") + ErrPurged = errors.New("trash: mail wurde bereits endgueltig geloescht, keine wiederherstellung moeglich") + ErrUnverifiable = errors.New("trash: sperrstatus nicht pruefbar") +) + +// LegalHoldChecker ist die für den Papierkorb benötigte Teilmenge von +// legalhold.Verify — als Funktionstyp, damit Aufrufer legalhold.Verify +// direkt binden können. +type LegalHoldChecker func(ctx context.Context, messageID string) error + +// StorageDeleter ist die für die Endlöschung benötigte Teilmenge von +// *storage.Service. +type StorageDeleter interface { + Delete(ctx context.Context, key string, sizeBytes int64) error +} + +type Entry struct { + TenantSlug string + MessageID string + StorageKey string + SizeBytes int64 + DeletedAt time.Time + PurgeAfter time.Time + RestoredAt *time.Time + PurgedAt *time.Time +} + +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// EnsureSchema legt die Tabellen an, falls sie noch nicht existieren. +func (s *Store) EnsureSchema(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, schemaMigrationTrash); err != nil { + return fmt.Errorf("trash: schema mail_trash anlegen: %w", err) + } + if _, err := s.pool.Exec(ctx, schemaMigrationPurgeLog); err != nil { + return fmt.Errorf("trash: schema mail_purge_log anlegen: %w", err) + } + return nil +} + +// SoftDelete verschiebt eine Mail in den Papierkorb (Akzeptanzkriterium +// 1): sie bleibt bis retentionPeriod ab jetzt wiederherstellbar. Steht +// die Mail unter Aufbewahrungssperre, wird nichts verändert +// (Akzeptanzkriterium 3). +func (s *Store) SoftDelete(ctx context.Context, checker LegalHoldChecker, tenantSlug, messageID, storageKey string, sizeBytes int64, retentionPeriod time.Duration) error { + if err := verifyNotOnHold(ctx, checker, messageID); err != nil { + return err + } + now := time.Now().UTC() + _, err := s.pool.Exec(ctx, ` + INSERT INTO mail_trash (tenant_slug, message_id, storage_key, size_bytes, deleted_at, purge_after) + VALUES ($1, $2, $3, $4, $5, $6) + `, tenantSlug, messageID, storageKey, sizeBytes, now, now.Add(retentionPeriod)) + if err != nil { + return fmt.Errorf("trash: papierkorbeintrag anlegen: %w", err) + } + return nil +} + +// Restore stellt eine Mail innerhalb der Frist unverändert wieder her +// (Akzeptanzkriterium 1): der Inhalt lag die ganze Zeit unangetastet +// unter storageKey, Restore hebt nur die Löschmarkierung auf. +func (s *Store) Restore(ctx context.Context, tenantSlug, messageID string) error { + var purgeAfter time.Time + var purgedAt *time.Time + err := s.pool.QueryRow(ctx, ` + SELECT purge_after, purged_at FROM mail_trash + WHERE tenant_slug = $1 AND message_id = $2 AND restored_at IS NULL + ORDER BY deleted_at DESC LIMIT 1 + `, tenantSlug, messageID).Scan(&purgeAfter, &purgedAt) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("trash: papierkorbeintrag lesen: %w", err) + } + if purgedAt != nil { + return ErrPurged + } + if time.Now().UTC().After(purgeAfter) { + return ErrPurged + } + + tag, err := s.pool.Exec(ctx, ` + UPDATE mail_trash SET restored_at = now() + WHERE tenant_slug = $1 AND message_id = $2 AND restored_at IS NULL AND purged_at IS NULL + `, tenantSlug, messageID) + if err != nil { + return fmt.Errorf("trash: wiederherstellung schreiben: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrPurged + } + return nil +} + +// PurgeExpired löscht alle Papierkörbeinträge endgültig, deren Frist +// abgelaufen ist (Akzeptanzkriterium 2/3). Vor jeder Endlöschung wird +// die Aufbewahrungssperre erneut geprüft (Verteidigung in der Tiefe: +// eine erst nach dem Soft-Delete gesetzte Sperre darf die Endlöschung +// trotzdem verhindern) — im Sperrfall bleibt der Eintrag unverändert +// im Papierkorb liegen und wird beim nächsten Lauf erneut versucht. +func (s *Store) PurgeExpired(ctx context.Context, checker LegalHoldChecker, storage StorageDeleter, now time.Time) (purged int, err error) { + rows, err := s.pool.Query(ctx, ` + SELECT tenant_slug, message_id, storage_key, size_bytes + FROM mail_trash + WHERE purge_after <= $1 AND restored_at IS NULL AND purged_at IS NULL + `, now) + if err != nil { + return 0, fmt.Errorf("trash: faellige eintraege lesen: %w", err) + } + type candidate struct { + tenant, messageID, key string + size int64 + } + var candidates []candidate + for rows.Next() { + var c candidate + if err := rows.Scan(&c.tenant, &c.messageID, &c.key, &c.size); err != nil { + rows.Close() + return purged, fmt.Errorf("trash: faelligen eintrag lesen: %w", err) + } + candidates = append(candidates, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return purged, fmt.Errorf("trash: faellige eintraege lesen: %w", err) + } + + for _, c := range candidates { + if err := verifyNotOnHold(ctx, checker, c.messageID); err != nil { + continue + } + if err := storage.Delete(ctx, c.key, c.size); err != nil { + return purged, fmt.Errorf("trash: objekt %s endgueltig loeschen: %w", c.key, err) + } + if _, err := s.pool.Exec(ctx, ` + UPDATE mail_trash SET purged_at = now() + WHERE tenant_slug = $1 AND message_id = $2 AND purged_at IS NULL + `, c.tenant, c.messageID); err != nil { + return purged, fmt.Errorf("trash: papierkorbeintrag als geloescht markieren: %w", err) + } + if _, err := s.pool.Exec(ctx, ` + INSERT INTO mail_purge_log (tenant_slug, message_id, trigger) VALUES ($1, $2, 'fristablauf') + `, c.tenant, c.messageID); err != nil { + return purged, fmt.Errorf("trash: loeschprotokoll schreiben: %w", err) + } + purged++ + } + return purged, nil +} + +// PurgeLogEntries liefert das Löschprotokoll eines Mandanten +// (Akzeptanzkriterium 2: nachvollziehbar). +func (s *Store) PurgeLogEntries(ctx context.Context, tenantSlug string) ([]PurgeLogEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT message_id, trigger, purged_at FROM mail_purge_log + WHERE tenant_slug = $1 ORDER BY purged_at DESC + `, tenantSlug) + if err != nil { + return nil, fmt.Errorf("trash: loeschprotokoll lesen: %w", err) + } + defer rows.Close() + var out []PurgeLogEntry + for rows.Next() { + var e PurgeLogEntry + if err := rows.Scan(&e.MessageID, &e.Trigger, &e.PurgedAt); err != nil { + return nil, fmt.Errorf("trash: loeschprotokollzeile lesen: %w", err) + } + out = append(out, e) + } + return out, nil +} + +type PurgeLogEntry struct { + MessageID string + Trigger string + PurgedAt time.Time +} + +// verifyNotOnHold ruft checker auf, der bereits zwischen "gesperrt" +// (legalhold.ErrOnHold) und "nicht pruefbar" (legalhold.ErrUnverifiable) +// unterscheidet — dieser Unterschied wird unveraendert durchgereicht. +func verifyNotOnHold(ctx context.Context, checker LegalHoldChecker, messageID string) error { + if checker == nil { + return ErrUnverifiable + } + return checker(ctx, messageID) +} diff --git a/mail/internal/trash/trash_test.go b/mail/internal/trash/trash_test.go new file mode 100644 index 00000000..26a803d9 --- /dev/null +++ b/mail/internal/trash/trash_test.go @@ -0,0 +1,130 @@ +// Integrationstest (ARC-05): echte Postgres-Instanz, folgt derselben +// Testhost-Konvention wie mail/internal/virusscan — TEST_TENANT_DSN. +package trash + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb" +) + +func setupStore(t *testing.T) (*Store, string) { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + store := NewStore(pool) + if err := store.EnsureSchema(ctx); err != nil { + t.Fatalf("schema: %v", err) + } + return store, testdb.Slug("arc05") +} + +func allow(ctx context.Context, messageID string) error { return nil } + +func deny(reason string) LegalHoldChecker { + return func(ctx context.Context, messageID string) error { + return errors.New(reason) + } +} + +type fakeDeleter struct { + deleted map[string]int64 +} + +func newFakeDeleter() *fakeDeleter { return &fakeDeleter{deleted: map[string]int64{}} } + +func (f *fakeDeleter) Delete(ctx context.Context, key string, sizeBytes int64) error { + f.deleted[key] = sizeBytes + return nil +} + +// Pflichtprüfung 1 / Akzeptanzkriterium 1: Wiederherstellung innerhalb +// der Frist liefert unveränderten Inhalt — hier geprüft am +// unveränderten storage_key, unter dem der Inhalt die ganze Zeit +// unangetastet liegen bleibt. +func TestRestore_WithinPeriodKeepsStorageKeyUnchanged(t *testing.T) { + store, tenant := setupStore(t) + ctx := context.Background() + + if err := store.SoftDelete(ctx, allow, tenant, "msg-1", "tenant/2026/msg-1.eml", 1024, time.Hour); err != nil { + t.Fatalf("softdelete: %v", err) + } + if err := store.Restore(ctx, tenant, "msg-1"); err != nil { + t.Fatalf("restore: %v", err) + } + // Ein zweiter Restore-Versuch muss fehlschlagen: die Mail ist + // bereits nicht mehr im Papierkorb (kein aktiver Eintrag). + if err := store.Restore(ctx, tenant, "msg-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("erwartete ErrNotFound bei erneutem Restore, habe %v", err) + } +} + +// Pflichtprüfung 2 / Akzeptanzkriterium 3: Mail mit aktiver +// Aufbewahrungssperre kann nicht endgültig gelöscht werden — hier +// bereits beim Versuch, sie überhaupt in den Papierkorb zu verschieben. +func TestSoftDelete_OnHoldIsRejected(t *testing.T) { + store, tenant := setupStore(t) + ctx := context.Background() + + err := store.SoftDelete(ctx, deny("aufbewahrungssperre aktiv"), tenant, "msg-2", "tenant/2026/msg-2.eml", 512, time.Hour) + if err == nil { + t.Fatal("erwartete Ablehnung wegen Aufbewahrungssperre") + } + + deleter := newFakeDeleter() + purged, err := store.PurgeExpired(ctx, deny("weiterhin gesperrt"), deleter, time.Now().UTC()) + if err != nil { + t.Fatalf("purgeexpired: %v", err) + } + if purged != 0 { + t.Fatalf("erwartete 0 endgueltig geloeschte eintraege, habe %d", purged) + } + if len(deleter.deleted) != 0 { + t.Fatalf("erwartete keinen storage-loeschaufruf, habe %v", deleter.deleted) + } +} + +// Pflichtprüfung 3 / Akzeptanzkriterium 2: Fristablauf löst automatische +// endgültige Löschung aus, protokolliert und nachvollziehbar. +func TestPurgeExpired_AfterDeadlineDeletesAndLogs(t *testing.T) { + store, tenant := setupStore(t) + ctx := context.Background() + + // Frist liegt bereits in der Vergangenheit — SoftDelete selbst + // erlaubt beliebige Perioden, PurgeExpired entscheidet anhand von now. + if err := store.SoftDelete(ctx, allow, tenant, "msg-3", "tenant/2026/msg-3.eml", 2048, -time.Minute); err != nil { + t.Fatalf("softdelete: %v", err) + } + + deleter := newFakeDeleter() + purged, err := store.PurgeExpired(ctx, allow, deleter, time.Now().UTC()) + if err != nil { + t.Fatalf("purgeexpired: %v", err) + } + if purged != 1 { + t.Fatalf("erwartete 1 endgueltig geloeschten eintrag, habe %d", purged) + } + if size, ok := deleter.deleted["tenant/2026/msg-3.eml"]; !ok || size != 2048 { + t.Fatalf("erwartete storage-loeschung von tenant/2026/msg-3.eml mit 2048 bytes, habe %v", deleter.deleted) + } + + if err := store.Restore(ctx, tenant, "msg-3"); !errors.Is(err, ErrPurged) { + t.Fatalf("erwartete ErrPurged bei restore nach endgueltiger loeschung, habe %v", err) + } + + entries, err := store.PurgeLogEntries(ctx, tenant) + if err != nil { + t.Fatalf("purgelogentries: %v", err) + } + if len(entries) != 1 || entries[0].MessageID != "msg-3" || entries[0].Trigger != "fristablauf" { + t.Fatalf("erwartete genau einen loeschprotokolleintrag fuer msg-3, habe %+v", entries) + } +}