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:
co-authored by
Claude Sonnet 5
parent
0fa4fad49d
commit
798cb2817c
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user