Files
archivmail/internal/api/ediscovery.go
T
sysopsandClaude Sonnet 5 88cdc3eb3e fix(PROJ-73): Upload-Job-Status bei Panic + nil-Guards nach GetByUsername
Upload-Job bleibt bei einem Panic im Verarbeitungspfad nicht mehr auf
"running" hängen, sondern zeigt "error" mit generischer Meldung (Panic-
Rohwert nur im Server-Log, ErrMsg geht ans Frontend und könnte sonst
Mail-Inhalt-Fragmente transportieren). Zusätzlich fail-closed nil-Guards
an allen 13 GetByUsername-Aufrufstellen in internal/api/, die das
Ergebnis bisher ungeprüft dereferenzierten.

Verifiziert auf 192.168.1.132: Build und go vet fehlerfrei für die
geänderten Dateien.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
2026-08-05 13:50:36 +02:00

337 lines
8.9 KiB
Go

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). PROJ-55: a tenant-scoped auditor
// (tenant_id set) uses the per-tenant index like domain_auditor; only a
// global auditor (no tenant_id) stays on the global index.
searchIdx := s.idx
usedTenantIndex := false
if s.idxMgr != nil && tenantID != nil && !auditorIsGlobal(sess) {
searchIdx = s.idxMgr.ForTenant(tenantID)
usedTenantIndex = true
}
result, err := searchIdx.Search(searchReq)
if err != nil {
writeError(w, http.StatusInternalServerError, "search failed")
return
}
// BUG-1 (PROJ-55): fail-closed tenant post-filter when we fell back to the
// global index but the session is tenant-scoped (idxMgr not wired). Without
// this a tenant-scoped auditor/domain_auditor would otherwise receive mails
// of ALL tenants via eDiscovery. Mirrors the GetAllIDsByTenant fallback in
// handleSearch (search_handlers.go).
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 && !auditorIsGlobal(sess) {
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
if idErr != nil {
writeError(w, http.StatusInternalServerError, "access check failed")
return
}
allowed := make(map[string]struct{}, len(allowedIDs))
for _, id := range allowedIDs {
allowed[id] = struct{}{}
}
filtered := result.Hits[:0]
for _, h := range result.Hits {
if _, ok := allowed[h.ID]; ok {
filtered = append(filtered, h)
}
}
result.Hits = filtered
result.Total = len(filtered)
}
// Global auditor (no tenant_id): restrict to no-tenant mails. A tenant-scoped
// auditor is already constrained by the per-tenant index above (PROJ-55).
var auditorAllowed map[string]struct{}
if auditorIsGlobal(sess) {
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 || u == 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,
TenantID: sess.TenantID,
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
}