diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 08b4579..cba89ea 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -177,6 +177,7 @@ func main() { } srv := api.New(apiCfg, mailStore, idx, authMgr, users, audlog, logger) srv.SetVersion(AppVersion, Modules) + srv.SetGlobalRetentionDays(cfg.Storage.RetentionDays) bind := cfg.API.Bind if bind == "" { diff --git a/cmd/archivmail/version.go b/cmd/archivmail/version.go index 1318f11..dee9141 100644 --- a/cmd/archivmail/version.go +++ b/cmd/archivmail/version.go @@ -12,17 +12,17 @@ const AppVersion = "0.9.1" // MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls // MINOR: Neue Funktionen, Bugfixes, Security-Patches var Modules = map[string]string{ - "storage": "1.7", // PROJ-33 MailWithUID, GetMailsWithUID, GetMailsByRecipient + "storage": "1.8", // PROJ-34 per-tenant retention lookup in Save() "smtpd": "1.3", // PROJ-28 FQDN-Fallback für EHLO-Banner "imapserver": "1.3", // PROJ-28 FQDN-Greeting (RFC 3501) "auth": "1.3", // JWT, bcrypt cost 12, TOTP "audit": "1.1", // PostgreSQL append-only, QueryFilter "index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index - "api": "1.7", // PROJ-33 IMAP-Modus-Einstellungen + "api": "1.8", // PROJ-34 Retention-API + pro-Mandant Endpoints "userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth "imap": "1.2", // IMAP-Sync, Scheduler, POP3 "labelstore": "1.0", // Labels, Tenant-Isolation - "tenantstore": "1.2", // PROJ-33 imap_mode, GetIMAPMode, SetIMAPMode + "tenantstore": "1.3", // PROJ-34 retention_days, GetRetentionDays, SetRetentionDays "ldapconfig": "1.1", // Pro-Mandant LDAP, TLS "mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion } diff --git a/internal/api/retention_handlers.go b/internal/api/retention_handlers.go index 3bd0dab..f8c46f1 100644 --- a/internal/api/retention_handlers.go +++ b/internal/api/retention_handlers.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "net/http" + "strconv" ) // handlePurge deletes all mails whose retention period has expired. @@ -16,3 +18,51 @@ func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) { "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 := getSession(r) + if s.audlog != nil { + _ = s.audlog.Log(r.Context(), sess.UserID, "tenant_retention_changed", map[string]interface{}{ + "tenant_id": tenantID, + "retention_days": body.RetentionDays, + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true}) +} diff --git a/internal/api/server.go b/internal/api/server.go index bec29b1..e863333 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -78,8 +78,9 @@ type Server struct { tenantStore *tenantstore.Store tenantLdapStore *ldapcfg.TenantStore idxMgr *index.TenantIndexManager - appVersion string - moduleVersions map[string]string + appVersion string + moduleVersions map[string]string + globalRetentionDays int // from storage config (PROJ-34) } // SetSMTPDaemon wires the SMTP daemon into the API server after construction. @@ -111,6 +112,11 @@ func (s *Server) SetVersion(appVersion string, modules map[string]string) { s.moduleVersions = modules } +// SetGlobalRetentionDays wires the global retention_days from storage config into the API server. +func (s *Server) SetGlobalRetentionDays(days int) { + s.globalRetentionDays = days +} + // New creates and wires up a new API server. func New( cfg config.APIConfig, @@ -170,8 +176,10 @@ func (s *Server) routes() { // SEC-17: Security fix actions require superadmin, not just domain_admin. s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix))) - // PROJ-34: Retention purge — superadmin only + // PROJ-34: Retention — superadmin only 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-33: IMAP mode settings — domain_admin only s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode)) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1922cd9..c0304ea 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -324,9 +324,16 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int } else { s.insertMetaMinimal(ctx, id, len(raw), tenantID) } - // PROJ-34: Set retention lock if configured - if s.retentionDays > 0 { - until := time.Now().AddDate(0, 0, s.retentionDays) + // PROJ-34: Set retention lock — prefer per-tenant retention, fall back to global. + effectiveRetention := s.retentionDays + 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 { + effectiveRetention = tenantDays + } + } + if effectiveRetention > 0 { + until := time.Now().AddDate(0, 0, effectiveRetention) _, _ = s.db.Exec(ctx, `UPDATE emails SET retain_until=$1 WHERE id=$2 AND retain_until IS NULL`, until, id) } } diff --git a/internal/tenantstore/store.go b/internal/tenantstore/store.go index 77c5691..ea02945 100644 --- a/internal/tenantstore/store.go +++ b/internal/tenantstore/store.go @@ -20,11 +20,12 @@ type Tenant struct { Active bool `json:"active"` CreatedAt time.Time `json:"created_at"` // Computed fields populated by List. - DomainCount int `json:"domain_count,omitempty"` - UserCount int `json:"user_count,omitempty"` - LDAPEnabled *bool `json:"ldap_enabled,omitempty"` - LDAPURL string `json:"ldap_url,omitempty"` - HasLogo bool `json:"has_logo,omitempty"` + DomainCount int `json:"domain_count,omitempty"` + UserCount int `json:"user_count,omitempty"` + LDAPEnabled *bool `json:"ldap_enabled,omitempty"` + LDAPURL string `json:"ldap_url,omitempty"` + HasLogo bool `json:"has_logo,omitempty"` + RetentionDays int `json:"retention_days"` // 0 = use global config } // TenantDomain is an e-mail domain assigned to a tenant. @@ -75,6 +76,7 @@ ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenan ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT ''; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal'; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS retention_days INT NOT NULL DEFAULT 0; ` // New connects to PostgreSQL and initialises the tenant schema. @@ -125,7 +127,8 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) { COUNT(DISTINCT u.id) AS user_count, tl.enabled AS ldap_enabled, tl.url AS ldap_url, - (t.logo_data IS NOT NULL) AS has_logo + (t.logo_data IS NOT NULL) AS has_logo, + t.retention_days FROM tenants t LEFT JOIN tenant_domains td ON td.tenant_id = t.id LEFT JOIN users u ON u.tenant_id = t.id @@ -141,7 +144,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) { var tenants []Tenant for rows.Next() { var t Tenant - if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays); err != nil { return nil, fmt.Errorf("tenantstore: scan: %w", err) } tenants = append(tenants, t) @@ -348,3 +351,30 @@ func (s *Store) SetIMAPMode(ctx context.Context, tenantID int64, mode string) er _, err := s.pool.Exec(ctx, `UPDATE tenants SET imap_mode = $1 WHERE id = $2`, mode, tenantID) return err } + +// ── Retention ───────────────────────────────────────────────────────────────── + +// GetRetentionDays returns the per-tenant retention_days (0 = use global config). +func (s *Store) GetRetentionDays(ctx context.Context, tenantID int64) (int, error) { + var days int + err := s.pool.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id = $1`, tenantID).Scan(&days) + if err != nil { + return 0, nil // safe default: fall back to global + } + return days, nil +} + +// SetRetentionDays sets the per-tenant retention_days. 0 means "use global config". +func (s *Store) SetRetentionDays(ctx context.Context, tenantID int64, days int) error { + if days < 0 { + return fmt.Errorf("tenantstore: retention_days must be >= 0") + } + tag, err := s.pool.Exec(ctx, `UPDATE tenants SET retention_days = $1 WHERE id = $2`, days, tenantID) + if err != nil { + return fmt.Errorf("tenantstore: set retention: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("tenantstore: tenant %d not found", tenantID) + } + return nil +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 494f81a..3889c0d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -76,6 +76,7 @@ import { LabelsTab } from "@/components/admin/tabs/LabelsTab"; 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 { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs"; const AUDIT_PAGE_SIZE = 25; @@ -807,6 +808,7 @@ export default function AdminPage() { {isSuperAdmin && Security} {isSuperAdmin && Zertifikat} {isSuperAdmin && Mandanten} + {isSuperAdmin && Retention} {isSuperAdmin && Module} @@ -1085,6 +1087,12 @@ export default function AdminPage() { )} + {isSuperAdmin && ( + + + + )} + diff --git a/src/components/admin/tabs/RetentionTab.tsx b/src/components/admin/tabs/RetentionTab.tsx new file mode 100644 index 0000000..2c4cd63 --- /dev/null +++ b/src/components/admin/tabs/RetentionTab.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +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"; + +interface TenantRetention { + id: number; + name: string; + slug: string; + retention_days: number; +} + +interface RetentionData { + global_retention_days: number; + tenants: TenantRetention[]; +} + +async function fetchRetention(): Promise { + const res = await fetch("/api/admin/retention", { credentials: "include" }); + if (!res.ok) throw new Error("Fehler beim Laden"); + return res.json(); +} + +async function setTenantRetention(tenantID: number, days: number): Promise { + const res = await fetch(`/api/admin/tenant/${tenantID}/retention`, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ retention_days: days }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as { error?: string }).error ?? "Fehler beim Speichern"); + } +} + +async function triggerPurge(): Promise { + const res = await fetch("/api/admin/purge", { + method: "POST", + credentials: "include", + }); + if (!res.ok) throw new Error("Purge fehlgeschlagen"); + const data = await res.json(); + return data.deleted ?? 0; +} + +function retentionLabel(days: number, globalDays: number): React.ReactNode { + if (days === 0) { + return globalDays > 0 ? ( + + Global ({globalDays} Tage) + + ) : ( + Kein Lock + ); + } + return {days} Tage; +} + +export function RetentionTab() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + // edit state + const [editTenant, setEditTenant] = useState(null); + const [editDays, setEditDays] = useState(""); + const [saving, setSaving] = useState(false); + + // purge state + const [purgeOpen, setPurgeOpen] = useState(false); + const [purging, setPurging] = useState(false); + const [purgeResult, setPurgeResult] = useState(null); + + const load = useCallback(() => { + setLoading(true); + fetchRetention() + .then(setData) + .catch(() => setError("Retention-Daten konnten nicht geladen werden")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const handleEditOpen = (t: TenantRetention) => { + setEditTenant(t); + setEditDays(t.retention_days > 0 ? String(t.retention_days) : ""); + setSaving(false); + }; + + const handleEditSave = async () => { + if (!editTenant) return; + const days = editDays.trim() === "" ? 0 : parseInt(editDays, 10); + if (isNaN(days) || days < 0) return; + setSaving(true); + try { + await setTenantRetention(editTenant.id, days); + setEditTenant(null); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Fehler"); + } finally { + setSaving(false); + } + }; + + const handlePurge = async () => { + setPurging(true); + setPurgeResult(null); + try { + const deleted = await triggerPurge(); + setPurgeResult(deleted); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Purge fehlgeschlagen"); + setPurgeOpen(false); + } finally { + setPurging(false); + } + }; + + const globalDays = data?.global_retention_days ?? 0; + + return ( +
+ {/* Global Policy */} + + + Globale Retention-Policy + + +
+ Globale Aufbewahrungsfrist: + {globalDays > 0 ? ( + {globalDays} Tage ({Math.round(globalDays / 365)} Jahre) + ) : ( + Kein globaler Lock (config.yml) + )} +
+

+ Die globale Frist wird in config.yml → storage.retention_days konfiguriert. + Mandanten-spezifische Einstellungen überschreiben diese, wenn gesetzt. +

+ + {error &&

{error}

} +
+
+ + {/* Per-tenant table */} + + + Retention pro Mandant + + + {loading ? ( +

Lädt...

+ ) : ( + + + + Mandant + Aufbewahrungsfrist + + + + + {(data?.tenants ?? []).map((t) => ( + + {t.name} + {retentionLabel(t.retention_days, globalDays)} + + + + + ))} + +
+ )} +
+
+ + {/* Edit Dialog */} + { if (!o) setEditTenant(null); }}> + + + Retention für "{editTenant?.name}" + + Aufbewahrungsfrist in Tagen. 0 = globale Einstellung verwenden. + GoBD: mind. 10 Jahre = 3650 Tage. + + +
+ + setEditDays(e.target.value)} + /> + {editDays && parseInt(editDays) > 0 && ( +

+ ≈ {(parseInt(editDays) / 365).toFixed(1)} Jahre +

+ )} +
+ + + + +
+
+ + {/* Purge Dialog */} + + + + Abgelaufene Mails löschen + + Alle Mails, deren Aufbewahrungsfrist abgelaufen ist, werden unwiderruflich gelöscht. + Mails innerhalb der Frist bleiben erhalten. + + + {purgeResult !== null ? ( +

+ {purgeResult === 0 + ? "Keine abgelaufenen Mails gefunden." + : `${purgeResult} Mail(s) erfolgreich gelöscht.`} +

+ ) : ( +

+ Dieser Vorgang kann nicht rückgängig gemacht werden. +

+ )} + + + {purgeResult === null && ( + + )} + +
+
+
+ ); +}