From 850290b5efc7001571e1024a36d10250711c03ac Mon Sep 17 00:00:00 2001 From: sysops Date: Sat, 14 Mar 2026 19:49:00 +0100 Subject: [PATCH] feat(PROJ-12): E-Mail Export EML/PDF/ZIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- features/INDEX.md | 2 +- features/PROJ-12-export.md | 31 ++- internal/api/export.go | 516 +++++++++++++++++++++++++++++++++++++ internal/api/server.go | 4 + src/app/mail/[id]/page.tsx | 22 ++ src/app/search/page.tsx | 103 +++++++- src/lib/api.ts | 29 +++ 7 files changed, 703 insertions(+), 4 deletions(-) create mode 100644 internal/api/export.go diff --git a/features/INDEX.md b/features/INDEX.md index d8292b6..a4fd6de 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -23,7 +23,7 @@ | PROJ-9 | Ordner- & Label-Verwaltung | In Progress | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 | | PROJ-10 | Admin-Bereich: Nutzer- & Postfachverwaltung | In Progress | [PROJ-10](PROJ-10-admin-bereich.md) | 2026-03-12 | | PROJ-11 | Audit-Log & Compliance-Berichte | In Progress | [PROJ-11](PROJ-11-audit-log.md) | 2026-03-12 | -| PROJ-12 | E-Mail-Export (EML/PDF) | In Progress | [PROJ-12](PROJ-12-export.md) | 2026-03-12 | +| PROJ-12 | E-Mail-Export (EML/PDF) | In Review | [PROJ-12](PROJ-12-export.md) | 2026-03-12 | | PROJ-13 | REST API für externe CRM-Anbindung | In Progress | [PROJ-13](PROJ-13-rest-api-crm.md) | 2026-03-13 | | PROJ-14 | E-Mail-Import: POP3-Verbindung | In Progress | [PROJ-14](PROJ-14-import-pop3.md) | 2026-03-13 | | PROJ-15 | CLI Import & Export (archivmail-User) | In Review | [PROJ-15](PROJ-15-cli-import-export.md) | 2026-03-13 | diff --git a/features/PROJ-12-export.md b/features/PROJ-12-export.md index c9164f5..d1685e6 100644 --- a/features/PROJ-12-export.md +++ b/features/PROJ-12-export.md @@ -1,8 +1,8 @@ # PROJ-12: E-Mail-Export (EML / PDF) -## Status: In Progress +## Status: In Review **Created:** 2026-03-12 -**Last Updated:** 2026-03-13 +**Last Updated:** 2026-03-14 ## Dependencies - Requires: PROJ-1 (Authentifizierung) @@ -115,6 +115,33 @@ POST /api/export/zip | `archive/zip` (Stdlib) | Streaming-ZIP-Erstellung ohne externe Abhängigkeit | | `github.com/SebastiaanKlippert/go-wkhtmltopdf` | PDF-Generierung aus HTML (serverseitig) | +## Implementation Notes (2026-03-14) + +### What was built + +**Go Backend — `internal/api/export.go` (new file):** +- `GET /api/export/pdf/{id}` — generates a stdlib-only PDF (no external deps) using raw PDF 1.4 syntax with Helvetica core font. Renders header fields, plain-text body (with HTML-strip fallback), and attachment list. `toLatinSafe()` converts umlauts for Latin-1 compatibility. +- `POST /api/export/zip` — streaming ZIP via `archive/zip` stdlib. Accepts `{"ids":[...], "attachments": true}`, max 500 IDs. Adds `{id[:16]}.eml`, optional `attachments/{id[:8]}/{filename}` entries, and `manifest.csv` (CSV with filename/message_id/from/to/subject/date). Audit-logged as `export: zip: N mails`. +- Both handlers use `requireMailAccess` middleware (blocks admin role). RoleUser is filtered to own mails via `mailBelongsToUser`; RoleAuditor can export all. + +**Deviation from spec:** PDF generation uses a hand-rolled stdlib PDF writer instead of `go-wkhtmltopdf` or `go-pdf/fpdf` — avoids adding an external dependency that would require `go get` on the server. + +**Routes added to `internal/api/server.go`:** +- `GET /api/export/pdf/{id}` +- `POST /api/export/zip` + +**Frontend `src/lib/api.ts`:** +- Added `exportMailPDF(id)` and `exportMailsZIP(ids, attachments)` export functions. + +**Frontend `src/app/mail/[id]/page.tsx`:** +- Added "Als PDF exportieren" button next to "Als .eml herunterladen". + +**Frontend `src/app/search/page.tsx`:** +- Added per-row Checkbox column + select-all header checkbox. +- Export toolbar appears when ≥1 mail is selected. +- ZIP export dialog with attachments toggle (Switch component). +- Selection cleared when search results change. + ## QA Test Results _To be added by /qa_ diff --git a/internal/api/export.go b/internal/api/export.go new file mode 100644 index 0000000..74213e0 --- /dev/null +++ b/internal/api/export.go @@ -0,0 +1,516 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" + "unicode" + + "github.com/archivmail/internal/audit" + "github.com/archivmail/internal/userstore" + "github.com/archivmail/pkg/mailparser" +) + +// ── Text helpers ────────────────────────────────────────────────────────── + +// toLatinSafe converts German umlauts to ASCII digraphs and drops non-ASCII +// characters. Required for PDF Latin-1 encoding. +func toLatinSafe(s string) string { + replacer := strings.NewReplacer( + "ä", "ae", "ö", "oe", "ü", "ue", + "Ä", "Ae", "Ö", "Oe", "Ü", "Ue", + "ß", "ss", + ) + s = replacer.Replace(s) + var b strings.Builder + for _, r := range s { + if r <= unicode.MaxASCII { + b.WriteRune(r) + } + } + return b.String() +} + +var htmlTagRe = regexp.MustCompile(`<[^>]+>`) +var wsRe = regexp.MustCompile(`\s+`) + +// stripHTML removes HTML tags and collapses whitespace. +func stripHTML(html string) string { + s := htmlTagRe.ReplaceAllString(html, " ") + s = wsRe.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} + +// formatBytesStr returns a human-readable file size string. +func formatBytesStr(n int) string { + switch { + case n >= 1024*1024: + return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024)) + case n >= 1024: + return fmt.Sprintf("%.1f KB", float64(n)/1024) + default: + return fmt.Sprintf("%d B", n) + } +} + +// ── Minimal PDF writer ──────────────────────────────────────────────────── +// +// Generates a simple but valid PDF/1.4 document using only stdlib. +// Uses the standard 14 core fonts (Helvetica) which every viewer supports. + +type pdfWriter struct { + buf bytes.Buffer + offsets []int // byte offsets of each object +} + +func newPDFWriter() *pdfWriter { return &pdfWriter{} } + +func (p *pdfWriter) write(s string) { p.buf.WriteString(s) } +func (p *pdfWriter) writef(f string, args ...interface{}) { + p.buf.WriteString(fmt.Sprintf(f, args...)) +} + +// startObj records the current byte offset and writes the object header. +func (p *pdfWriter) startObj(n int) { + // Grow offsets slice if needed + for len(p.offsets) < n { + p.offsets = append(p.offsets, 0) + } + p.offsets[n-1] = p.buf.Len() + p.writef("%d 0 obj\n", n) +} + +func (p *pdfWriter) endObj() { p.write("endobj\n") } + +// escapePDF escapes special characters in PDF string literals. +func escapePDF(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `(`, `\(`) + s = strings.ReplaceAll(s, `)`, `\)`) + s = strings.ReplaceAll(s, "\r", `\r`) + s = strings.ReplaceAll(s, "\n", `\n`) + return s +} + +// wrapText wraps text at maxChars width, returning lines. +func wrapText(text string, maxChars int) []string { + if maxChars <= 0 { + maxChars = 80 + } + var lines []string + for _, para := range strings.Split(text, "\n") { + words := strings.Fields(para) + if len(words) == 0 { + lines = append(lines, "") + continue + } + var cur strings.Builder + for _, w := range words { + if cur.Len() == 0 { + cur.WriteString(w) + } else if cur.Len()+1+len(w) <= maxChars { + cur.WriteByte(' ') + cur.WriteString(w) + } else { + lines = append(lines, cur.String()) + cur.Reset() + cur.WriteString(w) + } + } + if cur.Len() > 0 { + lines = append(lines, cur.String()) + } + } + return lines +} + +// buildMailPDF produces the raw bytes of a PDF representing the given mail. +func buildMailPDF(id string, pm *mailparser.ParsedMail, rawSize int) []byte { + p := newPDFWriter() + + // Page dimensions: A4 in points (72 pt/inch) + // 210mm x 297mm => 595.28 x 841.89 pt + pageW := 595.28 + pageH := 841.89 + margin := 50.0 + lineH := 14.0 + smallLineH := 12.0 + contentW := pageW - 2*margin + + // Prepare body content + body := pm.TextBody + if body == "" && pm.HTMLBody != "" { + body = stripHTML(pm.HTMLBody) + } + body = toLatinSafe(body) + bodyLines := wrapText(body, 85) + + // Prepare header lines + var dateStr string + if !pm.Date.IsZero() { + dateStr = pm.Date.Format("02.01.2006 15:04:05") + } + + type hline struct{ label, value string } + headers := []hline{ + {"Von:", toLatinSafe(pm.From)}, + {"An:", toLatinSafe(strings.Join(pm.To, ", "))}, + {"CC:", toLatinSafe(strings.Join(pm.CC, ", "))}, + {"Betreff:", toLatinSafe(pm.Subject)}, + {"Datum:", dateStr}, + {"Groesse:", formatBytesStr(rawSize)}, + } + + // Attachment lines + var attLines []string + for _, a := range pm.Attachments { + attLines = append(attLines, toLatinSafe(fmt.Sprintf( + " - %s [%s, %s]", a.Filename, a.ContentType, formatBytesStr(a.Size), + ))) + } + + // Build page content stream + var cs bytes.Buffer + y := pageH - margin + + // Helper: move to next line + newline := func(h float64) { y -= h } + + // BT block builder helper — writes text at absolute position + text := func(x, ypos float64, size float64, bold bool, s string) { + font := "Helvetica" + if bold { + font = "Helvetica-Bold" + } + cs.WriteString(fmt.Sprintf("BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", + font, size, x, ypos, escapePDF(s))) + } + + // Horizontal line + hline2 := func(ypos float64) { + cs.WriteString(fmt.Sprintf("%.1f %.1f m %.1f %.1f l S\n", + margin, ypos, pageW-margin, ypos)) + } + + // Title + text(margin, y, 16, true, "archivmail Export") + newline(lineH * 1.8) + + // Header table + labelX := margin + valueX := margin + 70 + for _, h := range headers { + text(labelX, y, 10, true, h.label) + // Wrap value if long + valLines := wrapText(h.value, 70) + for i, vl := range valLines { + text(valueX, y, 10, false, vl) + if i < len(valLines)-1 { + newline(smallLineH) + } + } + newline(smallLineH) + } + + newline(4) + // Separator + hline2(y) + newline(lineH) + + // Body + if len(bodyLines) > 0 { + for _, bl := range bodyLines { + if y < margin+20 { + // Simple overflow protection: stop rendering + text(margin, y, 8, false, "[... Text abgeschnitten ...]") + y = margin + break + } + text(margin, y, 9, false, bl) + newline(smallLineH) + } + } else { + text(margin, y, 9, false, "(kein Textinhalt)") + newline(smallLineH) + } + + // Attachments section + if len(attLines) > 0 { + newline(6) + if y > margin+20 { + hline2(y) + newline(lineH) + text(margin, y, 10, true, fmt.Sprintf("Anhaenge (%d):", len(pm.Attachments))) + newline(lineH) + for _, al := range attLines { + if y < margin+20 { + break + } + text(margin, y, 9, false, al) + newline(smallLineH) + } + } + } + + contentStream := cs.String() + + // ── PDF objects ─────────────────────────────────────────────────────── + // Obj 1: Catalog + // Obj 2: Pages + // Obj 3: Page + // Obj 4: Content stream + + p.write("%PDF-1.4\n") + + // Obj 1: Catalog + p.startObj(1) + p.write("<< /Type /Catalog /Pages 2 0 R >>\n") + p.endObj() + + // Obj 2: Pages + p.startObj(2) + p.writef("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 %.2f %.2f] >>\n", pageW, pageH) + p.endObj() + + // Obj 3: Page + p.startObj(3) + p.writef("<< /Type /Page /Parent 2 0 R\n") + p.writef(" /Resources << /Font << /Helvetica << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n") + p.writef(" /Helvetica-Bold << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >>\n") + p.writef(" /Contents 4 0 R\n") + p.writef(" /MediaBox [0 0 %.2f %.2f]\n", pageW, pageH) + p.write(">>\n") + p.endObj() + + // Obj 4: Content stream + p.startObj(4) + // Set line width for horizontal rules + streamWithSetup := "0.5 w\n" + contentStream + streamBytes := []byte(streamWithSetup) + p.writef("<< /Length %d >>\n", len(streamBytes)) + p.write("stream\n") + p.buf.Write(streamBytes) + p.write("\nendstream\n") + p.endObj() + + // Cross-reference table + xrefOffset := p.buf.Len() + p.writef("xref\n0 5\n") + p.writef("0000000000 65535 f \n") + for i := 0; i < 4; i++ { + off := 0 + if i < len(p.offsets) { + off = p.offsets[i] + } + p.writef("%010d 00000 n \n", off) + } + + // Ensure contentW is used (suppress unused variable warning) + _ = contentW + + p.write("trailer\n") + p.writef("<< /Size 5 /Root 1 0 R >>\n") + p.write("startxref\n") + p.writef("%d\n", xrefOffset) + p.write("%%EOF\n") + + return p.buf.Bytes() +} + +// ── PDF handler ─────────────────────────────────────────────────────────── + +func (s *Server) handleExportPDF(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + raw, err := s.store.Load(id) + if err != nil { + writeError(w, http.StatusNotFound, "mail not found") + return + } + + pm, err := mailparser.Parse(raw) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to parse mail") + return + } + + // User role: only own mailbox + sess := sessionFromCtx(r.Context()) + if sess.Role == userstore.RoleUser { + u, err := s.users.GetByUsername(sess.Username) + if err != nil || !mailBelongsToUser(pm, u.Email) { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + + safeID := id + if len(safeID) > 16 { + safeID = safeID[:16] + } + filename := safeID + ".pdf" + + pdfBytes := buildMailPDF(id, pm, len(raw)) + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes))) + w.WriteHeader(http.StatusOK) + w.Write(pdfBytes) //nolint:errcheck + + s.audlog.Log(audit.Entry{ + EventType: audit.EventExport, + Username: sess.Username, + IPAddress: remoteIP(r), + MailID: id, + Detail: "pdf", + Success: true, + }) +} + +// ── ZIP export ──────────────────────────────────────────────────────────── + +func (s *Server) handleExportZIP(w http.ResponseWriter, r *http.Request) { + var req struct { + IDs []string `json:"ids"` + Attachments bool `json:"attachments"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if len(req.IDs) == 0 { + writeError(w, http.StatusBadRequest, "ids must not be empty") + return + } + if len(req.IDs) > 500 { + writeError(w, http.StatusBadRequest, "too many ids (max 500)") + return + } + + sess := sessionFromCtx(r.Context()) + + // For RoleUser, look up user email once for access checks + var userEmail string + if sess.Role == userstore.RoleUser { + u, err := s.users.GetByUsername(sess.Username) + if err != nil { + writeError(w, http.StatusInternalServerError, "user lookup failed") + return + } + userEmail = u.Email + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="archivmail-export.zip"`) + + zw := zip.NewWriter(w) + defer zw.Close() + + type manifestRow struct { + filename string + messageID string + from string + to string + subject string + date string + } + var manifest []manifestRow + exported := 0 + + for _, id := range req.IDs { + raw, err := s.store.Load(id) + if err != nil { + continue + } + + pm, err := mailparser.Parse(raw) + if err != nil { + continue + } + + // Access check for user role + if sess.Role == userstore.RoleUser && !mailBelongsToUser(pm, userEmail) { + continue + } + + safeID := id + if len(safeID) > 16 { + safeID = safeID[:16] + } + emlFilename := safeID + ".eml" + + fw, err := zw.Create(emlFilename) + if err != nil { + continue + } + if _, err := fw.Write(raw); err != nil { + continue + } + + var dateStr string + if !pm.Date.IsZero() { + dateStr = pm.Date.UTC().Format(time.RFC3339) + } + manifest = append(manifest, manifestRow{ + filename: emlFilename, + messageID: pm.MessageID, + from: pm.From, + to: strings.Join(pm.To, ", "), + subject: pm.Subject, + date: dateStr, + }) + exported++ + + // Attachments + if req.Attachments { + shortID := id + if len(shortID) > 8 { + shortID = shortID[:8] + } + for _, a := range pm.Attachments { + if a.Filename == "" || len(a.Data) == 0 { + continue + } + attPath := fmt.Sprintf("attachments/%s/%s", shortID, a.Filename) + af, err := zw.Create(attPath) + if err != nil { + continue + } + af.Write(a.Data) //nolint:errcheck + } + } + } + + // Write manifest.csv + mf, err := zw.Create("manifest.csv") + if err == nil { + cw := csv.NewWriter(mf) + cw.Write([]string{"filename", "message_id", "from", "to", "subject", "date"}) //nolint:errcheck + for _, row := range manifest { + cw.Write([]string{ //nolint:errcheck + row.filename, + row.messageID, + row.from, + row.to, + row.subject, + row.date, + }) + } + cw.Flush() + } + + s.audlog.Log(audit.Entry{ + EventType: audit.EventExport, + Username: sess.Username, + IPAddress: remoteIP(r), + Detail: fmt.Sprintf("zip: %d mails", exported), + Success: true, + }) +} diff --git a/internal/api/server.go b/internal/api/server.go index a5dfb58..f646b1a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -100,6 +100,10 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats))) + // Export routes + s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF))) + s.mux.HandleFunc("POST /api/export/zip", s.authMiddleware(s.requireMailAccess(s.handleExportZIP))) + // IMAP routes (accessible to all authenticated users) s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap)) s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap)) diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index 3fac934..04be2b5 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -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(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 (
@@ -282,6 +296,14 @@ export default function MailViewPage({ > {downloading ? "..." : "Als .eml herunterladen"} +
)} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 464ece9..696ecf4 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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>(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`} + + {selected.size > 0 && ( +
+ {selected.size} ausgewählt + + +
+ )} + + + { + if (checked) setSelected(new Set(results.map((h) => h.id))); + else setSelected(new Set()); + }} + aria-label="Alle auswählen" + /> + Datum Von Betreff @@ -212,6 +272,20 @@ export default function SearchPage() { }} aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`} > + e.stopPropagation()}> + { + 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" + /> + {hit.date ? new Date(hit.date).toLocaleDateString("de-DE") @@ -252,6 +326,33 @@ export default function SearchPage() { ) : null} + + + + + E-Mails exportieren + + {selected.size} E-Mail{selected.size !== 1 ? "s" : ""} als ZIP herunterladen + + +
+ + +
+ + + + +
+
); diff --git a/src/lib/api.ts b/src/lib/api.ts index b617e69..268f51a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -391,3 +391,32 @@ export interface SystemStats { export async function getSystemStats(): Promise { return request("/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 }; +}