Code review — 2026-10-14
Review of the first implementation of migadu_counters (see ../plans/2026-10-13-migadu-counters.md).
Nothing in the code was changed by the review.
How it was done
- Two independent reviewers (agents) each read the whole repository. Neither saw the other's results.
- A — correctness and runtime behaviour:
reviewer-a-correctness.md - B — security, contracts and operability:
reviewer-b-security-contracts.md
- A — correctness and runtime behaviour:
- Each worked in its own scratch copy and proved findings with tests:
_proof_tests/. - The coordinating agent then verified every finding (see "Verification" below), merged duplicates and ranked them. 18 distinct issues remain; 4 of them were found by both reviewers.
Findings
Who found it: A / B / both, with the reviewer's own IDs for traceability.
High
1. If mailqueuer disconnects or times out before the reply, the count is lost — A (F1)
internal/server/server.go:156runs the Redis write with the request context. When the client hangs up, Go cancels the context and go-redis aborts before sending MULTI.- Re-run during verification: 20 of 20 delayed requests and 300 of 300 immediate ones were not counted. The most dangerous finding, because fire-and-forget clients often close early.
- Fix: run the counting under
context.WithoutCancel(r.Context())with its own time budget.
2. A bad value in a field that isn't counted rejects the whole request — both (B F1, A F3)
internal/api/validate.gostrictly validatesmail_fromalthough it only feeds the exempt check; a 400 there means the recipient is never counted.- Senders trigger it easily: mizu's SMTP parser turns
"john smith"@x.comintojohn smith@x.com, mizu runs with SMTPUTF8 (info@bücher.de), address literals and trailing dots pass, and mailqueuer forwardsX-Mail-Fromunchecked. - On
/out, an unusual external recipient (x@[192.0.2.1],x@host_name.example.com) gives 400 and the sender's outgoing count is lost too. - Fix: never reject because of
mail_from; validate sender and recipient sides separately and count whichever is valid; convert UTF-8 domains to punycode.
Medium
3. Support/token logins aren't counted — A (F2)
- mizu puts the raw SASL login into
X-Auth-User(pkg/smtp/auth_session.go:160→poster.go:186), so master logins arrive asuser@dom@SUFFIXand fail validation with 400. - Fix: strip the second
@SUFFIXand count the base address.
4. Redis failures with an unclear outcome lose the limit notification — both (B F3, A F4)
- If Redis applies MULTI/EXEC but the reply times out, or one command inside EXEC fails (a key holding a non-number) while the others succeed, the service answers 503 and logs "nothing counted" — but the counters moved.
- If that increment reached the limit, no later increment can reach it again: no notification that day.
- Fix: use each command's own result; after a timeout re-read the keys from the master; log "outcome unknown".
5. Huge CDB records are re-parsed on every request — A (F5)
boxmailhere.com(193 KB, 9,798 overrides): about 0.9 ms and 1.4 MB per lookup, 2.2 ms and 1.6 MB per request (re-measured).- Fix: cache parsed records per loaded CDB.
6. An empty auth_token leaves the API open with only a warning — B (F4)
- A typo such as
auth-token =starts the daemon unauthenticated; anyone who can reach it can inflate any customer's counters, which triggers the web app's limit actions. - Fix: make an empty token fatal unless explicitly opted out; make unknown config keys errors.
7. Plus-addressed recipients get their own counters — B (F2); A notes the Lua plugin did the same
- Mail to
b+1@…createscnt/A/b+1@…; the mailbox's own counter (the one the web app charts) never moves. If such a counter reaches its limit, the web app answers "mailbox not found". - rcptd strips the
+tag, so stripping it here too seems right. Needs a product decision.
8. One malformed CDB row breaks counting for anything touching that domain — both (B F5, A F6)
- A non-numeric org id gives 503 and drops the other side's valid count; the plan says such rows are skipped.
- Fix: treat the row as not hosted and log a warning.
Low
- Notification retries can send customers duplicate emails — B (F11), A (F9). Resolved disagreement: the
organisation handler has a once-per-day guard (
migadu_web core/accounts.ex:1928-1956), the mailbox handler (core/email.ex:3572-3584) emails on every call, so a retry after a slow-but-successful call means a second email. Also: shutdown cuts off pending retries, retries keep their concurrency slot while waiting, and the "after 5 attempts" log line can be wrong. - A SIGHUP during startup kills the daemon — A (F8). The signal handler is installed only after the Redis ping, which can take up to 2 s.
- Rejected requests leave no trace — both (B F9, A F2 note). Nothing in the counting log, no trace_id in the app log; together with #2 the lost counts are invisible.
- Override keys aren't lowercased — A (F7).
Info@/o=5would be ignored; none in today's data. - Credentials in the notify URL are written to the log — B (F7); plain
http://with basic auth isn't warned about. - Error responses expose internals — B (F10): internal Redis addresses and paths;
admin_addrdefaults to all interfaces without auth. - Sentinel mode starts without a Redis password — B (F12): every write then fails; only
/healthand metrics show it. - counterctl warns one day too early — B (F6): a date exactly 31 days back is reported as expired.
- A recovered panic isn't counted in
requests_total— B (F8). - Work keeps running after a handler timeout — A (F10, not reproduced): not tracked by shutdown, a late panic is silently lost. Largely covered by the fix for #1.
Test gaps — B (F13): the suite misses most of the above; TestValidation_JSONAndFields asserts the 400 behind #2
as correct; watcher tests can occasionally pass on a stale signal.
Verification
Done by the coordinating agent after both reviews:
- Both scratch copies were checked to have source files identical to the repo, so the proof tests ran against the reviewed code. B's tests fail as described; A's demonstration tests reproduce their evidence (#1 was worse on re-run: 0 counted instead of 21 of 300).
- External facts checked in the sibling repos:
- mailqueuer reads
X-Mail-Fromwithout validating it (internal/handler/ingest.go). - mizu's go-smtp fork unquotes quoted local parts and copies the domain verbatim (
parse.goparseMailbox,parseLocalPart); mizu setsEnableSMTPUTF8 = true(cmd/mizu-server/main.go:1363). - mizu stores the raw SASL username as the authenticated user (
pkg/smtp/auth_session.go:160) and sends it asX-Auth-User;user@domain@SUFFIXlogins are documented in mizu'sCLAUDE.md. - Web app: org-level limit handlers are guarded once per day, the mailbox-level ones are not (see #9).
- mailqueuer reads
- Reviewer disagreements: #7 (A: Lua parity, B: defect) is left as a product decision; #9 settled as above.
Suggested order
Fix #1–4, #6, #8 and #10 first (small, they protect the counts), then #5 and #7 (#7 after the decision), then the low items. Turn each proof test into a regression test as its finding is fixed.