fix(PROJ-34): Mandanten-Retention ist Opt-in — kein globaler Lock für Mandanten

storage.Save(): retention_days eines Mandanten muss explizit > 0 sein.
Globale config greift nie automatisch auf Mandanten-Mails.
Mails ohne Mandant: globale config als Fallback (unveraendert).
Frontend: Hinweis und Labels klargestellt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 10:50:54 +02:00
parent 4aadf7a4d2
commit ebc9e278ea
2 changed files with 18 additions and 22 deletions
+9 -6
View File
@@ -324,16 +324,19 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
} else { } else {
s.insertMetaMinimal(ctx, id, len(raw), tenantID) s.insertMetaMinimal(ctx, id, len(raw), tenantID)
} }
// PROJ-34: Set retention lock — prefer per-tenant retention, fall back to global. // PROJ-34: Set retention lock.
effectiveRetention := s.retentionDays // Mandanten-Mails: nur wenn der Mandant explizit retention_days > 0 gesetzt hat.
// Globale config greift NICHT automatisch — jeder Mandant muss selbst opt-in.
// Mails ohne Mandant (tenantID == nil): globale config als Fallback.
if tenantID != nil { if tenantID != nil {
var tenantDays int var tenantDays int
if err := s.db.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id=$1`, *tenantID).Scan(&tenantDays); err == nil && tenantDays > 0 { if err := s.db.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id=$1`, *tenantID).Scan(&tenantDays); err == nil && tenantDays > 0 {
effectiveRetention = tenantDays until := time.Now().AddDate(0, 0, tenantDays)
_, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id)
} }
} // else: tenant hat retention_days=0 → kein Lock gesetzt → keine automatische Löschung
if effectiveRetention > 0 { } else if s.retentionDays > 0 {
until := time.Now().AddDate(0, 0, effectiveRetention) until := time.Now().AddDate(0, 0, s.retentionDays)
_, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id) _, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id)
} }
} }
+9 -16
View File
@@ -63,17 +63,11 @@ async function triggerPurge(): Promise<number> {
return data.deleted ?? 0; return data.deleted ?? 0;
} }
function retentionLabel(days: number, globalDays: number): React.ReactNode { function retentionLabel(days: number): React.ReactNode {
if (days === 0) { if (days === 0) {
return globalDays > 0 ? ( return <Badge variant="secondary">Kein Lock (manuell setzen)</Badge>;
<span className="text-muted-foreground text-sm">
Global ({globalDays} Tage)
</span>
) : (
<Badge variant="secondary">Kein Lock</Badge>
);
} }
return <Badge>{days} Tage</Badge>; return <Badge>{days} Tage ({(days / 365).toFixed(1)} Jahre)</Badge>;
} }
export function RetentionTab() { export function RetentionTab() {
@@ -137,8 +131,6 @@ export function RetentionTab() {
} }
}; };
const globalDays = data?.global_retention_days ?? 0;
return ( return (
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{/* Global Policy */} {/* Global Policy */}
@@ -149,15 +141,16 @@ export function RetentionTab() {
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-medium">Globale Aufbewahrungsfrist:</span> <span className="text-sm font-medium">Globale Aufbewahrungsfrist:</span>
{globalDays > 0 ? ( {(data?.global_retention_days ?? 0) > 0 ? (
<Badge>{globalDays} Tage ({Math.round(globalDays / 365)} Jahre)</Badge> <Badge>{data!.global_retention_days} Tage ({Math.round(data!.global_retention_days / 365)} Jahre)</Badge>
) : ( ) : (
<Badge variant="secondary">Kein globaler Lock (config.yml)</Badge> <Badge variant="secondary">Kein globaler Lock (config.yml)</Badge>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Die globale Frist wird in <code className="text-xs bg-muted px-1 rounded">config.yml storage.retention_days</code> konfiguriert. Die globale Frist gilt nur für Mails ohne Mandant-Zuordnung (Systemkonten).
Mandanten-spezifische Einstellungen überschreiben diese, wenn gesetzt. <strong className="text-foreground"> Für Mandanten greift ausschließlich die individuelle Einstellung</strong>
solange ein Mandant keine eigene Frist gesetzt hat (0 Tage), werden seine Mails nie automatisch gelöscht.
</p> </p>
<Button <Button
variant="destructive" variant="destructive"
@@ -191,7 +184,7 @@ export function RetentionTab() {
{(data?.tenants ?? []).map((t) => ( {(data?.tenants ?? []).map((t) => (
<TableRow key={t.id}> <TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell> <TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{retentionLabel(t.retention_days, globalDays)}</TableCell> <TableCell>{retentionLabel(t.retention_days)}</TableCell>
<TableCell> <TableCell>
<Button size="sm" variant="outline" onClick={() => handleEditOpen(t)}> <Button size="sm" variant="outline" onClick={() => handleEditOpen(t)}>
Bearbeiten Bearbeiten