feat(PROJ-34): Retention-Tab + pro-Mandant Aufbewahrungsfristen
- tenantstore: retention_days Spalte, GetRetentionDays/SetRetentionDays
- storage.Save(): per-tenant retention überschreibt globale config
- API: GET /api/admin/retention, PUT /api/admin/tenant/{id}/retention
- Frontend: RetentionTab mit globaler Policy-Anzeige, Mandanten-Tabelle,
Bearbeiten-Dialog und Purge-Button (superadmin only)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
+11
-3
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user