feat(PROJ-12): E-Mail Export EML/PDF/ZIP

- GET /api/export/pdf/{id}: PDF-Generierung (stdlib, kein ext. Paket)
- POST /api/export/zip: Streaming-ZIP mit manifest.csv, Anhänge optional
- Max. 500 Mails pro Export, Zugriffscheck per Rolle
- Audit-Log für jeden Export
- Frontend: PDF-Button in Mail-Ansicht
- Frontend: Checkboxen + ZIP-Export-Dialog in Suchergebnissen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 19:49:00 +01:00
parent 30479cfc60
commit 850290b5ef
7 changed files with 703 additions and 4 deletions
+22
View File
@@ -6,6 +6,7 @@ import {
getMail,
downloadMailAttachment,
downloadMailRaw,
exportMailPDF,
type MailDetail,
type MailAttachment,
} from "@/lib/api";
@@ -227,6 +228,7 @@ export default function MailViewPage({
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
useEffect(() => {
if (!user) return;
@@ -250,6 +252,18 @@ export default function MailViewPage({
}
}
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);
}
}
if (authLoading || !user) {
return (
<div className="flex min-h-screen items-center justify-center">
@@ -282,6 +296,14 @@ export default function MailViewPage({
>
{downloading ? "..." : "Als .eml herunterladen"}
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePdfDownload}
disabled={pdfLoading}
>
{pdfLoading ? "..." : "Als PDF exportieren"}
</Button>
</div>
)}
</div>
+102 -1
View File
@@ -3,11 +3,20 @@
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, type SearchHit } from "@/lib/api";
import { searchEmails, exportMailsZIP, type SearchHit } from "@/lib/api";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
@@ -18,6 +27,7 @@ import {
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
const PAGE_SIZE = 25;
@@ -37,6 +47,17 @@ export default function SearchPage() {
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
// Selection state
const [selected, setSelected] = useState<Set<string>>(new Set());
const [exportOpen, setExportOpen] = useState(false);
const [exportAttachments, setExportAttachments] = useState(false);
const [exporting, setExporting] = useState(false);
// Clear selection when results change
useEffect(() => {
setSelected(new Set());
}, [results]);
const doSearch = useCallback(
async (p: number) => {
setSearching(true);
@@ -87,7 +108,27 @@ export default function SearchPage() {
doSearch(1);
}
async function handleExportZIP() {
setExporting(true);
try {
const { blob } = await exportMailsZIP(Array.from(selected), exportAttachments);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "archivmail-export.zip";
a.click();
URL.revokeObjectURL(url);
setExportOpen(false);
setSelected(new Set());
} catch (e) {
alert(`Export fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setExporting(false);
}
}
const totalPages = Math.ceil(total / PAGE_SIZE);
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
if (authLoading || !user) {
return (
@@ -189,10 +230,29 @@ export default function SearchPage() {
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
</div>
{selected.size > 0 && (
<div className="mb-2 flex items-center gap-2">
<span className="text-sm text-muted-foreground">{selected.size} ausgewählt</span>
<Button size="sm" onClick={() => setExportOpen(true)}>Als ZIP exportieren</Button>
<Button size="sm" variant="outline" onClick={() => setSelected(new Set())}>Auswahl aufheben</Button>
</div>
)}
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
if (checked) setSelected(new Set(results.map((h) => h.id)));
else setSelected(new Set());
}}
aria-label="Alle auswählen"
/>
</TableHead>
<TableHead className="w-32">Datum</TableHead>
<TableHead className="w-56">Von</TableHead>
<TableHead>Betreff</TableHead>
@@ -212,6 +272,20 @@ export default function SearchPage() {
}}
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(hit.id)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(hit.id);
else next.delete(hit.id);
return next;
});
}}
aria-label="Mail auswählen"
/>
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{hit.date
? new Date(hit.date).toLocaleDateString("de-DE")
@@ -252,6 +326,33 @@ export default function SearchPage() {
</>
) : null}
</div>
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mails exportieren</DialogTitle>
<DialogDescription>
{selected.size} E-Mail{selected.size !== 1 ? "s" : ""} als ZIP herunterladen
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 py-2">
<Switch
id="attachments"
checked={exportAttachments}
onCheckedChange={setExportAttachments}
/>
<Label htmlFor="attachments">Anhänge einschließen</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setExportOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleExportZIP} disabled={exporting}>
{exporting ? "Wird exportiert..." : "ZIP herunterladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</div>
);
+29
View File
@@ -391,3 +391,32 @@ export interface SystemStats {
export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>("/api/admin/system/stats");
}
// ── Export ────────────────────────────────────────────────────────────────
export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/export/pdf/${id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error("PDF export failed");
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") || "";
const filename = cd.match(/filename="([^"]+)"/)?.[1] || `${id.slice(0, 16)}.pdf`;
return { blob, filename };
}
export async function exportMailsZIP(ids: string[], attachments: boolean): Promise<{ blob: Blob }> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/export/zip`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ ids, attachments }),
});
if (!res.ok) throw new Error("ZIP export failed");
const blob = await res.blob();
return { blob };
}