package api import ( "bytes" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "archivmail/internal/audit" "archivmail/internal/index" "archivmail/internal/storage" "archivmail/internal/userstore" ) // dsgvoMaxHits caps the number of mails evaluated per request, mirroring the // eDiscovery export limit (PROJ-39). Beyond this the admin is asked to narrow // the date range. const dsgvoMaxHits = 10000 // canManageDSGVO reports whether the role may create/process DSGVO requests. func canManageDSGVO(role string) bool { switch role { case userstore.RoleSuperAdmin, userstore.RoleDomainAdmin, userstore.RoleAdmin: return true } return false } // canViewDSGVO reports whether the role may read DSGVO requests (incl. auditors). func canViewDSGVO(role string) bool { if canManageDSGVO(role) { return true } switch role { case userstore.RoleAuditor, userstore.RoleDomainAuditor: return true } return false } // handleCreateDSGVORequest creates a DSGVO erasure request, runs the address // search in the admin's tenant scope, evaluates retain_until per mail and // stores the documented result. // // POST /api/admin/dsgvo // Body: {address, date_from?, date_to?} func (s *Server) handleCreateDSGVORequest(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if !canManageDSGVO(sess.Role) { writeError(w, http.StatusForbidden, "insufficient permissions") return } tenantID := tenantFromCtx(r.Context()) var req struct { Address string `json:"address"` DateFrom string `json:"date_from"` DateTo string `json:"date_to"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } address := strings.ToLower(strings.TrimSpace(req.Address)) if address == "" || !strings.Contains(address, "@") { writeError(w, http.StatusBadRequest, "valid e-mail address required") return } // Build the address search across from/to/cc/bcc. searchReq := index.SearchRequest{ AnyAddress: address, PageSize: dsgvoMaxHits, 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 } } searchIdx := s.idx if s.idxMgr != nil && tenantID != nil { searchIdx = s.idxMgr.ForTenant(tenantID) } result, err := searchIdx.Search(searchReq) if err != nil { writeError(w, http.StatusInternalServerError, "search failed") return } // Persist the request first so the vorgang is documented even if later steps fail. dsReq, err := s.store.CreateDSGVORequest(r.Context(), tenantID, address, sess.Username) if err != nil { writeError(w, http.StatusInternalServerError, "could not create request") return } ids := make([]string, 0, len(result.Hits)) for _, h := range result.Hits { ids = append(ids, h.ID) } meta, err := s.store.GetDSGVOMailMeta(r.Context(), ids) if err != nil { writeError(w, http.StatusInternalServerError, "metadata lookup failed") return } now := time.Now() summary := storage.DSGVOResultSummary{ Truncated: result.Total > dsgvoMaxHits, } for _, id := range ids { m, ok := meta[id] if !ok { continue } summary.TotalHits++ am := storage.DSGVOAffectedMail{ MailID: id, Subject: m.Subject, RetainUntil: m.RetainUntil, } if !m.ReceivedAt.IsZero() { d := m.ReceivedAt am.Date = &d } if m.RetainUntil != nil && now.Before(*m.RetainUntil) { summary.Rejected++ am.Deletable = false am.Reason = fmt.Sprintf("Abgelehnt - gesetzliche Aufbewahrungspflicht bis %s", m.RetainUntil.Format("2006-01-02")) } else { summary.Deletable++ am.Deletable = true am.Reason = "Loeschbar - keine Aufbewahrungspflicht" } summary.Mails = append(summary.Mails, am) } status := dsgvoStatus(summary) if err := s.store.UpdateDSGVOResult(r.Context(), dsReq.ID, status, &summary); err != nil { writeError(w, http.StatusInternalServerError, "could not store result") return } dsReq.Status = status dsReq.ResultSummary = &summary if s.audlog != nil { s.audlog.Log(audit.Entry{ EventType: audit.EventDSGVORequest, Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: fmt.Sprintf("dsgvo: address=%q hits=%d rejected=%d deletable=%d truncated=%t", address, summary.TotalHits, summary.Rejected, summary.Deletable, summary.Truncated), }) } writeJSON(w, http.StatusOK, dsReq) } // dsgvoStatus computes the request status from the result summary. func dsgvoStatus(s storage.DSGVOResultSummary) string { if s.TotalHits == 0 { return storage.DSGVOStatusCompleted } if s.Rejected > 0 && s.Deletable > 0 { return storage.DSGVOStatusPartial } if s.Rejected > 0 { return storage.DSGVOStatusPartial } return storage.DSGVOStatusCompleted } // handleListDSGVORequests lists requests in the caller's tenant scope. // GET /api/admin/dsgvo func (s *Server) handleListDSGVORequests(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if !canViewDSGVO(sess.Role) { writeError(w, http.StatusForbidden, "insufficient permissions") return } tenantID := tenantFromCtx(r.Context()) list, err := s.store.ListDSGVORequests(r.Context(), tenantID) if err != nil { writeError(w, http.StatusInternalServerError, "list failed") return } writeJSON(w, http.StatusOK, map[string]interface{}{"requests": list}) } // handleGetDSGVORequest returns a single request detail. // GET /api/admin/dsgvo/{id} func (s *Server) handleGetDSGVORequest(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if !canViewDSGVO(sess.Role) { writeError(w, http.StatusForbidden, "insufficient permissions") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } tenantID := tenantFromCtx(r.Context()) req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID) if err != nil { writeError(w, http.StatusNotFound, "not found") return } writeJSON(w, http.StatusOK, req) } // handleDeleteDSGVOMails deletes the deletable mails of a request using the // existing retention-aware delete mechanism (PROJ-34). Mails under retention // lock are skipped. Each deletion is documented and the request summary is // updated to reflect what was actually removed. // // POST /api/admin/dsgvo/{id}/delete func (s *Server) handleDeleteDSGVOMails(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if !canManageDSGVO(sess.Role) { writeError(w, http.StatusForbidden, "insufficient permissions") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } tenantID := tenantFromCtx(r.Context()) req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID) if err != nil || req.ResultSummary == nil { writeError(w, http.StatusNotFound, "not found") return } searchIdx := s.idx if s.idxMgr != nil && tenantID != nil { searchIdx = s.idxMgr.ForTenant(tenantID) } deleted := 0 for i := range req.ResultSummary.Mails { m := &req.ResultSummary.Mails[i] if !m.Deletable || m.Deleted { continue } if err := s.store.Delete(m.MailID); err != nil { // Retention lock or missing file → leave as-is, keep documented. m.Reason = "Loeschung nicht moeglich: " + err.Error() m.Deletable = false continue } _ = searchIdx.Delete(m.MailID) m.Deleted = true m.Deletable = false m.Reason = "Geloescht auf DSGVO-Antrag" deleted++ if s.audlog != nil { s.audlog.Log(audit.Entry{ EventType: audit.EventDSGVORequest, Username: sess.Username, IPAddress: s.remoteIP(r), MailID: m.MailID, Success: true, Detail: fmt.Sprintf("dsgvo: deleted mail (request_id=%d address=%q)", id, req.RequestedAddress), }) } } // Recompute counters. req.ResultSummary.Deleted += deleted req.ResultSummary.Deletable = 0 for _, m := range req.ResultSummary.Mails { if m.Deletable { req.ResultSummary.Deletable++ } } status := dsgvoStatus(*req.ResultSummary) if req.ResultSummary.Deleted > 0 && req.ResultSummary.Deletable == 0 && req.ResultSummary.Rejected == 0 { status = storage.DSGVOStatusCompleted } if err := s.store.UpdateDSGVOResult(r.Context(), id, status, req.ResultSummary); err != nil { writeError(w, http.StatusInternalServerError, "could not update request") return } req.Status = status writeJSON(w, http.StatusOK, map[string]interface{}{"deleted": deleted, "request": req}) } // handleExportDSGVORequest produces a PDF summary suitable as a written reply // to the data subject, documenting the legal basis for any rejection. // // GET /api/admin/dsgvo/{id}/export func (s *Server) handleExportDSGVORequest(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if !canViewDSGVO(sess.Role) { writeError(w, http.StatusForbidden, "insufficient permissions") return } id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } tenantID := tenantFromCtx(r.Context()) req, err := s.store.GetDSGVORequest(r.Context(), id, tenantID) if err != nil { writeError(w, http.StatusNotFound, "not found") return } pdf := buildDSGVOPDF(req) filename := fmt.Sprintf("dsgvo-antrag-%d.pdf", req.ID) w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) _, _ = w.Write(pdf) if s.audlog != nil { s.audlog.Log(audit.Entry{ EventType: audit.EventExport, Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: fmt.Sprintf("dsgvo: export request_id=%d", req.ID), }) } } // buildDSGVOPDF renders a DSGVO request summary as a PDF using the existing // minimal PDF writer (PROJ-12 buildMailPDF pattern). func buildDSGVOPDF(req *storage.DSGVORequest) []byte { p := newPDFWriter() pageW := 595.28 pageH := 841.89 margin := 50.0 lineH := 14.0 smallLineH := 12.0 var cs bytes.Buffer y := pageH - margin newline := func(h float64) { y -= h } text := func(x, ypos, size float64, bold bool, s string) { font := "Helvetica" if bold { font = "Helvetica-Bold" } cs.WriteString(fmt.Sprintf("BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", font, size, x, ypos, escapePDF(toLatinSafe(s)))) } text(margin, y, 16, true, "DSGVO-Loeschersuchen - Bearbeitungsnachweis") newline(lineH * 2) text(margin, y, 10, true, "Antrags-ID:") text(margin+120, y, 10, false, strconv.FormatInt(req.ID, 10)) newline(smallLineH) text(margin, y, 10, true, "Betroffene Adresse:") text(margin+120, y, 10, false, req.RequestedAddress) newline(smallLineH) text(margin, y, 10, true, "Bearbeitet von:") text(margin+120, y, 10, false, req.RequestedBy) newline(smallLineH) text(margin, y, 10, true, "Erstellt am:") text(margin+120, y, 10, false, req.CreatedAt.Format("02.01.2006 15:04:05")) newline(smallLineH) text(margin, y, 10, true, "Status:") text(margin+120, y, 10, false, req.Status) newline(lineH * 1.5) if req.ResultSummary != nil { sum := req.ResultSummary text(margin, y, 12, true, "Zusammenfassung") newline(lineH) text(margin, y, 10, false, fmt.Sprintf("Treffer gesamt: %d", sum.TotalHits)) newline(smallLineH) text(margin, y, 10, false, fmt.Sprintf("Abgelehnt (Aufbewahrungspflicht): %d", sum.Rejected)) newline(smallLineH) text(margin, y, 10, false, fmt.Sprintf("Loeschbar: %d", sum.Deletable)) newline(smallLineH) text(margin, y, 10, false, fmt.Sprintf("Bereits geloescht: %d", sum.Deleted)) newline(smallLineH) if sum.Truncated { text(margin, y, 9, false, fmt.Sprintf("Hinweis: Ergebnis auf %d Treffer begrenzt - Zeitraum eingrenzen.", dsgvoMaxHits)) newline(smallLineH) } newline(6) text(margin, y, 12, true, "Begruendung der Rechtslage") newline(lineH) legal := "Soweit E-Mails einer gesetzlichen Aufbewahrungspflicht (GoBD/HGB/AO) " + "unterliegen, ueberwiegt diese den Loeschanspruch nach Art. 17 DSGVO. " + "Eine Loeschung erfolgt erst nach Ablauf der jeweiligen Aufbewahrungsfrist." for _, l := range wrapText(legal, 90) { text(margin, y, 9, false, l) newline(smallLineH) } newline(6) text(margin, y, 12, true, "Betroffene E-Mails") newline(lineH) for _, m := range sum.Mails { if y < margin+30 { text(margin, y, 8, false, "[... gekuerzt ...]") break } dateStr := "" if m.Date != nil { dateStr = m.Date.Format("2006-01-02") } subj := m.Subject if len(subj) > 60 { subj = subj[:60] } text(margin, y, 9, false, fmt.Sprintf("- [%s] %s", dateStr, subj)) newline(smallLineH) text(margin+12, y, 8, false, m.Reason) newline(smallLineH) } } contentStream := cs.String() p.write("%PDF-1.4\n") p.startObj(1) p.write("<< /Type /Catalog /Pages 2 0 R >>\n") p.endObj() p.startObj(2) p.writef("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 %.2f %.2f] >>\n", pageW, pageH) p.endObj() p.startObj(3) p.writef("<< /Type /Page /Parent 2 0 R\n") p.writef(" /Resources << /Font << /Helvetica << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n") p.writef(" /Helvetica-Bold << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> >>\n") p.writef(" /Contents 4 0 R\n") p.writef(" /MediaBox [0 0 %.2f %.2f]\n", pageW, pageH) p.write(">>\n") p.endObj() p.startObj(4) streamBytes := []byte("0.5 w\n" + contentStream) p.writef("<< /Length %d >>\n", len(streamBytes)) p.write("stream\n") p.buf.Write(streamBytes) p.write("\nendstream\n") p.endObj() xrefOffset := p.buf.Len() p.writef("xref\n0 5\n") p.writef("0000000000 65535 f \n") for i := 0; i < 4; i++ { off := 0 if i < len(p.offsets) { off = p.offsets[i] } p.writef("%010d 00000 n \n", off) } p.write("trailer\n") p.writef("<< /Size 5 /Root 1 0 R >>\n") p.write("startxref\n") p.writef("%d\n", xrefOffset) p.write("%%EOF\n") return p.buf.Bytes() }