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:
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user