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}) }