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:
@@ -47,6 +47,7 @@ func runStatus(args []string) {
|
||||
checkStorage(cfg),
|
||||
checkEncryption(cfg),
|
||||
checkAuditLog(cfg),
|
||||
checkRetention(cfg),
|
||||
}
|
||||
|
||||
allOK := true
|
||||
@@ -227,6 +228,41 @@ func checkAuditLog(cfg *config.Config) checkResult {
|
||||
return checkResult{Name: "Audit-Log", OK: true, Detail: detail}
|
||||
}
|
||||
|
||||
// checkRetention warns (PROJ-51) when effectively no deletion lock is active:
|
||||
// global retention_days = 0 AND min_retention_days = 0 AND no tenant with
|
||||
// retention_days > 0 AND no archiving rule carrying a retention_days value.
|
||||
// Like the encryption check it never reports a hard error (OK stays true), it
|
||||
// only surfaces the GoBD-relevant state in the detail text.
|
||||
func checkRetention(cfg *config.Config) checkResult {
|
||||
global := cfg.Storage.RetentionDays
|
||||
minRet := cfg.Storage.MinRetentionDays
|
||||
if global > 0 || minRet > 0 {
|
||||
return checkResult{Name: "Retention", OK: true,
|
||||
Detail: fmt.Sprintf("Löschsperre aktiv (global=%d Tage, min=%d Tage)", global, minRet)}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, cfg.Database.DSN())
|
||||
if err != nil {
|
||||
return checkResult{Name: "Retention", OK: true,
|
||||
Detail: "DB nicht erreichbar — Tenant-/Regel-Aufbewahrung nicht prüfbar"}
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
var tenantLocks, ruleLocks int
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM tenants WHERE retention_days > 0`).Scan(&tenantLocks)
|
||||
_ = pool.QueryRow(ctx, `SELECT COUNT(*) FROM archiving_rules WHERE retention_days IS NOT NULL`).Scan(&ruleLocks)
|
||||
|
||||
if tenantLocks > 0 || ruleLocks > 0 {
|
||||
return checkResult{Name: "Retention", OK: true,
|
||||
Detail: fmt.Sprintf("Löschsperre aktiv (%d Mandant(en), %d Regel(n) mit Aufbewahrung)", tenantLocks, ruleLocks)}
|
||||
}
|
||||
|
||||
return checkResult{Name: "Retention", OK: true,
|
||||
Detail: "WARNUNG — keine Löschsperre aktiv: retention_days=0, min_retention_days=0, keine Mandanten-/Regel-Aufbewahrung (GoBD-relevant)"}
|
||||
}
|
||||
|
||||
func formatBytes(b uint64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
|
||||
@@ -124,8 +124,9 @@ func main() {
|
||||
Dir: cfg.Storage.StorePath,
|
||||
Keyfile: cfg.Storage.Keyfile,
|
||||
DSN: cfg.Database.DSN(),
|
||||
RetentionDays: cfg.Storage.RetentionDays,
|
||||
CompressEnabled: cfg.Storage.Compress,
|
||||
RetentionDays: cfg.Storage.RetentionDays,
|
||||
MinRetentionDays: cfg.Storage.MinRetentionDays,
|
||||
CompressEnabled: cfg.Storage.Compress,
|
||||
}
|
||||
mailStore, err := storage.New(storeCfg)
|
||||
if err != nil {
|
||||
@@ -556,6 +557,12 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w
|
||||
logger.Warn("backfill: save meta failed", "id", id, "err", err)
|
||||
}
|
||||
|
||||
// PROJ-51: (re-)apply retention for mails without a retain_until yet.
|
||||
// Only sets a value when retain_until IS NULL — never shortens an
|
||||
// existing lock (no retroactive shortening per spec).
|
||||
tenantID, _ := store.GetTenantForMail(ctx, id)
|
||||
store.ApplyRetentionBackfill(ctx, id, pm, tenantID)
|
||||
|
||||
// Check if already indexed
|
||||
alreadyIndexed, err := store.IsIndexed(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,6 +19,11 @@ storage:
|
||||
astore_path: /var/archivmail/astore
|
||||
xapian_path: /var/archivmail/xapian
|
||||
keyfile: /etc/archivmail/keyfile
|
||||
# PROJ-34: GoBD-Löschsperre in Tagen (0 = keine; z.B. 3650 für 10 Jahre)
|
||||
retention_days: 0
|
||||
# PROJ-51: globale Mindest-Aufbewahrungsfrist; Regeln/Mandanten dürfen nur
|
||||
# verlängern, nie verkürzen (0 = keine Mindestfrist)
|
||||
min_retention_days: 0
|
||||
|
||||
index:
|
||||
path: /var/archivmail/xapian
|
||||
|
||||
+3
-2
@@ -72,8 +72,9 @@ type StorageConfig struct {
|
||||
StorePath string `yaml:"store_path"`
|
||||
AStorePath string `yaml:"astore_path"`
|
||||
Keyfile string `yaml:"keyfile"`
|
||||
RetentionDays int `yaml:"retention_days"` // 0 = kein Lock (GoBD-Compliance: z.B. 3650 für 10 Jahre)
|
||||
Compress bool `yaml:"compress"` // gzip-Kompression vor AES-256-GCM (spart ~40-60% Disk)
|
||||
RetentionDays int `yaml:"retention_days"` // 0 = kein Lock (GoBD-Compliance: z.B. 3650 für 10 Jahre)
|
||||
MinRetentionDays int `yaml:"min_retention_days"` // PROJ-51: globale Mindestfrist; Regeln/Tenants dürfen nur verlängern (0 = keine)
|
||||
Compress bool `yaml:"compress"` // gzip-Kompression vor AES-256-GCM (spart ~40-60% Disk)
|
||||
}
|
||||
|
||||
// DatabaseConfig holds PostgreSQL connection settings.
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@
|
||||
| PROJ-48 | Audit-Log Unveränderbarkeit (Nachbesserung PROJ-11) | Deployed | [PROJ-48](PROJ-48-audit-log-unveraenderbarkeit.md) | 2026-06-13 |
|
||||
| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Deployed | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 |
|
||||
| PROJ-50 | DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang) | Planned | [PROJ-50](PROJ-50-dsgvo-loeschersuchen.md) | 2026-06-13 |
|
||||
| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | In Progress | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 |
|
||||
| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | In Review | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 |
|
||||
| PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 |
|
||||
|
||||
<!-- Add features above this line -->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivmail/pkg/mailparser"
|
||||
)
|
||||
|
||||
// Retention source markers stored in emails.retain_until_source for auditor
|
||||
// traceability (PROJ-51). The "rule:<id>" form is built dynamically.
|
||||
const (
|
||||
RetainSourceTenantDefault = "tenant_default"
|
||||
RetainSourceGlobalDefault = "global_default"
|
||||
RetainSourceMinRetention = "min_retention"
|
||||
)
|
||||
|
||||
// Archiving rule condition types (PROJ-51 / minimal PROJ-43).
|
||||
const (
|
||||
RuleCondSenderDomain = "sender_domain"
|
||||
RuleCondRecipientDomain = "recipient_domain"
|
||||
RuleCondSenderAddress = "sender_address"
|
||||
RuleCondRecipientAddr = "recipient_address"
|
||||
)
|
||||
|
||||
// ArchivingRule is a minimal PROJ-43 rule extended with a PROJ-51 retention
|
||||
// period. A rule with RetentionDays != nil determines the retention period of
|
||||
// every mail it matches (highest priority wins, longest on a tie).
|
||||
type ArchivingRule struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID *int64 `json:"tenant_id"` // nil = applies to all tenants
|
||||
CondType string `json:"condition_type"` // RuleCond*
|
||||
Pattern string `json:"pattern"` // e.g. "firma.de" or "rechnung@firma.de"
|
||||
Priority int `json:"priority"` // higher = evaluated first
|
||||
RetentionDays *int `json:"retention_days"` // nil = no rule-specific retention
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// minRetentionDays is the global floor (config storage.min_retention_days).
|
||||
// Rules and tenant settings may only extend (never shorten) the period below
|
||||
// this value (PROJ-51).
|
||||
func (s *Store) initRetentionRulesSchema(ctx context.Context) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS archiving_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
condition_type TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
retention_days INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
_, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_archiving_rules_priority ON archiving_rules (priority DESC)`)
|
||||
// PROJ-51: traceability for which source set retain_until.
|
||||
_, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS retain_until_source TEXT`)
|
||||
}
|
||||
|
||||
// SetMinRetentionDays wires the global minimum retention floor (PROJ-51).
|
||||
func (s *Store) SetMinRetentionDays(days int) {
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
s.minRetentionDays = days
|
||||
}
|
||||
|
||||
// MinRetentionDays returns the configured global minimum retention floor.
|
||||
func (s *Store) MinRetentionDays() int { return s.minRetentionDays }
|
||||
|
||||
// ── Rule CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
func validCondType(t string) bool {
|
||||
switch t {
|
||||
case RuleCondSenderDomain, RuleCondRecipientDomain, RuleCondSenderAddress, RuleCondRecipientAddr:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ListArchivingRules returns all rules ordered by priority (desc). If tenantID
|
||||
// is non-nil, only rules for that tenant plus global rules (tenant_id IS NULL)
|
||||
// are returned.
|
||||
func (s *Store) ListArchivingRules(ctx context.Context, tenantID *int64) ([]ArchivingRule, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("storage: no db")
|
||||
}
|
||||
query := `SELECT id, tenant_id, condition_type, pattern, priority, retention_days, created_at
|
||||
FROM archiving_rules`
|
||||
var args []interface{}
|
||||
if tenantID != nil {
|
||||
query += ` WHERE tenant_id = $1 OR tenant_id IS NULL`
|
||||
args = append(args, *tenantID)
|
||||
}
|
||||
query += ` ORDER BY priority DESC, id ASC`
|
||||
|
||||
rows, err := s.db.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: list rules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ArchivingRule
|
||||
for rows.Next() {
|
||||
var r ArchivingRule
|
||||
if err := rows.Scan(&r.ID, &r.TenantID, &r.CondType, &r.Pattern, &r.Priority, &r.RetentionDays, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("storage: scan rule: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateArchivingRule inserts a new rule and returns its generated ID.
|
||||
func (s *Store) CreateArchivingRule(ctx context.Context, r ArchivingRule) (int64, error) {
|
||||
if s.db == nil {
|
||||
return 0, fmt.Errorf("storage: no db")
|
||||
}
|
||||
if !validCondType(r.CondType) {
|
||||
return 0, fmt.Errorf("storage: invalid condition_type %q", r.CondType)
|
||||
}
|
||||
if strings.TrimSpace(r.Pattern) == "" {
|
||||
return 0, fmt.Errorf("storage: pattern must not be empty")
|
||||
}
|
||||
if r.RetentionDays != nil && *r.RetentionDays < 0 {
|
||||
return 0, fmt.Errorf("storage: retention_days must be >= 0")
|
||||
}
|
||||
var id int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO archiving_rules (tenant_id, condition_type, pattern, priority, retention_days)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
r.TenantID, r.CondType, strings.ToLower(strings.TrimSpace(r.Pattern)), r.Priority, r.RetentionDays,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("storage: create rule: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateArchivingRule replaces all editable fields of an existing rule.
|
||||
func (s *Store) UpdateArchivingRule(ctx context.Context, r ArchivingRule) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("storage: no db")
|
||||
}
|
||||
if !validCondType(r.CondType) {
|
||||
return fmt.Errorf("storage: invalid condition_type %q", r.CondType)
|
||||
}
|
||||
if strings.TrimSpace(r.Pattern) == "" {
|
||||
return fmt.Errorf("storage: pattern must not be empty")
|
||||
}
|
||||
if r.RetentionDays != nil && *r.RetentionDays < 0 {
|
||||
return fmt.Errorf("storage: retention_days must be >= 0")
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE archiving_rules
|
||||
SET tenant_id=$1, condition_type=$2, pattern=$3, priority=$4, retention_days=$5
|
||||
WHERE id=$6`,
|
||||
r.TenantID, r.CondType, strings.ToLower(strings.TrimSpace(r.Pattern)), r.Priority, r.RetentionDays, r.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: update rule: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: rule %d not found", r.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteArchivingRule removes a rule. Existing retain_until values on already
|
||||
// archived mails are intentionally left unchanged (PROJ-51 edge case: no
|
||||
// retroactive shortening).
|
||||
func (s *Store) DeleteArchivingRule(ctx context.Context, id int64) error {
|
||||
if s.db == nil {
|
||||
return fmt.Errorf("storage: no db")
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM archiving_rules WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: delete rule: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("storage: rule %d not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Retention computation ────────────────────────────────────────────────────
|
||||
|
||||
// ruleMatches reports whether a parsed mail matches a rule's condition.
|
||||
func ruleMatches(r ArchivingRule, pm *mailparser.ParsedMail) bool {
|
||||
if pm == nil {
|
||||
return false
|
||||
}
|
||||
pat := strings.ToLower(strings.TrimSpace(r.Pattern))
|
||||
if pat == "" {
|
||||
return false
|
||||
}
|
||||
domainOf := func(addr string) string {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
if i := strings.LastIndex(addr, "@"); i >= 0 {
|
||||
return addr[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch r.CondType {
|
||||
case RuleCondSenderAddress:
|
||||
return strings.ToLower(strings.TrimSpace(pm.From)) == pat
|
||||
case RuleCondSenderDomain:
|
||||
return domainOf(pm.From) == pat
|
||||
case RuleCondRecipientAddr:
|
||||
for _, t := range pm.To {
|
||||
if strings.ToLower(strings.TrimSpace(t)) == pat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, c := range pm.CC {
|
||||
if strings.ToLower(strings.TrimSpace(c)) == pat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case RuleCondRecipientDomain:
|
||||
for _, t := range pm.To {
|
||||
if domainOf(t) == pat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, c := range pm.CC {
|
||||
if domainOf(c) == pat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveRetention computes the retention period (in days), the source marker,
|
||||
// and whether any lock applies for a mail. It implements the PROJ-51 ordering:
|
||||
// 1. matching rule with retention_days (highest priority, longest on tie)
|
||||
// 2. tenant_default (tenants.retention_days > 0)
|
||||
// 3. global_default (s.retentionDays > 0, only when no tenant)
|
||||
// 4. min_retention floor — may only extend the above
|
||||
//
|
||||
// Returns days=0 and apply=false when no lock applies at all.
|
||||
func (s *Store) resolveRetention(ctx context.Context, pm *mailparser.ParsedMail, tenantID *int64) (days int, source string, apply bool) {
|
||||
// Step 1: matching rule
|
||||
if s.db != nil && pm != nil {
|
||||
rules, err := s.ListArchivingRules(ctx, tenantID)
|
||||
if err == nil {
|
||||
var best *ArchivingRule
|
||||
for i := range rules {
|
||||
r := rules[i]
|
||||
if r.RetentionDays == nil {
|
||||
continue
|
||||
}
|
||||
// Tenant scoping: a tenant-specific rule only applies to its own
|
||||
// tenant; a global rule (nil) applies everywhere.
|
||||
if r.TenantID != nil {
|
||||
if tenantID == nil || *r.TenantID != *tenantID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !ruleMatches(r, pm) {
|
||||
continue
|
||||
}
|
||||
if best == nil {
|
||||
rr := r
|
||||
best = &rr
|
||||
continue
|
||||
}
|
||||
// Highest priority wins; on tie the longest retention wins.
|
||||
if r.Priority > best.Priority ||
|
||||
(r.Priority == best.Priority && *r.RetentionDays > *best.RetentionDays) {
|
||||
rr := r
|
||||
best = &rr
|
||||
}
|
||||
}
|
||||
if best != nil {
|
||||
days = *best.RetentionDays
|
||||
source = fmt.Sprintf("rule:%d", best.ID)
|
||||
apply = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2/3: tenant / global default (only if no rule set a value)
|
||||
if !apply {
|
||||
if tenantID != nil && s.db != nil {
|
||||
var tenantDays int
|
||||
if err := s.db.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id=$1`, *tenantID).Scan(&tenantDays); err == nil && tenantDays > 0 {
|
||||
days = tenantDays
|
||||
source = RetainSourceTenantDefault
|
||||
apply = true
|
||||
}
|
||||
} else if tenantID == nil && s.retentionDays > 0 {
|
||||
days = s.retentionDays
|
||||
source = RetainSourceGlobalDefault
|
||||
apply = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: global minimum floor — may only extend.
|
||||
if s.minRetentionDays > 0 && s.minRetentionDays > days {
|
||||
days = s.minRetentionDays
|
||||
source = RetainSourceMinRetention
|
||||
apply = true
|
||||
}
|
||||
|
||||
return days, source, apply
|
||||
}
|
||||
|
||||
// applyRetention computes and writes retain_until + retain_until_source for a
|
||||
// freshly stored mail (PROJ-51). It never shortens an existing retain_until:
|
||||
// it only writes when retain_until IS NULL.
|
||||
func (s *Store) applyRetention(ctx context.Context, id string, pm *mailparser.ParsedMail, tenantID *int64) {
|
||||
if s.db == nil {
|
||||
return
|
||||
}
|
||||
days, source, apply := s.resolveRetention(ctx, pm, tenantID)
|
||||
if !apply || days <= 0 {
|
||||
return
|
||||
}
|
||||
until := time.Now().AddDate(0, 0, days)
|
||||
_, _ = s.db.Exec(ctx,
|
||||
`UPDATE emails SET retain_until=$1, retain_until_source=$2 WHERE id=$3 AND retain_until IS NULL`,
|
||||
until, source, id)
|
||||
}
|
||||
|
||||
// ApplyRetentionBackfill is the exported entry point for runBackfill (PROJ-34 /
|
||||
// PROJ-51). It (re-)evaluates rules, tenant/global defaults and the minimum
|
||||
// floor for an existing mail and sets retain_until only when it is still NULL.
|
||||
// It never shortens an existing lock.
|
||||
func (s *Store) ApplyRetentionBackfill(ctx context.Context, id string, pm *mailparser.ParsedMail, tenantID *int64) {
|
||||
s.applyRetention(ctx, id, pm, tenantID)
|
||||
}
|
||||
|
||||
// EffectiveLockActive reports whether any retention lock can currently apply
|
||||
// system-wide: a positive global default, a positive minimum floor, any tenant
|
||||
// with retention_days > 0, or any rule carrying a retention_days value. Used by
|
||||
// the healthcheck (PROJ-51) to warn when effectively no deletion lock is in
|
||||
// force.
|
||||
func (s *Store) EffectiveLockActive(ctx context.Context) (bool, error) {
|
||||
if s.retentionDays > 0 || s.minRetentionDays > 0 {
|
||||
return true, nil
|
||||
}
|
||||
if s.db == nil {
|
||||
return false, nil
|
||||
}
|
||||
var n int
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM tenants WHERE retention_days > 0`).Scan(&n); err == nil && n > 0 {
|
||||
return true, nil
|
||||
}
|
||||
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM archiving_rules WHERE retention_days IS NOT NULL`).Scan(&n); err == nil && n > 0 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetRetentionInfo returns the stored retain_until and its source marker for a
|
||||
// single mail (PROJ-51), for the admin/auditor detail view.
|
||||
func (s *Store) GetRetentionInfo(ctx context.Context, id string) (until *time.Time, source string, err error) {
|
||||
if s.db == nil {
|
||||
return nil, "", fmt.Errorf("storage: no db")
|
||||
}
|
||||
var src *string
|
||||
err = s.db.QueryRow(ctx,
|
||||
`SELECT retain_until, retain_until_source FROM emails WHERE id=$1`, id,
|
||||
).Scan(&until, &src)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("storage: retention info: %w", err)
|
||||
}
|
||||
if src != nil {
|
||||
source = *src
|
||||
}
|
||||
return until, source, nil
|
||||
}
|
||||
+11
-16
@@ -32,6 +32,7 @@ type Config struct {
|
||||
Keyfile string // path to 32-byte AES key file; empty = no encryption
|
||||
DSN string // PostgreSQL DSN; empty = no DB
|
||||
RetentionDays int // 0 = no lock; >0 = GoBD retention period in days
|
||||
MinRetentionDays int // PROJ-51: global minimum retention floor (0 = none)
|
||||
CompressEnabled bool // gzip-compress emails and attachments before encryption
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ type Store struct {
|
||||
key []byte // nil = no encryption
|
||||
db *pgxpool.Pool // nil = no DB
|
||||
retentionDays int // 0 = no lock
|
||||
minRetentionDays int // PROJ-51: global minimum retention floor (0 = none)
|
||||
compressEnabled bool // gzip before encryption
|
||||
}
|
||||
|
||||
@@ -72,7 +74,7 @@ func New(cfg Config) (*Store, error) {
|
||||
}
|
||||
}
|
||||
|
||||
s := &Store{dir: cfg.Dir, retentionDays: cfg.RetentionDays, compressEnabled: cfg.CompressEnabled}
|
||||
s := &Store{dir: cfg.Dir, retentionDays: cfg.RetentionDays, minRetentionDays: cfg.MinRetentionDays, compressEnabled: cfg.CompressEnabled}
|
||||
|
||||
// Load encryption key
|
||||
if err := s.loadKey(cfg.Keyfile); err != nil {
|
||||
@@ -103,6 +105,8 @@ func New(cfg Config) (*Store, error) {
|
||||
_, _ = s.db.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_emails_uid ON emails (uid)`)
|
||||
// 2.0: storage_objects FK on emails
|
||||
_, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS storage_id BIGINT REFERENCES storage_objects(id)`)
|
||||
// PROJ-51: archiving_rules table + retain_until_source column
|
||||
s.initRetentionRulesSchema(ctx)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
@@ -470,21 +474,12 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
||||
if parseErr == nil {
|
||||
_ = s.saveAttachments(ctx, id, pm)
|
||||
}
|
||||
// PROJ-34: Set retention lock.
|
||||
// Mandanten-Mails: nur wenn der Mandant explizit retention_days > 0 gesetzt hat.
|
||||
// Globale config greift NICHT automatisch — jeder Mandant muss selbst opt-in.
|
||||
// Mails ohne Mandant (tenantID == nil): globale config als Fallback.
|
||||
if tenantID != nil {
|
||||
var tenantDays int
|
||||
if err := s.db.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id=$1`, *tenantID).Scan(&tenantDays); err == nil && tenantDays > 0 {
|
||||
until := time.Now().AddDate(0, 0, tenantDays)
|
||||
_, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id)
|
||||
}
|
||||
// else: tenant hat retention_days=0 → kein Lock gesetzt → keine automatische Löschung
|
||||
} else if s.retentionDays > 0 {
|
||||
until := time.Now().AddDate(0, 0, s.retentionDays)
|
||||
_, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id)
|
||||
}
|
||||
// PROJ-34 + PROJ-51: Set retention lock.
|
||||
// Priority: matching archiving_rule with retention_days > tenant
|
||||
// default > global default, then raised by the global minimum floor.
|
||||
// Behaviour without rules/min stays identical to PROJ-34 (tenant
|
||||
// opt-in; global default only for tenant-less mails).
|
||||
s.applyRetention(ctx, id, pm, tenantID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import { CertTab } from "@/components/admin/tabs/CertTab";
|
||||
import { ModulesTab } from "@/components/admin/ModulesTab";
|
||||
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
||||
import { RetentionTab } from "@/components/admin/tabs/RetentionTab";
|
||||
import { ArchivingRulesTab } from "@/components/admin/tabs/ArchivingRulesTab";
|
||||
import { QuotaTab } from "@/components/admin/tabs/QuotaTab";
|
||||
import { SMTPOutTab } from "@/components/admin/tabs/SMTPOutTab";
|
||||
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
||||
@@ -712,6 +713,7 @@ export default function AdminPage() {
|
||||
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="retention">Retention</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="archiving-rules">Regeln</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="quotas">Quotas</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="smtp-out">SMTP-Out</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||
@@ -975,6 +977,11 @@ export default function AdminPage() {
|
||||
<RetentionTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="archiving-rules">
|
||||
<ArchivingRulesTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="smtp-out">
|
||||
|
||||
@@ -46,6 +46,23 @@ function formatDate(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// PROJ-51: human-readable label for the retain_until_source marker.
|
||||
function retentionSourceLabel(source: string): string {
|
||||
if (source.startsWith("rule:")) {
|
||||
return `Archivierungsregel #${source.slice(5)}`;
|
||||
}
|
||||
switch (source) {
|
||||
case "tenant_default":
|
||||
return "Mandanten-Standard";
|
||||
case "global_default":
|
||||
return "Globaler Standard";
|
||||
case "min_retention":
|
||||
return "Globale Mindestfrist";
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -115,6 +132,20 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) {
|
||||
)}
|
||||
<OcrBadge status={mail.ocr_status} />
|
||||
</span>
|
||||
{/* PROJ-51: retention lock + source for auditor traceability */}
|
||||
{mail.retain_until && (
|
||||
<>
|
||||
<span className="font-medium text-muted-foreground">Aufbewahrung:</span>
|
||||
<span className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>bis {formatDate(mail.retain_until)}</span>
|
||||
{mail.retain_until_source && (
|
||||
<Badge variant="secondary">
|
||||
{retentionSourceLabel(mail.retain_until_source)}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
getArchivingRules,
|
||||
createArchivingRule,
|
||||
updateArchivingRule,
|
||||
deleteArchivingRule,
|
||||
getTenants,
|
||||
type ArchivingRule,
|
||||
type ArchivingRuleInput,
|
||||
type RuleConditionType,
|
||||
type Tenant,
|
||||
} from "@/lib/api";
|
||||
|
||||
const COND_LABELS: Record<RuleConditionType, string> = {
|
||||
sender_domain: "Absender-Domain",
|
||||
recipient_domain: "Empfänger-Domain",
|
||||
sender_address: "Absender-Adresse",
|
||||
recipient_address: "Empfänger-Adresse",
|
||||
};
|
||||
|
||||
// PROJ-51 retention suggestion values.
|
||||
const RETENTION_PRESETS = [
|
||||
{ days: 2190, label: "6 Jahre (Handels-/Geschäftsbriefe)" },
|
||||
{ days: 3650, label: "10 Jahre (Rechnungen, Belege)" },
|
||||
{ days: 36500, label: "Dauerhaft (100 Jahre)" },
|
||||
];
|
||||
|
||||
function retentionLabel(days: number | null): React.ReactNode {
|
||||
if (days === null) {
|
||||
return <Badge variant="secondary">kein Regel-Wert</Badge>;
|
||||
}
|
||||
if (days >= 36500) {
|
||||
return <Badge>Dauerhaft</Badge>;
|
||||
}
|
||||
return <Badge>{days} Tage ({(days / 365).toFixed(1)} Jahre)</Badge>;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
id: number | null; // null = create
|
||||
tenant_id: string; // "" = alle Mandanten
|
||||
condition_type: RuleConditionType;
|
||||
pattern: string;
|
||||
priority: string;
|
||||
retention_days: string; // "" = kein Regel-Wert
|
||||
}
|
||||
|
||||
const EMPTY_EDIT: EditState = {
|
||||
id: null,
|
||||
tenant_id: "",
|
||||
condition_type: "sender_domain",
|
||||
pattern: "",
|
||||
priority: "0",
|
||||
retention_days: "",
|
||||
};
|
||||
|
||||
export function ArchivingRulesTab() {
|
||||
const [rules, setRules] = useState<ArchivingRule[]>([]);
|
||||
const [minRetentionDays, setMinRetentionDays] = useState(0);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [edit, setEdit] = useState<EditState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const [deleteRule, setDeleteRule] = useState<ArchivingRule | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
Promise.all([getArchivingRules(), getTenants()])
|
||||
.then(([data, ts]) => {
|
||||
setRules(data.rules);
|
||||
setMinRetentionDays(data.min_retention_days);
|
||||
setTenants(ts);
|
||||
})
|
||||
.catch(() => setError("Archivierungsregeln konnten nicht geladen werden"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const tenantName = (id: number | null): React.ReactNode => {
|
||||
if (id === null) return <span className="text-muted-foreground">Alle Mandanten</span>;
|
||||
const t = tenants.find((x) => x.id === id);
|
||||
return t ? t.name : `#${id}`;
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEdit({ ...EMPTY_EDIT });
|
||||
setFormError("");
|
||||
};
|
||||
|
||||
const openEdit = (r: ArchivingRule) => {
|
||||
setEdit({
|
||||
id: r.id,
|
||||
tenant_id: r.tenant_id === null ? "" : String(r.tenant_id),
|
||||
condition_type: r.condition_type,
|
||||
pattern: r.pattern,
|
||||
priority: String(r.priority),
|
||||
retention_days: r.retention_days === null ? "" : String(r.retention_days),
|
||||
});
|
||||
setFormError("");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!edit) return;
|
||||
const pattern = edit.pattern.trim();
|
||||
if (!pattern) {
|
||||
setFormError("Muster darf nicht leer sein");
|
||||
return;
|
||||
}
|
||||
const priority = parseInt(edit.priority, 10);
|
||||
if (isNaN(priority)) {
|
||||
setFormError("Priorität muss eine Zahl sein");
|
||||
return;
|
||||
}
|
||||
let retention_days: number | null = null;
|
||||
if (edit.retention_days.trim() !== "") {
|
||||
retention_days = parseInt(edit.retention_days, 10);
|
||||
if (isNaN(retention_days) || retention_days < 0) {
|
||||
setFormError("Aufbewahrung muss eine positive Zahl sein");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const input: ArchivingRuleInput = {
|
||||
tenant_id: edit.tenant_id === "" ? null : parseInt(edit.tenant_id, 10),
|
||||
condition_type: edit.condition_type,
|
||||
pattern,
|
||||
priority,
|
||||
retention_days,
|
||||
};
|
||||
setSaving(true);
|
||||
setFormError("");
|
||||
try {
|
||||
if (edit.id === null) {
|
||||
await createArchivingRule(input);
|
||||
} else {
|
||||
await updateArchivingRule(edit.id, input);
|
||||
}
|
||||
setEdit(null);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setFormError(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteRule) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteArchivingRule(deleteRule.id);
|
||||
setDeleteRule(null);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Löschen fehlgeschlagen");
|
||||
setDeleteRule(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Globale Mindest-Aufbewahrungsfrist</CardTitle>
|
||||
<CardDescription>
|
||||
Gilt systemweit als unterste Grenze. Regeln und Mandanten-Einstellungen dürfen die
|
||||
Frist nur verlängern, nie verkürzen. Konfiguration über{" "}
|
||||
<code className="text-xs">storage.min_retention_days</code> in der{" "}
|
||||
<code className="text-xs">config.yml</code>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-6 w-48" />
|
||||
) : minRetentionDays > 0 ? (
|
||||
<Badge>
|
||||
{minRetentionDays} Tage ({(minRetentionDays / 365).toFixed(1)} Jahre)
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Keine Mindestfrist (0)</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Archivierungsregeln</CardTitle>
|
||||
<CardDescription>
|
||||
Regeln mit Aufbewahrungsfrist bestimmen bei Treffer die Mindestaufbewahrung einer
|
||||
Mail (höchste Priorität gewinnt).
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
Regel hinzufügen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Noch keine Archivierungsregeln definiert.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Prio</TableHead>
|
||||
<TableHead>Bedingung</TableHead>
|
||||
<TableHead>Muster</TableHead>
|
||||
<TableHead>Mandant</TableHead>
|
||||
<TableHead>Aufbewahrung</TableHead>
|
||||
<TableHead className="w-32"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.priority}</TableCell>
|
||||
<TableCell>{COND_LABELS[r.condition_type] ?? r.condition_type}</TableCell>
|
||||
<TableCell className="font-mono text-xs break-all">{r.pattern}</TableCell>
|
||||
<TableCell>{tenantName(r.tenant_id)}</TableCell>
|
||||
<TableCell>{retentionLabel(r.retention_days)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(r)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => setDeleteRule(r)}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create / Edit Dialog */}
|
||||
<Dialog open={!!edit} onOpenChange={(o) => { if (!o) setEdit(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{edit?.id === null ? "Regel hinzufügen" : "Regel bearbeiten"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Legt fest, welche Mails von dieser Regel erfasst werden und – optional – wie lange
|
||||
sie aufbewahrt werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{edit && (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Bedingungstyp</Label>
|
||||
<Select
|
||||
value={edit.condition_type}
|
||||
onValueChange={(v) =>
|
||||
setEdit({ ...edit, condition_type: v as RuleConditionType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(COND_LABELS) as RuleConditionType[]).map((k) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
{COND_LABELS[k]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-pattern">Muster</Label>
|
||||
<Input
|
||||
id="rule-pattern"
|
||||
placeholder="z.B. firma.de oder rechnung@firma.de"
|
||||
value={edit.pattern}
|
||||
onChange={(e) => setEdit({ ...edit, pattern: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Mandant</Label>
|
||||
<Select
|
||||
value={edit.tenant_id === "" ? "__all__" : edit.tenant_id}
|
||||
onValueChange={(v) =>
|
||||
setEdit({ ...edit, tenant_id: v === "__all__" ? "" : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Alle Mandanten</SelectItem>
|
||||
{tenants.map((t) => (
|
||||
<SelectItem key={t.id} value={String(t.id)}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-priority">Priorität</Label>
|
||||
<Input
|
||||
id="rule-priority"
|
||||
type="number"
|
||||
value={edit.priority}
|
||||
onChange={(e) => setEdit({ ...edit, priority: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Höher = wird zuerst geprüft. Bei mehreren Treffern gewinnt die höchste Priorität.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-retention">Aufbewahrung (Tage)</Label>
|
||||
<Input
|
||||
id="rule-retention"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="leer = kein Regel-spezifischer Wert"
|
||||
value={edit.retention_days}
|
||||
onChange={(e) => setEdit({ ...edit, retention_days: e.target.value })}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{RETENTION_PRESETS.map((p) => (
|
||||
<Button
|
||||
key={p.days}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEdit({ ...edit, retention_days: String(p.days) })}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{edit.retention_days.trim() !== "" &&
|
||||
parseInt(edit.retention_days, 10) > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
≈ {(parseInt(edit.retention_days, 10) / 365).toFixed(1)} Jahre
|
||||
{minRetentionDays > 0 &&
|
||||
parseInt(edit.retention_days, 10) < minRetentionDays && (
|
||||
<span className="text-amber-600">
|
||||
{" "}
|
||||
– wird durch globale Mindestfrist ({minRetentionDays} Tage) angehoben
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leer = diese Regel setzt keine eigene Frist (Mandanten-/globaler Standard gilt).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formError && <p className="text-sm text-destructive">{formError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEdit(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={handleSave}>
|
||||
{saving ? "Speichern..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={!!deleteRule} onOpenChange={(o) => { if (!o) setDeleteRule(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regel löschen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die Regel wird gelöscht. Bereits gesetzte Aufbewahrungsfristen bestehender Mails
|
||||
bleiben unverändert.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteRule && (
|
||||
<p className="py-2 text-sm">
|
||||
<span className="font-mono">{deleteRule.pattern}</span> (
|
||||
{COND_LABELS[deleteRule.condition_type]})
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteRule(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
|
||||
{deleting ? "Lösche..." : "Löschen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { request } from "./core";
|
||||
|
||||
// PROJ-51: Archiving rules (minimal PROJ-43) with optional retention period.
|
||||
// Superadmin-only CRUD under /api/admin/archiving-rules.
|
||||
|
||||
// Condition types — must match storage.RuleCond* constants in the Go backend.
|
||||
export type RuleConditionType =
|
||||
| "sender_domain"
|
||||
| "recipient_domain"
|
||||
| "sender_address"
|
||||
| "recipient_address";
|
||||
|
||||
export interface ArchivingRule {
|
||||
id: number;
|
||||
tenant_id: number | null; // null = applies to all tenants
|
||||
condition_type: RuleConditionType;
|
||||
pattern: string;
|
||||
priority: number;
|
||||
retention_days: number | null; // null = no rule-specific retention
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ArchivingRulesResponse {
|
||||
rules: ArchivingRule[];
|
||||
min_retention_days: number;
|
||||
}
|
||||
|
||||
export interface ArchivingRuleInput {
|
||||
tenant_id: number | null;
|
||||
condition_type: RuleConditionType;
|
||||
pattern: string;
|
||||
priority: number;
|
||||
retention_days: number | null;
|
||||
}
|
||||
|
||||
export async function getArchivingRules(): Promise<ArchivingRulesResponse> {
|
||||
return request<ArchivingRulesResponse>("/api/admin/archiving-rules");
|
||||
}
|
||||
|
||||
export async function createArchivingRule(
|
||||
input: ArchivingRuleInput
|
||||
): Promise<{ id: number }> {
|
||||
return request<{ id: number }>("/api/admin/archiving-rules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateArchivingRule(
|
||||
id: number,
|
||||
input: ArchivingRuleInput
|
||||
): Promise<void> {
|
||||
await request<void>(`/api/admin/archiving-rules/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteArchivingRule(id: number): Promise<void> {
|
||||
await request<void>(`/api/admin/archiving-rules/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -164,6 +164,19 @@ export {
|
||||
requestACMECert,
|
||||
} from "./system";
|
||||
|
||||
export type {
|
||||
RuleConditionType,
|
||||
ArchivingRule,
|
||||
ArchivingRulesResponse,
|
||||
ArchivingRuleInput,
|
||||
} from "./archiving_rules";
|
||||
export {
|
||||
getArchivingRules,
|
||||
createArchivingRule,
|
||||
updateArchivingRule,
|
||||
deleteArchivingRule,
|
||||
} from "./archiving_rules";
|
||||
|
||||
export type { SavedSearch } from "./saved_searches";
|
||||
export {
|
||||
listSavedSearches,
|
||||
|
||||
@@ -73,6 +73,10 @@ export interface MailDetail {
|
||||
// PROJ-44: OCR status and extracted-text length
|
||||
ocr_status?: OCRStatus;
|
||||
ocr_chars?: number;
|
||||
// PROJ-51: retention lock + its source for auditor traceability.
|
||||
// retain_until_source: "rule:<id>" | "tenant_default" | "global_default" | "min_retention"
|
||||
retain_until?: string | null;
|
||||
retain_until_source?: string | null;
|
||||
}
|
||||
|
||||
export interface ImapFolder {
|
||||
|
||||
Reference in New Issue
Block a user