feat(PROJ-51): Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien)

Fuehrt archiving_rules ein (PROJ-43-Basis: Tabelle + CRUD-API + Admin-UI) und
erweitert die Retention-Logik (PROJ-34) um Regel-basierte Fristen, eine
globale Mindestfrist (min_retention_days) sowie Nachvollziehbarkeit der
Frist-Quelle (retain_until_source) in API und Mail-Detailansicht.
This commit is contained in:
sysops
2026-06-13 20:48:16 +02:00
parent 7c08ebe1b7
commit 507dee6431
16 changed files with 1175 additions and 21 deletions
+7
View File
@@ -70,6 +70,7 @@ import { CertTab } from "@/components/admin/tabs/CertTab";
import { ModulesTab } from "@/components/admin/ModulesTab";
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab";
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
@@ -712,6 +713,7 @@ export default function AdminPage() {
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="archiving-rules">Regeln</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
@@ -975,6 +977,11 @@ export default function AdminPage() {
<RetentionTab />
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="archiving-rules">
<ArchivingRulesTab />
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="smtp-out">
+31
View File
@@ -46,6 +46,23 @@ function formatDate(iso: string): string {
}
}
// PROJ-51: human-readable label for the retain_until_source marker.
function retentionSourceLabel(source: string): string {
if (source.startsWith("rule:")) {
return `Archivierungsregel #${source.slice(5)}`;
}
switch (source) {
case "tenant_default":
return "Mandanten-Standard";
case "global_default":
return "Globaler Standard";
case "min_retention":
return "Globale Mindestfrist";
default:
return source;
}
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -115,6 +132,20 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) {
)}
<OcrBadge status={mail.ocr_status} />
</span>
{/* PROJ-51: retention lock + source for auditor traceability */}
{mail.retain_until && (
<>
<span className="font-medium text-muted-foreground">Aufbewahrung:</span>
<span className="flex flex-wrap items-center gap-2 text-sm">
<span>bis {formatDate(mail.retain_until)}</span>
{mail.retain_until_source && (
<Badge variant="secondary">
{retentionSourceLabel(mail.retain_until_source)}
</Badge>
)}
</span>
</>
)}
</div>
<button
@@ -0,0 +1,459 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
getArchivingRules,
createArchivingRule,
updateArchivingRule,
deleteArchivingRule,
getTenants,
type ArchivingRule,
type ArchivingRuleInput,
type RuleConditionType,
type Tenant,
} from "@/lib/api";
const COND_LABELS: Record<RuleConditionType, string> = {
sender_domain: "Absender-Domain",
recipient_domain: "Empfänger-Domain",
sender_address: "Absender-Adresse",
recipient_address: "Empfänger-Adresse",
};
// PROJ-51 retention suggestion values.
const RETENTION_PRESETS = [
{ days: 2190, label: "6 Jahre (Handels-/Geschäftsbriefe)" },
{ days: 3650, label: "10 Jahre (Rechnungen, Belege)" },
{ days: 36500, label: "Dauerhaft (100 Jahre)" },
];
function retentionLabel(days: number | null): React.ReactNode {
if (days === null) {
return <Badge variant="secondary">kein Regel-Wert</Badge>;
}
if (days >= 36500) {
return <Badge>Dauerhaft</Badge>;
}
return <Badge>{days} Tage ({(days / 365).toFixed(1)} Jahre)</Badge>;
}
interface EditState {
id: number | null; // null = create
tenant_id: string; // "" = alle Mandanten
condition_type: RuleConditionType;
pattern: string;
priority: string;
retention_days: string; // "" = kein Regel-Wert
}
const EMPTY_EDIT: EditState = {
id: null,
tenant_id: "",
condition_type: "sender_domain",
pattern: "",
priority: "0",
retention_days: "",
};
export function ArchivingRulesTab() {
const [rules, setRules] = useState<ArchivingRule[]>([]);
const [minRetentionDays, setMinRetentionDays] = useState(0);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [edit, setEdit] = useState<EditState | null>(null);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState("");
const [deleteRule, setDeleteRule] = useState<ArchivingRule | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(() => {
setLoading(true);
setError("");
Promise.all([getArchivingRules(), getTenants()])
.then(([data, ts]) => {
setRules(data.rules);
setMinRetentionDays(data.min_retention_days);
setTenants(ts);
})
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
const tenantName = (id: number | null): React.ReactNode => {
if (id === null) return <span className="text-muted-foreground">Alle Mandanten</span>;
const t = tenants.find((x) => x.id === id);
return t ? t.name : `#${id}`;
};
const openCreate = () => {
setEdit({ ...EMPTY_EDIT });
setFormError("");
};
const openEdit = (r: ArchivingRule) => {
setEdit({
id: r.id,
tenant_id: r.tenant_id === null ? "" : String(r.tenant_id),
condition_type: r.condition_type,
pattern: r.pattern,
priority: String(r.priority),
retention_days: r.retention_days === null ? "" : String(r.retention_days),
});
setFormError("");
};
const handleSave = async () => {
if (!edit) return;
const pattern = edit.pattern.trim();
if (!pattern) {
setFormError("Muster darf nicht leer sein");
return;
}
const priority = parseInt(edit.priority, 10);
if (isNaN(priority)) {
setFormError("Priorität muss eine Zahl sein");
return;
}
let retention_days: number | null = null;
if (edit.retention_days.trim() !== "") {
retention_days = parseInt(edit.retention_days, 10);
if (isNaN(retention_days) || retention_days < 0) {
setFormError("Aufbewahrung muss eine positive Zahl sein");
return;
}
}
const input: ArchivingRuleInput = {
tenant_id: edit.tenant_id === "" ? null : parseInt(edit.tenant_id, 10),
condition_type: edit.condition_type,
pattern,
priority,
retention_days,
};
setSaving(true);
setFormError("");
try {
if (edit.id === null) {
await createArchivingRule(input);
} else {
await updateArchivingRule(edit.id, input);
}
setEdit(null);
load();
} catch (e: unknown) {
setFormError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteRule) return;
setDeleting(true);
try {
await deleteArchivingRule(deleteRule.id);
setDeleteRule(null);
load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
setDeleteRule(null);
} finally {
setDeleting(false);
}
};
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Globale Mindest-Aufbewahrungsfrist</CardTitle>
<CardDescription>
Gilt systemweit als unterste Grenze. Regeln und Mandanten-Einstellungen dürfen die
Frist nur verlängern, nie verkürzen. Konfiguration über{" "}
<code className="text-xs">storage.min_retention_days</code> in der{" "}
<code className="text-xs">config.yml</code>.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-6 w-48" />
) : minRetentionDays > 0 ? (
<Badge>
{minRetentionDays} Tage ({(minRetentionDays / 365).toFixed(1)} Jahre)
</Badge>
) : (
<Badge variant="secondary">Keine Mindestfrist (0)</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div>
<CardTitle>Archivierungsregeln</CardTitle>
<CardDescription>
Regeln mit Aufbewahrungsfrist bestimmen bei Treffer die Mindestaufbewahrung einer
Mail (höchste Priorität gewinnt).
</CardDescription>
</div>
<Button size="sm" onClick={openCreate}>
Regel hinzufügen
</Button>
</CardHeader>
<CardContent>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : rules.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Noch keine Archivierungsregeln definiert.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Prio</TableHead>
<TableHead>Bedingung</TableHead>
<TableHead>Muster</TableHead>
<TableHead>Mandant</TableHead>
<TableHead>Aufbewahrung</TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{COND_LABELS[r.condition_type] ?? r.condition_type}</TableCell>
<TableCell className="font-mono text-xs break-all">{r.pattern}</TableCell>
<TableCell>{tenantName(r.tenant_id)}</TableCell>
<TableCell>{retentionLabel(r.retention_days)}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="outline" onClick={() => openEdit(r)}>
Bearbeiten
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setDeleteRule(r)}
>
Löschen
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Create / Edit Dialog */}
<Dialog open={!!edit} onOpenChange={(o) => { if (!o) setEdit(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"}
</DialogTitle>
<DialogDescription>
Legt fest, welche Mails von dieser Regel erfasst werden und optional wie lange
sie aufbewahrt werden.
</DialogDescription>
</DialogHeader>
{edit && (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Bedingungstyp</Label>
<Select
value={edit.condition_type}
onValueChange={(v) =>
setEdit({ ...edit, condition_type: v as RuleConditionType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(COND_LABELS) as RuleConditionType[]).map((k) => (
<SelectItem key={k} value={k}>
{COND_LABELS[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rule-pattern">Muster</Label>
<Input
id="rule-pattern"
placeholder="z.B. firma.de oder rechnung@firma.de"
value={edit.pattern}
onChange={(e) => setEdit({ ...edit, pattern: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Mandant</Label>
<Select
value={edit.tenant_id === "" ? "__all__" : edit.tenant_id}
onValueChange={(v) =>
setEdit({ ...edit, tenant_id: v === "__all__" ? "" : v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Alle Mandanten</SelectItem>
{tenants.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rule-priority">Priorität</Label>
<Input
id="rule-priority"
type="number"
value={edit.priority}
onChange={(e) => setEdit({ ...edit, priority: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Höher = wird zuerst geprüft. Bei mehreren Treffern gewinnt die höchste Priorität.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rule-retention">Aufbewahrung (Tage)</Label>
<Input
id="rule-retention"
type="number"
min={0}
placeholder="leer = kein Regel-spezifischer Wert"
value={edit.retention_days}
onChange={(e) => setEdit({ ...edit, retention_days: e.target.value })}
/>
<div className="flex flex-wrap gap-1">
{RETENTION_PRESETS.map((p) => (
<Button
key={p.days}
type="button"
size="sm"
variant="outline"
onClick={() => setEdit({ ...edit, retention_days: String(p.days) })}
>
{p.label}
</Button>
))}
</div>
{edit.retention_days.trim() !== "" &&
parseInt(edit.retention_days, 10) > 0 && (
<p className="text-xs text-muted-foreground">
{(parseInt(edit.retention_days, 10) / 365).toFixed(1)} Jahre
{minRetentionDays > 0 &&
parseInt(edit.retention_days, 10) < minRetentionDays && (
<span className="text-amber-600">
{" "}
wird durch globale Mindestfrist ({minRetentionDays} Tage) angehoben
</span>
)}
</p>
)}
<p className="text-xs text-muted-foreground">
Leer = diese Regel setzt keine eigene Frist (Mandanten-/globaler Standard gilt).
</p>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEdit(null)}>
Abbrechen
</Button>
<Button disabled={saving} onClick={handleSave}>
{saving ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<Dialog open={!!deleteRule} onOpenChange={(o) => { if (!o) setDeleteRule(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regel löschen</DialogTitle>
<DialogDescription>
Die Regel wird gelöscht. Bereits gesetzte Aufbewahrungsfristen bestehender Mails
bleiben unverändert.
</DialogDescription>
</DialogHeader>
{deleteRule && (
<p className="py-2 text-sm">
<span className="font-mono">{deleteRule.pattern}</span> (
{COND_LABELS[deleteRule.condition_type]})
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteRule(null)}>
Abbrechen
</Button>
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
{deleting ? "Lösche..." : "Löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { request } from "./core";
// PROJ-51: Archiving rules (minimal PROJ-43) with optional retention period.
// Superadmin-only CRUD under /api/admin/archiving-rules.
// Condition types — must match storage.RuleCond* constants in the Go backend.
export type RuleConditionType =
| "sender_domain"
| "recipient_domain"
| "sender_address"
| "recipient_address";
export interface ArchivingRule {
id: number;
tenant_id: number | null; // null = applies to all tenants
condition_type: RuleConditionType;
pattern: string;
priority: number;
retention_days: number | null; // null = no rule-specific retention
created_at: string;
}
export interface ArchivingRulesResponse {
rules: ArchivingRule[];
min_retention_days: number;
}
export interface ArchivingRuleInput {
tenant_id: number | null;
condition_type: RuleConditionType;
pattern: string;
priority: number;
retention_days: number | null;
}
export async function getArchivingRules(): Promise<ArchivingRulesResponse> {
return request<ArchivingRulesResponse>("/api/admin/archiving-rules");
}
export async function createArchivingRule(
input: ArchivingRuleInput
): Promise<{ id: number }> {
return request<{ id: number }>("/api/admin/archiving-rules", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateArchivingRule(
id: number,
input: ArchivingRuleInput
): Promise<void> {
await request<void>(`/api/admin/archiving-rules/${id}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export async function deleteArchivingRule(id: number): Promise<void> {
await request<void>(`/api/admin/archiving-rules/${id}`, { method: "DELETE" });
}
+13
View File
@@ -164,6 +164,19 @@ export {
requestACMECert,
} from "./system";
export type {
RuleConditionType,
ArchivingRule,
ArchivingRulesResponse,
ArchivingRuleInput,
} from "./archiving_rules";
export {
getArchivingRules,
createArchivingRule,
updateArchivingRule,
deleteArchivingRule,
} from "./archiving_rules";
export type { SavedSearch } from "./saved_searches";
export {
listSavedSearches,
+4
View File
@@ -73,6 +73,10 @@ export interface MailDetail {
// PROJ-44: OCR status and extracted-text length
ocr_status?: OCRStatus;
ocr_chars?: number;
// PROJ-51: retention lock + its source for auditor traceability.
// retain_until_source: "rule:<id>" | "tenant_default" | "global_default" | "min_retention"
retain_until?: string | null;
retain_until_source?: string | null;
}
export interface ImapFolder {