Files
archivmail/src/app/mail/[id]/page.tsx
T
sysops 507dee6431 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.
2026-06-13 20:48:16 +02:00

525 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { use, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
getMail,
getThread,
downloadMailAttachment,
downloadMailRaw,
downloadMailOCRText,
exportMailPDF,
type MailDetail,
type MailAttachment,
type ThreadMail,
} from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { OcrBadge } from "@/components/ocr-badge";
import { FileText } from "lucide-react";
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
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 {
try {
return new Date(iso).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// 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");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function blockExternalSrcs(html: string): string {
// Replace src= in img/video/audio tags with data-src= to block loading
return html
.replace(/<(img|video|audio|source)(\s[^>]*?\s)src(\s*=\s*["']https?:)/gi,
"<$1$2data-src$3")
.replace(/<(img|video|audio|source)(\s)src(\s*=\s*["']https?:)/gi,
"<$1$2data-src$3");
}
// ── Sub-components ─────────────────────────────────────────────────────────
function MailHeaderGrid({ mail }: { mail: MailDetail }) {
const [showRaw, setShowRaw] = useState(false);
return (
<div className="space-y-3">
<div className="grid grid-cols-[6rem_1fr] gap-x-4 gap-y-1.5 text-sm">
<span className="font-medium text-muted-foreground">Von:</span>
<span className="break-all">{mail.from || ""}</span>
<span className="font-medium text-muted-foreground">An:</span>
<span className="break-all">{mail.to || ""}</span>
{mail.cc && (
<>
<span className="font-medium text-muted-foreground">CC:</span>
<span className="break-all">{mail.cc}</span>
</>
)}
<span className="font-medium text-muted-foreground">Datum:</span>
<span>{formatDate(mail.date)}</span>
<span className="font-medium text-muted-foreground">Betreff:</span>
<span className="font-semibold">{mail.subject || "(kein Betreff)"}</span>
<span className="font-medium text-muted-foreground">Größe:</span>
<span>{formatBytes(mail.size)}</span>
{/* Verification status */}
<span className="font-medium text-muted-foreground">Integrität:</span>
<span className="flex flex-wrap items-center gap-2">
{mail.verify_ok === true ? (
<span className="inline-flex items-center gap-1 text-green-600 text-sm font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Verifiziert
</span>
) : mail.verify_ok === false ? (
<span className="inline-flex items-center gap-1 text-red-600 text-sm font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Manipuliert!
</span>
) : (
<span className="inline-flex items-center gap-1 text-muted-foreground text-sm">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Noch nicht geprüft
</span>
)}
<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
onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{showRaw ? "Header ausblenden" : "Original-Header anzeigen"}
</button>
{showRaw && (
<pre className="mt-2 max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap break-all">
{mail.raw_headers}
</pre>
)}
</div>
);
}
function MailBodyView({ mail }: { mail: MailDetail }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [showExternal, setShowExternal] = useState(false);
const html = mail.body_html ?? null;
const plain = mail.body_plain ?? null;
// Adjust iframe height to content
function handleIframeLoad() {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const body = iframe.contentDocument?.body;
if (body) {
iframe.style.height = `${body.scrollHeight + 32}px`;
}
} catch {
iframe.style.height = "600px";
}
}
if (!html && !plain) {
return (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Kein Inhalt vorhanden.
</div>
);
}
if (html) {
const srcdoc = showExternal ? html : blockExternalSrcs(html);
return (
<div className="space-y-2">
{!showExternal && (
<Alert>
<AlertDescription className="flex flex-col items-start gap-2 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span>Externe Inhalte (Bilder, Tracker) sind blockiert.</span>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowExternal(true)}
>
Externe Inhalte laden
</Button>
</AlertDescription>
</Alert>
)}
<div className="overflow-hidden rounded-md border">
<iframe
ref={iframeRef}
srcDoc={srcdoc}
sandbox="allow-same-origin"
title="E-Mail-Inhalt"
className="w-full"
style={{ minHeight: "200px", height: "600px", border: "none" }}
onLoad={handleIframeLoad}
/>
</div>
</div>
);
}
// Plain-text fallback
return (
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted p-4 text-sm whitespace-pre-wrap break-words leading-relaxed">
{plain}
</pre>
);
}
function AttachmentRow({
mailId,
attachment,
}: {
mailId: string;
attachment: MailAttachment;
}) {
const [downloading, setDownloading] = useState(false);
async function handleDownload() {
setDownloading(true);
try {
const { blob, filename } = await downloadMailAttachment(
mailId,
attachment.index
);
triggerDownload(blob, filename || attachment.filename);
} catch (e) {
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setDownloading(false);
}
}
return (
<div className="flex items-center justify-between gap-4 rounded-md border px-4 py-2.5 text-sm">
<div className="min-w-0 flex-1">
<span className="block truncate font-medium">{attachment.filename}</span>
<span className="text-xs text-muted-foreground">
{attachment.content_type} · {formatBytes(attachment.size)}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? "..." : "Herunterladen"}
</Button>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function MailViewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { user, loading: authLoading } = useAuth();
const [mail, setMail] = useState<MailDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrInfo, setOcrInfo] = useState<string | null>(null);
const [thread, setThread] = useState<ThreadMail[] | null>(null);
const [threadOpen, setThreadOpen] = useState(false);
useEffect(() => {
if (!user) return;
getMail(id)
.then((m) => {
setMail(m);
if (m.thread_id) {
getThread(m.thread_id).then((t) => {
if (t.total > 1) setThread(t.mails);
}).catch(() => {});
}
})
.catch((e) =>
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
)
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, user]);
async function handleEmlDownload() {
setDownloading(true);
try {
const { blob, filename } = await downloadMailRaw(id);
triggerDownload(blob, filename);
} catch (e) {
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setDownloading(false);
}
}
async function handlePdfDownload() {
setPdfLoading(true);
try {
const { blob, filename } = await exportMailPDF(id);
triggerDownload(blob, filename);
} catch (e) {
alert(`PDF-Export fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setPdfLoading(false);
}
}
async function handleOCRDownload() {
setOcrLoading(true);
setOcrInfo(null);
try {
const result = await downloadMailOCRText(id);
if (result.kind === "ok") {
triggerDownload(result.blob, result.filename);
} else if (result.kind === "pending") {
setOcrInfo("OCR läuft noch, bitte gleich nochmal versuchen.");
} else {
setOcrInfo("Kein OCR-Text verfügbar.");
}
} catch (e) {
alert(`OCR-Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setOcrLoading(false);
}
}
return (
<div className="min-h-screen">
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
<main className="mx-auto max-w-4xl px-4 py-6 space-y-4">
{(authLoading || !user) ? (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-64 w-full" />
</div>
) : (<>
{/* Back + Actions */}
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="outline" size="sm" asChild>
<Link href="/search"> Zurück zur Suche</Link>
</Button>
{mail && (
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<Badge variant="outline" className="max-w-[40vw] truncate font-mono text-xs sm:max-w-none">
{id}
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleEmlDownload}
disabled={downloading}
>
{downloading ? "..." : "Als .eml herunterladen"}
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePdfDownload}
disabled={pdfLoading}
>
{pdfLoading ? "..." : "Als PDF exportieren"}
</Button>
{mail.ocr_status === "done" && (mail.ocr_chars ?? 0) > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleOCRDownload}
disabled={ocrLoading}
title={`${(mail.ocr_chars ?? 0).toLocaleString("de-DE")} Zeichen erkannt`}
>
<FileText className="mr-1.5 h-4 w-4" />
{ocrLoading ? "..." : "OCR-Text"}
</Button>
)}
</div>
)}
</div>
{ocrInfo && (
<Alert>
<AlertDescription>{ocrInfo}</AlertDescription>
</Alert>
)}
{/* Loading */}
{loading && (
<Card>
<CardHeader className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
)}
{/* Error */}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Mail content */}
{mail && (
<>
{/* Header */}
<Card>
<CardHeader>
<MailHeaderGrid mail={mail} />
</CardHeader>
</Card>
{/* Body */}
<Card>
<CardContent className="pt-6">
<MailBodyView mail={mail} />
</CardContent>
</Card>
{/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && (
<Card>
<CardHeader className="pb-3">
<span className="text-sm font-medium">
Anhänge ({mail.attachments.length})
</span>
</CardHeader>
<Separator />
<CardContent className="pt-4 space-y-2">
{mail.attachments.map((att) => (
<AttachmentRow
key={att.index}
mailId={id}
attachment={att}
/>
))}
</CardContent>
</Card>
)}
{/* Thread panel */}
{thread && thread.length > 1 && (
<Card>
<CardHeader className="pb-3">
<button
onClick={() => setThreadOpen((v) => !v)}
className="flex items-center gap-2 text-sm font-medium hover:text-foreground text-muted-foreground"
>
<svg className={`w-4 h-4 transition-transform ${threadOpen ? "rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Konversation ({thread.length} Mails)
</button>
</CardHeader>
{threadOpen && (
<>
<Separator />
<CardContent className="pt-3 space-y-1">
{thread.map((m) => (
<Link
key={m.id}
href={`/mail/${m.id}`}
className={`flex items-center justify-between gap-3 rounded-md px-3 py-2 text-sm hover:bg-muted transition-colors ${m.id === id ? "bg-muted font-medium" : ""}`}
>
<span className="min-w-0 flex-1 truncate">
{m.subject || "(kein Betreff)"}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{m.date ? new Date(m.date).toLocaleDateString("de-DE") : ""}
</span>
</Link>
))}
</CardContent>
</>
)}
</Card>
)}
</>
)}
</>)}
</main>
</div>
);
}