"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 (
Von:
{mail.from || "–"}
An:
{mail.to || "–"}
{mail.cc && (
<>
CC:
{mail.cc}
>
)}
Datum:
{formatDate(mail.date)}
Betreff:
{mail.subject || "(kein Betreff)"}
Größe:
{formatBytes(mail.size)}
{/* Verification status */}
Integrität:
{mail.verify_ok === true ? (
Verifiziert
) : mail.verify_ok === false ? (
Manipuliert!
) : (
Noch nicht geprüft
)}
{/* PROJ-51: retention lock + source for auditor traceability */}
{mail.retain_until && (
<>
Aufbewahrung:
bis {formatDate(mail.retain_until)}
{mail.retain_until_source && (
{retentionSourceLabel(mail.retain_until_source)}
)}
>
)}
{showRaw && (
{mail.raw_headers}
)}
);
}
function MailBodyView({ mail }: { mail: MailDetail }) {
const iframeRef = useRef(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 (
Kein Inhalt vorhanden.
);
}
if (html) {
const srcdoc = showExternal ? html : blockExternalSrcs(html);
return (
{!showExternal && (
Externe Inhalte (Bilder, Tracker) sind blockiert.
)}
);
}
// Plain-text fallback
return (
{plain}
);
}
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 (
{attachment.filename}
{attachment.content_type} · {formatBytes(attachment.size)}
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function MailViewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { user, loading: authLoading } = useAuth();
const [mail, setMail] = useState(null);
const [error, setError] = useState(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(null);
const [thread, setThread] = useState(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 (
{(authLoading || !user) ? (
) : (<>
{/* Back + Actions */}
{mail && (
{id}
{mail.ocr_status === "done" && (mail.ocr_chars ?? 0) > 0 && (
)}
)}
{ocrInfo && (
{ocrInfo}
)}
{/* Loading */}
{loading && (
)}
{/* Error */}
{error && (
{error}
)}
{/* Mail content */}
{mail && (
<>
{/* Header */}
{/* Body */}
{/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && (
Anhänge ({mail.attachments.length})
{mail.attachments.map((att) => (
))}
)}
{/* Thread panel */}
{thread && thread.length > 1 && (
{threadOpen && (
<>
{thread.map((m) => (
{m.subject || "(kein Betreff)"}
{m.date ? new Date(m.date).toLocaleDateString("de-DE") : "–"}
))}
>
)}
)}
>
)}
>)}
);
}