Technical review (Opus) and security review (Fable) of a59e3d4..cb5a4a2;
reports in the session's scratchpad. Nothing critical, high or medium; no
outright bug. Fixed:
- F2, F3 (0008 backfill, before it runs on the live database): rows are
filled by the rule new rows follow - the application's domain, then the
name's - and only by a domain and application that existed when the row
was written (created_at); the inbound log likewise. Test: the migration's
UPDATEs re-run on prepared rows (before the domain, after, null sender,
application gone, a stranger's name). Dry run on a copy of the live
database: 68/68 and 10/10 rows filled, none disagreeing with its login's
domain, integrity ok.
- V2, F4: an application's statistics and level-2 count are its login's
rows of its domain (appByLogin), and StatsWindow starts no earlier than
the entity's creation; the message page's "may send as" only for a login
still of the row's domain. Test: a login taken again in another domain
starts at zero.
- F7: MAIL FROM:<> is held to the domain limit of its login
(ApplicationByLogin fills DomainName).
- V3: an imported DKIM selector is held to DNS labels (validate.Selector).
- F5, F6: an imported auto limit's numbers are dropped and recalculated
right after the import; its mode is stored trimmed.
- V6: the store refuses a role that is neither global nor domain.
- F9: SaveUser keeps the stored hash when no new password is given (the
form no longer writes back the hash it read). F10: a reach naming a
domain deleted meanwhile is ErrNoSuchDomain, answered on the form.
- V4, F12: from-lines-other.txt - lines naming another sender ({other}) in
a quoted string, a quoted pair, capitals, a comment, an encoded word, a
quoted local part; the unit test wants each refused, the e2e
(lines_naming_another_sender_never_signed_for_it) wants each refused and
none signed for it. The passed list gained a.{DOMAIN}, a comment after
the angle address, a quoted local part, and "b@ {other}" - passed by
design (security.md), and the e2e shows it signed for the own domain.
- F13: e2e login_in_other_capitals_does_not_authenticate - AUTH with the
login upper-cased gets 535.
Not changed, written down: V1/F1 - L8 is half closed (refused or passed
still tells an application which domains are hosted here); security.md's
accepted risks and the CHANGELOG say so, and the owner is asked whether to
refuse every foreign address in a display name (changes the 2.4.0 rule).
F11 - deleting a domain rewrites its rows' domain_id (seconds at 1e5 rows),
accepted at this size. F8 (test cannot tell write-first from read-first;
the reviewer verified it with two stores, 30/30), V5, V7, P2: noted.
Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 with the three new cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-14: show connections turned away and messages deferred
mid-message apart, not mixed. The stage 2b step now says: smtpd's warning
to the existing column (Connections turned away), cleanup's to a new column
of sender_check_days in the stage's migration (Messages deferred), both on
the card, the help topic saying what each counts; mockup first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
security.md
- § Roles: the last-global guard is inside the transaction that deletes or
demotes (write, then count); setup is one statement; a user is saved in
one transaction. A log row, outbound or inbound, belongs to a domain by
id; a domain added again starts with an empty log. The Inbound › Log row of
the table says by id.
- § Mail path, sender check: the agreement of the check and OpenDKIM is
tested (the shared list of passed From: lines); the refusal does not say
which other sender a line names.
- Accepted risks: "a row of the inbound log belongs to a domain by name"
struck (L12).
guide.md
- Domain export and import: the file is checked like the forms before
anything is created; a login keeps its case and a case clash is refused.
- The application form: a new login is stored in lower case.
schema-migrations.md was updated with 0008 in its commits. Audit plan line
ticked; stage 2's lines are all done. Roadmap 22/62.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
L8: a From: line naming another sender of the server was refused with
"it names another sender of this server, <domain>" - a confirmation, to
whoever tries names, that the domain is hosted here. The reason now stops
before the domain; the line as written stays in the reply (the client's own
text) and on the refused message's page (header_from). Unit test: the reason
of every refusal names none of the server's other domains; e2e: the reason
of each sender-check refusal does not contain other.e2e.test.
Which key OpenDKIM signs with (owner, 2026-10-13): the check and OpenDKIM
read From: with two parsers, and their agreement was argued, never run.
internal/milter/testdata/from-lines-passed.txt holds the lines the check
passes for an application - bare and bracketed, quoted names with a comma,
escaped quotes and an address inside, an extension, three encoded charsets,
two folded lines, comments (nested too), a stranger's address in the name -
as Go-quoted strings with {domain}. The unit test wants each passed; the new
e2e case passed_from_lines_signed_for_their_domain_only sends each as app1
and wants every DKIM signature at the sink to be for the sender domain,
with one valid. All fifteen were. "(the shop) a@domain" - a comment before
the address, valid RFC 5322 - is refused by the check ("it must be one
address"); strict, not a hole, so it is not in the file. Groups are refused
too.
Audit plan lines ticked (two) with what was found, CHANGELOG [Unreleased],
roadmap 21/62.
Evidence on the test server for this tree (with a618ffb): go vet clean, go
test ./... 23 packages ok, make e2e exit 0 - the new case passed in 4 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A domain user's log, the message page, a domain's statistics, the level-2
count and the notifier's refusal count picked send-log rows by domain name,
so a domain deleted and re-added under its old name showed its earlier mail
to the next assignee and went on counting it.
Migration 0008 (not run on the live database yet, so extended here):
send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ...
ON DELETE SET NULL, filled once by name from the registry as it is at
migration time, each with an (id, created_at) index.
- Writes: InsertQueued / InsertRejected set domain_id to the domain of the
row's login (loginDomainID) - the journal milter and the sender check's
refusal rows both go through them; the inbound journal sets
inbound_domain_id from the inbound domain's name at insert.
- Reads: SendLogFilter.DomainIDs (was Domains, by name) and
InboundSpamFilter.DomainIDs are the scope; the global role still reads
everything. The message page checks row.DomainID against the user's
domains. DomainSendStats and CountMessages keep their name argument and
select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier
joins send_log.domain_id to the domain and its application.
- DMARC reports stay by name on purpose (a report is about the DNS name).
Tests: store - scope by ids; a re-added domain's log, level-2 count and
statistics hold its own row only, the global log both; inbound scope by
ids. Handlers - the new assignee gets 404 for the old row's page and does
not see it in the log, the global role sees both. Tests that wrote rows for
a login that is no application now register it (a row is a domain's through
its application). Schema at 0008 with the two columns and indexes.
schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 19/62.
Evidence: go vet ./... clean; go test ./... ok locally but for the two
Windows-only tests; the server run follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
domain.Import trusted the file for everything but the domain name, the
selector and the password: DMARCRua was not validate.Email'ed, client IPs
not parsed, a rate limit's mode, window and count not checked - a window of
0 counted nothing, a garbage IP made the application refuse every client.
handlers.checkDomainImport runs in HandleImportDomain after the domain name
check and before Import, and refuses the file with "Invalid <what> in export
file: <the form's own text>": the report address (validate.Email), each
rate limit (parseRateLimitMode; auto - checkAutoMultiplier, 0 meaning the
default as an empty field does; manual - checkManualLimit, which the form
now uses too: count > 0, <= the level-1 backstop, window > 0; both 0 is an
export without a limit of its own), each application's client addresses
and the legacy ones on its rate limit (parseIPList, written back as the
form stores them; allow-list on with none is refused). Login, sending
addresses and password stay checked in app.ImportApplication.
Tests: a good export passes and its addresses come out normalised; nine
broken fields each refused with their message; a refused file posted to the
handler creates no domain.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 18/62.
Evidence: go vet clean; the import, rate-limit and application tests of
./internal/web/handlers ok; the server run follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
M7: DeleteUser counted the global users and then deleted; the handler's
demotion and CreateGlobalUser checked and then wrote. MaxOpenConns(1)
serialises statements, not pairs: two globals deleting or demoting each
other at once could leave none. Now:
- DeleteUser: a transaction that deletes first (taking the write lock) and
then requires a global left (requireAGlobal) - ErrLastGlobal rolls back.
- SaveUser: likewise for a demotion, the count inside the transaction after
the write. The handler's early count stays only to answer before hashing a
password; SaveUser decides, and ErrLastGlobal maps to the same message.
- CreateGlobalUser: INSERT ... SELECT ... WHERE NOT EXISTS (SELECT 1 FROM
users), one statement.
M9: submitUserUpdate made five writes in a row (UpdateUser, sessions,
SetUserRole, ClearUserReach with its error only logged, SetUserReach).
store.SaveUser(UserChange) writes name, hash, e-mail, role and reach in one
transaction (a global user's reach cleared); sessions end after it commits.
The create path had the same flaw - user, reach and then e-mail in three
writes - and now goes through AddUser, one transaction; CreateUser stays as
a wrapper for its many test callers. SetUserRole is gone; writeUserReach is
the reach write inside a transaction.
Tests: store - two globals demoting, and deleting, each other from two
goroutines: exactly one refused, one global left; a refused SaveUser (taken
name, a reach to no domain, nothing assigned) changes nothing; promoted to
global loses the reach; AddUser with a bad reach leaves no user; four setups
at once create one user. Handlers (the users pages, direct): the whole
change saved, back to domain with a reach; the last global not demoted and
the typed rename not written; a domain user with nothing assigned refused,
nothing changed; create with e-mail and reach.
CHANGELOG [Unreleased], audit plan lines ticked (two), roadmap 17/62.
Evidence: go vet ./... clean; go test ./internal/store ok; the users tests of
./internal/web/handlers ok; the server run follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Technical review (Opus) and security review (Fable) of 9efc9c7..a59e3d4;
reports in the session's scratchpad. Fixed:
- B1 (verified bug): store.UpdateStatus read before writing in its
transaction; a commit by another connection in between (the dmarc-ingest
process opens the same file) failed it at once with "database is locked"
and the delivery line was lost. The first statement is now a write.
- R3: a delivery line updated every row of its queue id and recipient; it
now updates only the newest (Postfix reuses short ids). The sweep closes
by row id (QueuedDelivery.ID, CloseUnreported(id)).
- R4: a contradicted sweep keeps a mark, reason "reconcile-contradicted",
instead of losing which row it was.
- R1: contradicted cannot see a lost line (never read late). New counter
reconcile_missed: at close, the sweep looks for the row's delivery line
in mail.log and mail.log.1 (QueueLines). 0007 edited - it has run on no
database but throwaway stands.
- R2: queued_over_lifetime cannot measure "what removing the sweep would
leave" while the sweep runs; its text now says rows stuck despite the
sweep, and the rule uses closed - missed - contradicted.
- cert-reload (R5, R6, R7, security medium/low): both files copied to .new
and the copies checked as a pair before both are renamed; acts on the copy
only when postconf says Postfix reads it; a failed reload is retried next
pass; umask 077; says so when the mounted files cannot be read. The e2e
case asserts Postfix reads the copy, the copy equals the mount, the key is
600 root.
- Security low: a recipient with a quoted local part - the journal cut
"a b"@x at the space, deliveryRe cut "a>b"@x at the ">" - matched no
delivery line and was closed by the sweep. cleanAddress ends a bracketed
address at the first ">" outside quotes; deliveryRe takes the recipient
up to the ">, " before relay= (test fixtures given the relay= every real
delivery line has).
- Security low: postqueue -p's error carries at most 512 bytes of output.
- Nits: addDeliveryLogDay refuses a column not on its list; the truncation
test checks its message.
- To stage 2b (page wording, mockup-first): the history step of a bounced
row says the receiving server refused it, false for a row the sweep
closed. To the owner when stage 2b is reached: since M13 the "deferred"
figure counts whole messages too, which "Connections turned away" would
not name.
Found while verifying: the e2e's main positive path failed once with "553
not owned by user app1" - an smtpd idle from before the panel's reload took
the connection in the reload's second. Measured on the stand (one-off
probe, not committed): sending 0, 10, 40, 110 s after the save (two seconds
at the soonest) always met an smtpd started after the reload. The e2e now
waits for the sender map after each application it creates
(waitForSenderMap: AUTH, MAIL, RCPT, RSET - no message, no row), and the
level-1 case uses it instead of its own loop.
Docs: architecture § Queue reconcile, schema-migrations (rows 7 and 8,
chain head 8), CHANGELOG, audit plan (stage 1 note, the measured window,
a new stage 2b line: 62 lines), roadmap 15/62.
Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 (renewed certificate, hung panel, level 1 at
49/60).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Postfix's reject_sender_login_mismatch compares the SASL login with the
sender map without regard to case - seen on the e2e stand this time (a
one-off probe, not committed: map rewritten to "APP1", client authenticated
as "app1", RCPT accepted). The panel stored logins as typed under a BINARY
UNIQUE, so "Ops" and "ops" were two applications to the panel and one sender
to Postfix: a tenant could send with another tenant's envelope sender.
- app.foldLogin: Service.Create lower-cases the login (owner: fold, do not
refuse); CreateWithSettings rolls back the stored (folded) login.
- ImportApplication keeps the login as written: folding it would rename the
SASL account and break the imported application's working credentials;
the index below refuses a case clash there too.
- Migration 0008 (DDL): CREATE UNIQUE INDEX idx_applications_login_nocase ON
applications (login COLLATE NOCASE). The column's own BINARY UNIQUE stays
(SQLite cannot change a collation without a table rebuild, which a
migration transaction cannot do with foreign keys on). Checked on the live
installation first: 4 logins, no case clash; "Quieter3C" keeps its case.
0008 is not run anywhere yet; send_log.domain_id joins it later in stage 2.
Tests: "Ops" created as ops, SASL account and map binding in lower case,
OPS in another domain and an imported oPs refused; the store refuses ops
beside Ops; schema at user_version 8 with the index.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 15/61.
Evidence on the test server for this tree (with the probe): go vet clean,
go test ./... 23 packages ok, make e2e exit 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
architecture.md
- § Log tailer: the rotation drain does not close the gap entirely
(postlogd writes to the renamed file until the reload reaches it); the fix
waits for the measurement. Delivery lines read at the head of the line
(M12).
- § Queue reconcile: reason "reconcile", the 4 MiB cap, the conditional
close; the measurement (delivery_log_days, its four counters) and the
decision rule written before the data.
- § Sender check, what it costs: cleanup meets a check that hangs mid-message,
with the warning seen on the stand; counted once per message.
guide.md
- § Outbound log: `bounced` is the panel's word when Postfix no longer holds
a message and the log never said; it is counted.
- § Overview and Health, Sender check: the message a hung panel deferred is
counted too.
Audit plan line ticked, roadmap 14/61. Stage 1's code items are done
(the two deferred tailer fixes wait for the data, as planned).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seen on the e2e stand, the panel frozen with SIGSTOP once DATA was
answered: the client gets 451 4.7.1 after 30 s, and the warning that names
the socket is cleanup's, not smtpd's:
postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sender.sock:
can't read SMFIC_HEADER reply packet header: Connection timed out
postfix/cleanup[1339]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE ...
4.7.1 Service unavailable - try again later; ...
The counter read smtpd lines only, so Health's "messages deferred" missed
the one case that tells a hung panel from a stopped one. milterLineRe now
takes smtpd and cleanup; cleanup's "milter unix:<socket>:" warning counts a
message each time (one cleanup process serves message after message, and the
first warning ends the message); smtpd keeps once per connection. The
composed fixture is replaced by the seen lines, with two messages through one
cleanup process and OpenDKIM hung in cleanup (not counted).
e2e panel_hung_mid_message_is_counted: freezes the panel after DATA's 354,
wants a 4xx at the end of DATA, logs what mail.log says, and waits for
Health's today count to grow. kill through sh -c: the image has no kill(1).
CHANGELOG [Unreleased], audit plan line ticked, roadmap 13/61.
Evidence on the test server for this tree (with the next commit's docs):
go vet clean, go test ./... 23 packages ok, make e2e exit 0 -
renewed_certificate_picked_up 1.2 s, panel_hung_mid_message_is_counted
31.7 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
postfix-config.sh copies the mounted certificate and key into
/etc/postfix/tls-internal at start (root-owned, key 0600) and Postfix reads
the copy; cert-reload only ran `postfix reload` daily, which re-read the
stale copy, so a certificate renewed while the container ran expired on
465/587 with the old one.
postfix-cert-reload.sh now, every TLS_RELOAD_INTERVAL_SECONDS: compares the
mount with the copy (cmp); when they differ and the certificate and key
belong together (openssl public keys equal - a renewer writes them one
after the other), writes each file beside its target, root-owned with the
start's modes, renames it over the target, and reloads Postfix. Nothing
changed - no reload. No copy (files missing at start) - the old periodic
reload. Never exits non-zero.
e2e renewed_certificate_picked_up: writes a new certificate into the
stand's mount and waits (30 s) for port 465 to present its serial;
compose.override.yml sets TLS_RELOAD_INTERVAL_SECONDS=2.
supervisord.conf comment, architecture.md (process table, § Postfix TLS
certs, the env knob), guide (after the proxies: picked up within a day).
CHANGELOG [Unreleased], audit plan line ticked, roadmap 12/61.
Evidence: GOOS=linux go vet in test/e2e clean; the server run of the stage
follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pattern that writes a row's status matched anywhere in a line: a
port-25 reject line ending helo=<ID: to=<victim@...>, status=sent> parsed
as (ID, victim, sent). parseDelivery now takes the stamp off with
SplitTimestamp and matches `^host program[pid]: <id>: to=<...>, ...
status=`, as queueLineRe and the inbound patterns do.
Tests: the probed HELO line, the same text in a quoted sender on a qmgr
line, the text after an smtpd line's queue id - none parse; a syslog-stamped
line as Postfix 3.7 writes it parses. The existing cases unchanged.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 11/61.
Evidence: go test ./internal/logtail ok but for TestFollowTailsAndRotates
(Windows); the server run of the stage follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 0007 (DDL only): delivery_log_days, a row per UTC day, kept a
year, shown nowhere - read off a copy of the database after a month by the
decision rule written in the audit plan.
- reconcile_closed: the sweep adds the rows it actually closed.
- reconcile_contradicted: store.UpdateStatus, in one transaction, counts the
rows with reason "reconcile" a delivery line now speaks of, writes the
log's status and clears the reason - so a re-read line counts once.
- tailer_blind_seconds: follow() tracks stretches in which the file is gone
or reopening fails (logtail.blindness); stored when a stretch ends and
every minute while it lasts; under 3 s not counted (the rotation's tick
between rename and create loses nothing).
- queued_over_lifetime: every sweep overwrites the day's level with the rows
queued past LongestLifetime + MaximalBackoff (a message Postfix still
works on stays queued until its first retry after the lifetime).
- retentionLoop prunes days older than a year.
Tests: store (sums per day, the level overwritten, under a second nothing,
prune; a contradicted sweep counted once and its reason cleared; count of
queued rows); logtail (the sweep counts what it closed; countStuck records
the level; blindness - a one-tick gap not counted, 75 s stored as 60 + 15
from where the last part ended). Schema tests at user_version 7.
schema-migrations.md: chain head 7 (it still said 5 since 2.4.0), row 7.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 10/61.
Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail
ok but for TestFollowTailsAndRotates (Windows); the server run of the stage
follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reconcile() closed a row through UpdateStatus (WHERE queue_id AND to_addr),
which could overwrite a "sent" the tailer wrote between the listing and the
close, and rewrite an old row of a reused queue id. store.CloseUnreported
sets status bounced and reason "reconcile" (store.ReasonReconcile; the
0006 column, no schema change) WHERE ... AND status = 'queued'; the sweep
counts rows actually closed.
Tests: store - a queued row is closed with the reason, a sent row is left
alone (0 rows); logtail - the sweep's close goes through CloseUnreported.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 9/61.
Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail
ok but for TestFollowTailsAndRotates (Windows rename of an open file, fails
here always); the server run of the stage follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QueueIDs parsed Queue()'s text, which caps postqueue -p at 4 MiB and
appends a note, and returned the ids before the cut with no error: the
reconcile sweep then closed every queued row past the cut as bounced while
Postfix still held the mail. listQueue() now returns the text and whether
it was cut; QueueIDs refuses a cut listing with an error (reconcile already
treats an error as no information), the way postqueueJSON does for the
notifier. Queue() keeps showing the cut listing with its note.
postqueueP is a var so tests run a fake postqueue (the test binary as a
helper process): 5 MiB -> QueueIDs errors, Queue ends with the note; 1000
bytes -> ids read.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 8/61.
Evidence: go test ./internal/postfix ok (Windows); the server run of the
stage follows before the push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage 0 of docs/audit-2026-10-12.md, 7/7: the release image says which
release it is (H1), the e2e module is compiled on every push (M20),
test/ is out of the build context (L33), the documents say the 2.x image
is not published and show the local build (M6), dependencies raised,
staticcheck clean, the negative e2e cases held to their own refusals
(which showed the level-1 rate-limit case had never seen the limit).
CHANGELOG cut; pin 2.4.2 in deploy/docker-compose.yml, README (with the
local-build note), guide; development.md "from pin" and the planned cuts
of the audit's stages (2.5.0 ... 2.6.0); roadmap row in progress and the
recommended order; stage 0 marked shipped in the plan. No tag (the
owner's command only).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sendResult.refusedAt(stage, code, text) and replyIs(err, code, text):
nil only for the server's reply with that code and text at that stage;
a reset, a timeout or another reply is described for the failure.
- level 1: only anvil's "450 4.7.1 Error: too much mail from", only after
a message of the loop was accepted. Held to that, it failed twice on
the stand, each time for a real reason:
1. waitForSMTPSReady took a TCP connect for readiness; Docker's port
proxy accepts before Postfix listens and then resets - the reset
once taken for the limit. It now waits for the TLS 220 greeting.
2. the first message then got "553 ... not owned by user l1app": an
smtpd idle from before the panel's postfix reload took the
connection with the old sender_login_maps. The level-1 limit had
never been what the case saw. The first message now waits (15 s at
most) for the map, failing at once on any other refusal.
Now: "level-1 limit tripped on attempt 51/60: 450 4.7.1 Error: too
much mail from 10.77.0.1".
- level 2: 451 4.7.1 at MAIL, and both MAIL FROM of one connection.
- sender mismatch: 553 at RCPT, "not owned by user".
- no AUTH and the relay case: 554 "Client host rejected: Access
denied" at RCPT; the relay case's comment claimed
reject_unauth_destination, which on 465 comes after the client
restriction - port 25 holds "554 Relay access denied".
- port 25: 550 5.1.1 for unknown recipients and report addresses.
- panel down: a 4xx reply (textproto), not just text starting with 4.
The product side (a second after an application is saved when its first
message can get 553) is written in the audit plan, not changed. Audit plan
line ticked with what was found, roadmap 7/61. No CHANGELOG line: tests only.
Evidence: make e2e on the test server for this tree, exit 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- handlers/authz.go: domainIDSet removed, nothing called it (U1000).
- handlers_outbound_pages_test.go: two refusals whose status code was
assigned and never read (SA4006) now check it is 400 - a custom DMARC
report mode without an address, export passwords that differ.
- view/pages_test.go: !strings.Contains for a missing box (S1003).
- Audit plan line ticked, roadmap 6/61. No CHANGELOG line: nothing an
operator sees changes.
staticcheck ./... clean afterwards. Evidence on the test server: see the
previous commit (same tree run).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- go get ...@latest + go mod tidy: sqlite 1.53.0 -> 1.60.1 (libc
1.77.1, memory 1.12.1), x/crypto 0.54.0 -> 0.58.0, x/sys 0.47.0 ->
0.49.0, go-isatty 0.0.24. The go line reads 1.26.0 (tidy's spelling of
1.26); no dependency asks for a newer Go.
- development.md: golang:1.26-bookworm floats to the newest patch, and
the standard library's security fixes arrive that way - rebuild the image
after each Go patch release; dependencies are raised by hand.
- CHANGELOG [Unreleased], audit plan line ticked, roadmap 5/61.
Evidence on the test server (Go 1.26.5) for this tree plus the next
commit's staticcheck fixes: go vet clean, go test ./... 23 packages ok,
make e2e exit 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README, guide and deploy/docker-compose.yml pinned
ghcr.io/mixeme/selfpost:2.4.1, which does not exist: ghcr.io holds 1.0.0
and 1.3.0 only. The owner decided (2026-10-12) that old tags and images
stay untouched and the pin points forward, made true by the next
publication. Until then:
- README Quick start and guide Fixed image tag say the 2.x line is not on
ghcr.io and show `docker build ... --build-arg VERSION=2.4.1 -t
ghcr.io/mixeme/selfpost:2.4.1 .`; with the image present locally,
docker run and Compose (pull_policy missing) use it without pulling.
- compose header says the same in two lines.
- development.md release steps: bump the version in those notes at each
cut, remove them at the first publication of the line.
- CHANGELOG [Unreleased], audit plan line ticked, roadmap 4/61.
Documents only; no test reads the pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- test.yml: after go test, `go vet ./...` and `go test -c -o /dev/null .`
in test/e2e - compiled and linked, not run. Not `go test -run xxx` as
the plan said: TestMain would still run and bring up the stand. The Go
cache now keys on test/e2e/go.sum too.
- .dockerignore: test (e2e leftovers in test/e2e/.stage - DKIM keys,
sasldb2 - no longer reach the builder; the sink image is built from
test/e2e/sink as its own context, so it is unaffected).
- development.md (test.yml line), CHANGELOG [Unreleased], audit plan
lines ticked, roadmap 3/61.
Evidence on the test server for this tree: make e2e (no
SELFPOST_E2E_VERSION, so the "e2e" default path) exit 0, including
image_stamped_with_version and the logrotate-mode build; in test/e2e
go vet ./... and go test -c -o /dev/null . clean; docker build of
build/Dockerfile with a file under test/e2e/.stage succeeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The e2e gate rebuilt selfpost:e2e through compose.override.yml with its
own VERSION=e2e, and that rebuild is the image release.yml pushed: every
published image printed "e2e", and the backup/restore version check
compared a backup's version with "e2e".
- compose.override.yml: VERSION: ${SELFPOST_E2E_VERSION:-e2e}.
- release.yml: the e2e step gets SELFPOST_E2E_VERSION = the release's
X.Y.Z; a new step before the push checks panel -version and
selfpost-backup -version of selfpost:e2e against it.
- e2e: image_stamped_with_version checks both binaries against
expectedVersion() (the variable, or "e2e" when unset).
- development.md, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 1/61.
Evidence: make e2e on the test server for this tree with
SELFPOST_E2E_VERSION=2.4.2-stamp - exit 0, TestE2E/image_stamped_with_version
PASS, and `docker run --rm --entrypoint <bin> selfpost:e2e -version` prints
2.4.2-stamp for panel and selfpost-backup. GOOS=linux go vet in test/e2e
clean. Published images are left as they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed with the owner, one question at a time; documents only, no code.
From from-header-binding's Not done list, each a step of the audit plan:
the Sender check column says "Connections turned away"; a stand test of
which key OpenDKIM signs with; Health connects to a milter socket instead
of stat-ing it; the cards layout orders a phone by the box's number;
a refusal the panel did not write is recovered from mail.log; a log
rotated while the panel was down is read to its end; the deferred counter
keeps a place in the log; downtime is counted when supervisord is silent.
"Today, UTC" became the roadmap item local-time (agreed): one instance
zone, no labels beside a time, days counted in that zone, with the
conditions for per-user zones and offsets, and the trap of mail.log's own
stamps written down.
The six steps on the log tailer are split off stage 1 into stage 2b, a
cut of its own (2.5.1); stages 3 and 4 move to 2.5.2 and 2.5.3.
Also: negative e2e cases pass only on their own refusal (stage 0);
notifications are owed per recipient (stage 4); a DNS outage is unknown,
not wrong (M14, M15 had no step); DMARC reports tested where they come in.
review-2026-08-followups is closed, its rows placed in stages or dropped
with a reason. Certificate warnings by lifetime: left as is, with the
condition for return.
The audit's checklist is 61 lines (was 48).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed with the owner, one question at a time; documents only, no code.
- csrf-tokens -> origin-strict (agreed): no tokens, the origin check
refuses a request with neither header. security.md's ADR records the
decision and when tokens are built after all.
- structured-logging, contributing: dropped, each with what brings it
back (new roadmap section "Dropped, with the condition for return").
- template-data-typing: done by 2.0.0; its last map (healthChecks) is a
step of the audit's stage 4.
- preflight -> health-setup-checks (certificate name, Postfix's greeting,
trusted proxy as lines on Health) and test-email, both agreed. The port
check is dropped with its reason; plans/preflight.md removed.
- password-reset: agreed; a reset link goes only to a confirmed e-mail,
so the item builds confirmation first.
- phone-desktop-mode: agreed, after stages 1 and 2 of the audit.
- backup-reauth (agreed): the account password before a backup or a
domain export, out of review-2026-08-followups.
- from-header-binding, Not done: the Delivery log box is required on a
refused message's page, with the line Postfix wrote, for all three
kinds of refusal (audit stage 1); the Outbound log gets a filter by
status and by day (audit stage 4).
The audit's checklist is 48 lines (was 45).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Stage 0 then 1+2 agreed; H4 folds logins; H3 measured without a screen
(delivery_log_days, decision rule, no card); L5 validated at save; L12 the
send log gets domain_id; L8 the reply names no other domain; L9 local
comment and test only; stage 5 taken whole. Migrations renumbered
0007-0009.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Docs only. docs/audit-2026-10-12.md: findings of a full-tree audit (four
high, twenty-one medium, the rest), doc drift, test gaps, what was checked
and found sound, and a six-stage plan; roadmap index row; CHANGELOG cut and
pin bump. Old tags and images stay as they are (owner): fixes go forward.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
from-header-binding (docs/plans/from-header-binding.md, 9/9), at the owner's
word (2026-10-12, "Да").
- A message's From: line is held to what its envelope sender is held to:
any address of the application's domain, or the addresses of its list,
with or without a +extension. Anything else is refused with 550 5.7.1 -
another registered domain, the server's own name, a domain this server
does not sign for. Until now an application could have mail signed with
another domain's key by writing that domain into From:.
- The check is the panel's, first in the milter chain and strict: while
the panel does not answer, Postfix defers mail.
- Server > Health counts what that costs - the panel's downtime under a
running Postfix and the submissions turned away - and is laid out anew,
boxes numbered by importance.
- A refused row of the Outbound log says why in its tag.
- Migration 0006.
Upgrading: mail whose From: is not the application's own stops being
accepted - another of your domains, an address off an application's list, a
visitor's address a form puts there. The guide says what to do about each.
A stopped panel now stops submission.
Not done, written into the plan at the owner's word: the Delivery log box on
a refused message's page, the log's status filter, "messages" that are
connections, and what was reasoned about rather than run - OpenDKIM's own
parser above all.
Cut: CHANGELOG 2.4.0 written (nothing had been put under [Unreleased]
while building); image pin 2.4.0 in compose, README, guide; version table,
roadmap, plan status. security.md: a malformed packet on a milter socket,
as an accepted risk. No tag: the owner tags when he decides to, so there is
no image of this version until then.
On the test server, on c817f08: go vet ./... ok, go test ./... ok
(23 packages), make e2e ok. This commit changes documents and the pin only.
Full detail: CHANGELOG.md (2.4.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner on the three things the reviews' commit left as found: can they be
fixed? They can.
- Downtime that crossed a UTC midnight was all booked on the day of the
restart: it is cut at the midnights between (byDay).
- A message with the null envelope sender - a bounce an application writes
itself - left a refusal row without a domain, which no domain
administrator would see: the row takes the domain the login is bound to.
- A log read again after an unclean stop counted its deferrals again: while
the tailer catches up, a warning no later than the last one counted is
passed over. The mark is kept to the fraction of a second the log gives.
And on the check being stricter than Postfix for an address with an
extension (invoices+x@ passed in the envelope, was refused in the header):
it makes Postfix's second try now, the address without its extension.
recipient_delimiter was the distribution's default; it is set in
postfix-config.sh, since the check depends on it. e2e sends one through.
Test server, this tree: go vet ok, go test 23 packages ok, make e2e ok (339 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both reviews were of c171fb5, read-only, given the code and questions.
Found by both, and the one that would have refused ordinary mail from the
first day: a folded From: line - what a mail library makes of a long display
name - did not parse and was refused with 550. The line is unfolded before it
is read. e2e sends one through Postfix now.
Technical (Opus)
- A display name in a charset Go does not convert (windows-1251, koi8-r) was
refused: such a name is passed through unread, only the address is judged.
- The statistics ran between binding the socket and serving it, with a
supervisorctl that has no timeout: they are kept beside the check now and
nothing of them is waited for. A supervisorctl that fails is logged.
- The count of deferred mail followed smtpd's reject lines and so missed a
check that stops answering in the middle of a message, where cleanup
writes them: it is taken at Postfix's warning, the line that names the
socket. The test line for that case had been invented with the wrong
process; it is marked as composed from Postfix's source.
- A card whose figures could not be read showed zeros and "ok": dashes and
"unknown".
- entrypoint removes the sockets a killed container left, which satisfied
the wrapper's wait before the check was listening.
- Outside quoted strings the line may hold one "@" only - a rule that needs
no knowledge of OpenDKIM's parser.
- Comments that still spoke of two sockets, or of a session replaced after a
refusal; the limits of the cards layout on a phone, said where it is defined.
Security (Fable; no Critical or High)
- Another sender's name in an encoded display name passed the second look:
the decoded name is looked at as well.
- A client could arm the deferred counter by sending the socket's name where
Postfix quotes it in a warning: the two warnings are matched from their
first word.
- A refused message wrote a row per recipient: one row, naming the first
recipient and how many more.
- Docs: the check's lookup is stricter than Postfix's for address extensions
and the case of logins, rather than "cannot disagree"; what a display name
looks like to a person is not promised; the timeouts and the 454 at AUTH
stated as they are; "messages" are connections turned away.
Left as found, in the plan: downtime that crosses midnight is booked on the
day of the restart; a message with the null envelope sender leaves a row
without a domain; a log read again after an unclean stop counts again.
Test server, this tree: go vet ok, go test 23 packages ok, make e2e ok (330 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A message's From: line is held against the application that sends it, under
the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by
that line, so until now an application could have mail signed as another
domain of the server or as the server itself.
The check (internal/milter/sender.go)
- A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in
smtpd_milters and strict: default_action=tempfail. The journal milter keeps
its socket and stays fail-open. postfix-wrapper waits for the new socket.
- It reads no database: what an application may send as comes from Postfix's
own sender_login_maps, what the server signs for from OpenDKIM's
SigningTable. Both are read again when they change; one that cannot be
read defers mail.
- One From: field, one address, allowed by the envelope's lookup; then no
other name of the SigningTable may appear anywhere in the raw line.
Refused: 550 5.7.1 naming the line, reduced to printable ASCII.
- A refusal is a send-log row with reason "from" and the header address
(migration 0006), written by another goroutine and not waited for. The
journal's refusals record their reason too (limit, client), and the
notifier takes only a limit's.
What it costs (docs/plans/from-header-binding.md, Statistics)
- The role marks itself alive every 2 s; at start the gap since the mark is
downtime, counted from Postfix's start if later and not at all when
supervisord says Postfix is not running.
- The log tailer counts the connections Postfix turned away: a warning that
names the socket, then milter-reject "Service unavailable" from the same
smtpd. Seen on the stand: the client is refused from CONNECT on and leaves
at AUTH (454), so no MAIL line is ever written.
- sender_check_days, a row per UTC day, kept a year.
Pages
- Health: the card "Sender check", the socket's row, and the cards layout -
boxes numbered by importance in two stacks read in rows; the left stack
polls, the three readings of the right one are swapped in by id.
- Outbound log: a refused row says why in its tag (wrong From, wrong client,
rate-limited); the message's page shows the From: line and what the
application may send as.
- Help: topic "Sender check", word for word the mockup's.
Found by the stand, fixed here: the names for the second look came from the
sender bindings, and a domain with a key and no application yet was not
among them.
e2e: negative_journal_milter_fail_open is gone - it proved that mail passes
with the panel down, which is no longer so. In its place
sender_check_holds_the_from_line (another domain's From:, the hostname's, a
stranger's, an address off the list, another sender in the display name -
refused; own addresses - delivered) and panel_down_defers_mail (4xx with the
panel stopped, the container alive, mail again after, downtime and one
deferred message on Health, the card warning).
Test server, this tree before the last cosmetic edits (a fixture row, a
fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s).
Locally after them: internal/web/view ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The topic was added with the Health card; its entry in the page's menu was
not. check.py: 0 violations, outlines unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accepted by the owner on 2026-10-12 from screenshots, one change at a time.
- health: a card "Sender check" (today / 30 days / 12 months: panel not
answering, messages deferred) and a row for its socket.
- health: the boxes are numbered by importance and laid out as `cards` -
two stacks, odd numbers on the left and even on the right, each closed
up. His words on the row layout: too many holes; and on stacked columns
numbered down each side: the third box was below the fold while the
fourth was not. On a phone the boxes come in the order of their numbers.
- theme.css, components: the `cards` layout; the first pair of stacks of
the components page is laid out so.
- out-log: a row refused for its From: line, tagged "wrong From".
- help: topic "Sender check"; "rejected" in the Outbound log topic names
the three reasons.
outlines.json rewritten (check.py --write): the structure of health changed,
and help has one more topic under Outbound. check.py: 0 violations.
The pages of internal/web follow in their own commit; until then the
outline guard fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The design questions were closed with the owner on 2026-10-12, one at a
time; the plan records what he chose and what I had proposed instead.
- The rule: From: is held to what the envelope is held to - any address of
the application's domain, or the addresses of its list. Anything else is
refused, a domain this server knows nothing of included (I had proposed
to refuse only names the server signs for).
- The check is the panel's and strict: while the panel does not answer,
Postfix defers mail. It gets a socket of its own so that the journal
stays fail-open, and reads Postfix's own file of sender bindings.
- Server > Health counts the panel's downtime under a running Postfix and
the mail deferred for it, to decide later whether the check needs a
process of its own.
- A refusal is 550 5.7.1, and its reason is the row's status tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
panel-notifications (docs/plans/panel-notifications.md, 10/10), at the
owner's word (2026-10-12, "Да").
- Four things are reported by e-mail, each once: a sending domain's
DNS record that does not match, a level-2 rate limit refusing mail,
the TLS certificate about to expire, a message more than three days
in the queue. Global users are told all of it, domain users about
their own domains.
- Sent from postmaster@<SELFPOST_HOSTNAME> and signed with a DKIM key
of the server's own name, which the panel generates.
- Account has the user's switch, Server > Settings the instance's and
the two DNS records to publish, Server > Health their state.
- An account e-mail may now be at a public mail host.
- Migrations 0004 and 0005.
Upgrading: publish the two records Server > Settings shows for the
hostname (a DKIM key and "v=spf1 a -all"); until then notifications are
sent and may be filed as spam. Every user with an e-mail starts with
notifications on. non_smtpd_milters now names OpenDKIM.
Accepted and recorded in security.md: an application can have a message
signed as the server's own name by writing it into From: (roadmap
candidate from-header-binding).
Cut: CHANGELOG [Unreleased] -> [2.3.0]; image pin 2.3.0 in compose,
README, guide; version table, roadmap, plan status. No tag: the owner
tags when he decides to (2026-10-11), so there is no image of this
version until then.
On the test server, on f09b60a: go vet ./... ok, go test ./... ok
(23 packages), make e2e ok.
Full detail: CHANGELOG.md (2.3.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neither review (read-only agents, Opus and Fable, over 972a9f8..400aa76) has
a Critical finding. Both found the same serious one, and more:
- The notification about refused mail was built on what a rejected row says
of itself. Its domain is the one in MAIL FROM, which the client typed and
Postfix has not yet held against the login when the milter refuses: an
application over its limit could have had the panel write a message per
made-up domain, to another tenant's users among others. And the same
status is written when an application is refused for its client address,
which is no limit. Refusals are now counted by the domain the login is
registered under, only for logins that exist and only while a limit is set
on the login or its domain; one subject per sending domain. They are read
through a partial index of their own (migration 0005): the query walked
the whole send log every minute.
- Account and user e-mails were checked with the DMARC report validator,
which refuses Gmail and the like - the owner's own kind of address. A new
validate.AccountEmail accepts them and refuses a local part beginning "-".
- "Said" now means sent: what arises while nobody can be told, or while
Postfix does not take the message, is told at a later check. Its ending
with nobody left to tell is simply over.
- Two DNS checks must be 45 minutes apart as well as two: the last check
before a restart and the first after it were minutes apart.
- The notifier's own mail waiting in the queue, and its bounce coming back
to postmaster@ this server, are not the queue's subject. A listing cut at
its size limit or with a line too long to read is no listing; postqueue -j
gets 30 seconds.
- The tables must name the selector the panel shows, not only have the line.
- An a/mx lookup that did not answer inside the SPF check made the record
"not authorising"; it is "cannot tell" now, for the pages too.
- A ceiling of 60 messages an hour; a pass stops between domains when the
process is asked to stop; sendmail gets a WaitDelay; days are rounded as a
person counts; a failed read of the switch shows it on, not off.
Recorded as accepted in security.md, with a roadmap candidate
(from-header-binding): an application can have a message signed as the
server's own name - or, as always, as another registered domain - by writing
it into the From: header, which nothing holds against the login.
Left as found: a recipient whose copy failed while another's went out is not
tried again; a certificate that lives under 14 days is reported at each
renewal.
On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (326 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found on the test server after the rollout of b905f3e: the SigningTable had
the three sending domains and no line for selfpost.mixfed.ru. The tables are
rebuilt when a domain is added or deleted, and nothing had done either since
the upgrade - notifications would have gone out unsigned until something did.
The e2e stand did not show it: it adds a domain right after its start.
The notifier now asks at its start whether the table on disk has the line
and rebuilds the tables once if it does not (domain.Service.EnsureServerSigned).
A failure is logged and costs the signature, not the message.
Checked on Windows: go vet clean; go test ./internal/domain passes apart from
TestWriteLoadPrivateKeyRoundtrip, which fails there on file modes and always
did; the new test holds one rebuild, none the second time, and that a longer
name is not taken for the server's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):
- Account: "Send me notifications" in the Profile form, saved with the
profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
mail with the DKIM and SPF records of the server's own name, drawn with the
record partial of a domain's DNS page and checked in DNS. Sending rate
limits are box 02 in the left column. A record that is not in place is
tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
and a link to Settings, beside Configuration (07); part of the polled
fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
account, word for word with help.html.
If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.
e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.
On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
guide.md gains Notifications: the four things, when each is said and said
again, who is told, the two switches, and the two DNS records of the server's
own name that make the mail arrive. architecture.md: the notifier as the
fifth role, its readings, state, recipients and sending; the server's own key
in OpenDKIM's tables; non_smtpd_milters. security.md: the panel submits mail
of its own - how, to whom, and what text from outside can reach a body.
That text - a queued message's sender, a DNS finding - is reduced to
printable ASCII and cut before it is printed. Refused mail is looked for
every minute instead of every five: it is one indexed query, and a refusal is
mail not being sent now. The image's note on the panel's postdrop membership
no longer calls it read-side only.
go test ./internal/notify passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
internal/notify applies the owner's rules. Each subject has a row of state,
so a thing is said once and a restart says nothing again:
- DNS of a sending domain: said after two hourly checks in a row found a
record wrong, said to be over after two found it right. A lookup that did
not answer moves nothing; a warning (no DMARC record) is not a subject.
- TLS certificate: at 14 days and at 3, per certificate.
- Queue: a message older than three days, once; once more when none is.
- Level-2 limit: from the rejected rows of the send log, every five minutes;
at most once a day per domain and application.
Global users are told everything, domain users about their domains; one
message per recipient. With the instance switch off nothing is checked and no
state moves. A message that could not be handed to Postfix is tried again at
the next check.
Mail leaves by sendmail(1) as postmaster@<hostname>, addresses after "--",
no shell. For it to be believed the server's own name is signed like a
sending domain: domain.Service keeps a key for it and a line in OpenDKIM's
tables without a registry row, and non_smtpd_milters now names OpenDKIM. A
sending domain of the same name shares its key with the server.
dnscheck.ServerMail checks the name's DKIM and SPF records;
postfix.QueueMessages reads postqueue -j for arrival times.
The notifier is a sixth role of the panel process. Nothing in the panel
shows or switches it yet.
Checked on Windows: go vet clean; go test passes for notify, store, postfix,
dnscheck, cmd, and domain apart from TestWriteLoadPrivateKeyRoundtrip, which
fails there on file modes and always did. Not yet run in a container: whether
the unprivileged panel may submit is for the e2e step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 0004_notifications.sql, DDL only: users.notify, a user's own
switch, on by default; notify_state, one row per subject the panel has
written about (dns:<domain>, cert, queue, limit:<domain>:<login>), so that
each thing is said once and a restart says nothing again.
Store: the instance switch as a settings key, on unless turned off;
SetUserNotify; NotifyRecipients - global users for everything, domain users
for the domains on their list or all of them, only with an e-mail and their
switch on, a shared mailbox once; the state's get, set, delete and list by
prefix; LimitRefusalsSince, the mail refused under a level-2 limit per domain
and application.
Nothing is sent yet. Checked on Windows: go vet clean, go test
./internal/store passes with the schema test at user_version 4. The page
guards fail from the design commit 87fa0a4 until the pages are built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accepted by the owner on 2026-10-12 from screenshots, after two changes of
his: DKIM for the server's own name as well as SPF ("we need the best
deliverability for these messages"), and the records themselves under
Settings with only their state on Health.
- account: a checkbox "Send me notifications" in the Profile box, under the
e-mail it writes to. No box of its own, no choice per event.
- settings: box 03 Notifications with the instance switch; box 04
Notification mail with the two DNS records of the server's own name, DKIM
and SPF, drawn with the record component of a domain's DNS page. Sending
rate limits move to the left column as box 02.
- health: card 06 Notification mail - the state of each record and a link to
Settings - beside Configuration, now 07, each half the width.
- help: a Notifications topic (what is sent, when, to whom, what makes it
arrive); Settings and Your account each gain a clause.
outlines.json rewritten with check.py --write: the structure of settings and
health changed. build.py: 35 pages; check.py: 32 screens, 52 sp- classes,
0 violations. The plan records both of his changes; step 1 of 10.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-12: "Делаем". Roadmap status agreed, 0/10; planned cut
2.3.0. The first step is the mockups, shown to him before any code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discussed on 2026-10-12, one question at a time; still a candidate.
plans/panel-notifications.md records the answers:
- Four events in the first version: a domain's DNS record stops matching,
a level-2 limit refuses mail, the certificate is about to expire, a
message is in the queue for more than three days (his number; the roadmap
said one).
- From postmaster@<hostname>, not from a sending domain: with several the
choice is arbitrary, and a certificate is no domain's subject. An SPF
record for the hostname makes such mail pass; Server > Health will check it.
- One checkbox per user instead of a choice per event; one instance switch.
- Hourly checks; a DNS mismatch is sent after two checks in a row; "cleared"
messages for DNS and the queue only; certificate at 14 and 3 days; a limit
at most once a day.
roadmap.md follows, and no longer says the screens are drawn: the box was
taken out of the mockups on 2026-10-10.
Docs only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
delivery-log-storage (docs/plans/delivery-log-storage.md, 8/8), at the
owner's word (2026-10-12, "Да").
- The log tailer stores what Postfix writes about queued outbound
messages; a message's page reads it from the database, so the
Delivery log box no longer empties when mail.log rotates. The lines
live as long as the send-log row and go with it.
- Mail that came in on port 25 is not stored.
- Migration 0003 (send_log_lines), DDL only.
What an operator should know: the stored lines are 1-2 KB a message
and are in a full backup, which mail.log is not (security.md). A
message accepted before this version shows its lines only while they
are in the current file.
Cut: CHANGELOG [Unreleased] -> [2.2.0]; image pin 2.2.0 in compose,
README, guide; version table, roadmap, plan status. No tag: the owner
tags when he decides to (2026-10-11), so there is no image of this
version until then.
On the test server, on a990e7c: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.
Full detail: CHANGELOG.md (2.2.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Neither review (read-only agents, Opus and Fable, over ae06d8b..815236a) has
a Critical or High finding. What they found and what was done:
- A stranger on port 25 could grow the table: every queue-id line of inbound
mail was stored and pruned an hour or more later, his own message-id among
them. The tailer now leaves out the lines of a queue id that
postfix/inbound/smtpd opened, until qmgr removes the message or another
listener opens one under the id; 4096 such ids are remembered.
- A commit, so a disk sync, per stored line on the connection the journal
milter shares, and a backlog written line by line. Lines are now written in
one transaction per pass over the log, 500 at most, before the read offset
is saved (InsertSendLogLines; follow() calls the keeper's flush).
- The prune asked the send log once per line and ran hourly. It asks once per
queue id and runs at start and with the six-hourly sweep; with port-25 mail
left out there is little without a row to wait for.
- The 4096-byte cut could split a character; it keeps characters whole.
- security.md did not say that a backup now carries these lines, and the plan
said the time window excludes a reused queue id where it only limits the
chance to the row's queue lifetime. Both are corrected; the collision is
recorded as an accepted risk with its trigger.
- Tests that did not prove their comments: the prune is now tested with the
sweep, a rejected row next to the prune, batching and the change from
catching up to following, port-25 mail left out and its id reused.
Left as found and written down in the plan: lines the tailer never reads
(a file rotated away while the panel was down), and a message in the queue
across the upgrade, which shows only its later lines.
On the test server, for this tree: go vet clean, go test ./... 22 packages
ok, make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New step delivery_log_survives_rotation: the page of the first delivered
message must list the line that opened its queue id and its delivery line,
then mail.log is rotated with logrotate -f, the current file is checked to
hold no delivery line, and the page must list both lines still. Before this
feature the page searched the current file and would show none.
The step runs after negative_level1_ratelimit: placed right after the first
delivery it rotated away the "connect from" line the level-2 step reads from
the current log, and that step failed.
On the test server, for this tree: go vet clean, go test ./... 22 packages
ok, make e2e ok (126 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
architecture.md § Log tailer gains "Stored lines": what is kept and why the
queue id is read from the head of a line, why the tailer does not check for a
row before writing, the one prune rule that is the table's retention, how a
row is given its lines when Postfix reuses queue ids, the write-once rule
while catching up, and the size. QueueLines is described as what it is now,
the fallback for rows without stored lines. guide.md: the message page keeps
its lines after rotation; retention also bounds the kept lines, which are in
a full backup.
Docs only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Delivery log box on a message's page was filled by searching the current
mail.log on every view; that file is rotated daily, so the box was empty for
anything not sent today. deliveryLog now asks the store for the row's lines -
its queue id, dated within the row's queue lifetime plus a day
(RetryPolicy.LongestLifetime) - and shows the latest 200, oldest first.
When nothing is stored the page still searches the current file, which is
what shows a message accepted before 2.2.0. When that finds nothing either,
the note no longer blames rotation for the ordinary case: it says lines are
kept with the row since 2.2.0. The Outbound log help topic in help.go follows
the mockup changed in fc184ec, word for word.
Checked on Windows: go vet clean; go test ./internal/web/... ./internal/postfix
./internal/store pass, the guards among them. New test: lines shown from the
table when mail.log holds none of them, without another queue id's lines and
without those of an earlier message under the same id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The topic said a message's lines are found in mail.log by queue id and that an
old message may show none, because that file is rotated. With
delivery-log-storage the lines are kept with the row: they stay on the page
for as long as the row does and go with it. A message accepted before 2.2.0
shows them only while they are in the current file.
Mockup source and its built page only; help.go follows with the page.
build.py: 35 pages; check.py: 32 screens, 52 sp- classes, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every mail.log line headed by a queue id is written to send_log_lines as the
tailer reads it, dated by the line's own stamp. The id is taken from the head
of the line only - the tail is the far end's text - and has to look like one:
"NOQUEUE:" and "warning:" stand in the same place.
The tailer does not check for a send-log row first: a message's first lines
come before the journal milter writes it. Lines that never get a row are
removed by the prune, which deletes lines older than an hour whose queue id
no row carries. It runs at start, after each send-log sweep and hourly.
While the tailer catches up at start a line is written only if it is not
there already, as the inbound journal does: its place is saved every few
seconds, so an unclean stop means reading some lines twice.
Checked on Windows: go vet clean; go test ./internal/logtail passes except
TestFollowTailsAndRotates, which fails there on renaming an open file and
always did. New tests: which line heads are queue ids, a line dated by its
stamp, no second copy after a rewound offset, the prune cadence and cutoff.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 0003_send_log_lines.sql, DDL only: queue id, the line's own time,
the line. No reference to send_log, because a message's first lines are
logged before the journal milter writes its row and one queue id is several
rows.
Store methods: InsertSendLogLine; InsertSendLogLineOnce for lines the tailer
reads twice after an unclean stop; SendLogLines, which gives a row the lines
of its queue id dated from an hour before the row to the queue lifetime after
it, so a later message under a reused id is not shown; PruneSendLogLines,
which deletes lines whose queue id has no send-log row - the whole of the
table's retention. A line is cut to 4096 bytes.
Nothing writes or reads the table yet. Checked on Windows: go vet and
go test ./internal/store pass, with the schema test pinning the new table,
its index and user_version 3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner agreed the item on 2026-10-12 (option 1 of the roadmap entry: store
the lines). plans/delivery-log-storage.md records what that means in detail:
every mail.log line headed by a queue id goes into send_log_lines with the
line's own time; a line is a row's when the id matches and its time lies
within the row's queue lifetime, which is the bound against Postfix reusing
ids; a line whose id has no send-log row is deleted after an hour, which is
both the retention and the clean-up of inbound and bounce lines; the page
reads the table and falls back to the current file for rows older than it.
Eight checklist steps, cut 2.2.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-12: take quarantine out of the main plan, it is an idea
to think about. Its index row and section leave roadmap.md; a new closing
section, "Ideas, not on the roadmap", keeps a paragraph and the link to the
notes in plans/inbound-quarantine.md. panel-notifications no longer lists
"mail is held in quarantine" among its events. The plans that called it a
candidate say what it is now. The drawn screens stay unbuilt, as they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
inbound-antispam-panel (docs/plans/inbound-antispam-panel.md, 12/12),
at the owner's word (2026-10-12, "Да").
- Inbound mail is recorded: accepted mail by a second, read-only
milter on port 25 with the spam filter's verdict, refused mail from
the mail log. The filter signs what it judged, and a verdict is
shown only next to its signature.
- Inbound > Log, with a sheet over the page that says who decided
and why; Inbound > Filter lists, allow and deny entries written as
rspamd maps, each with how often it decided a message.
- Migration 0002 - the first after the 2.0 baseline.
Security, outside the feature and in every release since 1.9.0: the
level-2 rate limit and an application's client IP list held only for
the first message of a connection. Fixed; e2e sends two MAIL FROM on
one connection.
Upgrading an installation with the antispam sidecar: copy
deploy/antispam/ again (the fragment mounts two new things) and
restart the sidecar. Port 25 now logs as postfix/inbound.
Cut: CHANGELOG [Unreleased] -> [2.1.0]; image pin 2.1.0 in compose,
README, guide; version table, schema-migrations, roadmap, plan status.
No tag: the owner tags when he decides to (2026-10-11), so there is no
image of this version until then.
On the test server, on 1f44e40: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.
Full detail: CHANGELOG.md (2.1.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The routine kept the key after its first read. SelfPost makes a new key
when the file is lost or damaged, or a backup brings back another, and
rspamd would then have gone on signing with the old one - every verdict
unrecorded - until someone restarted the sidecar. The file is one short
line; reading it per message costs nothing worth keeping a copy for.
The guide says what never needs doing: nothing of rspamd's is cleared
after a change of the lists or the key; a restart is for a change of
its own configuration only.
Prompted by the owner's question whether rspamd's cache needs clearing
(2026-10-12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes what the security review left as an accepted risk: with the
spam filter down - it is attached fail-open - nothing removes verdict
headers a sender wrote himself, and the inbound journal recorded his
verdict as the filter's. The owner asked whether rspamd could add a
header that proves the others are ours (2026-10-12) and agreed to a
signature: a fixed mark could be written by a sender as easily as the
verdict.
- inbound.FilterKey: a key of 32 random bytes, 64 hex characters, in
/data/antispam/filter.key, made on the first start with the relay
on. 0644 like the list files beside it: the filter is another user
in another container.
- deploy/antispam/rspamd/milter_headers.conf: a custom routine reads
the key through the directory the sidecar already mounts and adds
X-SelfPost-Filter, the HMAC-SHA256 of the message's queue id, hex;
a header of that name from the sender is removed.
- internal/milter/inbound.go: the verdict headers are kept as they
are read, and at end-of-message - when the queue id is known - they
count only if the signature fits (FilterSignature, hmac.Equal). No
key, no queue id, no signature or a wrong one: the row is that of
mail nobody judged (accept, selfpost, no detail).
The signature goes on to the upstream with the mail and is of no use
there: it fits one queue id.
Tried in a throwaway rspamd 3.11 container on the test server before
the Go side was written: the routine loads, signs
(2aa1ca6d... for key 0123...cdef and queue id 4A1B2C3D5E, the value
python's hmac gives and TestFilterSignatureIsWhatTheFilterWrites
pins), removes a sender's header of the same name, and adds nothing
without a queue id.
Tests: signed, unsigned, made-up, another message's, another key's,
the sender's followed by the real one, the next message on the
connection, no queue id; the key made once, read after, replaced when
damaged, left alone by the lists. e2e: filter.key is there, 644, 65
bytes.
docs/security.md: the accepted risk is replaced by a narrower one (the
key is readable on the host); Mail path describes the signature.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner asked for them to be put right (2026-10-12).
- A refusal is not logged twice after an unclean stop. The tailer
saves its place every five seconds, so on start it reads some
lines again; for the lines it reads on start a row is written only
if the same row is not there already (store.InsertInboundSpamOnce:
same moment, client, sender, recipient, decision, engine, detail).
Lines read as they are written are always new, so two identical
refusals in one second stay two rows.
- The Inbound log offers Deny / Allow sender only for a sender the
lists would take (inbound.Listable): an envelope sender may be
spelt in ways the form refuses, and the link led to a refusal.
- A network of one address is stored as the address: 192.0.2.7/32
and 192.0.2.7 were two entries of one list.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steps 11 and 12 of docs/plans/inbound-antispam-panel.md: 12/12. Two
read-only reviews of d65d632..HEAD by different models (Opus,
technical; Fable, security), each given the code and questions, not
conclusions. Nothing Critical or High in the feature; the High the
technical review found outside it is the commit before this one.
Log tailer (internal/logtail/inbound.go):
- the reply of a refusal ends at the first "; from=<", Postfix's own,
and the HELO name after it is whatever is left. Taking the longest
reply let a HELO name that spelt "; from=<..> to=<..> proto=.."
supply the row's sender, recipient and reply - and so make a
refusal read as the deny list's;
- an address with a quote, a bracket or a space is not kept: a quoted
sender could otherwise supply the recipient;
- the journal is cut to its row cap every 500 rows written, not only
every six hours;
- a queue id is forgotten when the message is queued or the id is
opened by another listener, and a stale place in the queue of
arrivals no longer drops a live id;
- milter-hold lines are not read: the held message reaches the
journal milter, which writes its row - it was written twice.
Lists (internal/inbound/lists.go): a change of the table and the
rewrite of the files are one step under a mutex; a failed rewrite puts
a removed entry back and rewrites after taking a new one out.
Store: the row cap removes the oldest by time, as the page orders them.
Page: on a sheet a refusal's text is a Reply whatever it starts with.
Help and the guide say that an allow entry for a sender address trusts
an address anyone can claim (design commit before this one).
docs/security.md: the two reviews; the roles table gains Inbound > Log
and Filter lists; Mail path describes the milters, the tailer and the
lists; four accepted risks - the filter's verdict while it is down, an
allow entry by sender address, the list files' mode, log rows held by
domain name. The plan's stale lines (size and rate refusals, who reads
the journal) are corrected.
Left as found, told to the owner: the journal's rows after an unclean
stop may be written twice (the tailer saves its place every five
seconds); a "Deny sender" link is offered for an address the form then
refuses; 1.2.3.4 and 1.2.3.4/32 are two entries.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security fix, outside the inbound antispam feature. Found by its
technical review (Opus, 2026-10-12), reproduced on the test stand
before the fix: over an application's level-2 limit, two MAIL FROM on
one connection were answered 451 and then accepted.
Cause. Postfix introduces a client to a milter once per connection.
The milter library (go-milter 0.4.1, session.go) replaces the milter's
session after any reply that is not "continue" - a refusal, or the
accept that used to end a message. The new session never saw Connect,
so it had no client address, and both checks passed a message without
one: overLimit returned early ("no client IP to key on") and
authIPAllowed did the same. So every message after the first on a
connection, and every MAIL FROM after a refused one, skipped the
level-2 limit and an application's client IP list. In the code since
1.9.0 for the list and longer for the limit.
Fix.
- build/postfix-config.sh: milter_mail_macros carries {client_addr},
so the address comes with every MAIL FROM; MailFrom reads it (both
spellings of IPv6) in the journal milter and the inbound journal
milter.
- overLimit no longer asks for a client address: a ceiling is on a
domain or an application.
- authIPAllowed refuses an application that has a client IP list when
no address is known.
- Body replies "continue" in both milters, which lets the message
through as "accept" did and keeps the session.
The inbound journal milter had the same cause with a smaller effect:
rows of a connection's later messages had no client address.
In the same file, the checks of INBOUND_ANTISPAM_MILTER and
INBOUND_JOURNAL_MILTER_SOCKET refuse any character outside their set
first: a glob's "*" matched anything after the first characters.
Tests: a session that never saw Connect is judged by the macro, a
listed and an unlisted address, no address, not an address; the limit
with no client address; the inbound milter answers only "continue".
test/e2e sends two MAIL FROM on one connection over the limit and
wants both refused.
Checked on the test server: e2e red before the fix on exactly that
check, green after; go vet ./... ok, go test ./... ok (22 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the security review of 2026-10-12: the allow list matches the
envelope sender, which nobody authenticates. Help's Filter lists topic
says so and points to client addresses and networks.
check.py: 32 screens, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner accepted Inbound > Filter lists on 2026-10-12 ("Да"), after
a deny entry he added refused my test message on the live test server
with the list's own reply (554 5.7.1 Refused by this server's deny
list), mail.log carried the milter-reject line the count reads and the
journal its row.
Steps 8-10 of the plan were done with each earlier step and are ticked
with where. 10/12: the technical and the security review remain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the design commit before it (owner's words quoted there).
The lists' tables are Value, Kind, Matched and Remove. The kind tag
opens the entry's sheet: note, day added, how often it decided a
message in the days the log keeps, and the last match.
Matches are read off the journal on each view, nothing is stored
(inbound.Lists.Usage, store.ListSpamListHits). A row counts for an
entry when the list decided the message - a rejected row whose reply is
the deny rule's message, an accepted row whose verdict carries the
allow rule's symbol for that kind - and the entry fits the row's sender
or client address the way rspamd matches it: an address whole, a domain
as the sender's domain, a network containing the client (an
IPv4-mapped address as IPv4). A failure to count shows the lists with
"never" and logs why.
For that the journal milter keeps the lists' own symbols ahead of its
six-symbol cap: rspamd writes symbols heaviest first, so an allow
entry's -100 came last and was cut behind any six others.
The plan gains "Filter list usage": what counting from the journal
implies, and - the owner asked for it in writing - the conditions under
which a counter kept with each entry becomes worth a migration (the
journal cut by its row cap and not by age; a short retention; counts
that must outlive the journal; a slow page). multimap.conf says which
of its texts SelfPost reads back.
Tests: Usage over every kind on both lists and the rows that are
nobody's; the milter's symbol order; the store query; the page and its
sheets; a handler test from journal rows to the numbers on the page.
Seen: the fixture page with a sheet open, headless Edge at 1360 px -
the same as the accepted drawing.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Not seen in a real run: a count above zero. It needs an entry added in
the panel, which I cannot sign in to, and then mail that the list
decides; in particular the deny rule's message arriving in mail.log as
the reply of a milter-reject line is what the trial with rspamc showed,
not yet what Postfix logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-12, on Inbound > Filter lists as built: "Note хочется
вынести в лист, подсказку, раскрывающийся блок. Тип в отдельную колонку.
Ещё полезно будет дать статистику использования правила, чтобы удалять
устаревшие." Drawn, then accepted ("Да") together with counting the
matches from the Inbound log.
- Kind is a column of its own; the tag no longer wraps under the
value.
- Note and Added leave the table for the entry's sheet, opened by the
kind tag (of his three - sheet, tooltip, disclosure - the sheet: it
exists and works under a finger).
- Matched: how often the entry decided a message, or "never". The
sheet adds the window ("in the last 90 days") and the last match.
A fifth column for the last match did not fit half the page width.
Help, Filter lists: what Matched counts and what "never" suggests.
check.py: 32 screens, 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 7/12 of docs/plans/inbound-antispam-panel.md. A full backup walks
all of /data, so data/antispam/ was in it from the day the directory
existed; TestCreateIncludesStateExcludesTransient now holds that. On
the first start after a restore the maps are written again from the
database (mailStack.Resync, already there). The guide says what is not
in a backup: the antispam/ directory with the compose fragment and the
rspamd configuration, which is installation and not state.
The plan records the owner's acceptance of Inbound > Log (2026-10-12,
"Этот экран утверждаем") and what he changed on the way to it.
go test ./internal/backup/ ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the two design commits before it (owner's decisions quoted
there).
Kit: a new component, Sheet / SheetRow (components.go), partials
sheet_tag and sheet (components.html), its rules in panel.css as in
theme.css, and its sample on the kit page next to status_tag. It is
the browser's popover: no script.
Inbound log: the Why column is gone; the decision tag opens a sheet
with who decided, then either the filter's verdict and its symbols one
a line (a detail the journal milter wrote) or the reply (a detail the
tailer read from mail.log - it starts with its code), and the subject
whole. The sheet's id is the journal row's. A row Postfix itself
refused no longer links to the filter lists.
Tests: the page fixture and view tests for the sheet's markup and
content (TestWhyRows), the link rule, three Deny and two Allow on the
mockup's six rows; e2e reads "Decided by" and "Reply" from the sheet
and wants no sender link on the refused row.
Seen: the fixture page with a sheet open, headless Edge at 1360 px -
the same as the accepted drawing.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sample sheet on the components page still held facts; it shows the
table of name and value the accepted sheet is. In in-log a refused
message's sheet has no Subject row - there is no subject to show whole.
check.py: 32 screens, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11/12, on the Inbound log as built:
- "Why сделать не столбцом, а листом поверх." Opened by the decision
tag ("Тег."), drawn first, then: its content as a table ("В лист
может быть таблицей?" - "Да. Но хорошо бы ещё её отцентрировать."),
with a narrow column of names and only what the row does not show.
- "Почему Deny у того, кто rejected?" - "Убирай": a message Postfix
itself refused offers no Deny sender. Accepted mail offers Deny,
mail the filter refused offers Allow.
A new component, sheet (theme.css, shown on the components page next to
status_tag): the browser's own popover, so no script - the tag is a
button with popovertarget; Esc, a click outside and the cross close it.
It rises from the bottom edge across the window; its content is a table
of name and value in a 44rem column in the middle. A tag that opens a
sheet carries a dotted underline to tell it from a plain tag.
in-log: the Why column is gone and rows are one height. A sheet holds
who decided, the verdict and the symbols (or the reply), and the
subject whole.
Guard: check.py WIDTH_OK gains ".sp-sheet" - the one component laid out
by the window and not by the page. The owner accepted the drawing with
that said.
check.py: 32 screens, 52 sp- classes, 0 violations; outlines.json
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11, of "From · client": "Client в названии столбца
зачем? Мне кажется, оно лишним." The client address stays under the
sender, in mono and muted; an address of that shape needs no label.
check.py: 32 screens, 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 6/12 of docs/plans/inbound-antispam-panel.md. Written by a Sonnet
subagent from the mockups, reviewed and corrected by me.
GET /inbound/log - the journal a page at a time (50), filtered by
domain, decision and day; QueryInboundSpam / CountInboundSpam with the
viewer's inbound domains as the scope. The global role reads all of it
and gets "Deny sender" / "Allow sender" links into the lists; a user
with the domain role reads the rows of the inbound domains assigned to
them, without the links. The plan said global-only for the journal;
the accepted mockup and 2.0's delegation of Inbound say otherwise -
recorded in the plan as built.
GET, POST /inbound/filter-lists and POST .../{id}/delete - global role,
404 otherwise. Add form (prefilled from the query), the two lists with
Remove as the kit's row action, and "Where the lists go" with the
filter's state and when the maps were last written
(Lists.LastWritten). A refused or duplicate entry is 422 with the
message and what was typed.
Server > Settings: "Keep inbound log rows, days" with the relay on.
Help: Inbound log, Filter lists. Navigation: Log for everyone who sees
Inbound, Filter lists for the global role (Shell.ShowFilterLists).
Status tags accept / reject / tempfail join the kit's mapping.
Tests: page fixtures for both screens, held to outlines.json; view and
handler tests (both roles, filters, paging, relay off, add / duplicate
/ invalid / delete, map file content, settings field, help). e2e reads
the journal from the page instead of the database and adds and removes
a deny entry through the panel, reading deny_domain.map after each.
Seen: both pages and Settings rendered from their fixtures and compared
with the mockups side by side (headless Edge, 1360 px) - same layout
and wording.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Guards and mockups only, ahead of the pages (inbound-antispam-panel
6/12). The owner agreed the feature ("Следующая по дорожной карте -
inbound-antispam-panel" / "Да", 2026-10-11) and started this step
("Начинай"), so the two screens are no longer "drawn but not built":
- guard_panel_test.go: in-log and in-filter-lists removed from
deferredScreens. From here TestLegacyRatchetMatchesTheEngine wants
the engine to have both pages - red until the next commit.
- guard_routes_test.go: the four routes join routes.inbound, so they
must exist with the relay on and answer 404 with it off.
Two mockup details brought to what the kit can draw, found when the
pages were built from it:
- help: the two new topics are one paragraph with a break in it, as
every topic is (panel.css has no gap between paragraphs of a topic);
- in-filter-lists: the directory in "Files" is mono at the text size -
the kit's inline code has no small variant.
check.py: 32 screens, 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mockup-first step of inbound-antispam-panel 6/12. The two screens were
drawn for the redesign before the feature existed; this brings them to
what steps 3-5 built.
in-log: the lead says where rows come from and how long they are kept;
a help link in the box head; Why is the engine and the detail as the
journal holds them (rspamd: action, score / threshold, weighted
symbols; postfix and a refusing filter: the reply text). No row
invents what the journal cannot hold: the "selfpost - upstream timed
out" tempfail and the Review link to a quarantine page that does not
exist are gone, a refused message has no subject, an allow-listed one
shows SELFPOST_ALLOW_FROM(-100.00).
in-filter-lists: Remove is a POST - the kit's row action, not a link;
a domain entry is shown as stored, without "@"; "Where the lists go"
names the directory and its six files instead of allow.map / deny.map,
says rspamd rereads them by itself and that Deny wins.
settings: "Keep inbound log rows, days" in the Log retention box.
help: two topics under Inbound, Inbound log and Filter lists, and a
sentence in Settings.
outlines.json rewritten: Help's Inbound box has six topics, was four.
check.py: 32 screens, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 5/12 of docs/plans/inbound-antispam-panel.md.
Store: AddSpamListEntry, ListSpamListEntries, DeleteSpamListEntry.
internal/inbound/lists.go validates and normalises an entry - sender
address, sender domain, client address or network (canonical text, an
IPv4-mapped address as IPv4, /0 refused) - and writes the lists as six
map files under /data/antispam (ANTISPAM_DIR), each atomically, 0644 in
a 0755 directory: the filter is another user in another container. All
six are written on every change and on every start with the relay on,
empty ones included. An entry whose maps could not be written is taken
back out of the table. The writer refuses a stored value with
whitespace, a newline or # in it.
The plan's allow.map / deny.map became one file per list and entry
type: to rspamd an IP map and a sender map are different map types.
deploy/antispam/rspamd/multimap.conf holds the rules; the fragment
mounts it and ./data/antispam into the sidecar. Deny is a prefilter
reject, allow a symbol weighing -100. Tried in a throwaway rspamd 3.11
container on the test server:
- with allow as a prefilter accept, an address on both lists was
accepted and an IP on both was refused - the rule that ran first
decided; with the weight, deny won in all five mixed cases;
- IPv4, IPv6, address (any case), domain (not its subdomains) match;
- a rewritten file is reread in about ten seconds, no restart;
- files that appear after rspamd started are loaded the same way.
No page edits the lists yet (step 6).
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - with the relay on
/data/antispam is 755 and holds the six maps, 644, empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 4/12 of docs/plans/inbound-antispam-panel.md.
The log tailer writes an inbound_spam_log row for mail port 25 refused
(internal/logtail/inbound.go): a "reject:" line of the port-25 smtpd is
Postfix's own policy; a milter-reject / milter-hold / milter-discard
line of cleanup is the filter's verdict. Only a recipient at an inbound
domain is a row, so "Relay access denied" for a stranger's domain is
not. The row is dated by its log line; the reply text is cut to 255
bytes of printable ASCII; a reply starting with 4 is a tempfail.
To tell port 25 from 465 / 587 the listener now logs as
postfix/inbound (syslog_name). cleanup logs under one name for every
listener, so its verdict counts only for a queue id the port-25 smtpd
was seen to open (4096 remembered). Lines are matched from the head,
after timestamp and host: their tail is the sender's own text.
Retention: PruneInboundSpam every six hours - older than the
inbound_spam_log_retention_days setting (seeded from
INBOUND_SPAM_LOG_RETENTION_DAYS, 90, range 7-365), then down to 10 000
rows. The setting has no field in the panel yet.
Known limits, in architecture.md: a log line names one recipient, so a
message refused at end-of-message is one row; a line re-read after an
unclean stop would be a second row.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the unknown recipient at
the inbound domain refused on port 25 has its row (reject by postfix),
the stranger's domain has none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A candidate, recorded at the owner's word (2026-10-11, screenshot of
Server > Health on selfpost.mixfed.ru): the body is laid out in columns
while the navigation is the phone stack with every section open. The
item states what was seen and a first guess at the cause (980 px falls
between Bulma's 769 and 1024 px breakpoints); nothing is investigated
yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 3/12 of docs/plans/inbound-antispam-panel.md.
The panel serves a second milter while the inbound relay is on
(internal/milter/inbound.go, socket INBOUND_JOURNAL_MILTER_SOCKET,
default /run/selfpost/inbound-journal.sock). build/postfix-config.sh
puts it last on port 25, after the optional antispam milter, with
default_action=accept; postfix-wrapper.sh waits for its socket. At
end-of-message it writes one inbound_spam_log row per recipient at an
inbound domain - other port-25 recipients (DMARC report addresses) get
none - and always accepts. With a filter attached it reads the verdict
from X-Rspamd-Action and X-Spamd-Result (action, score, up to six
weighted symbols, only what the patterns recognise, 255 bytes); without
one those headers are the sender's and are not read. The plan named
X-Rspamd-Score, which rspamd does not have.
The antispam compose fragment mounts rspamd/milter_headers.conf into
the sidecar so that it adds the two headers (checked in rspamd 3.11:
both routines exist and remove a sender's own headers of those names).
Store: InsertInboundSpam, IsInboundDomain.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the relayed message of
inbound_relay_delivers_and_refuses has its journal row (accept by
selfpost) and the DMARC report sent through the same listener has none.
Not yet seen in a real run: a verdict written by rspamd reaching this
milter. The e2e stand has no filter, and the test server's only inbound
domain has no upstream, so it accepts no mail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 2/12 of docs/plans/inbound-antispam-panel.md, the first migration
after the 2.0 baseline (2.0.1 was cut before it, as agreed).
0002_inbound_spam_log.sql adds inbound_spam_log (one row per recipient:
domain, client IP, envelope, subject, decision, engine, detail) with
indexes on created_at and (inbound_domain, created_at), and
inbound_spam_list (allow / deny entries, unique per list). DDL only;
decision, engine, list_type and entry_type carry CHECKs, as the
baseline does for its own enumerations. Nothing reads or writes the
tables yet.
Tests: TestBaselineSchema pins both tables and user_version 2;
TestOpenMigratesA20Database opens a file holding the baseline alone and
finds its user kept; TestInboundSpamConstraints.
go test ./internal/store/ ok; go vet ./... ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A patch on 2.0.0, all of it found on the test server after the cut:
- a message that is not a report this relay keeps is dropped at once
instead of being retried for days;
- a report is bounded before it is stored, and reports are kept per
domain (security review of the ingest path, nothing Critical or High);
- Full backup from the panel works (it had answered 200 with ten bytes
since 1.3.0);
- no backup holds the Postfix queue, and the panel says why.
Cut: CHANGELOG [Unreleased] -> [2.0.1]; image pin 2.0.1 in compose,
README, guide; version table.
On the test server, on dd0fefd: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.
Full detail: CHANGELOG.md (2.0.1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the private key is needed, and a backup from the
panel and one from the console have to be the same.
The commit that made the panel's backup work skipped a file under
certs/ the panel could not open - on the test server the private key,
which the certificate hook leaves to root and Postfix's group. That gave
an archive with a certificate and no key, and two kinds of archive.
- Params.Unprivileged is gone. Nothing is left out on account of who
makes the archive; the queue is out for everyone (previous commit).
- When the panel cannot read a file under certs/, backup.Check says so
before the download and the panel refuses with what to do on the host:
chgrp 998 certs/privkey.pem && chmod 640. backup.UnreadableCert tells
that case from any other failure.
- 998 is the container's selfpost group, which holds the panel and
Postfix. Its id was whatever groupadd gave; the Dockerfile now fixes
it at the value it has always had, because an operator addresses it
by number from the host.
- Guide: method 1 is the same archive as method 2, with the permission
the key needs and a note for whatever renews the certificate. Help and
the box follow the design commit before this one.
- e2e: the restore scenario first makes the key unreadable and expects
the refusal naming the file and the way out, then takes the backup and
expects the key in it; nothing is put back by hand before the restart.
go vet ./internal/backup/ ./internal/web/handlers/: ok
go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
make e2e on the test server: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the key is needed and the panel's archive must be the console's. The box no longer says a certificate file may be left out; Help says the key has to be readable by the panel (group 998 on the host) and that the panel refuses, naming the file, until it is.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the design commit before it: the paragraph of the Full backup box and the Backup topic of Help.
go test -count=1 ./internal/web/...: ok
Backup page at 1280: no overflow, the box holds the longer paragraph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the user has to be told why. The box gives the reason in a sentence - the queue cannot be copied reliably while the server is running; the Help topic explains it (Postfix keeps moving messages, a copy could hold one twice or miss it), what follows (waiting mail is not carried over, a restored server starts with an empty queue) and what to do to keep the queue (stop the container, archive the project directory).
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the queue goes out of the command's archive too.
The commit that made the panel's backup work left the queue in the
archive of selfpost-backup, which runs as root and can read it. But that
copy was never the queue: the archive is made while the instance runs,
Postfix moves messages between the queue's directories all the time, and
a message on its way could be in the archive twice or not at all. One
rule now - QueueDir is left out of every archive - and
Params.Unprivileged is only about a certs/ file the panel may not open.
What is not carried over is mail still waiting for delivery; a restored
instance builds an empty queue (the e2e restore scenario starts one).
To keep the queue: stop the instance and tar the project directory, as
the guide's methods 3 and 4 already describe.
Guide: the description of a full backup, methods 1 and 2 and the
comparison table. Help follows the design commit before this one.
go vet ./internal/backup/: ok
go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the queue is left out of the command's archive as well as the panel's. The Help topic no longer says selfpost-backup takes it; the command still takes the private key the panel cannot read.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the rate-limit checks the run takes a full backup through the
panel, reads the archive through while the container can still say what
went wrong, then removes data/, certs/, docker-compose.yml and .env,
unpacks the archive into the project directory, puts the private key
back (the panel's archive comes without it and without the queue, both
asserted), and starts the stand again.
What has to hold: manifest.json is consumed by the first start, the
administrator signs in, the domain shows the same DKIM value, and a
message sent with the application's old password reaches the sink and
verifies against the record published before the backup.
The port-25 scenarios then run on the restored installation.
make e2e on the test server: ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the e2e restore scenario of the next commit: the Full backup
button answered 200 with ten bytes. The archive includes the Postfix
queue, Postfix keeps every queue file and directory readable by its own
user alone (not configurable), and the panel runs as its own user - the
walk stopped at /data/postfix/queue/active with "permission denied"
after the headers were out. So since the queue moved under /data in
1.3.0. Confirmed on the test server: selfpost-backup as root makes the
whole archive, the same command as user panel fails the same way.
Owner, 2026-10-11: the panel's archive comes without the queue.
- backup.Params.Unprivileged: the queue is left out, and so is a file
under certs/ that cannot be opened (the private key on the test
server is root and Postfix's group only), named through OnWarn. The
selfpost-backup command leaves it unset and takes both, as before.
- backup.Check reads through everything Create would archive, writing
nothing. The panel runs it before the first byte: a failure is a
message on the page, not a download cut short.
- The box, Help (design commit before this one) and the guide say what
the panel's archive leaves out; the guide's comparison table has rows
for the queue and the private key, and tells to put the key back
before starting a restored instance.
go vet ./internal/backup/ ./internal/web/handlers/: ok
go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
make e2e on the test server, with the restore scenario: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the archive made from the panel comes without the Postfix queue (variant 1). The Full backup box and the Help topic say so, and name the other thing the panel may not read - a certificate file, usually the private key - and the command that takes both.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until now the suite only read back the configuration generated for the
two optional port-25 features. DMARC ingest shipped in 1.7.0 with a
delivery service Postfix could not start, and nothing sent a report
through it until a real one arrived on the test server on 2026-10-11.
The last steps recreate the container with INBOUND_RELAY_ENABLE and
DMARC_REPORTS_ENABLE on, over the same /data, and talk to port 25 as a
stranger's mail server:
- the container log has no postconf warning, the session survives;
- a domain takes the SelfPost-hosted report address; a gzip report
mailed to it appears under Outbound > DMARC reports; an address nobody
chose is refused at RCPT;
- a report on another domain and a message with no report are accepted,
dropped with "dropped: ..." on their delivery lines, counted as two
parse failures, absent from the queue and from the reports;
- an inbound domain with the sink as upstream and one listed recipient:
mail for it reaches the sink, another recipient and another domain are
refused at RCPT.
The stage directory now holds docker-compose.yml and .env, as an
operator's project directory does (a full backup needs them; the
restore scenario that uses it is not in this commit - the panel's full
backup turned out not to work, see the next one).
make e2e on the test server: ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security review of the DMARC report path (Fable, 2026-10-11), which ran
for the first time in 2.0.0. No Critical or High finding. Fixed:
- Medium: a report was bounded only as 8 MiB of XML - some 160 000
records or one name of megabytes, all of it rows and table cells. At
most 5 000 records (more is ErrRejected, dropped), text fields cut to
255 bytes on a character boundary, a record's count capped.
- Medium: the cap of 500 reports was for the whole server, so a stranger
filing under one domain's address evicted every other domain's
reports. PruneDMARCReports counts per domain.
- Low: the pipe service ran up to default_process_limit (100) commands
at once against the panel's database; maxproc is 1.
- Low: the reason logged for a dropped message carried the sender's
bytes (header line, zip entry name) as they came; cut to 200 bytes and
reduced to printable ASCII, zip entry names quoted.
- Low: SELFPOST_DATA_DIR / SELFPOST_DB_PATH with whitespace or "$" would
reach the command through export_environment as another path; refused
at start when DMARC ingest is on.
Left as they are, told to the owner: the parse-failure count is a
lifetime figure shown under "This week"; a report larger than the
command reads is dropped as unreadable; nested multipart is not searched.
The hub's Retention line follows the design commit before this one.
security.md: the review, and the accepted-risk entry says what a
stranger can and cannot do now. The roadmap no longer calls the review
pending.
go vet ./cmd/... ./internal/dmarc/ ./internal/store/: ok
go test ./cmd/... ./internal/dmarc/ ./internal/store/ ./internal/web/...: ok
make e2e on the test server (with the port-25 scenarios of the next
commit): ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retention cap is counted per domain (the security commit after this one); the Ingest box says so: 500 reports a domain or 90 days.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: foreign and malformed reports are turned away.
The command answered every failure with EX_TEMPFAIL, so Postfix kept a
message that could never be stored and offered it again for days, each
attempt adding to the parse-failure count. IngestMessage now marks those
failures (unreadable as an aggregate report, a domain not sent from
here, another domain's report address) with ErrRejected; the command
counts one failure, writes the reason where Postfix puts it on the delivery line of the mail log
and reports the delivery as done. No bounce: the envelope sender of such
a message is whatever its author wrote, and the report addresses are
public. A failing store is still EX_TEMPFAIL.
go vet ./cmd/... ./internal/dmarc/: ok
go test ./cmd/panel/ ./internal/dmarc/ ./internal/web/...: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panel redesign (docs/plans/panel-redesign.md, 27/27). A breaking
release: every panel URL changed and the database starts from a single
baseline - a 1.x /data is not upgraded.
Since the last cut also fixed, all found on the test server:
- the panel can read the mail queue;
- port 25 answers with DMARC report ingest on, also next to the
inbound relay;
- DMARC reports reach the panel (the delivery service never worked
since 1.7.0);
- Postfix start-up warnings about the queue directory.
Cut: CHANGELOG [Unreleased] -> [2.0.0]; image pin 2.0.0 in compose,
README, guide; roadmap, plan and version table.
On the test server, on 252e5d1: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.
Full detail: CHANGELOG.md (2.0.0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first DMARC report to reach the test server (Google, 2026-10-11
09:41 UTC) was accepted on port 25 and then sat in the queue:
postfix/pipe: fatal: missing user= command-line attribute
postfix/qmgr: warning: private/dmarc-ingest socket: malformed response
Three things stood between an accepted report and the panel, all since
the feature shipped in 1.7.0 - no report had ever been delivered:
- The service was addressed as dmarc-ingest/unix-pipe. Its type is unix:
postconf -P answered "unmatched service_name/type" for flags=, user=
and argv= (the warnings in the container log at every start), which
are arguments of pipe(8) anyway and not -o parameters, and the service
stayed a bare "pipe". For the same reason postconf -MX never removed
it. It is dmarc-ingest/unix now, with the arguments in the service line.
- pipe(8) gives the command export_environment and nothing else, so the
command would have refused to run: it reads DMARC_REPORTS_ENABLE (and
the data directory, when it is not the default) from the environment.
Those are now named in export_environment.
- The command took the recipient from RECIPIENT, which local(8) sets and
pipe(8) does not. With no recipient the check that a report came to the
address of its own domain is skipped. It is now ${recipient} in argv,
one recipient per run.
When DMARC ingest is off the service and both parameters are removed.
e2e: every port-25 combination now asks Postfix for the service and the
two parameters (postconf -M / -h), and for their absence when ingest is
off - which is how the wrong type was found. make e2e on the test
server: ok. go test ./cmd/panel/ ./internal/dmarc/ ./internal/postfix/:
ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows the design commit before it: Machine, Processes, TLS certificate, Milter sockets and Reverse DNS lead to /server/health as it opens; so does the link in the Needs attention line, which takes its address from the card.
go vet ./internal/web/...: ok
go test -count=1 ./internal/web/...: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11, after looking at the test server: the anchors work but mean little, Health is short and there is nothing to scroll past. The five cards (Machine, Processes, TLS certificate, Milter sockets, Reverse DNS) lose their #machine ... #hostname, as the links into Domain settings lost theirs.
check.py: 0 violations; outlines.json unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/preflight is /server/preflight and the retention setting is on /server/settings; the routes are renamed, so the plan no longer says once they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>