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
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,6 +23,43 @@ import {
|
||||
DialogTitle,
|
||||
} 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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -85,6 +123,11 @@ export function RetentionTab() {
|
||||
const [purging, setPurging] = useState(false);
|
||||
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(() => {
|
||||
setLoading(true);
|
||||
fetchRetention()
|
||||
@@ -93,7 +136,29 @@ export function RetentionTab() {
|
||||
.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) => {
|
||||
setEditTenant(t);
|
||||
@@ -163,6 +228,55 @@ export function RetentionTab() {
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user