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:
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
| PROJ-9 | Ordner- & Label-Verwaltung | In Progress | [PROJ-9](PROJ-9-ordner-und-labels.md) | 2026-03-12 |
|
| 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-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-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-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-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 |
|
| PROJ-15 | CLI Import & Export (archivmail-User) | In Review | [PROJ-15](PROJ-15-cli-import-export.md) | 2026-03-13 |
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PROJ-12: E-Mail-Export (EML / PDF)
|
# PROJ-12: E-Mail-Export (EML / PDF)
|
||||||
|
|
||||||
## Status: In Progress
|
## Status: In Review
|
||||||
**Created:** 2026-03-12
|
**Created:** 2026-03-12
|
||||||
**Last Updated:** 2026-03-13
|
**Last Updated:** 2026-03-14
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- Requires: PROJ-1 (Authentifizierung)
|
- Requires: PROJ-1 (Authentifizierung)
|
||||||
@@ -115,6 +115,33 @@ POST /api/export/zip
|
|||||||
| `archive/zip` (Stdlib) | Streaming-ZIP-Erstellung ohne externe Abhängigkeit |
|
| `archive/zip` (Stdlib) | Streaming-ZIP-Erstellung ohne externe Abhängigkeit |
|
||||||
| `github.com/SebastiaanKlippert/go-wkhtmltopdf` | PDF-Generierung aus HTML (serverseitig) |
|
| `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
|
## QA Test Results
|
||||||
_To be added by /qa_
|
_To be added by /qa_
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -100,6 +100,10 @@ func (s *Server) routes() {
|
|||||||
|
|
||||||
s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats)))
|
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)
|
// IMAP routes (accessible to all authenticated users)
|
||||||
s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap))
|
s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap))
|
||||||
s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap))
|
s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getMail,
|
getMail,
|
||||||
downloadMailAttachment,
|
downloadMailAttachment,
|
||||||
downloadMailRaw,
|
downloadMailRaw,
|
||||||
|
exportMailPDF,
|
||||||
type MailDetail,
|
type MailDetail,
|
||||||
type MailAttachment,
|
type MailAttachment,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -227,6 +228,7 @@ export default function MailViewPage({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
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) {
|
if (authLoading || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
@@ -282,6 +296,14 @@ export default function MailViewPage({
|
|||||||
>
|
>
|
||||||
{downloading ? "..." : "Als .eml herunterladen"}
|
{downloading ? "..." : "Als .eml herunterladen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePdfDownload}
|
||||||
|
disabled={pdfLoading}
|
||||||
|
>
|
||||||
|
{pdfLoading ? "..." : "Als PDF exportieren"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+102
-1
@@ -3,11 +3,20 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
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 { Navbar } from "@/components/navbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -18,6 +27,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -37,6 +47,17 @@ export default function SearchPage() {
|
|||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searched, setSearched] = 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(
|
const doSearch = useCallback(
|
||||||
async (p: number) => {
|
async (p: number) => {
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
@@ -87,7 +108,27 @@ export default function SearchPage() {
|
|||||||
doSearch(1);
|
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 totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
||||||
|
|
||||||
if (authLoading || !user) {
|
if (authLoading || !user) {
|
||||||
return (
|
return (
|
||||||
@@ -189,10 +230,29 @@ export default function SearchPage() {
|
|||||||
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
|
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
|
||||||
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
|
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<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-32">Datum</TableHead>
|
||||||
<TableHead className="w-56">Von</TableHead>
|
<TableHead className="w-56">Von</TableHead>
|
||||||
<TableHead>Betreff</TableHead>
|
<TableHead>Betreff</TableHead>
|
||||||
@@ -212,6 +272,20 @@ export default function SearchPage() {
|
|||||||
}}
|
}}
|
||||||
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
|
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">
|
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{hit.date
|
{hit.date
|
||||||
? new Date(hit.date).toLocaleDateString("de-DE")
|
? new Date(hit.date).toLocaleDateString("de-DE")
|
||||||
@@ -252,6 +326,33 @@ export default function SearchPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -391,3 +391,32 @@ export interface SystemStats {
|
|||||||
export async function getSystemStats(): Promise<SystemStats> {
|
export async function getSystemStats(): Promise<SystemStats> {
|
||||||
return request<SystemStats>("/api/admin/system/stats");
|
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