fix: Crash-Robustheit + Performance in Backend und Frontend härten

Backend: recover() in allen langlebigen Goroutinen (neues internal/safego-
Paket), MIME-Multipart-Tiefenlimit gegen Stack-Overflow, IMAP-Zeilenlängen-
und FETCH-Result-Limits gegen OOM, MBOX-Buffer-Aliasing-Bug (Datenkorruption
beim Import), ungeprüfte Type Assertions abgesichert, Data Race im
API-Key-Rate-Limiter behoben, SMTP-Session-Panic führt jetzt zu 451-Retry
statt Prozessabsturz. Performance: O(n²)-String-Concat in Mailparser und
IMAP-Parser durch strings.Builder ersetzt.

Frontend: Error Boundaries für Root und Mail-Detailansicht ergänzt (gab es
vorher nicht), zahlreiche Guards gegen nil-Slices aus dem Backend-JSON die
sonst .map()/.length-Crashes/White-Screens auslösten, defekte JSON-Antworten
in api/core.ts abgefangen, zwei React-Key-Bugs bei löschbaren Listen
korrigiert.

Verifiziert auf 192.168.1.132: Build und Tests der geänderten Pakete
fehlerfrei, keine Regressionen gegenüber vorbestehendem Stand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
This commit is contained in:
sysops
2026-08-05 13:39:00 +02:00
co-authored by Claude Sonnet 5
parent 0fa4fad49d
commit 798cb2817c
35 changed files with 502 additions and 112 deletions
@@ -103,9 +103,9 @@ export function ArchivingRulesTab() {
setError("");
Promise.all([getArchivingRules(), getTenants()])
.then(([data, ts]) => {
setRules(data.rules);
setMinRetentionDays(data.min_retention_days);
setTenants(ts);
setRules(Array.isArray(data?.rules) ? data.rules : []);
setMinRetentionDays(data?.min_retention_days ?? 0);
setTenants(Array.isArray(ts) ? ts : []);
})
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
+4 -4
View File
@@ -80,9 +80,9 @@ function MailTimeseriesChart({ points }: { points: TimeseriesPoint[] }) {
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground font-mono">
<span>{points[0]?.day.slice(5)}</span>
<span>{points[Math.floor(points.length / 2)]?.day.slice(5)}</span>
<span>{points[points.length - 1]?.day.slice(5)}</span>
<span>{points[0]?.day?.slice(5)}</span>
<span>{points[Math.floor(points.length / 2)]?.day?.slice(5)}</span>
<span>{points[points.length - 1]?.day?.slice(5)}</span>
</div>
</CardContent>
</Card>
@@ -436,7 +436,7 @@ export function DashboardTab({
)}
{/* Festplatten */}
{systemStats.disks.length > 0 && (
{Array.isArray(systemStats.disks) && systemStats.disks.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -135,7 +135,9 @@ export function ReconciliationCard() {
// Tages-Header aus dem ersten Quell-Eintrag ableiten (alle Quellen haben
// dieselben Tage in gleicher Reihenfolge).
const dayHeaders = data?.sources[0]?.points.map((p) => p.date) ?? [];
// Backend liefert leere Listen ggf. als null — defensiv normalisieren.
const sources = Array.isArray(data?.sources) ? data.sources : [];
const dayHeaders = sources[0]?.points?.map((p) => p.date) ?? [];
return (
<Card>
@@ -184,7 +186,7 @@ export function ReconciliationCard() {
Vollständigkeits-Report konnte nicht geladen werden: {error}
</AlertDescription>
</Alert>
) : !data || data.sources.length === 0 ? (
) : !data || sources.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Reconciliation-Daten vorhanden. Der tägliche Zähl-Job
(<code className="font-mono">archivmail reconcile</code>) hat noch
@@ -213,7 +215,7 @@ export function ReconciliationCard() {
</TableRow>
</TableHeader>
<TableBody>
{data.sources.map((s) => {
{sources.map((s) => {
const isImap = s.source_type === "imap";
return (
<TableRow key={s.source_key}>
@@ -229,7 +231,7 @@ export function ReconciliationCard() {
</TableCell>
) : (
<>
{s.points.map((p) => (
{(s.points ?? []).map((p) => (
<TableCell key={p.date} className="text-right">
<DayCell
archived={p.archived_count}
@@ -121,8 +121,8 @@ export function RoutingRulesTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
];
Promise.all(loaders)
.then(([rs, ts]) => {
setRules(rs);
setTenants(ts);
setRules(Array.isArray(rs) ? rs : []);
setTenants(Array.isArray(ts) ? ts : []);
})
.catch(() => setError("Routing-Regeln konnten nicht geladen werden"))
.finally(() => setLoading(false));
+10 -5
View File
@@ -77,12 +77,17 @@ export function SecurityTab({
"SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" },
};
// Backend kann bei leerem Ergebnis null statt [] liefern.
const checks: SecurityCheck[] = Array.isArray(securityAudit.checks)
? securityAudit.checks
: [];
return (
<div className="space-y-2">
{securityAudit.checks.map((check: SecurityCheck, i: number) => {
{checks.map((check: SecurityCheck, i: number) => {
const fix = check.status !== "ok" ? fixActions[check.name] : undefined;
return (
<Card key={i}>
<Card key={check.name || i}>
<CardContent className="p-4 flex items-start gap-3">
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${
check.status === "ok" ? "bg-green-500" :
@@ -130,9 +135,9 @@ export function SecurityTab({
{/* Summary */}
<div className="mt-4 grid grid-cols-3 gap-3 text-center text-sm">
{[
{ label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length },
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length },
{ label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length },
{ label: "OK", color: "bg-green-50 text-green-700", count: checks.filter((c: SecurityCheck) => c.status === "ok").length },
{ label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: checks.filter((c: SecurityCheck) => c.status === "warning").length },
{ label: "Fehler", color: "bg-red-50 text-red-700", count: checks.filter((c: SecurityCheck) => c.status === "error").length },
].map((s) => (
<div key={s.label} className={`rounded p-3 ${s.color}`}>
<p className="text-2xl font-bold">{s.count}</p>
+3 -2
View File
@@ -46,8 +46,9 @@ export function RestoreMailButton({ mailId, enabled }: RestoreMailButtonProps) {
getImapAccounts()
.then((data) => {
if (!active) return;
setAccounts(data);
if (data.length > 0) setSelected(String(data[0].id));
const list = Array.isArray(data) ? data : [];
setAccounts(list);
if (list.length > 0) setSelected(String(list[0].id));
})
.catch(() => {
// Ignore — without accounts the button stays hidden.
+1 -1
View File
@@ -162,7 +162,7 @@ export function SearchFilterBar(props: SearchFilterBarProps) {
type="button"
className="flex-1 text-left text-sm truncate"
onClick={() => onApplySavedSearch(s)}
title={Object.entries(s.query).map(([k, v]) => `${k}: ${v}`).join(", ")}
title={Object.entries(s.query ?? {}).map(([k, v]) => `${k}: ${v}`).join(", ")}
>
{s.name}
</button>
+1 -1
View File
@@ -33,7 +33,7 @@ const MATCH_FIELD_LABEL: Record<SearchMatchField, string> = {
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
{MATCH_FIELD_LABEL[field]}
{MATCH_FIELD_LABEL[field] ?? field}
</span>
);
}