# migadu_counters Real-time mail counting for Migadu. mailqueuer tells it about every ingested recipient; it increments per-address / per-domain / per-organisation daily counters in Redis (the keys the web app already charts) and, when an increment reaches a configured limit, notifies the web app, which decides what happens to the customer. It replaces the rspamd Lua plugin `migadu_counters.lua` and the ledger-derived quota in stats-merger. It returns no verdicts and enforces nothing itself. Design record: `plans/2026-10-13-migadu-counters.md`. ``` sender ──SMTP──▶ mizu ──POST /ingest──▶ mailqueuer │ per recipient, after a successful publish: │ fire-and-forget HTTP call, reply ignored ▼ POST /out {"trace_id","auth_user","mail_from","recipient"} ← X-Auth-User set POST /in {"trace_id","mail_from","recipient","junk"} ← X-Auth-User empty Authorization: Bearer ▼ ┌─────────────────────────────────────┐ rspamd_domains_db ───▶│ migadu_counters (:5555, admin :9095)│ (domain → org|limits) │ validate → 202 → queue → workers: │ │ lookup domain → MULTI/INCR → notify │──▶ GET admin.migadu.com/api/limits └──────────────────┬──────────────────┘ (on the increment that crosses a limit) ▼ Redis Sentinel: writes → master, reads → replicas DB 0: cnt///d// (TTL 31 d) counterctl --address x@y [--date YYYY-MM-DD] ──GET /counters──▶ migadu_counters ──▶ replicas ``` ## API (for mailqueuer) Every request: `Authorization: Bearer ` (same value in mailqueuer's config; the daemon refuses to start without one), `Content-Type: application/json`, body ≤ 64 KB, one JSON object. Unknown fields are ignored. mailqueuer never waits for or acts on the reply. Counting is **decoupled from the request**: `/in` and `/out` validate the body, enqueue a job and answer `202` at once; a worker pool does the CDB lookup, the Redis transaction and the notification under its own time budget, so a client that hangs up or times out can no longer cancel a count. The counted values appear in the counting log and in `GET /counters`, not in the reply. | | | |---|---| | `POST /out` | `{"trace_id":"…","auth_user":"x@example.com","mail_from":"x@example.com","recipient":"y@example.com"}` — one call per recipient of an authenticated submission. Sender A/D/O **outgoing** +1 (if the sender's domain is hosted), recipient A/D/O **incoming** +1 (if the recipient's domain is hosted). `auth_user` and `recipient` are validated independently: whichever side is a usable address is counted, the other is logged as `not usable`; `400` only when neither is. A support/token login `user@dom@SUFFIX` counts under `user@dom` (the suffix is logged). | | `POST /in` | `{"trace_id":"…","mail_from":"s@gmail.com","recipient":"y@example.com","junk":false}` — one call per recipient of inbound mail. Recipient A/D/O **incoming** +1 if hosted. `junk:true` is skipped unless `count_junk = true`. `mail_from` may be `""` (bounce) or anything mizu accepted — it only feeds the exempt check and never rejects the request. | | `GET /counters?address=…` or `?domain=…` `[&date=YYYY-MM-DD]` | Today's (UTC) or a given day's values and limits, read from the replicas. | Responses: `202 {"accepted":true}` (validated and queued; skips such as exempt/junk/unknown domain are decided by the worker and appear only in the counting log), `4xx {"error":"…"}` from the validation layer (`405` method, `413` size, `401` token, `403` source IP, `415` content type, `400` JSON / required fields / e-mail syntax), `503 {"error":"queue full"}` when the counting queue (`queue_size`) is full — the drop is counted in `dropped_total{route}` and logged — or `503` on a handler timeout of the synchronous part. Rules: addresses are trimmed and lowercased, Unicode domains are converted to punycode (the CDB key), and a plus-address tag is dropped so `b+tag@…` counts against the mailbox `b@…` (the counter the web app reads); mail to or from an exempt address (`support@migadu.com`, `contact@migadu.com`) is never counted; a domain that is not in `rspamd_domains_db` is not hosted here and not counted; the sender of `/in` mail is never counted; there is no reject/soft-reject — counting continues past the limit. ## Limits and keys Limits come from the rspamd domains CDB (tinycdb, built by the agent): `example.com → 1049|O/i=200,O/o=20,D/i=200,D/o=20,A/i=200,A/o=20,info@/o=5`. `@/` overrides the `A/` default for one mailbox; a missing key means unlimited. The file is reloaded on SIGHUP and automatically when it changes on disk (atomic `mv` is detected); a file that cannot be read or is empty is rejected and the current table kept. A row that exists but cannot be parsed (non-numeric org id) is treated as "not hosted" for that domain and warned about in the app log; the other side of the request is still counted. Always replace the file with `mv` (as the agent's `cdb -c` + `mv` do) — rewriting it in place truncates the inode the daemon holds open and breaks lookups until the next reload. Redis keys are exactly the web app's (`migadu_web` `core/redis_sentinel.ex`): `cnt///d//` in **DB 0**, UTC day, TTL 31 days, `INCR`+`EXPIRE` in one MULTI/EXEC. The web app may `DEL` a day key to reset a customer; the next crossing notifies again. Redis failures are handled per outcome: a key whose own `INCR` fails (it holds a non-number) is logged `redis error on key …` while the other keys are counted and checked for crossings; when the EXEC reply is lost after the write (timeout, reset) the keys are re-read once from the master and the block says `redis outcome unknown … re-read from master`, with a crossing reported when `count >= limit`; a definite failure (connection refused) logs `redis error: … (nothing counted)`. The CDB is pulled from the agent host by `contrib/pull-domains.sh` (see `contrib/README.md`): rsync, compare, verify, atomic `mv`, every 15 s. A limit is "reached" by the increment that moves the counter from below the limit to at or above it (`old < limit ≤ new`). Because INCRBY is atomic exactly one increment crosses, so the web app is notified once per level/id/flow/day — `GET ` with `$LEVEL $ID $FLOW $PERIOD(=d) $ADDRESS $COUNT $LIMIT` substituted (URL-escaped), basic auth, in the background, 5 attempts with exponential backoff; 4xx is not retried. An empty URL disables notifications for that level. ## Counting transaction log Every request writes one block in the shape of the old Lua plugin's log lines (rspamd's task id replaced by the trace id) to `logging.counting_output`: ``` 2026-10-06 01:12:06.412 out; request auth_user=noah@example.com recipient=y@example.org mail_from=noah@example.com 2026-10-06 01:12:06.412 out; local domain: example.com account_id=116681 2026-10-06 01:12:06.412 out; domain: example.com limits_map={A/i=500, A/o=100, D/i=10000, D/o=2200, O/i=10000, O/o=2200} 2026-10-06 01:12:06.412 out; determined limit (level=[A] flow=[o] key=[cnt/A/noah@example.com/d/2026-10-06/o]) => 100 2026-10-06 01:12:06.412 out; recipient domain not in domains db: example.org (not counted) 2026-10-06 01:12:06.414 out; tracked address counters for noah@example.com => count=12 limit=100 reached=false 2026-10-06 01:12:06.414 out; tracked account counters for 116681 => count=2200 limit=2200 reached=true 2026-10-06 01:12:06.414 out; limit reached (level=[O] id=[116681] flow=[o] by=[noah@example.com]) => notifying org_url 2026-10-06 01:12:06.903 out; sent limit exceeded notification to url "https://admin.migadu.com/api/limits?…" ``` `grep ''` returns one transaction; `grep 'skipped:'` every request that counted nothing; `grep 'not usable'` every side that failed validation (`sender address not usable: nobody (invalid e-mail address, not counted)`); `grep 'dropped:'` every request refused by a full queue. A token login shows as `… login_suffix=SUPPORTTOKEN` on the request line. `counting_format = "json"` emits the same events as one JSON object per line. Domains with thousands of per-mailbox overrides print the base keys plus the address's own overrides and a count of the rest. ## Running ``` migadu_counters -config /usr/local/etc/migadu_counters/config.toml # default paths: /etc/…, /usr/local/etc/… migadu_counters -version ``` Foreground process; two listeners: `addr` (API, default `:5555`) and `admin_addr` (`/health`, `/metrics`, no auth). Signals: `SIGHUP` reopens both log files and reloads the CDB; `SIGTERM`/`SIGINT` stop accepting, drain the counting queue (every accepted request is counted), wait for in-flight notifications (≤ 30 s) and exit 0. On FreeBSD run it like rcptd: an rc.d script with `/usr/sbin/daemon -f -H -p -o `; `service migadu_counters reload` sends SIGHUP. There is deliberately no self-daemonising or pidfile option (daemon(8) owns both). Startup fails without a readable CDB; an unreachable Redis is only warned about (`/health` answers 503 until it is back). See `config.toml.example` for every option. `auth_token` is mandatory and unknown keys (typos such as `auth-token`) are fatal at startup. Secrets may come from the environment: `MIGADU_COUNTERS_AUTH_TOKEN`, `MIGADU_COUNTERS_REDIS_PASSWORD`, `MIGADU_COUNTERS_NOTIFY_PASSWORD`. `redis.db` must stay 0. Production uses Sentinel (`sentinels`, `master_name = "mymaster"`): writes go to the master, reads to a replica (falling back to the master when none is known); without `sentinels` the static `write_addr`/`read_addrs` are used. Metrics (`/metrics` on the admin port): `migadu_counters_requests_total{route,status}`, `_request_duration_seconds{route}`, `_increments_total{level,flow}`, `_limit_reached_total{level,flow}`, `_notifications_total{level,result}`, `_redis_errors_total{op}` (`incr`, `incr_unknown`, `mget`, `ping`), `_dropped_total{route}`, `_queue_depth`, `_queue_wait_seconds`, `_cdb_domains`, `_cdb_reloads_total{result}`. ## counterctl ``` counterctl --address me@example.com # today, UTC counterctl --address me@example.com --date 2026-10-12 counterctl --address me@example.com --out # outgoing columns only (--in: incoming) counterctl --domain example.com --json ``` Reads `addr`/`auth_token` from the daemon's config (`--config`, default paths, `MIGADU_COUNTERS_AUTH_TOKEN` overrides the token; `--url` overrides the address) and calls `GET /counters`. Output: ``` me@example.com date 2026-10-13 (UTC) domain example.com org 1234 LEVEL ID IN LIMIT OUT LIMIT A me@example.com 12 500 200! 200 D example.com 40 1000 9 - O 1234 120 5000 30 1000 ``` `!` marks a value at or above its limit, `-` an unlimited level. `--in` / `--out` restrict the table to one flow (`LEVEL ID IN LIMIT` or `LEVEL ID OUT LIMIT`; both or neither show the full table; `--json` ignores them). `--date` takes an ISO 8601 calendar date (`YYYY-MM-DD`); future dates are refused and dates older than 31 days are warned about (keys have expired). Exit 0 on success, 1 on any error. ## Development ``` make build # bin/migadu_counters, bin/counterctl make test # go test -race ./... (tests use test_files/rspamd_domains_db.cdb and miniredis) make vet # go vet + gofmt make test-contrib # shell test for contrib/pull-domains.sh (needs tinycdb cdb + rsync) make freebsd # static FreeBSD amd64 binaries for the Ansible role ``` Manual check with a local Redis: `redis-server --port 6379`, a config in static mode (`write_addr = "127.0.0.1:6379"`) pointing `domains_cdb` at `test_files/rspamd_domains_db.cdb`, then ``` curl -s -XPOST localhost:5555/out -H 'Authorization: Bearer ' -H 'Content-Type: application/json' \ -d '{"trace_id":"t1","auth_user":"a@yagolivas.com","recipient":"x@gmail.com"}' # -> {"accepted":true} redis-cli -n 0 KEYS 'cnt/*' go run ./cmd/counterctl -config config.toml --address a@yagolivas.com --out ```