Files
archivmail/src/app/mail/[id]/page.tsx
T

431 lines
14 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,
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>
);
}