feat(PROJ-51): Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien)

Fuehrt archiving_rules ein (PROJ-43-Basis: Tabelle + CRUD-API + Admin-UI) und
erweitert die Retention-Logik (PROJ-34) um Regel-basierte Fristen, eine
globale Mindestfrist (min_retention_days) sowie Nachvollziehbarkeit der
Frist-Quelle (retain_until_source) in API und Mail-Detailansicht.
This commit is contained in:
sysops
2026-06-13 20:48:16 +02:00
parent 7c08ebe1b7
commit 507dee6431
16 changed files with 1175 additions and 21 deletions
+137
View File
@@ -0,0 +1,137 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"archivmail/internal/audit"
"archivmail/internal/storage"
)
// PROJ-51: Archiving rules CRUD (minimal PROJ-43). Rules optionally carry a
// retention_days value that determines a matching mail's retention period.
// Superadmin only — global, cross-tenant configuration.
type archivingRuleBody struct {
TenantID *int64 `json:"tenant_id"`
CondType string `json:"condition_type"`
Pattern string `json:"pattern"`
Priority int `json:"priority"`
RetentionDays *int `json:"retention_days"`
}
// handleListArchivingRules returns all archiving rules.
// GET /api/admin/archiving-rules — superadmin only.
func (s *Server) handleListArchivingRules(w http.ResponseWriter, r *http.Request) {
rules, err := s.store.ListArchivingRules(r.Context(), nil)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if rules == nil {
rules = []storage.ArchivingRule{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"rules": rules,
"min_retention_days": s.store.MinRetentionDays(),
})
}
// handleCreateArchivingRule creates a new archiving rule.
// POST /api/admin/archiving-rules — superadmin only.
func (s *Server) handleCreateArchivingRule(w http.ResponseWriter, r *http.Request) {
var body archivingRuleBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
id, err := s.store.CreateArchivingRule(r.Context(), storage.ArchivingRule{
TenantID: body.TenantID,
CondType: body.CondType,
Pattern: body.Pattern,
Priority: body.Priority,
RetentionDays: body.RetentionDays,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.auditRule(r, "archiving_rule_created", fmt.Sprintf("id=%d type=%s pattern=%s retention_days=%s",
id, body.CondType, body.Pattern, retDaysStr(body.RetentionDays)))
writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id})
}
// handleUpdateArchivingRule updates an existing archiving rule.
// PUT /api/admin/archiving-rules/{id} — superadmin only.
func (s *Server) handleUpdateArchivingRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid rule id")
return
}
var body archivingRuleBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if err := s.store.UpdateArchivingRule(r.Context(), storage.ArchivingRule{
ID: id,
TenantID: body.TenantID,
CondType: body.CondType,
Pattern: body.Pattern,
Priority: body.Priority,
RetentionDays: body.RetentionDays,
}); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.auditRule(r, "archiving_rule_updated", fmt.Sprintf("id=%d type=%s pattern=%s retention_days=%s",
id, body.CondType, body.Pattern, retDaysStr(body.RetentionDays)))
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
// handleDeleteArchivingRule deletes an archiving rule. Existing retain_until
// values on already-archived mails are left unchanged (PROJ-51).
// DELETE /api/admin/archiving-rules/{id} — superadmin only.
func (s *Server) handleDeleteArchivingRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid rule id")
return
}
if err := s.store.DeleteArchivingRule(r.Context(), id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
s.auditRule(r, "archiving_rule_deleted", fmt.Sprintf("id=%d", id))
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
func (s *Server) auditRule(r *http.Request, event, detail string) {
if s.audlog == nil {
return
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: event,
Username: sess.Username,
IPAddress: s.remoteIP(r),
Success: true,
Detail: detail,
})
}
func retDaysStr(d *int) string {
if d == nil {
return "none"
}
return strconv.Itoa(*d)
}
+14
View File
@@ -325,6 +325,18 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
ocrStatus = "pending"
}
// PROJ-51: retention lock + its source for auditor traceability.
var retainUntil interface{} = nil
var retainSource interface{} = nil
if until, source, rerr := s.store.GetRetentionInfo(r.Context(), id); rerr == nil {
if until != nil {
retainUntil = until.UTC().Format(time.RFC3339)
}
if source != "" {
retainSource = source
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": id,
"from": pm.From,
@@ -342,6 +354,8 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
"thread_id": threadID,
"ocr_status": ocrStatus,
"ocr_chars": ocrChars,
"retain_until": retainUntil,
"retain_until_source": retainSource,
})
}
+5
View File
@@ -230,6 +230,11 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge)))
s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention)))
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention)))
// PROJ-51: Archiving rules (retention categories) CRUD — superadmin only.
s.mux.HandleFunc("GET /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleListArchivingRules)))
s.mux.HandleFunc("POST /api/admin/archiving-rules", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCreateArchivingRule)))
s.mux.HandleFunc("PUT /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleUpdateArchivingRule)))
s.mux.HandleFunc("DELETE /api/admin/archiving-rules/{id}", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleDeleteArchivingRule)))
// SMTP-Out Relay Konfiguration — superadmin only
s.mux.HandleFunc("GET /api/admin/smtp-out", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetSMTPOut)))