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 := sanitizeExportFilename(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, }) } // sanitizeExportFilename replaces characters unsafe for filenames with underscores. func sanitizeExportFilename(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 }