Something went wrong. Try again.
forked from scottlanoue.com/atmosphere-mail
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160// 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)}