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
+3 -1
View File
@@ -199,7 +199,9 @@ export default function UploadPage() {
<ul className="space-y-1 mb-4 max-h-48 overflow-y-auto">
{selectedFiles.map((f, i) => (
<li
key={i}
// Name+Größe statt Index: beim Entfernen eines Eintrags
// wuerde React sonst DOM-Knoten falsch wiederverwenden.
key={`${f.name}-${f.size}-${f.lastModified}`}
className="flex items-center justify-between text-sm py-1"
>
<span className="truncate max-w-[calc(100%-6rem)]">
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Kein Mail-Inhalt loggen — nur Fehlertext/Digest (DSGVO).
console.error("Unerwarteter Fehler:", error.message, error.digest ?? "");
}, [error]);
return (
<div className="mx-auto flex min-h-screen max-w-lg items-center justify-center px-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Es ist ein Fehler aufgetreten</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Die Seite konnte nicht dargestellt werden. Du kannst es erneut
versuchen oder zur Suche zurückkehren.
</p>
{error.digest && (
<p className="font-mono text-xs text-muted-foreground">
Fehler-ID: {error.digest}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button onClick={() => reset()}>Erneut versuchen</Button>
<Button variant="outline" asChild>
<Link href="/search">Zur Suche</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Eigene Boundary für die Mail-Detailansicht: archivierte Mails können
// defekte Header/Encodings enthalten. Ein Renderfehler darf nur diese Route
// betreffen, nicht die gesamte App.
export default function MailError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Bewusst nur die Fehlermeldung, keine Mail-Inhalte (DSGVO).
console.error("Mail-Ansicht fehlgeschlagen:", error.message, error.digest ?? "");
}, [error]);
return (
<div className="mx-auto max-w-4xl px-4 py-10">
<Card>
<CardHeader>
<CardTitle>Diese E-Mail kann nicht angezeigt werden</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Der archivierte Inhalt ist unvollständig oder fehlerhaft. Die
Originaldatei lässt sich weiterhin über die Suche als .eml
herunterladen.
</p>
{error.digest && (
<p className="font-mono text-xs text-muted-foreground">
Fehler-ID: {error.digest}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button onClick={() => reset()}>Erneut versuchen</Button>
<Button variant="outline" asChild>
<Link href="/search">Zurück zur Suche</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+10 -4
View File
@@ -28,14 +28,18 @@ import { FileText } from "lucide-react";
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
if (typeof bytes !== "number" || !Number.isFinite(bytes)) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleString("de-DE", {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
@@ -304,9 +308,11 @@ export default function MailViewPage({
getMail(id)
.then((m) => {
setMail(m);
if (m.thread_id) {
if (m?.thread_id) {
getThread(m.thread_id).then((t) => {
if (t.total > 1) setThread(t.mails);
// Backend liefert bei leerem Thread ggf. null statt [].
const mails = Array.isArray(t?.mails) ? t.mails : [];
if (mails.length > 1) setThread(mails);
}).catch(() => {});
}
})
@@ -463,7 +469,7 @@ export default function MailViewPage({
</Card>
{/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && (
{Array.isArray(mail.attachments) && mail.attachments.length > 0 && (
<Card>
<CardHeader className="pb-3">
<span className="text-sm font-medium">
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function NotFound() {
return (
<div className="mx-auto flex min-h-screen max-w-lg items-center justify-center px-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Seite nicht gefunden</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Die aufgerufene Adresse existiert nicht.
</p>
<Button asChild>
<Link href="/search">Zur Suche</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
+1 -1
View File
@@ -74,7 +74,7 @@ export default function Pop3Page() {
const loadAccounts = useCallback(async () => {
try {
const data = await getPop3Accounts();
setAccounts(data);
setAccounts(Array.isArray(data) ? data : []);
} catch {
// ignore
} finally {
@@ -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>
);
}
+5 -1
View File
@@ -39,7 +39,11 @@ export function useAdminDashboard(active: boolean) {
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
setTimeseries(ts.status === "fulfilled" ? ts.value.points : []);
setTimeseries(
ts.status === "fulfilled" && Array.isArray(ts.value?.points)
? ts.value.points
: []
);
setDashRefreshed(new Date());
} finally {
setDashLoading(false);
+1 -1
View File
@@ -59,7 +59,7 @@ export function useImapAccounts(user: unknown) {
const loadAccounts = useCallback(async () => {
try {
const data = await getImapAccounts();
setAccounts(data);
setAccounts(Array.isArray(data) ? data : []);
} catch {
// ignore
} finally {
+14 -12
View File
@@ -101,24 +101,26 @@ export function useSavedSearches(params: UseSavedSearchesParams) {
}
function handleApplySavedSearch(s: SavedSearch) {
setQuery(s.query.q || "");
setFromFilter(s.query.from || "");
setToFilter(s.query.to || "");
setDateFrom(s.query.date_from || "");
setDateTo(s.query.date_to || "");
setHasAttachment(s.query.has_attachment === "true" ? true : undefined);
// query kann bei alten/defekten Datensaetzen fehlen (Backend: null).
const q = s.query ?? {};
setQuery(q.q || "");
setFromFilter(q.from || "");
setToFilter(q.to || "");
setDateFrom(q.date_from || "");
setDateTo(q.date_to || "");
setHasAttachment(q.has_attachment === "true" ? true : undefined);
setSavedListOpen(false);
// Trigger search after state updates
setTimeout(() => {
// We need to search with the saved query directly since state isn't updated yet
setSearching(true);
searchEmails({
q: s.query.q || undefined,
from: s.query.from || undefined,
to: s.query.to || undefined,
date_from: s.query.date_from || undefined,
date_to: s.query.date_to || undefined,
has_attachment: s.query.has_attachment === "true" ? true : undefined,
q: q.q || undefined,
from: q.from || undefined,
to: q.to || undefined,
date_from: q.date_from || undefined,
date_to: q.date_to || undefined,
has_attachment: q.has_attachment === "true" ? true : undefined,
page: 1,
page_size: pageSize,
})
+9 -1
View File
@@ -29,5 +29,13 @@ export async function request<T>(
if (res.status === 204) return {} as T;
return res.json();
// Leerer oder defekter Body darf nicht als unbehandelter SyntaxError
// durchschlagen — Aufrufer erwarten eine Error-Instanz.
const text = await res.text();
if (!text) return {} as T;
try {
return JSON.parse(text) as T;
} catch {
throw new Error("Ungültige Serverantwort");
}
}