The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.
- Wrong From: cleanup gives the message a queue id before the headers are
judged; the sender check reads {i} at end-of-headers and writes it on the
refused row. Its lines - stored under the id like any other's - are on the
page, but only those from 10 minutes before to 1 minute after the refusal
(store.SendLogLines for a rejected row): Postfix frees the id at once, and
a later message with it lends nothing. No mail.log fallback for a refused
row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
"NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
of that sender (and login, when the line names it) within two minutes; a
line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
"none - refused at MAIL FROM, before one is given"; the box's caption is
"The line from mail.log for this refusal" for the latter; a row from
before says the line was not kept. LogLineLevel colours a refusal by its
reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
not muted as in the picture: the kit has no muted inline.
Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.
schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
66 KiB
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:
caughtUpcloses after the first drain and never re-opens;reconcileLoop(:174-191) then runs every 5 min regardless. IfopenAtfails after a rotation (:573-576, e.g. postlogd recreated the file0600— 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 laterreconcile()finds the rows absent frompostqueue -p(they were delivered) and bounces them. - Truncated queue listing read as complete.
postfix/queue.go:64-68,81-87:
Queue()capspostqueue -pat 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 cutbouncedwhile Postfix still holds the mail.queuejson.go:48-52handles 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) isWHERE queue_id = ? AND to_addr = ?; the reconcile path can overwrite asentwritten 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:
CountMessagesby domain (ratelimits.go:186-190) andsendStats(stats.go:77-94) useidx_send_log_domain (domain)and applycreated_at >=by scanning every row of the domain over the retention window — on everyMAIL FROMof 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 theapp_loginhalf exists.ListQueuedOlderThan(sendlog.go:117-122) is a fullSCAN send_logevery 5 min (no index onstatus; the cutoff matches nearly every row). Mirror of what0005fixed forrejected: 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 FILEtruncates 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
Checkdoes not notice (backup.go:323-327); acerts/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
secretfilechunk are ignored (:400-416): two envelopes concatenated open as the first. - L4
parseTrustedProxiesturns a bare::ffff:10.0.0.1into::/32silently (main.go:247-253). - L5
tls_policyis keyed by next-hop; two inbound domains to one[host]:portwith 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 leavesrelay_domainsahead oftransport. - L7
atomicfile.Writenever 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 ignoresh, and the test passesnilso would not catch a "simplification" toh.Values("From"). - L10
parsePageis not clamped tolastPagefor the Outbound log (handlers_monitor.go:357-371); the inbound log clamps. Known roadmap row, confirmed. - L11
parseAutoMultiplieracceptsNaN(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
HandleReloadanswers failures as plain-texthttp.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.Syncis documented nowhere). - L14
http.Serverhas onlyReadHeaderTimeout(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.Deletereturns a bare error after the row is gone whenresync()fails (service.go:202-210);apps.Resync()is not reached, so the sender map keeps the purged logins. - L18 A
rate-limitedrefusal is a grey tag in the Outbound log (outbound_log.go:213, no such status incomponents.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 missingdate_rangeas 1970 (parse.go:125-136). - L21 Several
mailStackinstances per process with no shared lock on table rebuilds (run,newPanel,runNotifier); the notifier'sEnsureServerSignedat start can write a stale table over a domain added in the same second. L22IncrDMARCParseFailuresis read-modify-write across concurrentdmarc-ingestprocesses (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.Commandwithout context;/healthzand the 5 s status poll hang on a wedged supervisord. L25health/cert.go:70-83assumes the first PEM block is the leaf and grades a not-yet-valid certificate OK;machine.go:193-204advancesprevAtwhen 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,
CheckLimitsspawns a 20 s process per recipient per minute forever. - L27
v=dmarc1matched 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 onExpectedDKIM/ServerIPs. - L29
lineTimenormalises a Feb 29 line read the following January to Mar 1 (inbound.go:254-261);isoStampReaccepts a zone-less stamp thattime.Parsethen rejects (datednow). - L30 OpenDKIM:
Syslog nowith noLogWhy/Logfile— it logs nothing anywhere; noCanonicalization relaxed/simple; noOversignHeaders From(the header-append replay is the class 2.4.0 is about). - L31 Postfix:
smtpd_tls_protocolsat the 3.7 default>=TLSv1,disable_vrfy_command = no(VRFY on 25 enumeratesrelay_recipient_maps), the stock(Debian/GNU)banner — all confirmed on the server.RATE_LIMIT_*,*_SIZE_LIMIT,MILTER_*_TIMEOUTreachpostconf -eunvalidated; a non-integer passespostfix checkand kills every smtpd at connect. - L32
crashexitfires on FATAL only, which arises only fromstartretriesexhausted withinstartsecs(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
.dockerignoreexcludesdocs,dev,binbut nottest/; a developer build after an e2e run shipstest/e2e/.stage/data(DKIM keys,sasldb2) into the build context. Base images pinned by tag, not digest. - L34 e2e:
stage.go:77pullsalpine:3.20from the internet;hostname_gate_test.go:97-101runs with the host's real network;sender_check_test.go:141has a fixedtime.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
Seven stages (six, and stage 2b split off stage 1 on 2026-10-14), 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
(stage 2b after 2.5.0, before the others).
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 (shipped 2026-10-14)
- Stamp the right version (H1). Make the e2e build take the version from
the environment (
VERSION: ${SELFPOST_E2E_VERSION:-e2e}incompose.override.yml, set byrelease.yml), or have the e2e reuse the image already built instead of rebuilding. Done when the e2e assertspanel -versionequals what it was told, andrelease.ymlruns that assertion before the push. — Opus - Compile the e2e in
test.yml(M20):cd test/e2e && go vet ./... && go test -c -o /dev/null .(notgo test -run xxx:TestMainwould still run and bring up the stand). — 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.ymlis 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-bookwormfloats, so a rebuild suffices). — Haiku - The four
staticcheckitems. — Haiku - A negative e2e case passes only on the refusal it is about (owner,
2026-10-14; seen, not found by the audit).
negative_level1_ratelimitsends up to sixty messages and waits for one to be refused; it once passed on "limit tripped on attempt 1/60" when the connection was reset because Postfix was still starting. The case counts as the limit only the reply code of the limit, and only after at least one message was accepted; a reset, a timeout or any other reply fails it and says what came. Then every other negative case of the suite is read for the same mistake - any failure taken for the expected refusal - and held to its own reply code. - Opus Done 2026-10-14; what the stand showed:waitForSMTPSReadytook a TCP connect for readiness, and Docker's port proxy accepts before Postfix listens - that was the reset; it now waits for the TLS greeting. Held to anvil's450, the case then failed on its first message:553 … not owned by user l1app, an smtpd process from before the panel'spostfix reloadstill holding the oldsender_login_maps- so the level-1 limit had never been the refusal the case saw. The first message now waits (at most 15 s) for the map; the limit trips at 51/60 with450 4.7.1 Error: too much mail from. In the product this is a second or so after an application is saved in which its first message can get a permanent 553; not worth a change (an application is configured after it is created, not within the second). Measured 2026-10-14: applications created and used after 0, 10, 40 and 110 s of waiting (the first send two seconds after the save at the soonest) were all served by an smtpd started after the reload; the stale one only ever answered within the reload's own second. The e2e waits for the map after each application it creates (waitForSenderMap), after the main positive path failed on it once. The other cases are held to451 4.7.1(level 2, both MAIL FROM of one connection),553at RCPT (sender mismatch),554 … Client host rejectedat RCPT (no AUTH, and the relay case, whose comment claimedreject_unauth_destination- on 465 the client restriction comes first; port 25 holds554 … Relay access denied),550 5.1.1(port 25 unknown recipients), any4xxreply (panel down).
Stage 1 — mail that is shown wrong (H3, H2, M12, M13) → 2.5.0 (shipped 2026-10-14)
QueueIDson a truncated listing returns an error, aspostqueueJSONdoes;reconcilealready treats an error as no information. Test: a 5 MiB fakepostqueue -p. — Opus- Reconcile only closes
queuedrows (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 /
lostquestion needs real data, and today the only record of a sweep is a stdout line that dies with the container). A tabledelivery_log_days(migration0007, DDL only; a row per UTC day, kept a year, the shape ofsender_check_days) with four counters:reconcile_closed(rows the sweep closed);reconcile_contradicted(areason = 'reconcile'row the tailer later rewrote from astatus=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 stillqueuedpastmaximal_queue_lifetimeat 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 = 0for a month → remove the sweep;closed > 0, contradicted = 0→ keep;contradicted > 0→ each case explained bytailer_blindmeans fix the tailer and keep the sweep, otherwiselostor remove. Decide after 30 days of data, or sooner ifcontradictedis ever non-zero. — Opus Changed by the reviews of stage 1 (2026-10-14): a fifth counter,reconcile_missed- a closed row whose delivery line was inmail.logormail.log.1when the sweep closed it;contradictedalone cannot see a lost line, which is never read late.queued_over_lifetimecannot show "what removing the sweep would leave visible" while the sweep runs (those rows are closed by then); it counts rows stuck despite the sweep, and what removing it would leave isclosed - missed - contradicted. A contradicted row keeps the markreconcile-contradicted. The rule reads accordingly in architecture.md § Log tailer. - 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 followstailer_blind_seconds. - Anchor
deliveryReat 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.shcompares the mounted files to the copy and re-copies beforepostfix 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 -STOPthe 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; shipped 2026-10-14)
- Logins lower-cased at validation (owner, 2026-10-12: fold, do not
refuse) and
applications.login COLLATE NOCASE(migration0008rebuilds the unique index). Before the migration a check that no two existing logins differ only in case (there is one installation; look). Tests:Ops/opsrefused; the map written lower- case. Then verify Postfix's compare on the stand once (MAIL FROMas the other case) so the doc's claim is seen, not assumed. — Opus - Last global in one transaction:
DeleteUser/SetUserRoletake atx, count and act inside it;CreateGlobalUserlikewise. A concurrency test with two goroutines. — Opus submitUserUpdatein one transaction (store method that takes the whole change). The users pages get direct handler tests while they are rewired - a row of review-2026-08-followups, placed here 2026-10-14. — 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 NULLin migration0008, 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; thedomainname 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 Done 2026-10-14. DMARC reports stay by name on purpose: a report is about the name in DNS, and whoever holds the name now is its reader. Changed by the reviews of stage 2: the backfill follows the rule new rows follow (the application's domain, then the name) and gives a row only to a domain - and an application - that existed when it was written; an application's numbers are its login's rows of its domain, from its creation on. Deleting a domain now rewrites its rows' domain_id (14 s for 333k rows, measured by the review; milliseconds at this installation's size) - accepted, to revisit if a log grows that large. - Docs: security.md § Roles (the guard) and the struck accepted risk, guide § Domain export, schema-migrations.md. — Sonnet
- Which key OpenDKIM signs with, seen and not reasoned (owner,
2026-10-13, from the Not verified list of from-header-binding). The check
and OpenDKIM read
From:with two parsers; that they agree was argued from the check's two rules, never run. An e2e case sends, as one application, theFrom:lines the check passes - quoted strings, comments, folded lines, groups, escapes, encoded words, an address extension - and reads thed=of the signature on what arrives: any domain but the application's fails the test. The lines live in one table shared with the check's unit test, so a line added to one is run by the other. A disagreement found is fixed in the check before the cut, and security.md § Mail path says the claim is tested. - Opus Changed by the reviews of stage 2: a second list, lines naming another sender in a quoted string, a comment, a quoted pair, capitals, an encoded word (from-lines-other.txt), each refused and never signed for it; the passed list gained capitals, a quoted local part and a name with a space after its@(passed by design, signed for the own domain). L8 is half closed: refused-or-passed still tells an application which domains are hosted here - the owner decides whether to refuse every foreign address in a display name (asked 2026-10-14). Done 2026-10-14: fifteen lines (internal/milter/testdata/from-lines-passed.txt), each signed for the application's domain alone, the signature valid. No disagreement. Groups are refused by the check, so none is in the file; a comment before the address -(the shop) a@domain, valid RFC 5322 - is refused too ("it must be one address"): strict, not a hole, left as it is. - The 550 reply names no other domain (L8):
sender.go:334stops at "it names another sender of this server"; the refused message's page still shows theFrom: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 2b — the log tailer, and what the panel shows of a refusal → 2.5.1
Split off stage 1 on 2026-10-14 at the owner's word: the six steps below were
agreed on 2026-10-13/14, after the plan, and had made stages 1 and 2 a cut of
twenty-one steps. Stages 1 and 2 ship as 2.5.0 in their first size; this
stage follows as a cut of its own, with its own reviews. Five of the six
change one thing, the tailer and the state it saves, and are done in this
order - each stands on the one before.
- A log rotated while the panel was down is read to its end (owner,
2026-10-13; the Not done list of from-header-binding had it as "deferrals
are lost", and it is wider). At start, a saved offset whose fingerprint
is not the current file's means "start on the new file"
(offset.go); what Postfix wrote between
the panel's stop and the rotation is never read - delivery statuses,
stored delivery lines, refusals, inbound journal rows, the deferred
counter, and the recovery of unwritten refusals above. The tailer first
opens the rotated file (
mail.log.1), checks by fingerprint that it is the one the offset belongs to, reads it from the offset to its end, then goes on to the new file. The fingerprint gains the inode, which closes the roadmap's review-2026-08-followups row on collisions. Not found, or rotated twice: logged, with from when to when nothing was read. A test that stops the tailer, appends, rotates, appends, starts it, and finds both halves once. - Opus - The deferred counter remembers a place in the log, not a second
(owner, 2026-10-14, from the Not done list of from-header-binding; he
chose it over leaving the case written down). After an unclean stop the
tailer re-reads lines it had read, and the counter passes over every
warning no later than the last one it counted
(logtail/sendercheck.go,
last);mail.logstamps whole seconds, so a warning of that very second, later in the file, is lost. The count and the place in the log it has reached are saved in one transaction, and what to pass over is decided by the place. Done after the rotation step above, on the same saved state - the place must stay true across a rotated file. A test that stops the tailer between two warnings of one second and finds both counted, once each.- Opus
- A refused message's page shows what Postfix wrote (owner,
2026-10-13: "the log box is required" - the box is the reason
delivery-log-storage was built, and its absence from the picture he
approved for 2.4.0 was an oversight, not a decision). Today every
rejectedrow -wrong From,wrong client,rate-limited- has no queue id, and its page shows the box with one sentence, "This message never reached the queue, so Postfix wrote no delivery lines for it" (handlers_monitor.go). The sentence is untrue: Postfix wrote a line for each (milter-reject: END-OF-MESSAGE ... 5.7.1 From: ...from cleanup,NOQUEUE: reject: ...from smtpd). The tailer attaches that line to the row it belongs to, by client address, time and the addresses in the line, and stores it insend_log_lineslike any other; the page shows it. First on the stand: readmail.logfor the three kinds of refusal and write down what identifies each line. Then the mockup: the refused variant ofout-messagegets the box with the line in it (design:commit), then the code. The sentence stays only for a row whose line was truly never written or is older than this change, and says that. Done when the e2e shows the stored line on the page of each of the three refusals. - Opus - A message the sweep closed does not say the receiving server refused
it (review of stage 1, 2026-10-14). The history step of a
bouncedrow reads "the receiving server refused the message permanently, or Postfix gave up ... The reason is in the delivery log below" (handlers_monitor.go); for a row with reasonreconcileneither is known and the log has nothing. Such a row gets its own step: Postfix no longer held the message andmail.lognever said what became of it, so the panel closed it as a failure. Wording in the mockupout-messagefirst (design:commit), with the box of the step above. - Sonnet - A refusal the panel did not get to write is written from
mail.log(owner, 2026-10-13, from the Not done list of from-header-binding; built on the step above). The check answers 550 and writes the row a moment later, through a buffer; a panel that stops in between - a rollout, a crash - leaves an application that was refused and a log that does not know it. When the tailer meets a refusal line that has no row, it makes the row from the line (client, sender, recipient, reason), marked as recovered. The tailer resumes from its saved offset, so this covers a kill as well as a clean stop - which a flush of the buffer at shutdown would not. Done when the e2e refuses a message, kills the panel before the row is written, and finds the row after the restart, once. - Opus - Downtime is counted when supervisord does not answer at start
(owner, 2026-10-13, from the Not done list of from-header-binding). The
panel asks supervisord once since when Postfix runs
(sendercheck.go,
postfixStarted); an error is logged and that start's downtime is not counted at all - the number the owner will decide by reads low. The question is repeated for about thirty seconds. Still no answer: the downtime is taken from the alive mark alone - an upper bound, Postfix may not have been running - and the day is marked as estimated (a column insender_check_days, the next migration), which the card shows. The mark on the card is drawn in the mockup with the renamed column above (onedesign:commit). - Opus - The Sender check card names what it counts (owner, 2026-10-13, from
the Not done list of from-header-binding): the column Messages deferred
becomes Connections turned away. Postfix refuses from the connection on
while the check does not answer, before any message is offered, so a
client that connects three times with one message is three, and a monitor
that only connects is one; messages cannot be counted. Decided by the
owner, 2026-10-14: since the M13 fix the figure also counts whole
messages - those a panel that hangs mid-message makes cleanup defer, one
per message, with a queue id - and the two are shown apart, not mixed:
Connections turned away (smtpd's warning, the existing
deferredcolumn) and Messages deferred (cleanup's warning, a new column ofsender_check_daysin this stage's migration, beside the estimated mark). The counter writes each warning to its own column; the card has both columns, and the help topic says what each counts. The figures collected before do not mix: cleanup's case was counted only from the M13 fix on, not yet deployed when this was decided. The mockuphealthand help topicsender-checkfirst (design:commit), then the page andview/help.go, word for word. - Sonnet - Docs: architecture § Log tailer (the rotated file, the saved place, a
refusal's line and a recovered row), guide § Outbound log and § Health,
schema-migrations.md for the mark on
sender_check_days. — Sonnet
Stage 3 — persistence and restore (M1–M5, L1–L4, L7) → 2.5.2
- Consume the manifest after the resync succeeds (
CheckRestorereports;rundeletes afterresyncAfterRestorereturns nil). Test: Resync fails → manifest kept → second boot resyncs. And a corrupted or truncated archive is refused on restore and on import with its message, nothing half written (a row of review-2026-08-followups, placed here 2026-10-14). — Opus - Indexes:
idx_send_log_domain_created_at (domain_id, created_at)— on the id column stage 2 adds, replacingidx_send_log_domain; partialidx_send_log_queued (created_at) WHERE status = 'queued'; consider(domain, id)for the scoped page. Migration0009DDL 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 -overifies the password (first chunk) before opening the output;OnWarnfor skipped symlinks andCheckfails whencerts/is named but empty. — Opus secretfilecaps: bound128·N·rto a stated budget (e.g. 128 MiB) andpto 1-2; refuse the rest withErrHeader; reject trailing bytes after the last chunk. Tests. — Opusatomicfile: 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.3
- 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:afterRequeston the box (or passsource), handlesendError/timeoutwith back-off;RequireAuthanswers anHX-RequestwithHX-Redirect: /logininstead of a 303. A minimal JS test (node + jsdom) or at least an e2e that restarts the panel with Overview open. — Sonnet parsePageclamp;NaNrefused;rate-limitedtag red (add the status tocomponents.go, flip the test); the recalc button posts only what it needs or warns;HandleReloadeither covers DMARC maps and filter lists or the flash and the two comments say what it covers. The log's paging is tested at its edges: first page, last, past the last, an empty log (a row of review-2026-08-followups, placed here 2026-10-14). — Sonnetdomain.Service.Delete: say the domain is deleted when a later step fails, and runapps.Resync()anyway. — Opus- A status filter and a day filter in the Outbound log (owner, 2026-10-13, from the
Not done list of from-header-binding). The mockup
out-loghas drawn a third list since the redesign and the built log never had it; the Inbound log has its counterpart built (domain, decision, day). One list: Any status,queued,sent,deferred,bounced,rejected- the statuses of a row, andrejectedshows all three kinds of refusal together. He was shown the list with the refusals split by reason and chose it without the split. The mockup's own list is wrong (it offersrate-limited, which is a reason, and lacksqueuedandrejected): correct it first (design:commit). And a filter by day, as the Inbound log has it (owner, the same day): the same control, drawn in the mockup first. Both filters survive paging and the rows' polling. - Sonnet - Milter sockets: "Listening" is asked of the socket (owner,
2026-10-13, from the Not done list of from-header-binding).
health.CheckSocket(socket.go) stats the file and writes "Listening"; a process that died and left its socket reads ok while mail is deferred or goes out unsigned. It connects and closes instead, and a refused connection is an error that says nobody answers. Find out first, on the stand: whether the panel's user may connect to OpenDKIM's socket, and whether a connection closed without a byte ends the sender check (security.md § Accepted risks: the milter library panics on an empty packet) - if it does, that is fixed first, in this step. A test with a socket file nobody listens on. - Opus - The
cardslayout takes its phone order from the box's number (owner, 2026-10-13, from the Not done list of from-header-binding). The order on a phone is written inpanel.cssfor four boxes to a stack (.sp-cards > .column:first-child > .box:nth-child(1..4)); a fifth box gets no rule and lands out of place with nothing failing. The box partial sets an order class from its own number (a class, not a style: the CSP allows no inline styles), the stylesheet orders by that class, and a test checks that the numbers of a renderedcardspage run in order. A change to a component: the mockup's components page andtheme.cssfirst (design:commit). - Sonnet - A notification is owed to each recipient, not to the subject
(owner, 2026-10-14; from the reviews of panel-notifications, not found by
the audit).
Notifier.send(notify.go) counts a subject as said when one recipient's copy was handed to Postfix; a recipient whose copy failed, or was "held back" by the hourly ceiling, is never told. Unseen while the owner is the only recipient; the first domain administrator makes it "the global administrator knows the domain's DNS broke, its administrator does not". The state of a subject is kept per recipient (the next migration), and a later check sends to those not yet told - so the ceiling holds a copy back instead of dropping it. A user who gains the right to hear of a subject while it is already open is not mailed about it. "Cleared" goes to those who were told of the problem. Tests: one copy fails and goes out at the next check, once; the ceiling; a user added mid-subject. - Opus - A DNS outage is "unknown", not "wrong" (M14, M15; owner,
2026-10-14 - the two findings had no step). Each resolver gets a time
budget of its own, so a silent first resolver no longer leaves the second
an expired context (dnscheck/resolver.go);
a timeout is Unknown, not "does not resolve" or "no PTR record"
(dnscheck/server.go); a failed lookup
keeps the server's addresses it had instead of emptying them, so SPF does
not go Unknown with it. Notifications: a domain's DNS is reported broken
only on a result that is wrong, never on Unknown - checked in
notify, and tested. Tests: a first resolver that drops packets, a PTR timeout. architecture.md:339-340 and theDefaultResolverscomment say what is true. — Opus - DMARC reports tested where they come in (owner, 2026-10-14; rows of
review-2026-08-followups). The panel parses reports that arrive by mail
from anyone, and the
internal/dmarcservice (service.go,addresses.go) and the DMARC handlers have no tests; no other step touches them. Tests of the service, and of a report that is malformed, oversized, compressed past its limit, or names a domain the instance does not have: refused or skipped with a reason, nothing stored, the panel alive. A finding is fixed in this step. — Opus - The last untyped page data (owner, 2026-10-13):
healthChecks(handlers_status.go) returns a struct instead ofmap[string]any, so a mistyped key inhealthPage/healthCardsis a compile error and not an empty card on Server › Health. No template change. This closes what is left of the roadmap's formertemplate-data-typing. — Sonnet - Upstream TLS mode validated at save (L5): saving an inbound domain
whose
[host]:portis already used by another domain with a different TLS mode is refused with the validator's text naming that domain and its mode;TestRenderInboundMapsgets 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/opendkimkeepselfpostfor the file paths. — Opus - OpenDKIM:
Syslog yes(supervisord captures it) orLogfile;Canonicalization relaxed/simple;OversignHeaders From. e2e: DKIM still verifies; a message with twoFrom:is refused before signing anyway. — Opus - Postfix:
smtpd_tls_protocols = >=TLSv1.2,smtpd_tls_mandatory_protocolslikewise,disable_vrfy_command = yes,smtpd_banner = $myhostname ESMTP; integer validation ofRATE_LIMIT_*,*_SIZE_LIMIT,MILTER_*_TIMEOUTat the top ofpostfix-config.shwith 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-permissionsbranch. — Opus extract-cert.sh:set -o pipefailunderbash, check both outputs are non-empty PEM before moving them into place, create0600first. — Opuscrashexit: subscribe toPROCESS_STATE_EXITEDwith a restart-count threshold, or raisestartsecsso a crash after 1 s still reaches FATAL; say in architecture.md what actually exits the container. — Opusenvdoc_testmade real: parseenvDefault|envInt|os.Getenv("…")out ofcmd/panel/*.goand${VARout ofbuild/*.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: done ahead on 2026-10-13/14 with the owner -
template-data-typingstruck,structured-loggingdropped,review-2026-08-followupsclosed with each row placed in a stage or dropped with its reason. What is left: check that every Test gaps entry above is named by the step that touches its code, and add it to that step where it is not. — 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
staticcheckadded totest.ymlso 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_policyper 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.Headerssaying whyhis ignored, andTestSenderCheckResetsBetweenMessagespassing anhthat still holds the first message'sFrom:(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.