431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { use, useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
getMail,
|
||
downloadMailAttachment,
|
||
downloadMailRaw,
|
||
exportMailPDF,
|
||
getLabels,
|
||
getMailLabelIds,
|
||
type MailDetail,
|
||
type MailAttachment,
|
||
type MailLabel,
|
||
} from "@/lib/api";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { Navbar } from "@/components/navbar";
|
||
import { LabelPicker } from "@/components/LabelPicker";
|
||
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";
|
||
|
||
// ── 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;
|
||
}
|
||
}
|
||
|
||
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>
|
||
{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>
|
||
)}
|
||
</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 items-center justify-between gap-4 text-sm">
|
||
<span>Externe Inhalte (Bilder, Tracker) sind blockiert.</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
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);
|
||
|
||
// Labels state
|
||
const [allLabels, setAllLabels] = useState<MailLabel[]>([]);
|
||
const [assignedLabelIds, setAssignedLabelIds] = useState<number[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
getMail(id)
|
||
.then(setMail)
|
||
.catch((e) =>
|
||
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
|
||
)
|
||
.finally(() => setLoading(false));
|
||
// Load labels
|
||
getLabels().then(setAllLabels).catch(() => {});
|
||
loadMailLabels();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [id, user]);
|
||
|
||
function loadMailLabels() {
|
||
getMailLabelIds(id).then(setAssignedLabelIds).catch(() => setAssignedLabelIds([]));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 items-center gap-2">
|
||
<Badge variant="outline" className="font-mono text-xs">
|
||
{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>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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>
|
||
|
||
{/* Labels */}
|
||
{allLabels.length > 0 && (
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4">
|
||
<LabelPicker
|
||
emailId={id}
|
||
assignedLabelIds={assignedLabelIds}
|
||
allLabels={allLabels}
|
||
onUpdate={loadMailLabels}
|
||
/>
|
||
</CardContent>
|
||
</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>
|
||
)}
|
||
</>
|
||
)}
|
||
</>)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|