diff --git a/features/INDEX.md b/features/INDEX.md index 12972dc..a6d017b 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -54,8 +54,13 @@ | PROJ-35 | OCR & Anhang-Volltext-Indexierung | Planned | [PROJ-35](PROJ-35-ocr-anhang-volltext.md) | 2026-04-04 | | PROJ-36 | gzip-Kompression + storage_objects-Tabelle | Deployed | [PROJ-36](PROJ-36-compression-storage-objects.md) | 2026-04-05 | | PROJ-37 | Attachment-Deduplication (Hash-basiert) | Deployed | [PROJ-37](PROJ-37-attachment-deduplication.md) | 2026-04-05 | -| PROJ-38 | Mail-Threading (In-Reply-To / References) | In Progress | [PROJ-38](PROJ-38-mail-threading.md) | 2026-04-05 | +| PROJ-38 | Mail-Threading (In-Reply-To / References) | Deployed | [PROJ-38](PROJ-38-mail-threading.md) | 2026-04-05 | +| PROJ-39 | eDiscovery Export (ZIP + Metadaten-CSV) | In Progress | [PROJ-39](PROJ-39-ediscovery-export.md) | 2026-04-05 | +| PROJ-40 | Prometheus Metriken + Health-Check | Planned | [PROJ-40](PROJ-40-prometheus-metriken.md) | 2026-04-05 | +| PROJ-41 | Dashboard Zeitreihe + Speicherprognose | Planned | [PROJ-41](PROJ-41-dashboard-zeitreihe.md) | 2026-04-05 | +| PROJ-42 | Gespeicherte Suchanfragen | Planned | [PROJ-42](PROJ-42-gespeicherte-suchanfragen.md) | 2026-04-05 | +| PROJ-43 | Automatische Archivierungsregeln | Planned | [PROJ-43](PROJ-43-archivierungsregeln.md) | 2026-04-05 | -## Next Available ID: PROJ-39 +## Next Available ID: PROJ-44 diff --git a/internal/api/ediscovery.go b/internal/api/ediscovery.go new file mode 100644 index 0000000..68eb3e8 --- /dev/null +++ b/internal/api/ediscovery.go @@ -0,0 +1,305 @@ +package api + +import ( + "archive/zip" + "crypto/sha256" + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "archivmail/internal/audit" + "archivmail/internal/index" + "archivmail/internal/userstore" + "archivmail/pkg/mailparser" +) + +// handleExportEDiscovery runs a search server-side and streams all matching +// mails as a ZIP archive with a metadata CSV and README. +// +// POST /api/export/ediscovery +// Body: {case_name, q, from, to, date_from, date_to, has_attachment} +func (s *Server) handleExportEDiscovery(w http.ResponseWriter, r *http.Request) { + var req struct { + CaseName string `json:"case_name"` + Query string `json:"q"` + From string `json:"from"` + To string `json:"to"` + DateFrom string `json:"date_from"` + DateTo string `json:"date_to"` + HasAttachment *bool `json:"has_attachment"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + caseName := strings.TrimSpace(req.CaseName) + if caseName == "" { + caseName = "archivmail-export" + } + + sess := sessionFromCtx(r.Context()) + tenantID := tenantFromCtx(r.Context()) + + // domain_auditor without tenant → deny + if sess.Role == userstore.RoleDomainAuditor && tenantID == nil { + writeError(w, http.StatusForbidden, "access denied") + return + } + + // Build search request — fetch up to 10 000 mails in one pass. + searchReq := index.SearchRequest{ + Query: req.Query, + From: req.From, + To: req.To, + HasAttachment: req.HasAttachment, + PageSize: 10000, + Page: 1, + } + if req.DateFrom != "" { + if t, err := time.Parse(time.DateOnly, req.DateFrom); err == nil { + searchReq.DateFrom = &t + } + } + if req.DateTo != "" { + if t, err := time.Parse(time.DateOnly, req.DateTo); err == nil { + t = t.Add(24*time.Hour - time.Second) + searchReq.DateTo = &t + } + } + + // Choose index (per-tenant or global) + searchIdx := s.idx + if s.idxMgr != nil && tenantID != nil && sess.Role != userstore.RoleAuditor { + searchIdx = s.idxMgr.ForTenant(tenantID) + } + + result, err := searchIdx.Search(searchReq) + if err != nil { + writeError(w, http.StatusInternalServerError, "search failed") + return + } + + // Auditor: restrict to no-tenant mails + var auditorAllowed map[string]struct{} + if sess.Role == userstore.RoleAuditor { + ids, err := s.store.GetAllIDsWithoutTenant(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "access check failed") + return + } + auditorAllowed = make(map[string]struct{}, len(ids)) + for _, id := range ids { + auditorAllowed[id] = struct{}{} + } + } + + // User: only own mails + 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 = strings.ToLower(u.Email) + if userEmail == "" { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + + // Stream ZIP response + safeCase := sanitizeFilename(caseName) + zipName := fmt.Sprintf("%s-%s.zip", safeCase, time.Now().UTC().Format("20060102-150405")) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, zipName)) + + zw := zip.NewWriter(w) + defer zw.Close() + + type metaRow struct { + id string + messageID string + from string + to string + cc string + subject string + date string + sizeBytes int + hasAttach bool + sha256Hash string + threadID string + emlFilename string + } + var rows []metaRow + exported := 0 + + for _, hit := range result.Hits { + id := hit.ID + + if auditorAllowed != nil { + if _, ok := auditorAllowed[id]; !ok { + continue + } + } + + raw, err := s.store.Load(id) + if err != nil { + continue + } + + pm, err := mailparser.Parse(raw) + if err != nil { + continue + } + + if userEmail != "" && !mailBelongsToUser(pm, userEmail) { + continue + } + + // Compute SHA-256 of raw mail + sum := sha256.Sum256(raw) + hash := fmt.Sprintf("%x", sum[:]) + + safeID := id + if len(safeID) > 16 { + safeID = safeID[:16] + } + emlFilename := fmt.Sprintf("%04d_%s.eml", exported+1, safeID) + + 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) + } + + // Thread ID from DB + var threadID string + if ti, err := s.store.GetThreadInfo(r.Context(), []string{id}); err == nil { + if info, ok := ti[id]; ok { + threadID = info.ThreadID + } + } + + rows = append(rows, metaRow{ + id: id, + messageID: pm.MessageID, + from: pm.From, + to: strings.Join(pm.To, ", "), + cc: strings.Join(pm.CC, ", "), + subject: pm.Subject, + date: dateStr, + sizeBytes: len(raw), + hasAttach: len(pm.Attachments) > 0, + sha256Hash: hash, + threadID: threadID, + emlFilename: emlFilename, + }) + exported++ + } + + // Write metadata.csv + if mf, err := zw.Create("metadata.csv"); err == nil { + cw := csv.NewWriter(mf) + _ = cw.Write([]string{ + "filename", "id", "message_id", "from", "to", "cc", + "subject", "date", "size_bytes", "has_attachments", "sha256", "thread_id", + }) + for _, row := range rows { + hasAttach := "false" + if row.hasAttach { + hasAttach = "true" + } + _ = cw.Write([]string{ + row.emlFilename, + row.id, + row.messageID, + row.from, + row.to, + row.cc, + row.subject, + row.date, + fmt.Sprintf("%d", row.sizeBytes), + hasAttach, + row.sha256Hash, + row.threadID, + }) + } + cw.Flush() + } + + // Write README.txt + if rf, err := zw.Create("README.txt"); err == nil { + var sb strings.Builder + sb.WriteString("archivmail eDiscovery Export\n") + sb.WriteString(strings.Repeat("=", 40) + "\n\n") + sb.WriteString(fmt.Sprintf("Case: %s\n", caseName)) + sb.WriteString(fmt.Sprintf("Exported at: %s\n", time.Now().UTC().Format(time.RFC3339))) + sb.WriteString(fmt.Sprintf("Exported by: %s (%s)\n", sess.Username, sess.Role)) + sb.WriteString(fmt.Sprintf("Total mails: %d\n\n", exported)) + sb.WriteString("Filter parameters:\n") + if req.Query != "" { + sb.WriteString(fmt.Sprintf(" Query: %s\n", req.Query)) + } + if req.From != "" { + sb.WriteString(fmt.Sprintf(" From: %s\n", req.From)) + } + if req.To != "" { + sb.WriteString(fmt.Sprintf(" To: %s\n", req.To)) + } + if req.DateFrom != "" { + sb.WriteString(fmt.Sprintf(" From date: %s\n", req.DateFrom)) + } + if req.DateTo != "" { + sb.WriteString(fmt.Sprintf(" To date: %s\n", req.DateTo)) + } + if req.Query == "" && req.From == "" && req.To == "" && req.DateFrom == "" && req.DateTo == "" { + sb.WriteString(" (no filters — all archived mails)\n") + } + sb.WriteString("\nFiles:\n") + sb.WriteString(" metadata.csv — full metadata for all exported mails\n") + sb.WriteString(" *.eml — individual mail files (RFC 2822)\n") + _, _ = rf.Write([]byte(sb.String())) + } + + s.audlog.Log(audit.Entry{ + EventType: audit.EventExport, + Username: sess.Username, + IPAddress: s.remoteIP(r), + Detail: fmt.Sprintf("ediscovery: case=%q mails=%d", caseName, exported), + Success: true, + }) +} + +// sanitizeFilename replaces characters unsafe for filenames with underscores. +func sanitizeFilename(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + result := b.String() + if len(result) > 64 { + result = result[:64] + } + if result == "" { + result = "export" + } + return result +} diff --git a/internal/api/server.go b/internal/api/server.go index d80f6a5..dd6605d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -235,6 +235,7 @@ func (s *Server) routes() { // Export routes s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF))) s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP))) + s.mux.HandleFunc("POST /api/export/ediscovery", s.auth(s.handleExportEDiscovery)) // Upload routes (admin only) s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload)) diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 82d826a..37dd089 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; -import { searchEmails, exportMailsZIP, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api"; +import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api"; import { Navbar } from "@/components/navbar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -68,6 +68,9 @@ export default function SearchPage() { const [exportOpen, setExportOpen] = useState(false); const [exportAttachments, setExportAttachments] = useState(false); const [exporting, setExporting] = useState(false); + const [ediscoveryOpen, setEdiscoveryOpen] = useState(false); + const [ediscoveryCaseName, setEdiscoveryCaseName] = useState(""); + const [ediscoveryLoading, setEdiscoveryLoading] = useState(false); // Upload state const [uploadOpen, setUploadOpen] = useState(false); @@ -141,6 +144,31 @@ export default function SearchPage() { doSearch(1); } + async function handleEDiscoveryExport() { + setEdiscoveryLoading(true); + try { + const { blob, filename } = await exportEDiscovery({ + case_name: ediscoveryCaseName || undefined, + q: query || undefined, + from: fromFilter || undefined, + to: toFilter || undefined, + date_from: dateFrom || undefined, + date_to: dateTo || undefined, + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + setEdiscoveryOpen(false); + } catch (e) { + alert(`eDiscovery Export fehlgeschlagen: ${e instanceof Error ? e.message : e}`); + } finally { + setEdiscoveryLoading(false); + } + } + async function handleExportZIP() { setExporting(true); try { @@ -346,13 +374,18 @@ export default function SearchPage() { : `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`} - {selected.size > 0 && ( -