35 KiB
Plan: migadu_counters — real-time mail counting service (Go)
Context
Daily send/receive limits used to be counted and enforced by the rspamd Lua plugin
ansible-freebsd2/roles/rspamd-postfix/templates/rspamd/plugins.d/migadu_counters.lua
(Redis counters per address + org, soft-reject/reject on breach, web app notified once).
Mail now flows mizu → mailqueuer and bypasses rspamd, so nothing counts in real time.
stats-merger/internal/quota recomputes usage from the delivery ledger after each merge, but it is
reporting-only today: only fernrohr reads /api/quota/{status,stats,overquota}; nothing calls /check.
migadu_counters is a new standalone Go service that becomes the single counting source and the
enforcement trigger:
- mailqueuer calls it fire-and-forget, once per ingested recipient, from its publish loop;
- one route per flow:
POST /in,POST /out(/rellater, not planned here); - it increments Redis counters at Address / Domain / Organization level, in the key format the web app already reads. The domain level is turned back on (Lua defined it but never tracked it);
- when an increment crosses a limit it notifies the web app (
migadu_webGET /api/limits), which decides what to do (notify the user, remove sending/receiving); - a small CLI,
counterctl, shows an address's incoming/outgoing counters for today or a given date (see "CLI: counterctl" below).
The service returns no verdicts and enforces nothing itself.
Out of scope: /rel, the mailqueuer client change, retiring stats-merger quota, the web app's D branch,
deployment (see prerequisites at the end).
How it fits together
sender ──SMTP──▶ mizu ──POST /ingest──▶ mailqueuer
X-Trace-Id, X-Mail-From, │ one queue message per recipient;
X-Mail-To, X-Auth-User, │ after each successful publish:
X-Junk │ 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 <token> (mailqueuer sends it, migadu_counters checks it; 401 if wrong)
│
▼
┌─────────────────────────────────────┐
│ migadu_counters (daemon :8095) │
│ ┌─────────────────────────────────┐ │ rejected here, nothing counted:
│ │ HTTP validation layer │ │ 405 method · 413 body > 64 KB
│ │ method → size → bearer token → │ │ 401 bad/missing token · 403 source IP
│ │ source IP → content-type → JSON │ │ 415 content-type · 400 json / fields /
│ │ → fields → e-mail syntax │ │ e-mail syntax · 503 handler timeout
│ └───────────────┬─────────────────┘ │
│ ▼ validated request │
rspamd_domains_db ───▶│ counting: 1. domain in CDB? → org_id, limits │
(CDB: domain → │ 2. MULTI: INCR A/D/O keys, EXPIRE 31 d │
org_id | limits) │ 3. did this INCR cross a limit? → notify │
└───┬─────────────────────────┬───────┘ admin :9095 /health /metrics
│ │
Redis Sentinel ◀──┘ └──────▶ GET https://admin.migadu.com/api/limits
writes → master ?level=&id=&flow=&period=d&by=&count=
reads → replicas (basic auth) → migadu_web notifies the user
DB 0: cnt/<A|D|O>/<id>/d/<YYYY-MM-DD>/<i|o> or disables sending/receiving
counterctl --address x@y [--date YYYY-MM-DD] ──GET /counters (Bearer)──▶ migadu_counters ──▶ Redis replicas
Existing pieces reused or matched
- Limits: the CDB the Lua plugin reads,
/usr/local/etc/rspamd/db/rspamd_domains_db, built byansible-freebsd2/roles/agent/templates/extract.sh.j2:478with tinycdbcdb -m(no NUL terminators, exact-byte lookups like rcptd). Key =domain, value =<org_id>|O/i=..,O/o=..,D/i=..,A/o=..,alice@/o=... Parsing and limit resolution followstats-merger/internal/quota/quota.go(ParseLimitsL66,DomainLimits.limitL116): keys lowercased, malformed pairs skipped,<local>@/<flow>overridesA/<flow>, missing key = unlimited. A domain present in the CDB is "Migadu-hosted". - Redis keys — must match the web app's key builder (
migadu_web/apps/core/lib/core/redis_sentinel.ex:73-103):cnt/<A|D|O>/<id>/d/<YYYY-MM-DD>/<i|o>with id = address / domain / org_id, date in UTC (web app usesDate.utc_today()), Redis DB 0 (web app reads:redis_sentinel_db0; rspamd usesredis_rspamd_db: 0). TTL 31 days. DB 1 and the/sstorage keys belong to the web app; never touched. The web app may DEL today's keys to reset a customer (redis_sentinel.ex:145-153); counting must keep working after that, and a second crossing must notify again. - Web app notification:
GET https://admin.migadu.com/api/limits?level=&id=&flow=&period=&by=&count=with basic auth (apps/admin_web/lib/admin_web/controllers/internal_api/notification_controller.ex:33,router.ex:99Plug.BasicAuth). All six params are pattern-matched, so all must be present.id= address for A, domain name for D, org id for O. Credentials:vault_migadu_web.internal_api_basic_auth_*(seerspamd-postfix/templates/rspamd/local.d/migadu_counters.conf.j2). - Redis topology:
ansible-freebsd2/roles/redis-sentinel,inventory/hosts.ini:160: hostsredisSentinel0-2, Redis :6379 + Sentinel :26379, master namemymaster, 1 master + 2 replicas, one password (vault_redis_sentinel_password) for Redis; Sentinel itself has norequirepass. - Service conventions from
~/Work/rcptd(cmd/rcptd/main.go): TOML config via-config+ default paths, bearerauth_tokenwith env override, slog, CDB behindatomic.Pointerwith a 30 s grace close, SIGHUP + fsnotify reload (internal/watcher/watcher.gotable mode), admin port with/health+/metrics, graceful shutdown. No dependency on rcptd'ssharedmodule (not present locally). - Libraries (in the module cache):
github.com/colinmarc/cdb,github.com/BurntSushi/tomlv1.6.0,github.com/fsnotify/fsnotifyv1.9.0,github.com/redis/go-redis/v9v9.7.3,github.com/prometheus/client_golang; tests:github.com/alicebob/miniredis/v2v2.34.0.
API (called by mailqueuer, fire-and-forget, one request per recipient)
Revised 2026-10-14 (review round 1,
review/README.md#1–#6):/inand/outnow validate, enqueue and answer202 {"accepted":true}; counting runs in a worker pool (queue_size,workers) under its own time budget, and the counted values appear only in the counting log andGET /counters. A full queue answers503 {"error":"queue full"}. Only the counted sides are validated (mail_fromnever rejects;auth_userandrecipientindependently), IDN domains are converted to punycode,user@dom@SUFFIXlogins count under the base address.INCRreplacesINCRBY; per-key EXEC failures and lost replies are handled individually (re-read from the master). Parsed CDB records are cached (max_cached_domains).auth_tokenis mandatory, unknown config keys are fatal, the default API port is:5555. The200 {"counted":…}contract below is the original design and is superseded by the README.
mailqueuer's ingest handler is already per recipient: it loops over toPublish
(mailqueuer/internal/handler/ingest.go:235-306), publishes one queue message per recipient (own uuid id,
L237) and marks dedup only after a successful publish (L263-272). Both routes are called from inside that loop,
right after a successful Publish, with exactly one recipient. The route is chosen by X-Auth-User:
non-empty → /out, empty → /in. mailqueuer never waits for or acts on the response.
Authentication: every request carries Authorization: Bearer <token> — mailqueuer sends it (the same
header it already sends to rcptd, internal/routing/routing.go:256-258), migadu_counters validates it
(constant-time compare against auth_token; missing/wrong → 401, nothing counted). One shared token,
set in both configs; no other auth. All routes take a JSON body ≤ 64 KB. Processing is synchronous: one Redis
round trip, then the response. Only web-app notifications run in the background.
200{"counted":[{"level":"A","id":"x@example.com","flow":"o","count":12,"limit":100,"reached":false},…]}(reached: trueon the increment that crossed the limit → notification triggered). Skipped requests return{"counted":[],"skipped":"exempt|unknown_domain|junk"}.400invalid body/address,401bad token,503Redis error (nothing counted).
POST /out — authenticated submission
{ "trace_id": "…", "auth_user": "x@example.com", "mail_from": "x@example.com", "recipient": "y@example.com" }
- Sender outgoing +1 on A(
auth_user) / D(its domain) / O(its org) — only ifauth_user's domain is in the CDB. - Recipient incoming +1 on A / D / O of the recipient's domain row — only if that domain is in the CDB
(any Migadu-hosted domain, any org). That mail never passes
/in.
Example: x@example.com → y@example.com, s@gmail.com = two /out calls.
Call 1: out A x +1, D example.com +1, O +1; in A y +1, D example.com +1, O +1. Call 2: out +1 on each; gmail not
in CDB → no incoming. Totals: outgoing +2 per level, incoming for y +1 per level.
POST /in — inbound mail from outside
{ "trace_id": "…", "mail_from": "s@gmail.com", "recipient": "y@example.com", "junk": false }
Recipient incoming +1 on A / D / O if its domain is in the CDB. The sender is never counted.
junk = true is skipped unless count_junk = true (Lua only counted no action mail).
GET /counters?address=… or ?domain=… [&date=YYYY-MM-DD]
Read-only view (used by counterctl), served from the read client. date is an ISO 8601 calendar date
(YYYY-MM-DD, interpreted as a UTC day); default = today UTC; a future date is 400. Keys live 31 days, so
older dates return zeros. Exactly one of address/domain is required.
{ "query": "me@example.com", "mode": "address", "date": "2026-10-13",
"managed": true, "exempt": false, "domain": "example.com", "org_id": 1234,
"levels": [
{"level": "A", "id": "me@example.com", "in": 12, "in_limit": 500, "out": 3, "out_limit": 200},
{"level": "D", "id": "example.com", "in": 40, "in_limit": 1000, "out": 9, "out_limit": 400},
{"level": "O", "id": "1234", "in": 120, "in_limit": 5000, "out": 30, "out_limit": 1000} ] }
in_limit/out_limit are null when unlimited. mode: "domain" returns the D and O rows only.
managed: false (domain not in the CDB) still returns the raw counters with null limits — Lua-era keys may exist.
Values are read with one MGET of the ≤ 6 keys.
Shared rules
- Addresses are trimmed and lowercased; validation = one
@, non-empty parts, ≤ 254 chars. - Exempt list (default
support@migadu.com,contact@migadu.com): ifauth_user,mail_fromorrecipientis exempt, nothing is counted for that request (Lua: mail to/from support never costs quota). trace_id(mizu'sX-Trace-Id, shared by all recipients of one message) is logged for correlation only; mailqueuer's internal per-recipient queue id is not sent. No Redis idempotency keys: mailqueuer already guarantees at-most-once per (trace, recipient) publish; the residual case (mailqueuer restart + mizu retry) also double-delivers the mail, so the count stays truthful.OandDlimits come from the domain row that triggered the increment (sender's row for outgoing, recipient's row for incoming) — same caveat as Lua if domains of one org carry differentO/*values.
HTTP validation layer (mailqueuer → migadu_counters)
Everything mailqueuer sends passes this layer before any CDB or Redis access; a request that fails any step is
answered with a JSON error {"error":"…"} and nothing is counted. Two parts:
1. Middleware chain (internal/server/middleware.go, applied in this order to /in, /out; /counters
gets the same chain with method GET and no body steps):
| # | Step | Rule | Failure |
|---|---|---|---|
| 1 | recover | panic in a handler → log with stack, keep the daemon alive | 500 |
| 2 | method | POST only (GET for /counters) |
405 + Allow header |
| 3 | body size | http.MaxBytesReader 64 KB |
413 |
| 4 | bearer token | Authorization: Bearer <token>, constant-time compare with auth_token |
401 + WWW-Authenticate: Bearer |
| 5 | source IP | optional allowed_cidrs (config); matched on RemoteAddr only — X-Forwarded-For is never trusted (no proxy in front) |
403 |
| 6 | content type | Content-Type: application/json (parameters such as charset ignored) |
415 |
| 7 | JSON decode | exactly one JSON object; syntax errors rejected; unknown fields ignored so mailqueuer can add fields without breaking counting | 400 |
| 8 | request validation | per-route field rules below | 400 |
| 9 | timeout | http.TimeoutHandler (handler_timeout, default 4 s) around the counting work |
503 |
Every rejection is logged at warn with route, remote address, status, reason and trace_id (if parsable) and
counted in requests_total{route,status} — a burst of 400/401 from mailqueuer means contract drift or a
token mismatch and should alert.
2. Request validators (internal/api/validate.go, pure functions, table-tested):
| Field | /out |
/in |
Rule |
|---|---|---|---|
trace_id |
optional | optional | ≤ 128 chars, printable ASCII; control characters/newlines stripped before it reaches any log line (log-injection guard) |
auth_user |
required | — (ignored) | e-mail syntax |
recipient |
required | required | e-mail syntax |
mail_from |
optional | optional ("" = bounce sender <>) |
e-mail syntax when non-empty |
junk |
— (ignored) | optional, default false |
boolean |
E-mail syntax = trimmed, lowercased, ≤ 254 chars, exactly one @, non-empty local part and domain, no
whitespace/control characters; the domain is ASCII (punycode accepted). Deliberately permissive: the CDB lookup
decides whether an address is counted, the validator only guards against garbage and log injection.
Errors name the field: {"error":"recipient: missing"}, {"error":"auth_user: invalid e-mail address"}.
Config: allowed_cidrs = ["10.0.0.0/8"] (optional; empty = any source), handler_timeout (see daemon section).
The response codes mailqueuer may see are therefore: 200 counted/skipped, 4xx rejected by this layer,
503 Redis unavailable or timeout — it ignores all of them (fire-and-forget), they exist for operators and tests.
Redis (internal/store)
Config has a write side and a read side:
- Sentinel mode (production):
sentinels,master_name,password(+ optionalsentinel_password). Write =redis.NewFailoverClient(follows failover). Read =NewFailoverClient{ReplicaOnly: true}; go-redis picks a random replica and falls back to the master when none is known (sentinel.go:526). - Static mode (dev/tests, when
sentinelsis empty):write_addr+read_addrs(round-robin; defaults towrite_addr). - Both pooled (
pool_size); onetimeout_msfor dial/read/write.dbmust be0(validated, startup error otherwise).
Counting = one TxPipeline (MULTI/EXEC) per request: INCRBY key 1 + EXPIRE key 2678400 for every affected key
(≤ 6 keys). EXEC returns the new values, so no read-after-write and replica lag is irrelevant. Increments are
atomic (fixes the Lua GET-then-INCR race); counting continues past the limit.
Limit crossing: for each incremented key with a configured limit L, notify iff new − amount < L ≤ new
(with amount 1: new == L). Exactly one increment can cross, across all workers/instances, with no marker key.
After a web-app reset (DEL) the counter crosses again and notifies again. Accepted gap (same as Lua): a limit
lowered below the current count mid-day produces no crossing.
Redis errors → 503, log, redis_errors_total{op}.
Notification (internal/notify)
- One URL per level in
[notify]:org_url(O),address_url(A),domain_url(D). Empty URL = that level is counted andlimit_reached_totalis incremented, but no HTTP call (useful while the web app'sDbranch is a TODO). Basic auth and timeout are shared. GET <url>with template vars$LEVEL $ID $FLOW $PERIOD(=d) $ADDRESS $COUNT $LIMIT, eachurl.QueryEscaped.ADDRESS= the address that triggered it (sender for outgoing, recipient for incoming).COUNT= new value.- Runs in a background goroutine (semaphore of 8 to not flood the web app): up to 5 attempts, exponential
backoff 1 s → 16 s. Final failure → error log +
notifications_total{level,result="failed"}(alertable). Shutdown waits ≤ 10 s for in-flight notifications.
CLI: counterctl
counterctl --address me@dimitrijedimitrijevic.com # today (UTC)
counterctl --address me@dimitrijedimitrijevic.com --date 2026-10-12
counterctl --domain dimitrijedimitrijevic.com --json
- Flags (Go
flag, so-xand--xboth work):--address|--domain(exactly one),--date YYYY-MM-DD(ISO 8601 calendar date; default today UTC; parsed withtime.Parse("2006-01-02"); future date = error; older than 31 days = warning on stderr that keys have expired),--json(raw API response),--config PATH,--url(override),--version. - Talks to the running service's
GET /countersinstead of Redis directly — one code path for key building and limit resolution, no Redis/CDB access needed from the operator's shell, same approach asrcptctl. Config discovery mirrors the service andrcptctl(cmd/rcptctl/main.go:65-75):--config, else/etc/migadu_counters/config.toml,/usr/local/etc/migadu_counters/config.toml; readsaddrandauth_tokenonly (MIGADU_COUNTERS_AUTH_TOKENoverrides);addrlike:8095becomeshttp://localhost:8095(rcptctl/api.go:52-60); warns when the host is not local (token over plaintext). - Output (
text/tabwriter), limits shown as-when unlimited,!appended when value ≥ limit:me@dimitrijedimitrijevic.com date 2026-10-13 (UTC) domain dimitrijedimitrijevic.com org 1234 LEVEL ID IN LIMIT OUT LIMIT A me@dimitrijedimitrijevic.com 12 500 3 200 D dimitrijedimitrijevic.com 40 1000 9 400 O 1234 120 5000 30 1000managed: falseprints adomain is not in rspamd_domains_db (not counted)note after the table;exempt: trueprintsaddress is exempt from counting. - Exit codes:
0success,1usage error / bad date / service unreachable / non-200 (message on stderr).
Counting transaction log (internal/txlog)
A second logger, separate from the slog application log, that writes the Lua plugin's step-by-step lines for
every counting transaction (the Lua infox lines from migadu_counters.lua:217/235/266/268/271/283),
so existing grep habits keep working. rspamd's task id <ad8525> is replaced by <trace_id> (shared with
mizu/mailqueuer logs via X-Trace-Id), and the route is added. breach= becomes reached= because the
meaning changed (Lua: already over → rejected; here: this increment crossed the limit).
Text format (default), timestamps UTC with milliseconds, one block per request:
2026-10-06 01:12:06.412 <ad8525> out; request auth_user=noah@wearemiraclesnow.com recipient=y@example.org mail_from=noah@wearemiraclesnow.com
2026-10-06 01:12:06.412 <ad8525> out; local domain: wearemiraclesnow.com account_id=116681
2026-10-06 01:12:06.412 <ad8525> out; domain: wearemiraclesnow.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 <ad8525> out; determined limit (level=[A] flow=[o] key=[cnt/A/noah@wearemiraclesnow.com/d/2026-10-06/o]) => 100
2026-10-06 01:12:06.412 <ad8525> out; determined limit (level=[D] flow=[o] key=[cnt/D/wearemiraclesnow.com/d/2026-10-06/o]) => 2200
2026-10-06 01:12:06.412 <ad8525> out; determined limit (level=[O] flow=[o] key=[cnt/O/116681/d/2026-10-06/o]) => 2200
2026-10-06 01:12:06.412 <ad8525> out; recipient domain not in domains db: example.org (not counted)
2026-10-06 01:12:06.414 <ad8525> out; tracked address counters for noah@wearemiraclesnow.com => count=12 limit=100 reached=false
2026-10-06 01:12:06.414 <ad8525> out; tracked domain counters for wearemiraclesnow.com => count=1830 limit=2200 reached=false
2026-10-06 01:12:06.414 <ad8525> out; tracked account counters for 116681 => count=2200 limit=2200 reached=true
2026-10-06 01:12:06.414 <ad8525> out; limit reached (level=[O] id=[116681] flow=[o] by=[noah@wearemiraclesnow.com]) => notifying org_url
2026-10-06 01:12:06.903 <ad8525> out; sent limit exceeded notification to url "https://admin.migadu.com/api/limits?level=O&id=116681&flow=o&period=d&by=noah%40wearemiraclesnow.com&count=2200"
- A local recipient produces the same
local domain / limits_map / determined limit / tracked …block forflow=[i]under the recipient's domain row./inrequests log the recipient block only. determined limit … => unlimitedwhen no limit is configured. Skips are logged as one line:skipped: exempt address <addr>,skipped: junk,recipient domain not in domains db: <d> (not counted),sender domain not in domains db: <d> (not counted).- Notification outcome lines come from the background sender, tagged with the same
<trace_id>:sent limit exceeded notification to url "…"orfailed limit exceeded notification to url "…" after 5 attempts: <err>. - Redis failure:
redis error: <err> (nothing counted). format = "json"switches the same lines to one JSON object per line (ts, trace_id, route, event, level, flow, key, id, count, limit, reached, domain, org_id, limits, url, error) for later ingestion.- The "tracked" lines are written after the pipeline EXEC (counts are the returned values); the earlier lines are written before the Redis call, so a Redis failure still leaves the lookup trail in the log.
- Writer: the same
LogWritertype as the app log (mutex, unbuffered fortail -f,Reopenon SIGHUP). Config:[logging] counting_output(default: same destination asoutput;"off"disables),counting_format(text|json). The per-request details live only here; the app log keeps startup, reloads, warnings and errors, to avoid duplication.
Process model: standalone daemon
cmd/migadu_counters is a long-running foreground process:
- Two
http.Servers: the API (/in,/out,/counters, bearer auth,http.TimeoutHandler) onaddr, and the admin server (/health,/metrics, no auth) onadmin_addr. Server timeouts as in rcptd:read_header_timeout=5,write_timeout=30,idle_timeout=60,handler_timeout=4(seconds). - Startup: load config → logger → open CDB (fatal if unreadable) → create Redis clients + PING the write side (warn, not fatal: the service must come up while Redis is briefly away) → start fsnotify watcher → listen.
- Signals:
SIGHUP→ reopen both log files (app + counting) + reload CDB (atomic.Pointerswap, old file closed after 30 s);SIGTERM/SIGINT→ stop accepting, drain in-flight requests (≤ 10 s), wait for background notifications (≤ 10 s), close Redis clients, exit 0. - Supervision on FreeBSD mirrors rcptd (
ansible-freebsd3/roles/rcptd/templates/rcptd.rc.j2): an rc.d script runs the binary under/usr/sbin/daemon -f -H -p <pidfile> -o <logfile>; daemon(8) owns the pidfile, restarts on exit and captures stderr;service migadu_counters reload=kill -HUP $(cat pidfile)(the child's pid). Therefore nodaemon/pid_fileoptions (rcptd's config template warns that writing the same pidfile from the process breaks the supervisor). Logging defaults to stderr for daemon(8)-o; a file path is supported withReopenon SIGHUP for newsyslog. [logging]is rcptd'sLoggingConfigverbatim (output= stderr|stdout|path,format= console|json,level); copyrcptd/internal/config/logger.go(self-contained).- Binaries carry
version/commit/datevia ldflags (-versionflag); Makefile mirrors rcptd's:build,test,freebsd(CGO_ENABLED=0,migadu_counters-freebsd-amd64,counterctl-freebsd-amd64),build-linux.
Layout
migadu_counters/
├── go.mod # module migadu_counters (exists)
├── Makefile # build / test / freebsd / build-linux, ldflags version info
├── cmd/migadu_counters/main.go # config, logger, CDB load/reload, redis clients, HTTP + admin servers, signals
├── cmd/counterctl/main.go # CLI: flags, config discovery, GET /counters, table/json output
├── internal/api/types.go # JSON types shared by the server (/in, /out, /counters) and counterctl
├── internal/api/validate.go # per-route request validators, e-mail syntax, trace_id sanitizing
├── internal/config/config.go # TOML structs, defaults, validation (sentinel vs static, db==0, urls)
├── internal/config/logger.go # LoggingConfig + LogWriter.Reopen (from rcptd), counting_* fields
├── internal/txlog/txlog.go # counting transaction logger: text (Lua-like) / json, per-request block
├── internal/domains/domains.go # CDB open/lookup, ParseRecord/ParseLimits, Limit(level, flow, local)
├── internal/watcher/watcher.go # fsnotify reload of the CDB (rcptd table mode, trimmed)
├── internal/store/redis.go # write/read clients, Incr pipeline, Get for /counters
├── internal/counting/counting.go # /in, /out request → increments (level, id, flow, limit), exempt/junk/unknown
├── internal/notify/notify.go # per-level URL, templating/escaping, background sender with retries
├── internal/server/middleware.go # recover, method, body size, bearer token, allowed_cidrs, content-type, decode, timeout
├── internal/server/server.go # /in, /out, /counters handlers; admin /health, /metrics
├── internal/metrics/metrics.go # prometheus definitions
├── config.toml.example
└── README.md # API contract for mailqueuer, key format, counting rules, ops
Example config (/usr/local/etc/migadu_counters/config.toml; discovery order -config, /etc/migadu_counters/config.toml,
/usr/local/etc/migadu_counters/config.toml):
addr = ":5555" # was :8095 before review round 1
admin_addr = ":9095"
auth_token = "…" # or MIGADU_COUNTERS_AUTH_TOKEN; same value in mailqueuer's config
allowed_cidrs = [] # optional source-IP allowlist for /in, /out, /counters, e.g. ["10.0.0.0/8"]
domains_cdb = "/usr/local/etc/rspamd/db/rspamd_domains_db"
exempt = ["support@migadu.com", "contact@migadu.com"]
count_junk = false
read_header_timeout = 5
write_timeout = 30
idle_timeout = 60
handler_timeout = 4
[logging]
output = "stderr" # daemon(8) -o captures it; or a file path (reopened on SIGHUP)
format = "console" # or "json"
level = "info"
counting_output = "/var/log/migadu_counters/counting.log" # default: same as output; "off" disables
counting_format = "text" # Lua-like lines (default) or "json"
[redis]
db = 0 # must stay 0: migadu_web reads message counters from DB 0
password = "…" # or MIGADU_COUNTERS_REDIS_PASSWORD
pool_size = 32
timeout_ms = 500
# Sentinel mode (production): writes → master, reads → replicas
master_name = "mymaster"
sentinels = ["10.0.0.10:26379", "10.0.0.11:26379", "10.0.0.12:26379"]
sentinel_password = ""
# Static mode (used when sentinels is empty)
# write_addr = "127.0.0.1:6379"
# read_addrs = ["127.0.0.1:6379"]
[notify]
# One URL per level; leave empty to disable notifications for that level.
org_url = "https://admin.migadu.com/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
address_url = "https://admin.migadu.com/api/limits?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
domain_url = ""
user = "…"
password = "…" # or MIGADU_COUNTERS_NOTIFY_PASSWORD
timeout_seconds = 5
Metrics: 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}, _cdb_domains (gauge), _cdb_reloads_total{result}.
/health: 200 when the CDB is readable and the write client answers PING within 200 ms; else 503 with reason.
Behaviour changes vs the Lua plugin (intentional)
- Separate
/inand/outroutes, one request per recipient, instead of rspamd'sMIGADU_FLOWsymbol. - Outgoing counted per recipient (Lua: once per message, skipped when all recipients were same-domain).
Existing
*/olimits therefore become stricter — a 50-recipient message costs 50; limits may need re-tuning before the web app'soutgoing_limit_exceeded!is relied upon. - Local recipients of authenticated mail get incoming counted via
/out. - Domain (
D) level counted; notified only whendomain_urlis set. - Atomic increments; counters don't stop at the limit; no reject/soft-reject anywhere.
- Notification on the exact crossing increment; re-fires after a web-app reset.
- UTC day keys (matches the web app; Lua used server local time). Malformed CDB rows are skipped, not fatal.
Deployment prerequisites (not part of this repo)
- An Ansible role in
ansible-freebsd3cloned fromroles/rcptd: user/group,config.toml.j2, the rc.d script (daemon(8) with pidfile/var/run/migadu_counters/migadu_counters.pid, log/var/log/migadu_counters/…), newsyslog entry, binarymigadu_counters-freebsd-amd64+counterctl-freebsd-amd64in/usr/local/bin. - The CDB must exist on the host running the service (the agent delivers it to rspamd hosts today; stats-merger pulls the text variant) — same file, so a copy/pull job is enough.
- Firewall: mailqueuer hosts → service port; service → Sentinel hosts :26379/:6379 and admin.migadu.com:443.
- Secrets: bearer token shared with mailqueuer, Redis password, web app basic-auth credentials.
Implementation order
- Copy this plan into the repo as
plans/2026-10-13-migadu-counters.md(requested; kept as the design record). go.moddeps,Makefile,config.toml.example,internal/config(structs, defaults, validation, logger from rcptd).internal/domains(+ tests with a temp CDB),internal/watcher(trimmed from rcptd).internal/store(sentinel/static clients, Incr pipeline, MGET) + miniredis tests, incl. crossing detection.internal/counting(request → increments, exempt/junk/unknown) + tests for the/outand/inexamples.internal/txlog(text + json, golden tests),internal/notify(per-level URLs, retries) + httptest tests.internal/apitypes,internal/server(/in,/out,/counters, admin),internal/metrics.cmd/migadu_counters/main.go(wiring, signals, shutdown),cmd/counterctl/main.go(+ tests).README.md(API contract for mailqueuer, key format, counting rules, ops), manual verification below.
Verification
go vet ./... && go test -race ./...domains: parse/override/unlimited/malformed rows via a temp CDB (cdb.Create); unknown domain.counting: the/outexample (two calls → out +2 per level, y in +1 per level); external-only recipient; sender domain not in CDB (only recipient counted); exempt sender/recipient → nothing;/inlocal/unknown/junk.store+ miniredis: key format, TTL set, DB 0, crossing fires exactly once (limit 3: 3rd incrementreached, 4th not; afterDELthe next crossing fires again); concurrent increments → one crossing; read-side round-robin / fallback; Redis down → 503.notify: each level hits its own URL, empty URL skips, all vars escaped (@→%40), retries then failure metric.txlog: golden-file tests with an injected clock for the/outexample above (local + external recipient, one level reached), an/inrequest, each skip reason, a Redis failure, and thejsonformat;Reopenafter the file is moved writes to the new path.api/validate: table tests per field and route (missing, empty, too long, two@, whitespace, control chars, uppercase → lowercased, punycode accepted,mail_fromempty on/in,trace_idsanitized).server/middleware: one test per step in chain order — 405, 413 (65 KB body), 401 (missing/wrong/Basic), 403 (allowed_cidrsmiss) vs pass, 415, 400 (syntax error, two objects, unknown field accepted), 503 (handler timeout with a blocked Redis), 500 (panic recovered, daemon still serving); each rejection leaves Redis untouched (miniredis key count unchanged) and incrementsrequests_total{status}.server: 401, 400, 200 withcountedpayload;/counterswith address/domain, default date = today UTC, explicitdate, future date → 400, malformed date → 400, unmanaged domain →managed:false+ null limits; notify targets are per-levelhttptest.Servers.counterctl: date parsing/validation (valid ISO, malformed, future, > 31 days warning), exactly-one-of--address/--domain,addr→ URL normalization, table rendering against a fixed API response (httptest.Server),--jsonpassthrough, exit codes.
- Manual: local Redis in static mode, CDB built from a sample line (
cdb -m -c, or the test helper),go run ./cmd/migadu_counters -config config.toml.examplewithorg_url/address_urlpointed at a local listener.curl -XPOST localhost:8095/out -H 'Authorization: Bearer …' -d '{…}'repeatedly; checkredis-cli -n 0 KEYS 'cnt/*',GET /counters?address=…, exactly one notification per level on crossing,redis-cli DELthe A key → crossing notifies again; edit the CDB +kill -HUP→ limits reload.go run ./cmd/counterctl --config config.toml.example --address x@example.comshows the same numbers asredis-cli;--date <yesterday>shows zeros/older data;--date 2099-01-01is rejected.tail -f counting.logwhile sending the curl requests shows the full block per request, andgrep '<trace_id>' counting.logreturns one transaction. Daemon behaviour:kill -TERMexits 0 after draining; withlogging.output/counting_outputset to files,kill -HUPreopens them (rotate withmvfirst and confirm new lines land in the new files);make freebsdproduces the two static binaries. Optionally a local Sentinel (1 master + 1 replica) to confirm reads hit the replica.