# Plan: domain-stats-auto-ratelimit **Status:** candidate **Date:** 2026-08-17 **Version:** `1.x` MINOR; migrations must stay compatible with `1.0.0`. --- ## Goal Give the operator **30-day sending statistics** per domain and per application (total volume, peak and average rate), and an optional **auto** level-2 rate limit that sets `max_messages` from the average rate (avg × multiplier) over the level-1 window. ## Scope **In:** - Rolling **30-day** stats on each domain page (domain aggregate + per-app rows): message count, peak msg/h, average msg/h. - Level-2 rate limit mode **manual** (today) or **auto** for domain and application scopes. - Auto formula: `max_messages = ceil(avg_hourly × multiplier)`, window = level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); capped at level 1. - Background recalculation (e.g. every 6 h, alongside send-log prune); milter reads stored `max_messages` / `window_seconds` only (no aggregates on the hot path). - Panel UI: stats card, manual/auto toggle, multiplier field, read-only computed limit in auto mode, optional «Recalculate now». - RBAC: domain-admin sees stats and may configure auto/manual for assigned domains only; same authz as existing rate-limit handlers. - Tests, [guide.md](../guide.md), [CHANGELOG.md](../../CHANGELOG.md); security review (Fable) for rate-limit path changes. **Out:** - Changing level 1 (Postfix env) — auto only fills level 2 under the L1 cap. - Automated IP warmup schedules ([guide.md](../guide.md) § IP warmup stays operator-driven). - Prometheus/Grafana, alerting, APIs. - Counting level-1 refusals or `rejected` rows as sent volume. - Per-client-IP analytics. ## Data source All metrics come from SQLite `send_log`, same rules as [`CountMessages`](../../internal/store/ratelimits.go): - One message = one distinct `queue_id` (many recipients = one count). - `status != rejected` (level-2 refusals never queued). - Level-1 refusals are **not** in `send_log` — stats under-count refusals; document in UI copy. Retention today is env `SEND_LOG_RETENTION_DAYS` (default 90). Stats use the last **30 days** of rows still present. If retention < 30 days (after [send-log-retention](send-log-retention.md)), the stats window is `min(30, retention)` with a warning. ### Metrics | Metric | Definition | |---|---| | **total** | `COUNT(DISTINCT queue_id)` in the stats window | | **peak rate** | maximum messages in any **hourly** bucket in that window (msg/h) | | **avg rate** | `total / hours_in_window`, where `hours_in_window = min(720, age of domain/app in hours, retention hours)` | Keys: `send_log.domain` (domain scope), `send_log.app_login` (application scope). ## Architecture ```mermaid flowchart LR milter["journal-milter"] --> sendLog["send_log"] sendLog --> statsQuery["StatsQuery 30d"] statsQuery --> domainPage["domain page stats card"] statsQuery --> autoJob["auto recalc job"] autoJob --> rateLimits["rate_limits"] rateLimits --> milter ``` 1. **[`internal/store/stats.go`](../../internal/store/stats.go)** — `SendStats` with `Total`, `PeakPerHour`, `AvgPerHour`; `DomainSendStats(name, since)` / `AppSendStats(login, since)`. 2. Hourly buckets: `strftime('%Y-%m-%d %H', created_at)` + `GROUP BY`; subquery for peak; total via distinct `queue_id`. 3. Indexes `idx_send_log_domain` and `idx_send_log_created_at` exist; add composite `(domain, created_at)` only if profiling shows need. 4. **Auto recalc** — panel goroutine (same interval as send-log prune): for each `rate_limits` row with `mode = auto`, recompute `max_messages`, set `auto_updated_at`. Milter unchanged except reading new columns via existing `RateLimit` lookup. ### Auto rate limit Extend [`RateLimit`](../../internal/store/ratelimits.go): ```go type RateLimit struct { // existing: Scope, RefID, AllowedIPs, MaxMessages, WindowSeconds Mode string // "manual" | "auto" AutoMultiplier float64 // default 2.5 when Mode == "auto" AutoUpdatedAt time.Time } ``` **Formula:** ``` avg_hourly = total_messages_in_window / hours_in_window max_messages = ceil(avg_hourly * auto_multiplier) window_seconds = L1 window (not editable in auto mode) max_messages = min(max_messages, L1 max) ``` When `total == 0`: auto limit stays **inactive** (same as empty manual limit); UI explains that traffic is required before auto can apply. **Application overrides** ([`handlers_ratelimit.go`](../../internal/web/handlers/handlers_ratelimit.go)): - Trusted IPs required. - Auto app ceiling **strictly above** domain limit when domain limit is active. - Ceiling ≤ L1. **Fail-open:** store errors during recalc must not weaken enforcement of the last successfully written limit; recalc failures are logged only. ### Migration (`0006_rate_limit_auto.sql`) ```sql ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual' CHECK (mode IN ('manual', 'auto')); ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL; ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT; ``` Existing rows → `manual`. ### Domain export Today rate limits are **not** exported. This plan adds them (including `mode`, `auto_multiplier`) to domain transfer JSON — document as a boundary change in [guide.md](../guide.md) § Export. ## Panel UI - Domain page ([`domain_detail.html`](../../internal/web/view/templates/domain_detail.html)): - **Sending statistics (30 days)** — total, peak msg/h, avg msg/h. - Per-application stats in the app list. - Rate limit: Manual / Auto, multiplier (e.g. 1.5–5.0, default 2.5), read-only computed max/window in auto mode, «Recalculate now». - Optional later: «30d» column on domain list (global admin only). ## Tests - `internal/store/stats_test.go` — fixtures → total / peak / avg. - `internal/store/ratelimits_test.go` — auto recalc, L1 cap, app > domain. - Handler tests — auto form validation, multiplier bounds. - Milter tests — enforced limit matches last recalculated values. `go test` / `go vet` on touched packages. ## Done when - Domain and app 30-day stats visible on the domain page; domain-admin scoping enforced. - Manual/auto toggle works for domain and app; auto recalc updates `rate_limits` and milter enforces stored ceilings. - Zero-traffic auto stays inactive with clear UI copy. - [guide.md](../guide.md) and [CHANGELOG.md](../../CHANGELOG.md) updated; security review passed. ## Risks - Heavy aggregation on large `send_log` tables — mitigate with indexes or nightly rollups (phase 2). - Stats without level-1 visibility — mitigate with operator-facing caveat. - Auto limit too tight after a spike — multiplier is operator-tuned; show peak alongside avg in auto UI. ## Dependencies - [`send-log-retention`](send-log-retention.md) is a separate roadmap item but should land before or in parallel so operators can set retention ≥ 30 days from the panel. **Version:** `1.x` MINOR.