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") // SEC-22: Validate mail ID format to prevent path traversal. if !isValidMailID(id) { writeError(w, http.StatusBadRequest, "invalid mail id") return } 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 } sess := sessionFromCtx(r.Context()) // SEC-05: Tenant isolation for PDF export. if sess.TenantID != nil { mailTenant, _ := s.store.GetTenantForMail(r.Context(), id) if mailTenant == nil || *mailTenant != *sess.TenantID { writeError(w, http.StatusForbidden, "access denied") return } } // user and auditor: only own mails; domain_auditor: all tenant mails (no filter) if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor { 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: s.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"` } 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 } // SEC-22: Validate all mail IDs before processing. for _, id := range req.IDs { if !isValidMailID(id) { writeError(w, http.StatusBadRequest, "invalid mail id") return } } sess := sessionFromCtx(r.Context()) // SEC-05: Tenant isolation — verify all requested IDs belong to caller's tenant. if sess.TenantID != nil { allowedIDs, err := s.store.GetAllIDsByTenant(r.Context(), sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "tenant check failed") return } allowed := make(map[string]struct{}, len(allowedIDs)) for _, aid := range allowedIDs { allowed[aid] = struct{}{} } for _, id := range req.IDs { if _, ok := allowed[id]; !ok { writeError(w, http.StatusForbidden, "access denied") return } } } // User and Auditor: look up email once for per-mail access checks var userEmail string if sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor { 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 } // user and auditor: only own mails; domain_auditor: all tenant mails (no filter) if (sess.Role == userstore.RoleUser || sess.Role == userstore.RoleAuditor) && !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++ } // 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: s.remoteIP(r), Detail: fmt.Sprintf("zip: %d mails", exported), Success: true, }) }