Files
selfpost/docs/audit-2026-10-12.md
T
mixandClaude Fable 5.1 b9353db507
test / test (push) Canceled after 0s
audit: the plan as a checklist, with how to resume it
45 steps as '- [ ]' lines per development.md § Plan checklists; roadmap
progress 0/45; a resume note naming the active stage and the working rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 23:21:30 +03:00

47 KiB
Raw Blame History

Code audit — 2026-10-12

What this is. A full-tree audit of the code and infrastructure at 8f226bd (release 2.4.0): leftovers, unfinished work, defects, performance, doc drift and test gaps — explicit ones (there are no TODO/FIXME markers anywhere in the product code) and the implicit ones that only reading finds. The panel's visual design (mockups, panel.css, layout) is a separate audit and is not touched here. Nothing was changed in the tree; this file is the only output.

Method. Six parallel readers, one per zone (mail path; background roles; store/backup/composition root; HTTP surface; view and services; build/deploy/ CI/e2e), each reading every file of its zone and its tests in full, each told to find where the code does not do what architecture.md says, and to prove it (a throwaway test, EXPLAIN QUERY PLAN, a probe) rather than guess. Machine checks: go build, go vet, gofmt -l, go mod tidy/verify, staticcheck, govulncheck, go test ./.... Five claims the readers could not settle from source were settled on the test server (selfpost.mixfed.ru, running a locally built 2.3.0-post.c817f08) and against GHCR. Two hypotheses were refuted there and are not findings: saslpasswd2 -d of a missing login exits 0 (deletion is idempotent, as the code says), and a duplicate key in a texthash: map is a warning with the first entry winning, not a fatal error.

Machine results. Build, vet, gofmt, tidy, verify: clean. staticcheck: four items (an unused domainIDSet in handlers/authz.go:63, two unused assignments in handlers_outbound_pages_test.go:174,194, one S1003 in view/pages_test.go:170). govulncheck: nine standard-library findings (net/http HTTP/2, net/textproto, html/template), all fixed in the current Go patch release; golang.org/x/crypto 0.54.0 has three published fixes in 0.56.0 that this code does not call. modernc.org/sqlite is seven minor versions behind (1.53.0 → 1.60.1). go test ./...: green on Linux semantics; on Windows two tests fail for OS reasons (TestWriteLoadPrivateKeyRoundtrip expects a POSIX mode; TestFollowTailsAndRotates renames an open file) — the tests, not the product, are platform-bound.

Severity: critical — mail or data lost or the security boundary crossed today; high — a documented guarantee is false in a way an operator will hit; medium — wrong under a realistic condition, or a stated guard that is not there; low — wrong under an unlikely condition, or cost without failure; nit — hygiene. Confidence is stated where a finding was not proven.


Findings

High

H1. Every published release image is stamped VERSION=e2e, so the backup/restore version check never checks anything. release.yml:75-84 builds the image with VERSION=X.Y.Z and tags it selfpost:e2e; the e2e step then runs docker compose build through test/e2e/compose.override.yml:23-32, which rebuilds the same tag with args: VERSION: e2e; docker tag selfpost:e2e …X.Y.Z-amd64 pushes the rebuilt one. Confirmed: docker run ghcr.io/mixeme/selfpost:1.3.0 panel -version prints e2e. Consequences: the footer and -version lie; backup.CheckRestore compares a manifest's version to e2e, so a backup of any release restores into any other and the promise in development.md:270 and guide.md:191 ("the version is the single source … so they cannot drift apart") is false. Every image this workflow has ever published is affected; they stay as they are (owner, 2026-10-12: old tags and images are history and are not touched) — the fix is for the next publication.

H2. Renewed TLS certificates are never picked up while the container runs. postfix-config.sh:75-89 copies the mounted cert and key into /etc/postfix/tls-internal/ at start and points Postfix at the copy; postfix-cert-reload.sh runs postfix reload daily but nothing re-copies. The copy is the only tls-internal reference in the tree; on the test server the copy carries the container's start time. Scenario: Let's Encrypt renews at day 60, nobody restarts, from day 90 ports 465/587 serve an expired certificate. architecture.md:33,154-155 and supervisord.conf:92-95 say the opposite.

H3. The queue-reconcile sweep marks delivered mail bounced whenever the tailer is blind. Four mechanisms, one consequence (a row the log would have resolved as sent is closed as a failure, which the Outbound log shows and the notifier believes):

  • Tailer stuck, sweep running. logtail.go:559-581: caughtUp closes after the first drain and never re-opens; reconcileLoop (:174-191) then runs every 5 min regardless. If openAt fails after a rotation (:573-576, e.g. postlogd recreated the file 0600 — entrypoint.sh:132 says this happens) the loop logs and retries forever on the old inode while every new delivery line goes unread; two minutes later reconcile() finds the rows absent from postqueue -p (they were delivered) and bounces them.
  • Truncated queue listing read as complete. postfix/queue.go:64-68,81-87: Queue() caps postqueue -p at 4 MiB and appends a text note; QueueIDs() parses that text and returns the first 4 MiB of ids with no error. A deferred backlog of ~30k messages — the outage the sweep exists for — makes every row past the cut bounced while Postfix still holds the mail. queuejson.go:48-52 handles the same cap correctly for the notifier; the two drifted.
  • Rotation drain window. :567-578 drains the renamed inode once more; postlogd keeps writing to it until master's reload reaches it (seconds), so lines written in that window are never read. architecture.md:372-373 claims the drain covers it.
  • No status guard on close. UpdateStatus (store/sendlog.go:86-98) is WHERE queue_id = ? AND to_addr = ?; the reconcile path can overwrite a sent written an instant earlier, and a reused short queue id to the same recipient rewrites a months-old row.

H4. Application logins are case-sensitive in the panel and case-insensitive in Postfix; a tenant can take another tenant's envelope sender. app/validate.go:35-48 accepts A-Z and nothing lowercases a login; applications.login is UNIQUE under BINARY collation (0001_init.sql:91), so Ops and ops are two applications. The sender map is written as stored (postfix/postfix.go:95-115); Postfix's reject_sender_login_mismatch compares logins without regard to case (the code's own comment, milter/sender.go:35-36, and architecture.md:239-240 say so). Scenario: domain A has Ops bound to @a.com; a domain-role administrator of B creates ops in B, authenticates as ops, sends MAIL FROM:<x@a.com> with From: y@b.com. Postfix accepts the envelope (@a.com Ops matches), the sender check judges only the header, which is B's — the message leaves with A as its SPF and bounce identity. Confidence: the panel side is confirmed; Postfix's compare is taken from the code's own statement. Fix shape: lower-case at validation, COLLATE NOCASE on the column (a migration — check the live registry for a pair first).

Medium

Persistence, restore, backup

M1. The restore manifest is consumed before anything that can fail. backup.go:436-446 deletes manifest.json inside CheckRestore; main.go:282-300 runs store.Open and resyncAfterRestore afterwards. If either fails, supervisord restarts the panel with restored == false and the one-time drift-healing Resync never runs. Docs say "on the first successful boot"; the code says "on the first attempt". restore_test.go passes testNoReload=true and never fails Open/Resync, so nothing covers it.

M2. Three queries on the single connection run without the index they need. Measured with EXPLAIN QUERY PLAN against the real migrations:

  • CountMessages by domain (ratelimits.go:186-190) and sendStats (stats.go:77-94) use idx_send_log_domain (domain) and apply created_at >= by scanning every row of the domain over the retention window — on every MAIL FROM of an application under a domain-level limit, with the milter, tailer and HTTP queued behind it. The baseline's comment promises a composite index for "the journal milter … on every MAIL FROM"; only the app_login half exists.
  • ListQueuedOlderThan (sendlog.go:117-122) is a full SCAN send_log every 5 min (no index on status; the cutoff matches nearly every row). Mirror of what 0005 fixed for rejected: a partial index (created_at) WHERE status = 'queued' makes it a handful of rows.
  • Scoped QuerySendLog (sendlog.go:214-219) sorts a domain's whole scope with a temp b-tree for every page a domain user opens.

M3. selfpost-backup with a wrong SELFPOST_DB_PATH creates an empty database and ships a "valid" backup of nothing. backup.go:394-405 opens the source without mode=ro or an existence check; VACUUM INTO on a non-existent path succeeds (measured), creates the file on disk and snapshots a 4 KiB empty database; Create and Check pass. A restore of it opens the /setup link on a host that still carries live DKIM keys and sasldb2.

M4. secretfile's KDF cap does not do what its comment says. secretfile.go:108-113,184: maxLogN=20, maxR=32, maxP=16 lets a 44-byte header demand 128·2²⁰·32 = 4 GiB and sixteen times the CPU, under a comment that says the cap exists "so a hostile header cannot make the reader allocate its way to an out-of-memory kill". The domain-import form calls NewReader on the upload. Operator foot-gun rather than attack surface (global role only), but the stated guarantee is false; the real writer never exceeds N=2¹⁵, r=8, p=1.

M5. The CLI enforces no password floor for an encrypted backup. cmd/selfpost-backup/main.go:170-187 takes any non-empty string; the panel enforces validate.MinSecretFilePasswordLen (12); security.md:291 says the minimum applies to these files. (The roadmap's "empty password accepted" item: an empty password is not accepted as an encryption password — empty means plain output by design; the gap is the floor.)

M6. The compose pin, README and guide point at an image that does not exist. deploy/docker-compose.yml:24, README.md:115 and guide.md:187 pin ghcr.io/mixeme/selfpost:2.4.0 (2.4.1 since this cut); GHCR holds 1.0.0 and 1.3.0 only (checked through the Packages API and by pulling); no 2.x was ever published, and tag v2.0.0 exists in git without a release. The quick start cannot work for anyone but the owner, who runs locally built -post.<sha> images. Decided (owner, 2026-10-12): the old tags and images are history and stay untouched; the pin points forward — it is made true by the next publication, after H1 is fixed, and until then the documents should not claim an image that is not there.

HTTP surface

M7. The last-global-user guard is check-then-act without a transaction. store/users.go:315-341 (DeleteUser: CountGlobalUsers then DELETE), handlers_users.go:277-287,317-322 (demotion), users.go:80-97 (CreateGlobalUser: UserExists then INSERT). MaxOpenConns(1) serialises statements, not pairs. Two globals deleting or demoting each other at once leave no global; /server/* answers 404 to everyone and recovery is database surgery. security.md:155 promises the guard.

M8. Login discloses whether a username exists by timing. auth/handlers.go:109-121: unknown user returns at :114 without bcrypt; a known one costs ~250 ms (cost 12). The test compares bodies only. Rate limiting slows enumeration; a dummy compare stops it.

M9. submitUserUpdate is five writes with no transaction. handlers_users.go:304-336: UpdateUser → endSessionsAfterPasswordSet → SetUserRole → ClearUserReach (error only logged) → SetUserReach. A failure after the first leaves a user renamed and signed out everywhere with the old role, or a domain user with empty reach — the state the "Assign at least one domain" check exists to prevent.

M10. Domain import trusts the export file for everything but the domain name, selector and password. domain/transfer.go:145-190: DMARCRua is not run through validate.Email (the form path is), client IPs not through parseIPList, rate-limit Mode/WindowSeconds/MaxMessages not through the form's parsers. WindowSeconds: 0 yields a limit that counts nothing (milter/ratelimit.go:61); garbage IPs make the imported application refuse every client with no word at import. app/validate.go:50-55 calls the file "untrusted input" — for the password only.

M11. Polling stops for good after one failed refresh. panel.js:202-257: the scheduled poll is htmx.ajax(...) without source, so htmx attributes it to document.body; htmx:responseError therefore reaches an element with no data-poll and nothing reschedules; htmx:sendError and htmx:timeout are not handled at all. A panel restart while Overview or Health is open freezes the page until the tab is hidden and shown. Related: an expired session answers an HX-Request with 303 /login (auth/middleware.go:14); the XHR follows it and the sign-in document is swapped into the box — no HX-Redirect on either side.

Log tailer and checks

M12. deliveryRe is unanchored; a stranger's HELO parses as a delivery result. logtail.go:85: a port-25 reject line ending helo=<41E862C00D9E: to=<victim@…>, status=sent> yields (id, recipient, sent) (probed), and UpdateStatus rewrites the matching row. The attacker must guess an id+recipient pair, so impact is bounded — but lines.go:21-24 and inbound.go:48-51 anchor at the head for exactly this reason, and this pattern, the one that writes status, does not.

M13. The sender-check deferred counter misses the case it is documented to see. sendercheck.go:50 matches only postfix/…smtpd[pid]; header/EOH events run in cleanup(8), so the warning for a panel that accepts the socket but hangs at end-of-headers is logged by postfix/cleanup. The fixture at sendercheck_test.go:64-68 puts those lines under smtpd and says it was "composed from Postfix's source rather than seen". architecture.md:286-287 claims the figure sees "a panel that is running and not answering". Confidence: plausible; one kill -STOP on the stand settles it.

M14. Resolver fall-back is hollow when the first resolver drops packets. dnscheck/resolver.go:72-90 tries resolvers in turn under one 5 s context; a blackhole eats the budget and the second is asked with an expired context. The DefaultResolvers comment promises otherwise. M15. dnscheck/server.go:26-33,42-46 turns a resolver timeout into "does not resolve" / "no PTR record" (every other check routes transient errors to Unknown), and empties ServerIPs so the SPF check goes Unknown in the same minute. architecture.md:339-340.

Infrastructure

M16. envdoc_test.go compares three hand-written lists to each other and reads neither main.go, the scripts nor guide.md. envdoc_test.go:70-136. Already drifted: ANTISPAM_DIR, INBOUND_JOURNAL_MILTER_SOCKET, INBOUND_SPAM_LOG_RETENTION_DAYS, RATE_LIMIT_MESSAGES_PER_IP, RATE_LIMIT_WINDOW_SECONDS are read by loadConfig and absent from loadConfigKeys; postfix-config.sh and postfix-wrapper.sh read INBOUND_JOURNAL_MILTER_SOCKET, SELFPOST_DATA_DIR, SELFPOST_DB_PATH, INBOUND_RELAY_ENABLE, absent from buildScriptKeys. The tests pass; development.md:353-357 and :503-504 describe a guard that does not exist.

M17. The supervisord control socket is 0770 root:selfpost and the postfix and opendkim users are in selfpost. supervisord.conf:26-29 ("only root and the two SelfPost services"), Dockerfile:68-71 (three users); confirmed on the server. A compromised smtpd can supervisorctl stop panel (all submission deferred), stop opendkim or shutdown. No escalation; a whole-stack denial from the least trusted process.

M18. DMARC maps are never ownership-normalised. entrypoint.sh:108-125 normalises five maps; dmarc_recipients/dmarc_transport/dmarc_relay_domains are created by postfix-config.sh:315-317 as root only when missing. A tar restore as root (guide, methods 3/4) yields root:root 0640, which smtpd cannot open → 451 table lookup problem on every report until the panel next rewrites them. Confidence: plausible, needs a run.

M19. logrotate state lives in the container layer. logrotate-loop.sh:42 uses the default /var/lib/logrotate/status, not under /data. On the server the state file is dated the container's start; with no entry logrotate records today and does not rotate, so a host that recreates the container daily (.env change, image bump) never rotates mail.log. The e2e uses -f and cannot see it.

M20. test.yml never compiles test/e2e. A type error there surfaces in release.yml after the GitHub Release is published and the tag checked out.

M21. deploy/traefik/extract-cert.sh writes empty PEM files and exits 0 on a jq miss (:17-25, sh without pipefail; jq exits 0 on no match); the files are 0644 until :27. Postfix TLS breaks at the next restart.

Low

  • L1 -decrypt -o FILE truncates the output before the password is verified (main.go:132-148); a wrong password leaves an empty file where a good one may have been.
  • L2 Backups skip symlinks silently and Check does not notice (backup.go:323-327); a certs/ of symlinks (the Let's Encrypt layout) yields an archive with no TLS material while the guide promises refusal.
  • L3 Trailing bytes after the last secretfile chunk are ignored (:400-416): two envelopes concatenated open as the first.
  • L4 parseTrustedProxies turns a bare ::ffff:10.0.0.1 into ::/32 silently (main.go:247-253).
  • L5 tls_policy is keyed by next-hop; two inbound domains to one [host]:port with different TLS modes write duplicate keys and the first (by domain order) wins with a warning — confirmed on the server. The per- domain TLS mode in the form is not what Postfix does (postfix/inbound.go:69-72).
  • L6 Multi-file map rebuilds are not all-or-nothing (inbound.go:42-55, dmarc.go:31-41); a write failure mid-sequence leaves relay_domains ahead of transport.
  • L7 atomicfile.Write never fsyncs the directory and the package has no tests (atomicfile.go:40-44); every map writer goes through it.
  • L8 The refusal reply names the other sender's domain (sender.go:334) — an oracle for the server's signing names, available to any authenticated application.
  • L9 go-milter accumulates every message's headers for a connection's life (library session.go:173-187, cleared on ABORT only); the sender check is safe because it ignores h, and the test passes nil so would not catch a "simplification" to h.Values("From").
  • L10 parsePage is not clamped to lastPage for the Outbound log (handlers_monitor.go:357-371); the inbound log clamps. Known roadmap row, confirmed.
  • L11 parseAutoMultiplier accepts NaN (handlers_ratelimit.go:44-49); SQLite stores NULL, read-back is 0, recalc silently uses 2.5 (probed).
  • L12 Outbound rows belong to a domain by name (handlers_monitor.go:97,345): a domain deleted and re-added shows its earlier rows to the new assignee. security.md:419 accepts this for the inbound log only.
  • L13 HandleReload answers failures as plain-text http.Error (handlers_domains.go:173-184), and the flash "Configuration regenerated from the database" oversells: DMARC maps and filter-list files are skipped (architecture.md:856-859 says so for DMARC; Lists.Sync is documented nowhere).
  • L14 http.Server has only ReadHeaderTimeout (httpserver.go:145-149).
  • L15 The Account password change shares the per-IP login bucket (handlers_account.go:142): ten wrong current passwords lock sign-in for that IP 15 min.
  • L16 Login issues a fresh token but does not destroy the session a presented cookie already holds (auth/handlers.go:123-130).
  • L17 domain.Service.Delete returns a bare error after the row is gone when resync() fails (service.go:202-210); apps.Resync() is not reached, so the sender map keeps the purged logins.
  • L18 A rate-limited refusal is a grey tag in the Outbound log (outbound_log.go:213, no such status in components.go:132-146); its two siblings are red, and the test pins the grey.
  • L19 Recalculate rate limit on the application form submits the whole form and discards every unsaved edit (out_app.html:67, handlers_ratelimit.go:178-189).
  • L20 DMARC parse rewrites <pct>0</pct> to 100 and stores a missing date_range as 1970 (parse.go:125-136).
  • L21 Several mailStack instances per process with no shared lock on table rebuilds (run, newPanel, runNotifier); the notifier's EnsureServerSigned at start can write a stale table over a domain added in the same second. L22 IncrDMARCParseFailures is read-modify-write across concurrent dmarc-ingest processes (dmarc.go:163-173).
  • L23 The restore-boot Resync reloads Postfix before Postfix exists (panel priority 200, postfix 300) — an error line per restore boot; the DMARC bootstrap works around the same thing with a no-op hook and does not say why (main.go:301-307).
  • L24 health/processes.go:43, supervisor.go:17,30: exec.Command without context; /healthz and the 5 s status poll hang on a wedged supervisord. L25 health/cert.go:70-83 assumes the first PEM block is the leaf and grades a not-yet-valid certificate OK; machine.go:193-204 advances prevAt when only one of cpu/net succeeded.
  • L26 Only successful sends count toward the notifier's 60/h ceiling (notify.go:196-207); with sendmail broken, CheckLimits spawns a 20 s process per recipient per minute forever.
  • L27 v=dmarc1 matched case-insensitively (records.go:74,121); RFC 7489 §6.4 says the version must match exactly, so a lower-case record is graded OK and ignored by receivers. L28 The domain cache is keyed by name only (dnscheck.go:155-173) though the verdict depends on ExpectedDKIM/ServerIPs.
  • L29 lineTime normalises a Feb 29 line read the following January to Mar 1 (inbound.go:254-261); isoStampRe accepts a zone-less stamp that time.Parse then rejects (dated now).
  • L30 OpenDKIM: Syslog no with no LogWhy/Logfile — it logs nothing anywhere; no Canonicalization relaxed/simple; no OversignHeaders From (the header-append replay is the class 2.4.0 is about).
  • L31 Postfix: smtpd_tls_protocols at the 3.7 default >=TLSv1, disable_vrfy_command = no (VRFY on 25 enumerates relay_recipient_maps), the stock (Debian/GNU) banner — all confirmed on the server. RATE_LIMIT_*, *_SIZE_LIMIT, MILTER_*_TIMEOUT reach postconf -e unvalidated; a non-integer passes postfix check and kills every smtpd at connect.
  • L32 crashexit fires on FATAL only, which arises only from startretries exhausted within startsecs (1 s): a program that crashes after one second cycles forever and never exits the container. architecture.md:40-41 reads wider than that.
  • L33 .dockerignore excludes docs, dev, bin but not test/; a developer build after an e2e run ships test/e2e/.stage/data (DKIM keys, sasldb2) into the build context. Base images pinned by tag, not digest.
  • L34 e2e: stage.go:77 pulls alpine:3.20 from the internet; hostname_gate_test.go:97-101 runs with the host's real network; sender_check_test.go:141 has a fixed time.Sleep(2s) against development.md:409 "no fixed sleep".

Nits

Dead code: handlers/authz.go:63 domainIDSet; view.Facts (components.go:414); dmarc.Service.HostedSuggestion, ValidateHostedAddress (dmarc/service.go:98-109); app.Service.UpdateMode (app/service.go:132-145, superseded by SaveSettings); Settings.WithResult (view/account.go:137-145) re-implements resultFlash; KitPage()'s hard-coded User: "admin" (overwritten by the handler); dead method checks in handlers_dmarc.go:20-24 and the default: branches of HandleUserNew/HandleUserEdit/HandleServerSettings (mux patterns are method-bound); the postfix set-permissions branch in entrypoint.sh:170-172 (postfix check has already created the tree). Duplication: canViewDMARCDomain repeats lookupDomain's check; ParseForm twice on the user-edit POST; Lookup then Touch per request in auth/middleware.go:17,28; retry.go:58-67 repeats the six postconf keys as literals instead of retryConfKeys; three header-name canonicalisers with two behaviours (milter.go:124, inbound.go:89, sender.go:248). Behaviour: submitUserCreate does CreateUser then UpdateUser for the e-mail; self-demotion redirects to a page that is now 404; redirectHome gives a domain user with no outbound reach an empty 200 where the inbound list gives 404; sort.Slice in postfix/inbound.go:59 reorders the caller's slice; strings.Split(raw, "\n") in sender.go:65,104 keeps \r; lists.go:428 trims the filter key while the Lua side reads it verbatim; InDomain.WithMX labels a warn status "NOT CHECKED"; senderSession does not override Body (the library discards the session after every accepted message — harmless, inconsistent with milter.go:130-134). The handlers test package takes ~105 s on this machine (likely bcrypt cost 12 on the user-creation paths).

Doc drift

architecture.md: :167 (§ OpenDKIM) still says the From: header is not held against the login — contradicted by 2.4.0 and by § Sender check in the same file; :272-274 credits /run being tmpfs for a fresh .alive mark (it is a plain image directory; a restarted container keeps the mark); :328-329 "a deleted domain's row goes with it" — only dns: rows, limit: lingers up to 48 h; :493 and § Who may open what omit /inbound/log and /inbound/filter-lists; :800 names only migration 0002; :832-836 omits inbound_spam_log, inbound_spam_list, notify_state, sender_check_days; :838 "the panel is the only writer" — dmarc-ingest is a separate process that opens the store, migrates and writes; :856-859 vs backup.go:19-23 and httpserver.go:52-53 ("the same Resync") — the button skips DMARC maps and filter lists; the Internal env list omits ANTISPAM_DIR and INBOUND_JOURNAL_MILTER_SOCKET; § View engine calls the kit page "fixtures" (it is production data). schema-migrations.md: :20 "chain head user_version = 5" (six files; TestBaselineSchema asserts 6); the "Schema at head (v2)" table omits sender_check_days and send_log.reason/header_from. guide.md: :214 INBOUND_JOURNAL_MILTER_SOCKET "Set in: compose" (it is not); :1260 lines "deleted together with the row" (at the next six-hourly prune, and not while a reused id has a row); :720 "first successful boot" (M1). development.md: :270 version/tag coupling (H1); :353-357, :503 the env guard (M16); :404 "milter fail-open" as a covered negative (the suite now proves deferral); :409 "no fixed sleep". roadmap.md: template-data-typing describes renderDomainDetail with forty map keys — gone since 2.0 (one typed-before- render map remains, the health sampler's); structured-logging says logf "in five packages" — it is in 21 files of internal/web; review-2026-08-followups row "empty password" is misdescribed (M5). Code comments: milter.go:7-11 "it never rejects" (it tempfails at MAIL FROM); store/sendlog.go:19-22,61-65 StatusRejected/InsertRejected describe the level-2 case only; store.go:2-3 "domain-admins"; schema_test.go:15 TestBaselineIsTheOnlyMigration; health/machine.go:225 "see roadmap panel-docs … later" (shipped 1.8.0); inboundspam.go:349-350 "does not read the whole journal" (it scans it); logtail.go:466 mentions copytruncate; supervisord.conf:25 "two services", :107 "7-14 files"; entrypoint.sh:119-120 "no secrets" in /data/antispam (filter.key); docker-compose.yml:85,88 cite a guide section by a title it does not have.

Test gaps

No test for: the H1 stamp (a panel -version assertion in the e2e against the version the stand was told to build); cert re-copy (H2); follow() with an unreadable or recreated log, a truncated postqueue -p, lines written to the old inode after the switch, UpdateStatus from sent (H3); two logins differing in case (H4); M1's ordering (restore tests never fail Open/Resync or exercise the real reload hook); CheckRestore with malformed JSON; M3, M5, L1, L2; secretfile concatenation (L3); the sender-check counter with the real program name (M13); a LookupAddr timeout (M15); TestLimitRefusalsSince EXPLAINs a stand-in query, not the real one; TestOpenMigratesA20Database stops at 0004; Import with a bad rua / IPs / window 0 / mode (M10); panel.js has no test at all (M11); login timing (M8); concurrent last-global (M7); TestOriginCheck lacks the Host-with-port vs Origin case; TestServerSubtreeIsGlobalOnly skips any /server/ route not in routes.always; internal/atomicfile and internal/supervisor have no tests; TestRenderInboundMaps has no two-routes-one-next-hop case (L5); TestSenderCheckResetsBetweenMessages passes h = nil (L9); the handler test rows of review-2026-08-followups (apps/domains/users/dmarc handlers directly, internal/dmarc service) are still open.

Checked and sound

Worth writing down so the next review does not re-read it: the sender check's layered rule holds against every From: shape tried (quoted local parts with @, CFWS, encoded words decoding to addresses, groups, <>, address literals, obs-route) — the "one @ outside quotes" rule is the real backstop; extension stripping and case folding match Postfix; every error path of the check tempfails; level-2 accounting reserves under one lock and cannot be inflated by multi-RCPT or bypassed by ABORT/reconnect; the inbound verdict is trusted only under the queue-id-bound HMAC; the port-25 -o overrides are complete and read back by the e2e through postconf -Ph; the generated milter chain is exactly sender(tempfail) → OpenDKIM(tempfail) → journal(accept); /server/ is guarded at registration and per handler; scopes fail closed (1 = 0); sessions store the hash only, __Host- cookie, constant-time setup token; SaveSettings restores prior state on mid-way failure; secretfile's AAD binds header, counter and last-flag, and truncation, reorder and tamper are tested; shutdown order (tailer offset saved, HTTP drained, DB closed last) is right; rspamd map names, deny message, symbol prefixes and key-file format match the Go side byte for byte.


Plan

Six stages, each a self-contained cut, each small enough for one sitting, with model routing per development.md (security/infra → Opus, UI/docs → Sonnet). "Done when" is the test that proves it, not the edit.

Agreed with the owner on 2026-10-12 (roadmap row audit-2026-10-12): stage 0 first, then stages 1 and 2 as one cut, the rest after in any order. This is the active plan in the sense of development.md § Plan checklists: the - [ ] lines below are the implementation checklist, a line is ticked in the commit that finishes it, and the roadmap's Progress column counts them. After a context reset, start at the first unchecked line of stage 0. The owner's decisions on the contested items are written next to the steps they changed and under Decided with the owner at the end; they are not open.

Working rules that apply, from development.md and the project's memory: every step updates CHANGELOG [Unreleased] in its own commit; a version is cut only at the owner's word; main is pushed, tags are not; design changes (the only one in this plan would be a Delivery log card, and it is not planned) are mockup-first; a migration the test server has already run is never edited — the next file is added; the published 1.x tags and images are history and are not touched.

Stage 0 — release hygiene (no product code) → 2.4.2

  • Stamp the right version (H1). Make the e2e build take the version from the environment (VERSION: ${SELFPOST_E2E_VERSION:-e2e} in compose.override.yml, set by release.yml), or have the e2e reuse the image already built instead of rebuilding. Done when the e2e asserts panel -version equals what it was told, and release.yml runs that assertion before the push. — Opus
  • Compile the e2e in test.yml (M20): cd test/e2e && go vet ./... && go test -run xxx ./.... — Haiku
  • .dockerignore + test/ (L33). — Haiku
  • The pin (M6): old tags and images are not touched (owner, 2026-10-12). The next publication through the fixed release.yml is the one the pin names; until then README/guide say that the current line is not yet published rather than name an image that is not there. — Sonnet
  • Toolchain and deps: modernc.org/sqlite, x/crypto, x/sys; note in development.md that the image is rebuilt after each Go patch release (golang:1.26-bookworm floats, so a rebuild suffices). — Haiku
  • The four staticcheck items. — Haiku

Stage 1 — mail that is shown wrong (H3, H2, M12, M13) → 2.5.0

  • QueueIDs on a truncated listing returns an error, as postqueueJSON does; reconcile already treats an error as no information. Test: a 5 MiB fake postqueue -p. — Opus
  • Reconcile only closes queued rows (AND status = 'queued' in the reconcile path's update) and marks what it closed: reason = 'reconcile' on the row (the 0006 column; a new value, no schema change). — Opus
  • Measure before deciding the sweep's fate (owner, 2026-10-12: the keep / remove / lost question needs real data, and today the only record of a sweep is a stdout line that dies with the container). A table delivery_log_days (migration 0007, DDL only; a row per UTC day, kept a year, the shape of sender_check_days) with four counters: reconcile_closed (rows the sweep closed); reconcile_contradicted (a reason = 'reconcile' row the tailer later rewrote from a status= line — the log wins, and the rewrite is counted); tailer_blind_seconds (follow() unable to read: reopen failing, file gone — the sender check's downtime mechanics); queued_over_lifetime (rows still queued past maximal_queue_lifetime at the day's end — what removing the sweep would leave visible). No screen for this (owner, 2026-10-12: nothing is drawn that ordinary operation will not need): the table is read off a copy of the database when the month is up. Whether a durable Delivery log card (tailer blindness, stuck rows — the two counters that outlive the decision) earns a place on Health is a separate question, asked only if the data shows those numbers are ever non-zero. Decision rule, written now: closed = 0 for a month → remove the sweep; closed > 0, contradicted = 0 → keep; contradicted > 0 → each case explained by tailer_blind means fix the tailer and keep the sweep, otherwise lost or remove. Decide after 30 days of data, or sooner if contradicted is ever non-zero. — Opus
  • Deferred until the data is in (not a step yet): pausing the sweep while the tailer is not reading (publish "healthy" from follow, skip the sweep otherwise), and the rotation window (keep draining the old inode until it has not grown for a tick and the new file has a line). Both are fixes to the tailer's blindness and stay on the list whatever the sweep's fate; their priority follows tailer_blind_seconds.
  • Anchor deliveryRe at the head of the line like its two siblings; test with the probed HELO and quoted-sender lines. — Opus
  • TLS re-copy (H2): postfix-cert-reload.sh compares the mounted files to the copy and re-copies before postfix reload (atomically, same modes); e2e replaces the cert in the stand and reads the new serial off 465. — Opus
  • Deferred counter (M13): on the stand, kill -STOP the panel mid-message and record which program logs the warning; widen the matcher accordingly and replace the composed fixture with the seen lines. — Opus
  • Docs: architecture § Log tailer, § Queue reconcile (the measurement and the rule), § Sender check "what it costs"; guide § Delivery log, § Health. — Sonnet

Stage 2 — the tenant boundary and the registry (H4, M7, M9, M10) → in 2.5.0 (migration 0008)

  • Logins lower-cased at validation (owner, 2026-10-12: fold, do not refuse) and applications.login COLLATE NOCASE (migration 0008 rebuilds the unique index). Before the migration a check that no two existing logins differ only in case (there is one installation; look). Tests: Ops/ops refused; the map written lower- case. Then verify Postfix's compare on the stand once (MAIL FROM as the other case) so the doc's claim is seen, not assumed. — Opus
  • Last global in one transaction: DeleteUser/SetUserRole take a tx, count and act inside it; CreateGlobalUser likewise. A concurrency test with two goroutines. — Opus
  • submitUserUpdate in one transaction (store method that takes the whole change). — Opus
  • Import validates like the form: route DMARCRua, IPs, limit mode, window and count through the same parsers; refuse the file with the validator's text. Tests per field. — Opus
  • Send-log rows belong to a domain by id (L12; owner, 2026-10-12). send_log.domain_id INTEGER REFERENCES domains(id) ON DELETE SET NULL in migration 0008, backfilled once by name against the registry as it is at migration time (rows of a domain already gone stay NULL); written by the journal milter and by the sender check's refusal rows at insert, looked up from the login's domain; the domain name column stays for display and for the global view. A domain user's log, stats, auto rate limit and the notifier's refusal count select by id; a NULL row is nobody's but the global role's. A domain deleted and re-added under its old name therefore starts with an empty log. The inbound journal (inbound_spam_log, the same accepted risk in security.md:419) gets the same treatment in the same migration — my proposal, so that the two logs do not follow two rules. Tests: delete-and-re-add shows nothing to the new assignee; the old rows are still in the global log. — Opus
  • Docs: security.md § Roles (the guard) and the struck accepted risk, guide § Domain export, schema-migrations.md. — Sonnet
  • The 550 reply names no other domain (L8): sender.go:334 stops at "it names another sender of this server"; the refused message's page still shows the From: line as written (header_from), so the operator loses nothing. Test pins the reply text; e2e's sender-check negatives assert the reply does not contain the other tenant's name. — Opus

Stage 3 — persistence and restore (M1–M5, L1–L4, L7) → 2.5.1

  • Consume the manifest after the resync succeeds (CheckRestore reports; run deletes after resyncAfterRestore returns nil). Test: Resync fails → manifest kept → second boot resyncs. — Opus
  • Indexes: idx_send_log_domain_created_at (domain_id, created_at) — on the id column stage 2 adds, replacing idx_send_log_domain; partial idx_send_log_queued (created_at) WHERE status = 'queued'; consider (domain, id) for the scoped page. Migration 0009 DDL only; TestLimitRefusalsSince-style EXPLAIN tests against the real query strings. — Opus
  • CLI backup: open the source mode=ro, refuse a missing file; password floor of 12 shared with the panel (validate.MinSecretFilePasswordLen); -decrypt -o verifies the password (first chunk) before opening the output; OnWarn for skipped symlinks and Check fails when certs/ is named but empty. — Opus
  • secretfile caps: bound 128·N·r to a stated budget (e.g. 128 MiB) and p to 1-2; refuse the rest with ErrHeader; reject trailing bytes after the last chunk. Tests. — Opus
  • atomicfile: fsync the directory after rename; a test file for the package. — Opus
  • Docs: security.md § Backup; guide § Backup. — Sonnet

Stage 4 — panel behaviour (M8, M11, L10–L19) → 2.5.2

  • Login: dummy bcrypt on unknown user; a test that measures both paths within a tolerance. Account password change gets its own limiter bucket. Login destroys the presented session. — Opus
  • http.Server: ReadTimeout, WriteTimeout, IdleTimeout. — Opus
  • Polling recovery: schedule from htmx:afterRequest on the box (or pass source), handle sendError/timeout with back-off; RequireAuth answers an HX-Request with HX-Redirect: /login instead of a 303. A minimal JS test (node + jsdom) or at least an e2e that restarts the panel with Overview open. — Sonnet
  • parsePage clamp; NaN refused; rate-limited tag red (add the status to components.go, flip the test); the recalc button posts only what it needs or warns; HandleReload either covers DMARC maps and filter lists or the flash and the two comments say what it covers. — Sonnet
  • domain.Service.Delete: say the domain is deleted when a later step fails, and run apps.Resync() anyway. — Opus
  • Upstream TLS mode validated at save (L5): saving an inbound domain whose [host]:port is already used by another domain with a different TLS mode is refused with the validator's text naming that domain and its mode; TestRenderInboundMaps gets the two-routes-one-next-hop case and the map is asserted to hold one key. Guide § Inbound domains says a next hop has one TLS mode. — Opus

Stage 5 — image and Postfix hardening (M17–M19, M21, L30–L32) → 2.6.0

  • Supervisor socket: a group of its own (supervisor), panel only; postfix/opendkim keep selfpost for the file paths. — Opus
  • OpenDKIM: Syslog yes (supervisord captures it) or Logfile; Canonicalization relaxed/simple; OversignHeaders From. e2e: DKIM still verifies; a message with two From: is refused before signing anyway. — Opus
  • Postfix: smtpd_tls_protocols = >=TLSv1.2, smtpd_tls_mandatory_protocols likewise, disable_vrfy_command = yes, smtpd_banner = $myhostname ESMTP; integer validation of RATE_LIMIT_*, *_SIZE_LIMIT, MILTER_*_TIMEOUT at the top of postfix-config.sh with a clear exit. — Opus
  • logrotate state under /data/log/.logrotate.status (-s), group- readable as the directory is. — Opus
  • entrypoint: normalise the three DMARC maps with the other five; remove the dead set-permissions branch. — Opus
  • extract-cert.sh: set -o pipefail under bash, check both outputs are non-empty PEM before moving them into place, create 0600 first. — Opus
  • crashexit: subscribe to PROCESS_STATE_EXITED with a restart-count threshold, or raise startsecs so a crash after 1 s still reaches FATAL; say in architecture.md what actually exits the container. — Opus
  • envdoc_test made real: parse envDefault|envInt|os.Getenv("…") out of cmd/panel/*.go and ${VAR out of build/*.sh, parse the key tables out of guide.md and architecture.md, compare. — Opus
  • Docs: architecture § Image and processes, § Configuration; security.md § Process. — Sonnet

Stage 6 — documentation and roadmap sweep (no product code, no cut)

  • Every item under Doc drift above, file by file. — Sonnet
  • roadmap.md: strike template-data-typing (done by 2.0; one sampler map left, typed before render); reword structured-logging; in review-2026-08-followups mark parsePage and secretfile rows as addressed by stages 3-4, keep the offset.go inode row (still open: fingerprint of the first 512 bytes only), and the dnscheck fan-out row now points at M14. Add the open tests list above as the test row's new content. — Sonnet
  • Comments named above (milter.go, sendlog.go, store.go, schema_test.go, machine.go, inboundspam.go, logtail.go, supervisord.conf, entrypoint.sh, docker-compose.yml). — Haiku
  • Dead code removal from § Nits, with staticcheck added to test.yml so it stays gone. — Haiku

Decided with the owner on 2026-10-12

Four items I had first put below the line; three of them are in the plan above after discussion, which is why they are recorded here with the reasoning rather than struck.

  • L5 (tls_policy per next-hop): the full fix is a model change (TLS mode per upstream, not per domain) — a design item. Decided (owner, 2026-10-12): validate instead — stage 4, item 6.
  • L12 (rows by domain name): decided (owner, 2026-10-12): the send log gets the domain's id — stage 2, item 5.
  • L8 (the signing-name oracle): decided (owner, 2026-10-12): do it — stage 2, item 7. (The names are not public: rDNS gives the hostname only, so the reply told one tenant which other domains are hosted here.)
  • L9 (go-milter header accumulation): decided (owner, 2026-10-12): locally only — a comment on senderSession.Headers saying why h is ignored, and TestSenderCheckResetsBetweenMessages passing an h that still holds the first message's From: (stage 2, with item 7). The library itself stays as vendored; an upstream patch is a separate, unhurried matter.
  • The Windows-bound tests: not worth platform guards; CI is Linux.