// SPDX-License-Identifier: AGPL-3.0-or-later package admin import ( "encoding/json" "net/http" "time" didpkg "comail/internal/did" ) // --- Stats --- // handleMigrateForwardToEncryption handles POST /admin/migrate-forward-to-encryption. // Admin-authenticated. Rotates any legacy plaintext `forward_to` cells // in member_domains into AES-256-GCM ENC: blobs in place. Idempotent // (safe to re-run). Returns {rotated, skipped} as JSON. internal ref. // // Gated behind enableMigrations — when the operator hasn't opted into // migration mode (the default), the endpoint returns 404 so a probing // scan cannot confirm it exists. func (a *API) handleMigrateForwardToEncryption(w http.ResponseWriter, r *http.Request) { if !a.enableMigrations { http.NotFound(w, r) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !a.requireAuth(w, r) { return } res, err := a.store.RotateLegacyForwardToRows(r.Context()) if err != nil { http.Error(w, "migration failed: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) } // handleMigrateDKIMEncryption handles POST /admin/migrate-dkim-encryption. // Admin-authenticated. Rotates any legacy plaintext DKIM private-key // rows in member_domains into AES-256-GCM blobs in-place. Idempotent // (safe to re-run). Returns {rotated, skipped} as JSON. internal ref. // // Gated behind enableMigrations — same rationale as the forward-to // counterpart above. Default-off; operator turns it on for the // migration window only. func (a *API) handleMigrateDKIMEncryption(w http.ResponseWriter, r *http.Request) { if !a.enableMigrations { http.NotFound(w, r) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !a.requireAuth(w, r) { return } res, err := a.store.RotateLegacyDKIMRows(r.Context()) if err != nil { // The most likely failure here is "no PIIKey configured" — // surface the underlying message so the operator can diagnose // without hunting through logs. http.Error(w, "migration failed: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) } func (a *API) handleStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !a.requireAuth(w, r) { return } stats, err := a.store.Stats(r.Context()) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]int64{ "members": stats.Members, "domains": stats.Domains, "messages": stats.Messages, }) } // senderReputationDefaultWindow is the default rolling window for the // reputation rollup if the caller does not pass `?since=`. 30 days // matches the postmaster-industry convention for sender-reputation // scoring (Gmail, Outlook, Yahoo) and is the window used by the // clean-sender label computation. const senderReputationDefaultWindow = 30 * 24 * time.Hour // senderReputationMaxWindow caps the lookback to a year — beyond that // the underlying tables thin out (relay_events 30d retention per // privacy policy §3) and the result becomes meaningless. Bounding it // also prevents a runaway full-table scan from a malformed request. const senderReputationMaxWindow = 365 * 24 * time.Hour // handleSenderReputation serves GET /admin/sender-reputation?did=did:plc:...&since=RFC3339. // Admin-authenticated. Returns the per-DID rollup of total sends, // bounces, complaints, and current suspension status over the window. // // `since` is optional and defaults to 30 days ago; if provided it must // parse as RFC3339 and not be older than senderReputationMaxWindow. func (a *API) handleSenderReputation(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !a.requireAuth(w, r) { return } did := r.URL.Query().Get("did") if did == "" { http.Error(w, "missing required query param: did", http.StatusBadRequest) return } if !didpkg.Valid(did) { http.Error(w, "invalid did format", http.StatusBadRequest) return } since := time.Now().UTC().Add(-senderReputationDefaultWindow) if raw := r.URL.Query().Get("since"); raw != "" { t, err := time.Parse(time.RFC3339, raw) if err != nil { http.Error(w, "since must be RFC3339", http.StatusBadRequest) return } if time.Since(t) > senderReputationMaxWindow { http.Error(w, "since exceeds max lookback (365d)", http.StatusBadRequest) return } since = t.UTC() } rep, err := a.store.SenderReputation(r.Context(), did, since) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(rep) }