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.
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|