feat(PROJ-39): eDiscovery Export + Feature-Specs PROJ-40–43
- Neuer POST /api/export/ediscovery Handler (internal/api/ediscovery.go) - Route in server.go registriert - Feature-Specs PROJ-39 bis PROJ-43 angelegt - INDEX.md aktualisiert Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user