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
@@ -12,11 +12,15 @@ import (
|
|||||||
"archivmail/internal/storage"
|
"archivmail/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runPurge deletes all mails whose retain_until has passed, removes them
|
// runPurge deletes mails that are BOTH past retain_until AND explicitly
|
||||||
// from the search index, and writes one audit log entry per deleted mail
|
// marked_for_deletion=TRUE by a user in the UI, removes them from the
|
||||||
// (GoBD-Nachvollziehbarkeit). Intended to be cron-driven (PROJ-56c), mirrors
|
// search index, and writes one audit log entry per deleted mail
|
||||||
// the manual /api/admin/purge endpoint but adds index cleanup + audit trail,
|
// (GoBD-Nachvollziehbarkeit). Intended to be cron-driven (PROJ-56c).
|
||||||
// which the plain Store.Purge() helper intentionally does not do.
|
//
|
||||||
|
// 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]
|
// Usage: archivmail purge [-config /path/to/config.yml] [-dry-run]
|
||||||
func runPurge(args []string) {
|
func runPurge(args []string) {
|
||||||
@@ -48,13 +52,13 @@ func runPurge(args []string) {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
ids, err := mailStore.ListExpiredMailIDs(ctx)
|
ids, err := mailStore.ListExpiredMarkedMailIDs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("purge: list expired failed", "err", err)
|
logger.Error("purge: list expired+marked failed", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
logger.Info("purge: nothing to do, no expired mails")
|
logger.Info("purge: nothing to do, no expired+marked mails")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +120,7 @@ func runPurge(args []string) {
|
|||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
MailID: id,
|
MailID: id,
|
||||||
Success: true,
|
Success: true,
|
||||||
Detail: "automatischer Purge nach Ablauf der Aufbewahrungsfrist (retain_until)",
|
Detail: "Cron-Purge: Aufbewahrungsfrist abgelaufen UND vom Nutzer zur Löschung markiert",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,106 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"archivmail/internal/audit"
|
"archivmail/internal/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// handleListExpiredMails returns metadata (no body content, SEC-29) for all
|
||||||
|
// mails whose retain_until has passed, so an admin can review and mark
|
||||||
|
// individual mails for the cron-driven purge (PROJ-56c).
|
||||||
|
// GET /api/admin/retention/expired — domain_admin+, tenant-scoped.
|
||||||
|
func (s *Server) handleListExpiredMails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
|
||||||
|
mails, err := s.store.ListExpiredMails(r.Context(), sess.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
RetainUntil string `json:"retain_until"`
|
||||||
|
Marked bool `json:"marked"`
|
||||||
|
MarkedBy string `json:"marked_by,omitempty"`
|
||||||
|
}
|
||||||
|
out := make([]item, len(mails))
|
||||||
|
for i, m := range mails {
|
||||||
|
out[i] = item{
|
||||||
|
ID: m.ID,
|
||||||
|
From: m.From,
|
||||||
|
Subject: m.Subject,
|
||||||
|
ReceivedAt: m.ReceivedAt.UTC().Format(time.RFC3339),
|
||||||
|
RetainUntil: m.RetainUntil.UTC().Format(time.RFC3339),
|
||||||
|
Marked: m.Marked,
|
||||||
|
MarkedBy: m.MarkedBy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"mails": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetMarkedForDeletion sets or clears marked_for_deletion for one mail
|
||||||
|
// (PROJ-56c). This is the only way a mail becomes eligible for the
|
||||||
|
// cron-driven purge — Store.ListExpiredMarkedMailIDs requires retain_until
|
||||||
|
// to have passed AND this flag to be set; an expired retention date alone
|
||||||
|
// is never enough to delete a mail unattended.
|
||||||
|
// PUT /api/admin/mails/{id}/mark-deletion — domain_admin+ only.
|
||||||
|
func (s *Server) handleSetMarkedForDeletion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing mail id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
mailTenant, err := s.store.GetTenantForMail(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "mail not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tenantAccessAllowed(sess, mailTenant) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Marked bool `json:"marked"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.SetMarkedForDeletion(r.Context(), id, body.Marked, sess.Username); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.audlog != nil {
|
||||||
|
eventType := "mail_marked_for_deletion"
|
||||||
|
detail := fmt.Sprintf("mail_id=%s zur Löschung markiert", id)
|
||||||
|
if !body.Marked {
|
||||||
|
eventType = "mail_unmarked_for_deletion"
|
||||||
|
detail = fmt.Sprintf("mail_id=%s Löschmarkierung entfernt", id)
|
||||||
|
}
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: eventType,
|
||||||
|
Username: sess.Username,
|
||||||
|
TenantID: sess.TenantID,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
MailID: id,
|
||||||
|
Success: true,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "marked": body.Marked})
|
||||||
|
}
|
||||||
|
|
||||||
// handlePurge deletes all mails whose retention period has expired.
|
// handlePurge deletes all mails whose retention period has expired.
|
||||||
// POST /api/admin/purge — superadmin only (PROJ-34).
|
// POST /api/admin/purge — superadmin only (PROJ-34).
|
||||||
func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("POST /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateArchivingRule)))
|
s.mux.HandleFunc("POST /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateArchivingRule)))
|
||||||
s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule)))
|
s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule)))
|
||||||
s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule)))
|
s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule)))
|
||||||
|
// PROJ-56c: pro-Mail Löschmarkierung — domain_admin+, tenant-scoped (kein
|
||||||
|
// Mail-Lesezugriff nötig, daher requireRole statt requireMailAccess).
|
||||||
|
s.mux.HandleFunc("GET /api/admin/retention/expired", s.authAdmin(s.handleListExpiredMails))
|
||||||
|
s.mux.HandleFunc("PUT /api/admin/mails/{id}/mark-deletion", s.authAdmin(s.handleSetMarkedForDeletion))
|
||||||
|
|
||||||
// PROJ-50: DSGVO Löschersuchen — admin (manage) / auditor (read-only),
|
// PROJ-50: DSGVO Löschersuchen — admin (manage) / auditor (read-only),
|
||||||
// role enforced inside the handlers.
|
// role enforced inside the handlers.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -345,6 +345,20 @@ func (s *Store) initSchema(ctx context.Context) error {
|
|||||||
_, err = s.db.Exec(ctx, `
|
_, err = s.db.Exec(ctx, `
|
||||||
ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_chars BIGINT DEFAULT 0;
|
ALTER TABLE emails ADD COLUMN IF NOT EXISTS ocr_chars BIGINT DEFAULT 0;
|
||||||
`)
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROJ-56c: explizite "zur Löschung markiert"-Markierung. Der
|
||||||
|
// Purge-Cron löscht NUR Mails, die sowohl retain_until überschritten
|
||||||
|
// haben ALS AUCH hier markiert wurden — eine reine Datumsabfrage allein
|
||||||
|
// reicht nicht, das Löschen muss von einem Nutzer im UI ausgelöst werden.
|
||||||
|
_, err = s.db.Exec(ctx, `
|
||||||
|
ALTER TABLE emails ADD COLUMN IF NOT EXISTS marked_for_deletion BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE emails ADD COLUMN IF NOT EXISTS marked_for_deletion_by TEXT;
|
||||||
|
ALTER TABLE emails ADD COLUMN IF NOT EXISTS marked_for_deletion_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_emails_marked_for_deletion ON emails (marked_for_deletion) WHERE marked_for_deletion = TRUE;
|
||||||
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -22,6 +23,43 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface ExpiredMail {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
received_at: string;
|
||||||
|
retain_until: string;
|
||||||
|
marked: boolean;
|
||||||
|
marked_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExpiredMails(): Promise<ExpiredMail[]> {
|
||||||
|
const res = await fetch("/api/admin/retention/expired", { credentials: "include" });
|
||||||
|
if (!res.ok) throw new Error("Fehler beim Laden");
|
||||||
|
const data = await res.json();
|
||||||
|
return data.mails ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMarkedForDeletion(id: string, marked: boolean): Promise<void> {
|
||||||
|
const res = await fetch(`/api/admin/mails/${id}/mark-deletion`, {
|
||||||
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ marked }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Markierung fehlgeschlagen");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("de-DE", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TenantRetention {
|
interface TenantRetention {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -85,6 +123,11 @@ export function RetentionTab() {
|
|||||||
const [purging, setPurging] = useState(false);
|
const [purging, setPurging] = useState(false);
|
||||||
const [purgeResult, setPurgeResult] = useState<number | null>(null);
|
const [purgeResult, setPurgeResult] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// PROJ-56c: expired-mails review + mark-for-deletion state
|
||||||
|
const [expired, setExpired] = useState<ExpiredMail[]>([]);
|
||||||
|
const [expiredLoading, setExpiredLoading] = useState(true);
|
||||||
|
const [markingID, setMarkingID] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchRetention()
|
fetchRetention()
|
||||||
@@ -93,7 +136,29 @@ export function RetentionTab() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
const loadExpired = useCallback(() => {
|
||||||
|
setExpiredLoading(true);
|
||||||
|
fetchExpiredMails()
|
||||||
|
.then(setExpired)
|
||||||
|
.catch(() => setError("Abgelaufene Mails konnten nicht geladen werden"))
|
||||||
|
.finally(() => setExpiredLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); loadExpired(); }, [load, loadExpired]);
|
||||||
|
|
||||||
|
const handleToggleMark = async (mail: ExpiredMail) => {
|
||||||
|
setMarkingID(mail.id);
|
||||||
|
try {
|
||||||
|
await setMarkedForDeletion(mail.id, !mail.marked);
|
||||||
|
setExpired((prev) =>
|
||||||
|
prev.map((m) => (m.id === mail.id ? { ...m, marked: !mail.marked } : m))
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Markierung fehlgeschlagen");
|
||||||
|
} finally {
|
||||||
|
setMarkingID(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditOpen = (t: TenantRetention) => {
|
const handleEditOpen = (t: TenantRetention) => {
|
||||||
setEditTenant(t);
|
setEditTenant(t);
|
||||||
@@ -163,6 +228,55 @@ export function RetentionTab() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* PROJ-56c: expired mails — review + mark for deletion */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Abgelaufene Mails — zur Löschung markieren</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Diese Mails haben ihre Aufbewahrungsfrist überschritten. Der automatische
|
||||||
|
nächtliche Purge-Job löscht <strong className="text-foreground">ausschließlich</strong> Mails,
|
||||||
|
die hier explizit markiert wurden — ein abgelaufenes Datum allein reicht nicht.
|
||||||
|
Der Button "Jetzt löschen" oben löscht dagegen sofort alle abgelaufenen Mails, unabhängig von der Markierung.
|
||||||
|
</p>
|
||||||
|
{expiredLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Lädt...</p>
|
||||||
|
) : expired.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Keine abgelaufenen Mails.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
|
<TableHead>Von</TableHead>
|
||||||
|
<TableHead>Betreff</TableHead>
|
||||||
|
<TableHead>Abgelaufen seit</TableHead>
|
||||||
|
<TableHead>Markiert von</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{expired.map((m) => (
|
||||||
|
<TableRow key={m.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={m.marked}
|
||||||
|
disabled={markingID === m.id}
|
||||||
|
onCheckedChange={() => handleToggleMark(m)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[16rem] truncate">{m.from || "–"}</TableCell>
|
||||||
|
<TableCell className="max-w-[20rem] truncate">{m.subject || "(kein Betreff)"}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(m.retain_until)}</TableCell>
|
||||||
|
<TableCell>{m.marked ? (m.marked_by || "–") : "–"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Per-tenant table */}
|
{/* Per-tenant table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user