Files
archivmail/internal/api/retention_handlers.go
T
sysopsandClaude Sonnet 4.6 b3ff8e6cf9 feat(PROJ-56c): Purge-Cron löscht nur explizit markierte Mails
Der automatische Purge-Cron darf nicht allein anhand des abgelaufenen
retain_until löschen — eine Mail muss zusätzlich von einem Admin im UI
zur Löschung markiert worden sein. Dafür: neue Spalten
marked_for_deletion(_by/_at) auf emails, Store.ListExpiredMarkedMailIDs()
(retain_until abgelaufen UND markiert) für den Cron-Pfad, und
Store.SetMarkedForDeletion() zum Setzen/Löschen der Markierung.

Neue Endpoints (domain_admin+, tenant-scoped):
- GET  /api/admin/retention/expired      Metadaten abgelaufener Mails
                                          (kein Body-Zugriff, SEC-29)
- PUT  /api/admin/mails/{id}/mark-deletion  Markierung setzen/entfernen,
                                          mit Audit-Log-Eintrag

RetentionTab.tsx zeigt abgelaufene Mails mit Checkbox zum Markieren.
Der bestehende manuelle "Jetzt löschen"-Button (/api/admin/purge) bleibt
unverändert und löscht weiterhin alle abgelaufenen Mails auf einen Klick —
nur der unbeaufsichtigte Cron-Job ist jetzt auf markierte Mails beschränkt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:40:18 +02:00

172 lines
5.1 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"archivmail/internal/audit"
)
// handleListExpiredMails returns metadata (no body content, SEC-29) for all
// mails whose retain_until has passed, so an admin can review and mark
// individual mails for the cron-driven purge (PROJ-56c).
// GET /api/admin/retention/expired — domain_admin+, tenant-scoped.
func (s *Server) handleListExpiredMails(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
mails, err := s.store.ListExpiredMails(r.Context(), sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type item struct {
ID string `json:"id"`
From string `json:"from"`
Subject string `json:"subject"`
ReceivedAt string `json:"received_at"`
RetainUntil string `json:"retain_until"`
Marked bool `json:"marked"`
MarkedBy string `json:"marked_by,omitempty"`
}
out := make([]item, len(mails))
for i, m := range mails {
out[i] = item{
ID: m.ID,
From: m.From,
Subject: m.Subject,
ReceivedAt: m.ReceivedAt.UTC().Format(time.RFC3339),
RetainUntil: m.RetainUntil.UTC().Format(time.RFC3339),
Marked: m.Marked,
MarkedBy: m.MarkedBy,
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"mails": out})
}
// handleSetMarkedForDeletion sets or clears marked_for_deletion for one mail
// (PROJ-56c). This is the only way a mail becomes eligible for the
// cron-driven purge — Store.ListExpiredMarkedMailIDs requires retain_until
// to have passed AND this flag to be set; an expired retention date alone
// is never enough to delete a mail unattended.
// PUT /api/admin/mails/{id}/mark-deletion — domain_admin+ only.
func (s *Server) handleSetMarkedForDeletion(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing mail id")
return
}
sess := sessionFromCtx(r.Context())
mailTenant, err := s.store.GetTenantForMail(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "mail not found")
return
}
if !tenantAccessAllowed(sess, mailTenant) {
writeError(w, http.StatusForbidden, "access denied")
return
}
var body struct {
Marked bool `json:"marked"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if err := s.store.SetMarkedForDeletion(r.Context(), id, body.Marked, sess.Username); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if s.audlog != nil {
eventType := "mail_marked_for_deletion"
detail := fmt.Sprintf("mail_id=%s zur Löschung markiert", id)
if !body.Marked {
eventType = "mail_unmarked_for_deletion"
detail = fmt.Sprintf("mail_id=%s Löschmarkierung entfernt", id)
}
s.audlog.Log(audit.Entry{
EventType: eventType,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
MailID: id,
Success: true,
Detail: detail,
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "marked": body.Marked})
}
// handlePurge deletes all mails whose retention period has expired.
// POST /api/admin/purge — superadmin only (PROJ-34).
func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
deleted, err := s.store.Purge(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": deleted,
})
}
// handleGetRetention returns the global retention config and per-tenant overrides.
// GET /api/admin/retention — superadmin only.
func (s *Server) handleGetRetention(w http.ResponseWriter, r *http.Request) {
tenants, err := s.tenantStore.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"global_retention_days": s.globalRetentionDays,
"tenants": tenants,
})
}
// handleSetTenantRetention sets retention_days for a specific tenant.
// PUT /api/admin/tenant/{id}/retention — superadmin only.
func (s *Server) handleSetTenantRetention(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
tenantID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
var body struct {
RetentionDays int `json:"retention_days"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if err := s.tenantStore.SetRetentionDays(r.Context(), tenantID, body.RetentionDays); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
sess := sessionFromCtx(r.Context())
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "tenant_retention_changed",
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
Detail: fmt.Sprintf("tenant_id=%d retention_days=%d", tenantID, body.RetentionDays),
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}