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>
172 lines
5.1 KiB
Go
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})
|
|
}
|