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:
+7
-2
@@ -54,8 +54,13 @@
|
|||||||
| PROJ-35 | OCR & Anhang-Volltext-Indexierung | Planned | [PROJ-35](PROJ-35-ocr-anhang-volltext.md) | 2026-04-04 |
|
| 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-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-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 |
|
||||||
|
|
||||||
<!-- Add features above this line -->
|
<!-- Add features above this line -->
|
||||||
|
|
||||||
## Next Available ID: PROJ-39
|
## Next Available ID: PROJ-44
|
||||||
|
|||||||
@@ -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
|
// Export routes
|
||||||
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF)))
|
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/zip", s.auth(s.requireMailAccess(s.handleExportZIP)))
|
||||||
|
s.mux.HandleFunc("POST /api/export/ediscovery", s.auth(s.handleExportEDiscovery))
|
||||||
|
|
||||||
// Upload routes (admin only)
|
// Upload routes (admin only)
|
||||||
s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload))
|
s.mux.HandleFunc("POST /api/admin/upload", s.authAdmin(s.handleUpload))
|
||||||
|
|||||||
+79
-3
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
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 { Navbar } from "@/components/navbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -68,6 +68,9 @@ export default function SearchPage() {
|
|||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [exportAttachments, setExportAttachments] = useState(false);
|
const [exportAttachments, setExportAttachments] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [ediscoveryOpen, setEdiscoveryOpen] = useState(false);
|
||||||
|
const [ediscoveryCaseName, setEdiscoveryCaseName] = useState("");
|
||||||
|
const [ediscoveryLoading, setEdiscoveryLoading] = useState(false);
|
||||||
|
|
||||||
// Upload state
|
// Upload state
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
@@ -141,6 +144,31 @@ export default function SearchPage() {
|
|||||||
doSearch(1);
|
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() {
|
async function handleExportZIP() {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
@@ -346,13 +374,18 @@ export default function SearchPage() {
|
|||||||
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
|
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center gap-2 flex-wrap">
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<>
|
||||||
<span className="text-sm text-muted-foreground">{selected.size} ausgewählt</span>
|
<span className="text-sm text-muted-foreground">{selected.size} ausgewählt</span>
|
||||||
<Button size="sm" onClick={() => setExportOpen(true)}>Als ZIP exportieren</Button>
|
<Button size="sm" onClick={() => setExportOpen(true)}>Als ZIP exportieren</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setSelected(new Set())}>Auswahl aufheben</Button>
|
<Button size="sm" variant="outline" onClick={() => setSelected(new Set())}>Auswahl aufheben</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEdiscoveryOpen(true)}>
|
||||||
|
eDiscovery Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -485,6 +518,49 @@ export default function SearchPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{/* eDiscovery Export Dialog */}
|
||||||
|
<Dialog open={ediscoveryOpen} onOpenChange={setEdiscoveryOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>eDiscovery Export</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Exportiert alle Mails der aktuellen Suche als ZIP mit Metadaten-CSV und README.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="case-name">Case-Name (optional)</Label>
|
||||||
|
<input
|
||||||
|
id="case-name"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="z.B. Ermittlung-2026-Q1"
|
||||||
|
value={ediscoveryCaseName}
|
||||||
|
onChange={(e) => setEdiscoveryCaseName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-muted px-3 py-2 text-sm text-muted-foreground space-y-1">
|
||||||
|
<div><span className="font-medium">Aktive Filter:</span></div>
|
||||||
|
{query && <div>Suche: <span className="font-mono">{query}</span></div>}
|
||||||
|
{fromFilter && <div>Von: <span className="font-mono">{fromFilter}</span></div>}
|
||||||
|
{toFilter && <div>An: <span className="font-mono">{toFilter}</span></div>}
|
||||||
|
{dateFrom && <div>Von Datum: {dateFrom}</div>}
|
||||||
|
{dateTo && <div>Bis Datum: {dateTo}</div>}
|
||||||
|
{!query && !fromFilter && !toFilter && !dateFrom && !dateTo && (
|
||||||
|
<div className="italic">Keine Filter — alle archivierten Mails werden exportiert</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEdiscoveryOpen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEDiscoveryExport} disabled={ediscoveryLoading}>
|
||||||
|
{ediscoveryLoading ? "Wird exportiert..." : "ZIP herunterladen"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Upload Dialog */}
|
{/* Upload Dialog */}
|
||||||
<Dialog open={uploadOpen} onOpenChange={(open) => { if (!open) handleUploadClose(); else setUploadOpen(true); }}>
|
<Dialog open={uploadOpen} onOpenChange={(open) => { if (!open) handleUploadClose(); else setUploadOpen(true); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -301,6 +301,29 @@ export async function getPop3Progress(id: number): Promise<Pop3Account> {
|
|||||||
return request<Pop3Account>(`/api/pop3/${id}/progress`);
|
return request<Pop3Account>(`/api/pop3/${id}/progress`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── eDiscovery Export ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function exportEDiscovery(params: {
|
||||||
|
case_name?: string;
|
||||||
|
q?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
has_attachment?: boolean;
|
||||||
|
}): Promise<{ blob: Blob; filename: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/export/ediscovery`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`eDiscovery Export fehlgeschlagen: ${res.status}`);
|
||||||
|
const cd = res.headers.get("Content-Disposition") || "";
|
||||||
|
const filename = cd.match(/filename="([^"]+)"/)?.[1] || "archivmail-export.zip";
|
||||||
|
return { blob: await res.blob(), filename };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Thread ────────────────────────────────────────────────────────────────────
|
// ── Thread ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getThread(threadID: string): Promise<ThreadResponse> {
|
export async function getThread(threadID: string): Promise<ThreadResponse> {
|
||||||
|
|||||||
Reference in New Issue
Block a user