The security documentation's last open item of panel-dkim-signing (the
owner's decision, 2026-10-16): go-milter v0.4.1 added every header of a
session to a map of its own, emptied only on ABORT, and handed it to
Headers - which no milter of the panel reads. Postfix bounds one header,
not their number, so a message of nothing but headers up to the message
size limit was held whole by the library in the sender check, the signer
and the journal milter on 465/587 and in the inbound journal milter on port
25, where strangers send; the signer's 1 MiB refusal came after it was all
in.
third_party/go-milter is v0.4.1 from the module cache (BSD 2-Clause,
LICENSE kept) with session.go changed: CodeHeader hands the header to
Header and keeps nothing, CodeEOH hands Headers an empty map. PATCHES.md
says what and why, and how to take a new upstream version. go.mod replaces
the module with it; the Dockerfile copies third_party before "go mod
download". TestTheLibraryKeepsNoHeaders holds it through the library's own
client over two messages of one connection. Comments, security.md (the
accepted risk becomes a note), development.md's library table, the plan and
CHANGELOG follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's decision (2026-10-16) on the security review's F1: a Sender:
line - the sending agent's, shown beside From: as "on behalf of", and
signed since 2.8.0 - was neither held to the login nor, before, signed: an
application could write its own From: and Sender: ceo@bank.com under this
server's signature. Now, when a message has one, it must be one address
the application may send as, and at most one such line (RFC 5322 § 3.6);
From: is judged first.
The refusal is "550 5.7.1 Sender: <address> - <why>", a send-log row of
reason "sender" (store.ReasonSender), tagged "wrong Sender"; the message
page names the line. The log tailer recovers such a row from cleanup's line
as it does a From: refusal (fromRefusalRe reads either). Help text as in
the design commit d9d9af3; guide, architecture, security and CHANGELOG say
it, security.md keeps Resent-From: (signed, not judged) as an accepted
risk.
Tests: TestSenderLineHeldToTheSameRule (own address, a name, another
application's, a stranger's, two addresses, two lines, From: first), the
recovery of a Sender: refusal line, the tag, the message page; e2e
sender_check_holds_the_sender_line (own Sender: passes and is signed,
another domain's refused at DATA and logged).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's decision (2026-10-16, security review of panel-dkim-signing,
F1): a Sender: line, shown beside From: as "on behalf of", is held to the
same rule when a message has one. The topic says how a system that writes
for people outside the server's domains does it instead (its own From:, the
person in Reply-To:), and the Outbound log topic names Sender: among the
refusals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
architecture § OpenDKIM becomes § DKIM signing (keys, the selector table
and when it is rewritten, the two sockets, what is signed and the limits,
the old tree); the process table, start order, liveness, the mail-path
diagram, § Sender check, Persistence and the restore Resync follow the
code. security.md: the panel signs with the login's key, the keys are the
panel's alone, group selfpost and CAP_KILL as they are now; OpenDKIM's
accepted risks go; new ones: what sendmail(1) submits is signed as the
server, Sender: is signed but not judged (the owner's decision), and
go-milter keeps every header per session, so the signer's 1 MiB refusal
does not bound the library's copy. guide: Health, the container log's
dkim-signer lines, backups, the long-line limit, and upgrading to 2.8.0 -
with the rollback to 2.7.x: its start reads the queue as its own postfix
(uid 101) before it hands the queue over, so the hand-over by name is run
in the 2.7.x image first (measured on a copy of the test server's queue:
postsuper failed before, passed after). development, README, SECURITY,
product, the assumptions register's header: what left the image.
entrypoint.sh: a comment, the rollback note as the guide gives it. Plan
stage 3 lines ticked, roadmap 12/14.
Written by four agents, one document each, against a brief of what the
code does; reviewed here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first rollout of faa3c11 to the test server crash-looped: without
opendkim the image's package accounts got other numbers (postfix 101 ->
100, postdrop 103 -> 102), the queue under /data kept the old ones, and
postsuper, which "postfix check" runs as the mail owner
(src/postsuper/postsuper.c:1442), could not open it. Rolled back within
five minutes. The entrypoint now hands an existing queue to Postfix's users
by name before postfix-config.sh, and again after it (own_queue).
Technical review (F1-F10):
- F1 high: a backup of an upgraded installation carried /data/opendkim, a
restore left it root's, and the panel refused every start. The tree is
left out of archives, and the entrypoint makes it the panel's alone
(0700/0600; also the security review's F3 - nothing of 2.8 needs the
group any more; a 2.7.x start re-normalises it).
- F2: List-Unsubscribe-Post is signed (RFC 8058); Sender too (security F1).
- F3: SetServerName keeps the name in lower case - a key written under
"Mail.Example.com" was never found. Only the DKIM side: the SASL realm
is compared as written.
- F4: the signer rewrites the selector table at every start
(WriteSelectors, replacing EnsureServerSigned), and tries again every
10 s while a rewrite fails.
- F5: e2e signature_over_awkward_headers (folded, tab-led, no space, two
To:, 8-bit body, List-Unsubscribe-Post) checked by dkimpy;
TestStartsOnA27Data starts on a 2.7 /data (old key tree, a queue of
foreign numbers) and checks the copied key signs, the old tree's modes
and the queue's owners; unit tests for repeated headers and the header
limit.
- F6: the table's writers are serialised (tableMu).
- F7: the table is read for every message; keys the table no longer names
are dropped from memory.
- F8: the signature's value brings its own space (SMFIP_HDR_LEADSPC gets
none from Postfix, cleanup_milter.c:1085).
- F9: a body line over 998 bytes is logged with its domain: Postfix's SMTP
client breaks it after signing (smtp_proto.c:1266-1274).
- F10: the keys are copied while /data/dkim holds none (not only when it
is absent), after the old tree is made the panel's (an install from 2.6
had another uid); the plan's rollback note says what a rollback keeps.
Security review: F2 a header section over 1 MiB is refused (552 5.3.4)
and the kept headers freed once the block is built - fifty connections of
header-only messages could take the panel down; F5 the shown headers a
message has (Subject, Date, To, Cc, Reply-To, Sender) are over-signed,
only when present, since cleanup adds a missing Date: after the milters.
F1 (holding Sender: to the login like From:) changes what is refused and
waits for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.
Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.
Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.
Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.
KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.
e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Health: the opendkim process row goes; the Milter sockets card shows the
panel's signer on its two sockets, dkim-signer · applications (sign.sock)
and · notifications (sign-local.sock) - Q3 of the plan; Configuration
says it reloads Postfix. Domain delete: "stop signing mail as the
domain" for the OpenDKIM reload. System log: mail.log is Postfix's log
(OpenDKIM never wrote to it), its sample line goes. Message page: the
sample's OpenDKIM line becomes a qmgr line. Help, sender check: the
From: line is read as a mail program reads it and must be one address;
DMARC holds the signature to it; the sentence the mockup carried twice
is once. Overview: 5 of 5 sockets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.cursor/rules/panel-ui-system.mdc named docs/assets/panel-ui the source of
truth for mockups; that folder is gone (6099716) and the redesign's rule
(.cursor/rules/panel-design.mdc) is the one in force. The two plans that
mention the removed folders say they were removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/assets/panel-ui (the 1.x experiment, superseded by the redesign) and
docs/assets/panel-redesign/{a,b,c} (the three stage-1 directions; C was
taken) are history - the plan says so - and nothing builds, checks or
links them but the page that compared the directions. That page,
docs/assets/panel-redesign/index.html, which the documents link as the
way into the mockups, is now that: the screen map and what the design
holds, with a line saying where the removed folders are (git history).
build.py and check.py: 32 screens, 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
internal/milter/sign.go: a milter on two sockets (cmd/panel/signer.go):
sign.sock for 465/587, the key of the domain the SASL login belongs to
(Bindings.DomainOf, the sender_login_maps Postfix reads), and
sign-local.sock for the panel's own sendmail(1) mail, the server's name.
go-msgauth/dkim v0.7.0, relaxed/simple, OpenDKIM's dkim_should_signhdrs[]
(libopendkim/dkim.c:223-246) in message order plus From once more; keys
and selectors from the KeyTable the panel writes, a key path outside the
key directory refused; strict - whatever cannot be signed is deferred.
From the sources: Postfix 3.10.13 milter8.c:2329-2400 hands a folded
header \n-separated, so the signer joins its lines with CRLF; inserts are
1-based in cleanup_ins_header() (cleanup_milter.c:1193), so index 0 is the
top, where OpenDKIM puts its own (opendkim.c:3673). go-milter v0.4.1 calls
no Abort when a connection ends, so a watchdog closes go-msgauth's
streaming signer after an hour and a late end of message is deferred.
Postfix: smtpd_milters = sender check, signer, OpenDKIM, journal;
non_smtpd_milters = the signer's local socket, OpenDKIM - both sign every
message until stage 2. disable_mime_output_conversion=yes (default off,
mail_params.h:3088): an 8-bit message is no longer re-encoded after it is
signed.
Tests: unit tests sign and verify folded, tab-led and space-free headers,
an added From:, a changed body, two messages on a connection, odd bodies,
and five cases deferred. e2e: the sink image has python3-dkim (dkimpy
1.1.8-2) and the stand's DNS; on an application's message and on a
notification the two signatures must agree on h=, bh=, c=, s=, a= and each
must pass dkimverify. On the server: make e2e exit 0 (714 s), go vet + go
test 27 packages ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15.
The image runs debian:trixie-slim with Postfix 3.10.13, libmilter
8.18.1, OpenSSL 3.5; every citation re-read against the trixie sources.
smtpd_hide_client_session=yes on 465/587 (owner's decision). The
selfpost group is 10998 and the panel 10999:10999, outside the system
range; the entrypoint re-owns /data by name. The panel's database is
0600; the entrypoint removes symbolic links the panel could leave under
/data. Commits 68ec8b2, 1c6e04b, a3050af.
Rolled out to the test server as 2.6.1-post.a3050af (the same code),
2026-10-15 17:07 UTC: healthy, every program RUNNING, no restarts, three
messages sent, the queue empty, no warning but outside password guessing.
The canary: one message from an application through 465 to Gmail, Yandex
and the test mailbox - Received: in the short form everywhere; Gmail
dkim=pass spf=pass dmarc=pass; Yandex spf=pass dkim=pass, not spam.
No migration, schema 11.
CHANGELOG cut; pin 2.7.0 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the rows of
base-trixie and panel-dkim-signing; roadmap row and recommended order.
No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signer in the panel's milter with go-msgauth/dkim v0.7.0 (sign.go
read: streaming NewSigner, HeaderKeys over-signing, last-first picking),
relaxed/simple, OpenDKIM's dkim_should_signhdrs[] plus From, the key by
the login's domain and by the server's name for local mail, strict. From
Postfix 3.10.13 milter8.c: multi-line headers come \n-separated, so the
signer rebuilds them with CRLF. disable_mime_output_conversion=yes, unset
until now. The oracle dkimpy (python3-dkim 1.1.8-2 in trixie). Three
stages, 2.8.0. Open for the owner: where the signer runs, the key
directory's name, Health's label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security review (1c6e04b):
- The panel's database and its -wal/-shm were 0644 under a traversable
/data, holding the panel users' password hashes and session token
hashes. store.Open now makes them 0600 (SQLite gives the next -wal/-shm
the database's mode: findCreateFileMode(), unixOpenSharedMemory());
TestOpenKeepsTheDatabasePrivate.
- The entrypoint removes a symbolic link the panel user could leave where
a /data directory, table, map, the queue or mail.log is expected, before
its root steps chmod or create them (drop_links).
- The panel user is 10999:10999, outside the system range: in trixie
useradd gave it group 998, the host's systemd-network on the test
server; bookworm's 999 was the host's systemd-journal.
- security.md said the database was 0640; it was 0644.
Technical review (1c6e04b):
- mail_helpers.go: smtp-sink does add a Received: header, "(smtp-sink)"
(smtp-sink.c:520-531); the "(Postfix)" filter leaves it out.
- CHANGELOG/roadmap: OpenDKIM's signing and From:-parsing code is
unchanged, not all of it; the retry-policy bug predates 3.9 (any
postconf warning); three entries moved from Fixed to Changed, as no
released image had them.
- internal/app/sasl.go: db_dump's stdout alone is parsed.
- mynetworks comment: postinst:180, fset_all_changed.
Tests: on the server, make e2e exit 0 (712 s), go vet + go test 27
packages ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runtime base is debian:trixie-slim, the build stage golang:1.26-trixie:
Postfix 3.10.13 with upstream's 2026 security fixes up to the August
batch, libmilter 8.18.1, OpenSSL 3.5. Every citation of another program in
code, scripts and documents was re-read against the trixie sources
(dev/upstream; bookworm's kept in dev/upstream-bookworm) and updated;
development.md's cited-package table holds the trixie versions.
What the re-reading changed in behaviour:
- logtail: reject lines with sasl_method/sasl_username/sasl_sender
(smtpd_check.c:1019-1026); cleanup's "removed (canceled)" forgets a
port-25 id (cleanup_api.c:363-364).
- internal/postfix/retry.go reads postconf's stdout only: since 3.9 a
deprecated-parameter warning on stderr made one value too many and the
panel fell back to the compiled-in retry policy.
- logrotate-loop.sh: logrotate 3.22 exits 1 when it ignores a config;
the loop refuses on that output whatever the status.
- postfix-config.sh: cyrus_sasl_config_path set explicitly (bookworm's
07_sasl_config.diff is gone); maillog_file_permissions=0640; mynetworks
explicit; Dockerfile: SYNC_CHROOT= in /etc/default/postfix,
opendkim-tools dropped, openssl named, systemd-standalone-sysusers so
cron does not bring the whole of systemd.
- The selfpost group is 10998, outside the system range 100-999: in
trixie a package took 998 and the build failed (owner's decision:
a conflict-free id, the distribution's own ids untouched). The
entrypoint hands /data back by name when the user or the group differs.
Guide, backup refusal text, help (design commit 68ec8b2), e2e.
- smtpd_hide_client_session=yes on smtps and submission (owner's
decision): Received: names no client, TLS, login or protocol; port 25
keeps the full header. e2e checks both.
- test/upstream/opendkim-probe.sh makes keys with openssl genrsa.
Tests: on the server, make e2e exit 0 (674 s, the image built on trixie),
go vet + go test 27 packages ok. CHANGELOG [Unreleased], guide,
architecture.md, security.md, roadmap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The private key in certs/ is given to group 10998 on the host, no longer
998: in the trixie image systemd's packages take ids in 100-999, and 998
is systemd-network on the test server's host. The page's help.go follows
in the base-trixie commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15.
The inbound relay's smtp/inet listener sets
smtpd_forbid_bare_newline=normalize, the SMTP-smuggling defence Postfix
3.7.11 leaves off (aaa9412, port25-bare-newline, the first of the three
rows from own-mta-builds).
Rolled out to the test server as 2.6.0-post.aaa9412 (the same code): the
container healthy, every program RUNNING; the server runs the inbound
relay, and postconf -P shows smtp/inet/smtpd_forbid_bare_newline =
normalize next to syslog_name postfix/inbound; no warning or fatal in
docker logs. No migration, schema 11.
CHANGELOG cut; pin 2.6.1 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the row;
roadmap row shipped and the recommended order. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Postfix 3.7.11 leaves its 2023 SMTP-smuggling defence off (default "no"
before 3.9, src/smtpd/smtpd.c:807), and the inbound relay's smtp/inet
listener did not turn it on: a "." line ended by a bare LF ended DATA and
what followed was read as the next transaction. The listener now sets
normalize (bare_lf_mask_table, smtpd.c:1656; the DATA loop drops such a
"." line, 3649-3652), the setting Postfix recommends and 3.9's default.
The submission ports keep the default (own-mta-builds, section 5).
Tests: e2e inbound_bare_newline_is_one_message sends the smuggling
sequence over raw SMTP (net/smtp's DotWriter would rewrite the bare LF)
and finds one message at the upstream with the smuggled part inside;
port25 config check asserts the parameter on smtp/inet. On the server:
make e2e exit 0 (511 s), go vet + go test 27 packages ok.
Docs: CHANGELOG [Unreleased] Security, security.md, architecture.md,
guide.md; roadmap: port25-bare-newline, base-trixie and
panel-dkim-signing agreed by the owner in this order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/own-mta-builds-2026-10-15.md weighs patched builds of Postfix and
OpenDKIM against what the panel works around. No Postfix limit needs a
patch (the panel reads it through the interfaces it offers); every limit
worked around is OpenDKIM's, a project with no release since 2018, three
2026 CVEs open in every Debian suite and no one answering (read in
dev/upstream: dkim_process_set runs on a tenant's DKIM-Signature header
in signing mode too). Bookworm is in LTS since 2026-07-12 and its Postfix
3.7.11 lacks the June, August and September 2026 upstream security
batches, which carry no CVE ids.
Proposed as candidates: port25-bare-newline (smtpd_forbid_bare_newline
defaults to no on 3.7; our smtp/inet block does not set it), base-trixie
(postfix 3.10.13; 331 citation lines to re-read), panel-dkim-signing
(go-msgauth/dkim in the panel's milter; OpenDKIM, the syslog relay and
the parser port removed, about 1,500 lines).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The last stage of the 2026-10-12 audit; no cut.
Doc drift: architecture.md (the sender check in § OpenDKIM, the limit:
row's 48 hours, the inbound log and filter-list routes and who may open
them, migrations to 0011 and every table, KitPage as production data,
Reload configuration as a restore writes, the panel's roles counted),
schema-migrations.md (tables and columns since v2), guide.md (a delivery's
lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to
architecture.md's internal list), development.md (the env guard, the
deferral the e2e proves, its fixed sleeps), roadmap.md.
Comments say what the code does now; TestBaselineIsTheOnlyMigration is
TestChainStartsAtTheBaseline; the milter package doc names its three
milters.
Dead code gone: view.Facts, dmarc HostedSuggestion and
ValidateHostedAddress, app UpdateMode (each with the test that alone kept
it), the method checks the method-bound routes made dead;
Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on
both modules, clean: the e2e package builds without its tests now (a test
and two constants moved out of non-test files), three findings fixed.
The test moved, TestImageBuildPreservesLogrotateMode, had sat in a
non-test file and never run; run, it failed on the entrypoint (docker run
without a hostname) and now asks with --entrypoint stat.
The test gaps no step named, each closed: follow() with mail.log gone past
blindMin and made again; CheckRestore with a manifest that is not JSON;
the origin check with a port; every /server/ pattern in web.go through
server() and in the routes table (it found GET /server/health/fragment
missing from the table - guarded, not tested); L9 as the owner decided it
on 2026-10-12, the comment on senderSession.Headers and the test passing
an h that holds the earlier messages' From:.
Plan stage 6 ticked; roadmap audit row done, 81/81.
Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 513 s) on this tree; staticcheck locally on both modules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15.
The supervisord socket is root:supervisor, the panel the group's one member
(M17). Postfix takes TLS 1.2 at least, the client too where TLS is
required; VRFY off; the banner without the distribution; the start refuses
a rate, size or milter timeout that is not a number, under its minimum or
past an int (L31). OpenDKIM logs through the syslog relay to the container
log and oversigns From: (L30). crashexit takes the container down after
five unexpected exits of a mail-path program in ten minutes (L32).
logrotate keeps its state under /data/log. extract-cert.sh refuses what is
not PEM and renames whole files (M21). Plus the findings of both reviews
(a39eda5). Commits dab7715, a39eda5, b09e525.
Rolled out to the test server as 2.5.4-post.95af363 (the same code): the
container healthy, every program RUNNING, syslog-relay among them;
OpenDKIM's start line in docker logs under "syslog: "; the postconf values
as set; /run/supervisor.sock root:supervisor 0770 and the panel reaches it;
/data/log/.logrotate.status written. No migration, schema 11.
CHANGELOG cut; pin 2.6.0 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 5 marked shipped in the plan. No
tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner asked on 2026-10-15 whether our own builds would lift limits we
work around; recorded as a candidate for analysis with the starting points:
the limits met so far, for, against, and the middle ways to compare.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The e2e's numeric-settings test ran a container that passed every gate
and never ended: the good values are checked beside a bad one after
them; each run is named, bounded to a minute and removed.
- The gates refuse a value under the parameter's minimum (a zero window
kills anvil, a zero milter timeout smtpd) and one past an int, in
seconds for a time value (src/global/conv_time.c, src/anvil/anvil.c).
- OpenDKIM waits, five seconds at most, for the relay's socket.
- The relay marks its lines `syslog: `, writes control characters out,
reads into a queue at once and drops past 200 lines a second, saying
how many (security F1, F2; a stuck relay would hold OpenDKIM in
syslog(3)).
- smtp_tls_mandatory_protocols >=TLSv1.2 too.
- extract-cert.sh keeps the old files' group and sets its trap before
mktemp.
- crashexit spares cert-reload; architecture.md "four times running" and
what else exits the container; security.md the restart after five panel
exits and the /dev/log socket.
- Measured against the technical review: smtpd has the selfpost group
(Groups 104 998), so it did reach the supervisor socket before this
stage.
Accepted, reasons in the plan: the logrotate state file in the postfix
user's directory, envdoc_test's reading of `${VAR:-}` only, crashexit's
window pruning untested.
Tested on the test server: go vet + go test of 27 packages, make e2e (all
pass, 592 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
M17: the supervisord control socket is root:supervisor 0770, a group whose
one member is the panel; OpenDKIM and Postfix keep `selfpost` for the files
under /data and no longer reach the socket.
L30, read in OpenDKIM 2.11.0~beta2's source first: it logs through
syslog(3) only and has no Logfile option, and nothing in the container
served /dev/log. internal/syslogrelay (the panel binary with
-syslog-relay=/run/syslog/log, program syslog-relay before OpenDKIM,
/dev/log a link to its socket) passes the datagrams to stdout, error and
worse marked. opendkim.conf: Syslog yes, Canonicalization relaxed/simple,
OversignHeaders From (RFC 6376 § 3.4, § 8.15). e2e: OpenDKIM's start line
reaches the container log; every program RUNNING; DKIM verifies.
Tested on the test server: go vet + go test of 26 packages, make e2e (all
pass, 626 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15.
Signing in costs the same for an unknown name (M8) and ends the session the
browser presented (L16); the Account password check has its own limit
(L15). The HTTP server bounds the request, the answer and idle connections;
the full backup moves its own deadline (L14). Monitoring refreshes are
scheduled from htmx:afterRequest with a back-off and a 30 s timeout, and a
lost session sends htmx to sign-in (M11). The Outbound log clamps its page
(L10) and filters by status and day; the multiplier refuses NaN (L11); a
rate-limited refusal is red (L18); Recalculate asks before it drops an
unsaved edit (L19); Reload configuration writes what a restore writes and
removes the keys of deleted domains (L13, L17). The cards layout orders by
number. A notification is owed to each recipient (migration 0011). A DNS
outage is unknown, each resolver with its own time (M14, M15). DMARC
reports too large are refused for their size. An inbound upstream has one
TLS mode, changed for all its domains (L5). Plus the findings of both
reviews (eaa7de0). Commits 07be0cf, 643892a, ca876c1, 80e3979, 2b8e0c0,
b90a2f0, eee089a, 006bed7, 32dfe4a, eaa7de0.
Rolled out to the test server as 2.5.3-post.eaa7de0 (the same code):
migration 0011 applied, schema 11; all programs RUNNING, the container
healthy. Resync's key cleanup checked against the live data: every key
directory is a domain's or the hostname's, nothing to remove.
CHANGELOG cut; pin 2.5.4 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 4 marked shipped in the plan. No
tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Two inbound domains on one upstream could never change its TLS mode,
each save refused for the other (medium): SetTransportAs changes the
mode for every domain on the upstream when the one saving may change
them all; a domain user who may not is refused with the mode, not the
other tenant's domain name (security F1: the first version named it).
- A deletion's leftover DKIM key is removed by Resync (Reload
configuration, a restore's first start) for names no domain has.
- The backup's write deadline moves before Check.
- hasUnsavedEdit skips password and file fields and compares a multiple
select's options; htmx.config.timeout 30 s, so a refresh never answered
ends and is tried again; a failed refresh's promise is handled.
- An empty filtered Outbound log says no message matches the filters.
- The dummy bcrypt hash is made at start; a DMARC part over 8 MiB is
refused for its size like the other two limits.
- Health's Configuration box says what Reload writes (design first,
32dfe4a).
- Docs: CHANGELOG, guide § Inbound, security.md (the shared upstream's
mode, the profile form's "username taken"), architecture.md (owed to an
address), the plan's notes corrected.
Tests: the shared upstream (service, handler), the leftover key, all eight
Health boxes ordered, the subject from before migration 0011, the new JS
cases.
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 636 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since stage 4 the button also writes the spam filter's lists and the DMARC
maps (Postfix maps), and removes the keys of deleted domains; the box said
OpenDKIM and Postfix only (review of audit stage 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
M14, M15: queryEach gives each resolver 2 s within the check's context and
asks the one that answered last first; an address or PTR lookup not
answered is Unknown, an answered mismatch beside it still an error;
Checker.Server keeps the last answer's addresses, so SPF does not go Unknown
with it. Notifications report faults only, now tested with SPF unknown
alone and beside a wrong DKIM.
DMARC reports: tests of malformed XML, gzip and zip bombs, a message over
the limit, the service's Resync, hosted suggestion and validation. The
finding: both limits cut silently and an oversized report failed as
damaged; readAtMost refuses it for its size (MaxMessage 10 MiB, moved into
IngestMessage).
healthReadings replaces map[string]any for Health and Overview.
L5: an inbound upstream has one TLS mode - a second domain to the same
[host]:port with another mode is refused, naming the first; the TLS policy
map holds each next hop once.
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 631 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cards layout (owner, 2026-10-13; the mockup first, b90a2f0): box_open
writes data-order for a box with InCards and a number, and panel.css orders
by it up to twelve - Health's eight and the kit's three.
TestCardsBoxesCarryTheirNumberAsTheirOrder reads the rendered pages.
Notifications (owner, 2026-10-14): migration 0011, notify_addressees. open
sends a subject's first message to every recipient and records who was
handed it; followUp, on every check while the subject is open and still
wrong, hands it to the owed and untold who are still recipients; closed
sends the end to the told (all recipients for a subject from before the
table). Tests: a failed copy goes out at the next check, once; the hourly
ceiling holds one back; a user added mid-subject hears nothing of it.
Also in the roadmap, from the owner today: dns-record-collapse,
notification-mail-recheck, settings-save-row (agreed, design first).
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 644 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a phone the stylesheet ordered a cards box by its place in its stack,
four to a stack; a fifth box got no rule and landed out of place with
nothing failing (owner, 2026-10-13). Each box of the layout carries its
number as data-order, and theme.css orders by it, up to twelve: an
attribute, since check.py refuses classes the pages do not use, and not a
style, which the CSP forbids. Health and the kit's cards carry it; the look
is the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
L17: past DeleteDomain both rebuilds run, their errors joined under
domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables
were rebuilt (they may still name it otherwise); the domain list says the
domain is deleted and that Reload configuration finishes it, not "internal
error". Tests in domain (the tables failing, the sender map failing) and
handlers.
The Outbound log's status and day filters (owner, 2026-10-13; the mockup
first, 80e3979): view.OutLogFilter carried by the poll, the pages, the rows'
links and the message's way back; SendLogFilter.Status, Since, Until (whole
UTC days, as the Inbound log's); rejected is the three refusals together; a
status not in the list is no narrowing. Tests: the filters through the
handler, the poll and the way back; the day boundaries in store.
The plan's milter-socket step is ticked: done in 2.5.2 (98e09fe).
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 642 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The status list is the statuses of a row - queued, sent, deferred, bounced,
rejected, the last the three kinds of refusal together (owner, 2026-10-13).
The mockup's own list was wrong: it offered rate-limited, a reason, and had
neither queued nor rejected. And the days, from and to, the Inbound log's
control.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 4 of stage 4 (L10, L11, L13, L18, L19 and a row of
review-2026-08-followups):
- The Outbound log counts first and clamps the page to the last; a number
too large for an int (strconv's ErrRange with the largest int) is the
last page on both logs. Tested at the edges: first, last, past the last
however far, nonsense, an empty log asked for page 5.
- checkAutoMultiplier is "not within", so NaN and the infinities are out.
- rate-limited is red (components.go; the mockup first, 643892a).
- data-unsaved on both recalculate buttons: panel.js asks on submit
(ev.submitter) when a field of the page differs from what it was drawn
with; testdata/unsaved_test.js under TestPanelJS.
- HandleReload writes what a restore's first start writes - the filter
lists and the DMARC maps too - and a failed step is an error flash on
Health that names it, not plain text; the success flash says what was
written.
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 695 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
out-log drew the rate-limited tag grey beside wrong From and bounced in red
(audit 2026-10-12, L18); it is is-danger now. The kit's status table names
the red statuses as the panel does: reject and rate-limited join error,
fail, mismatch, bounced (reject was in the code's list and not here).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steps 1-3 of stage 4, tested together (their docs share files).
Step 1 (M8, L15, L16): an unknown name pays a bcrypt comparison at the
panel's work factor too (noSuchUserHash, made once) -
TestLoginTakesAsLongForAnUnknownName holds the medians within a factor of
two; the Account form's current-password check has its own limiter, per
user (AllowPasswordChange); a sign-in destroys every session the request
presents before it issues its own.
Step 2 (L14): newHTTPServer - ReadTimeout 30 s, WriteTimeout 60 s,
IdleTimeout 2 min beside ReadHeaderTimeout; the full backup moves its write
deadline to two hours through http.ResponseController (e2e: the log shows
no failure to move it).
Step 3 (M11), read in htmx 2.0.4's source (dev/upstream/htmx-2.0.4, the npm
build byte-equal to the shipped htmx.min.js): the poll passes source: box,
and the next refresh is scheduled from htmx:afterRequest, which htmx fires
on load, error, abort and timeout, and after a swap on the nearest parent
left (the box found by detail.target's id); failures in a row double the
wait to 60 s. RequireAuth answers htmx with 401 + HX-Redirect: /login,
which htmx follows before it looks at the status. testdata/poll_test.js
runs panel.js in node against a stand-in DOM and clock (TestPanelJS; skipped
without node, which the test server lacks - run here, and it fails on the
old panel.js).
Also measured: the stage-3 e2e regression for H1 fails as it should on HEAD
with the old entrypoint ("check data/postfix/e2e_extra_map: permission
denied", the panel restarting).
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 639 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15: "Вопросы про резку не
задавай, просто режь."
A restore is ended by its Resync: the manifest stays until the first start
has regenerated the maps (M1). Every archive ends with data/contents.json and
the first start after a restore compares /data with it before it opens the
database: half an archive, a damaged file, a journal left beside the
database is refused, named. The entrypoint hands every map under
/data/postfix to the panel (a restore unpacked as root left the DMARC maps
unreadable - found by the review). Migration 0010: the send log's sweep,
refusal-line check, level-2 count and pages each by its index, pinned by
EXPLAIN tests on the statements the store runs (M2). The backup CLI refuses
a missing database (M3) and a password under 12 (M5), writes -o whole,
checks -decrypt's password before the output (L1), warns of what it leaves
out and refuses an empty certs/ (L2). An encrypted file may ask scrypt for
128 MiB at most, r <= 64, p <= 2, and nothing may follow its last chunk
(M4, L3). TRUSTED_PROXY_CIDR's bare ::ffff:a.b.c.d is one host (L4). Maps
are durable across a power loss: the directory is synced (L7). Commits
8e3241e, 508ac96, 242515c, 76b21b3.
Rolled out to the test server as 2.5.2-post.76b21b3 (the same code):
migration 0010 applied, schema 10, the four new indexes present; all
programs RUNNING, Postfix with its three milters, nothing in the logs. On
the live container: a CLI backup ends with data/contents.json, a wrong
SELFPOST_DB_PATH is refused and creates nothing, a short password is
refused.
CHANGELOG cut; pin 2.5.3 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 3 marked shipped in the plan. No
tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one
commit: the review fixes sit in the same hunks.
Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list):
- backup.snapshotDB refuses a database that is not there and opens the
source mode=ro (measured: mode=ro creates no file).
- selfpost-backup holds an encrypting password to
validate.MinSecretFilePasswordLen; -o of both directions writes a
temporary file beside the name and renames it whole; -decrypt peeks the
first chunk (password and type authenticated) before the output exists.
- what a backup leaves out (a link under data/, a link under certs/ to a
directory, a socket...) is told to OnWarn; certs/ with no file is
backup.ErrNoCerts, refused by Check and Create, named by the panel.
- parseTrustedProxies: a bare address takes /128 when written with a
colon (::ffff:10.0.0.1/32 was ::/32).
Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2;
bytes after the last chunk are ErrCorrupt.
Step 5 (L7): atomicfile.SyncDir after the rename, used by Write,
replaceSASLDB and the CLI; atomicfile_test.go.
Step 6: security.md and guide on backup and restore.
Reviews (technical, security):
- H1: a restore of a server with DMARC on was refused on every start - the
archive records no owner, tar as root left the DMARC maps root's, and the
entrypoint only chowned the maps it named. It now hands every file beside
queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map
before the backup and finds it panel:selfpost 640 after a root unpack.
- the comparison reads regular files only (Lstat, size, LimitReader); a
record unpacked again wins over the earlier check's mark; ConsumeRestore
removes both; the mark is synced; a journal file beside a freshly unpacked
database is refused.
- AttachRefusalLine refuses an empty line as the old statement did; skipped
entries named in words; -decrypt of an empty payload is no failure; guide
wording on the shell's '>' and a killed CLI; the in-place restore's
'mv .' (older than this stage) fixed.
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 643 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 0010 (DDL only; 0009 went to stage 2b). EXPLAIN QUERY PLAN of
every send_log statement showed, beyond M2 as written (stage 2 had already
moved the by-domain counts onto (domain_id, created_at)):
- the tailer's sweep of open rows (every 5 min) and its count scanned the
table: partial idx_send_log_open (created_at) WHERE status IN ('queued',
'deferred'), the statuses literal in the statements, ORDER BY +id (else
SQLite walks the table in id order to skip a sort);
- AttachRefusalLine's NOT EXISTS (... refusal_line = ?1) scanned the table
for every refusal line: partial idx_send_log_refusal_line, the query
carrying the index's WHERE; its row found by idx_send_log_rejected
(status literal, +queue_id) instead of every refusal without a queue id;
- an application's level-2 count went through its domain's index:
+domain_id in appByLogin;
- a page of one domain's or one application's log sorted all of it:
idx_send_log_domain_id, idx_send_log_app_login (id order; several domains
still sort, top-N). idx_send_log_domain stays for the global filter.
The statements are constants and builders (countMessagesQuery,
peakPerHourQuery, listOpenQuery, countOpenQuery, sendLogPageQuery,
attachRefusalLineQuery, limitRefusalsQuery) and
TestSendLogQueriesUseTheirIndexes explains exactly those: the index each
must use, no SCAN of send_log, no sort where none is wanted; the literal
statuses checked against the constants. It replaces the hand-written plan
check of TestLimitRefusalsSince.
Tested on the test server: go vet + go test of 24 packages, make e2e (all
pass) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The restore manifest was consumed as soon as its version was checked,
before store.Open and the Resync; a start that failed in between left a
data directory the next start took for an ordinary one (M1). openData now
runs CheckRestore, store.Open, the Resync, and only then ConsumeRestore.
A truncated archive had nothing to refuse it: the manifest is the first
entry, so a download cut short or a tar stopped by a damaged block was a
restore on half a database. Every archive now ends with data/contents.json,
the size and SHA-256 of each data/ file (deploy files are the operator's to
edit), and CheckRestore compares /data with it before the database is
opened, then renames it contents.checked.json so a start after a failed one
does not compare again unless the manifest changed.
Tests: a start whose Resync fails, then a start that resyncs and consumes;
an archive cut in the database, before the deploy files and in its last
entry (refused, database untouched, no -wal); a damaged key named; the
record's names confined to data/; the domain import of a cut plain export,
a cut and an altered .spde (refused, nothing created). e2e on the test
server: full_backup_restore now also sees contents.json in the archive and
gone after the first start, through the real entrypoint and daemons (all
pass, 762 s); go vet + go test of 24 packages pass there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner's word, 2026-10-15: "Да, режем."
Every place SelfPost relies on another program was read in that program's
source, of the version the image runs, and is cited in the code (621
entries, docs/assumptions-2026-10-14.md); what the sources contradicted is
fixed: rspamd signing a sender's own verdict, the sender map's first key,
queue expiry and Postfix's time values, refusal and recipient lines as
Postfix writes them, the rotated log read until postlogd lets it go,
inbound mail over 10 MB, local delivery to an address literal, supervision
(Postfix stopped, give-ups FATAL, the reload's exit read), sasldb2 through
a copy, Health dialling sockets and showing the certificate Postfix
serves, Referrer-Policy same-origin, migrations under BEGIN IMMEDIATE,
certbot's links followed, about forty sentences of the documents, and ten
measurements on the stand. The sender check reads From: with OpenDKIM's
own parser, ported and checked against the C and against OpenDKIM's
signing; L8 is closed; an address OpenDKIM would cut short is refused.
Plus the findings of both reviews of the stage (9eddd49).
Rolled out to the test server as 2.5.1-post.9eddd49 (the same code): no
migration, schema 9; all programs RUNNING, the container healthy,
Postfix with its three milters, nothing in the logs.
CHANGELOG cut; pin 2.5.2 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin"; roadmap row and
recommended order; stage 2c marked shipped in the plan. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Technical review (Opus, 11 findings, 2 medium) and security review (Fable,
no high or medium) of 3c86280..HEAD. Both confirm the port gives
dkim_mail_parse()'s result on every input it parses, and that no line the
check passes is signed with another domain's key.
- F1 (medium): OpenDKIM's parser takes lines no mail program reads as one
address - "ceo@bank.com:me@mine.com", "Name <ceo@bank.com:me@mine.com>",
"<b@evil.com, a@mine.com>", "ceo@bank.com <me@mine.com>",
"ceo@bank.com@mine.com" - and signs for the last while a lenient reader
shows the first. The line must now also be one address in RFC 5322's
grammar (net/mail, display names in any charset passed through), at the
domain OpenDKIM signs for. A quoted display name may still hold anything.
- F2 (medium): the bindings were asked about OpenDKIM's form of the
address, quotes and all, so "x"@mine.com passed for an application
holding @mine.com while x@mine.com belonged to another. They are now
asked about the reader's address, its local part unquoted, as Postfix
holds the envelope's.
- F3 / security (low): Bindings.Allowed and DomainOf fold case in ASCII
only: Postfix folds as Unicode (casefold.c, ICU), but every key and
login the panel writes is ASCII, and the Unicode fold let the Kelvin sign
pass for "k" (net/mail then refuses such a line on the domain test).
- F4 (low): a TERM between "postfix start-fg &" and "child=$!" is no longer
lost: the wrapper stops Postfix as soon as the pid is known.
- F5 (low): for startsecs after a start any failed reload passed as "not
running". postfix-reload is now build/postfix-reload.sh: exit 75 when
master -t says no master runs, else exec postfix reload; RunOnce returns
EndError with the status, and only 75 is taken as done. supervisor.State
goes.
- F6 (low): crashexit spares postfix-reload too - a reload that could not
be spawned is reported to whoever asked, not a reason to stop the
container.
- F7 (low): a certs/ that is itself a link is resolved before the walk
(filepath.WalkDir does not follow its root).
- F8 (low): at shutdown the tailer reads what is left of the rotated
file before saving its place. Not changed: a refusal line still waiting
on the old file at shutdown is not kept - the panel writes refused rows
itself, only a kill loses one, and that window is the minute's grace.
- F9 (low): pct read as text - empty or unreadable is 100, the rest held
to 0..100.
- F10 (low): the directory is synced after sasldb2's rename.
- F11 (low): POSTFIX_STARTSECS by expr, so "010" or "08" in
MILTER_WAIT_TIMEOUT are decimal, not octal or an error.
- Security (low): pagehide also empties defaultValue - the input's value
attribute and the textarea's text - not only .value. A sasldb2.new left
by a killed panel is removed at start (entrypoint) and never archived.
security.md says that links under certs/ are followed and by whom they
can be placed.
Tests: sender check cases for F1 (four lines), F2 (a bound address
quoted, for its owner and for another), F3; EndError's state and status;
pct cases. architecture.md and CHANGELOG describe the reader rule. Stand
(2026-10-15, make e2e, exit 0); go vet and go test ./... pass on the
server (24 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last check of the port, and the plan's last line of stage 2c. Through
Postfix the sender check refuses most lines before OpenDKIM sees them, so
TestPortAgainstOpenDKIM (internal/milter) talks to OpenDKIM directly:
test/upstream/opendkim-probe.sh starts OpenDKIM from the SelfPost image in
a throwaway container, with keys and a refile SigningTable for three
names, and the test plays it each From: line through go-milter's client,
as a client OpenDKIM trusts (127.0.0.1, InternalHosts' default). For each
line the d= of the signature OpenDKIM adds must be the domain the port
predicts - its address, the domain cut to DKIM_MAXHOSTNAMELEN, the whole
cut to MAXADDRESS, matched against "^.*@<domain>$" case-insensitive as
dkimf_apply_signtable() matches - or neither signs.
Run on the test server, 2026-10-15 (OpenDKIM Filter v2.11.0 from the
image): 385 lines - both shared files and lines composed from display
names plain, quoted, escaped and encoded, comments nested and open,
groups, lists, brackets, source routes, domain literals and quoted local
parts, with the other sender's name and the server's in each place - 385
agree, 287 signed. The two lines padded so that, cut at 256 bytes, they
end in "@other.e2e.test" are signed by OpenDKIM with other.e2e.test's key:
the danger the sender check's 256-byte rule (3584adc) closes is real, and
the test now asserts it.
Measured while writing it: OpenDKIM answers a step it is done with by
accepting the message and then nothing more, and go-milter v0.4.1's client
needs read timeouts set in ClientOptions (the defaults apply only to its
default options) - the probe reads every step's action.
The differential test of the parser and this probe are now scripts in the
repository, test/upstream/mailparse-diff.sh and opendkim-probe.sh, both
run from it on the server: 200 019 lines agree with the C, 385 with the
signing. go vet and go test ./... pass on the server (24 packages). Stage
2c's plan lines are all ticked; roadmap 49/81.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit 2026-10-12, stage 2c, the owner's decision of 2026-10-14: the check
decides by the address OpenDKIM signs for, whatever a display name or a
comment holds.
internal/milter/mailparse.go ports libopendkim's dkim_mail_parse() with
dkim_mail_first_special(), dkim_mail_token() and
dkim_mail_matching_paren() from Debian's opendkim 2.11.0~beta2-8+deb12u1,
line for line: the '<' form, group and list punctuation, an '@' before the
first ':', ';' or ',' deciding where the address starts, source routes
dropped, quoted strings and domain literals kept with their delimiters,
comments skipped, whitespace between atoms one space, no encoded word
decoded; and the C's own quirks ("@d" gives d as the local part too).
testdata/mailparse_harness.c compiles the C from the image's source; the
test feeds it and the port 200 019 lines (the hand-written cases and
200 000 random ones over the specials) - 93 443 parse and agree byte for
byte, 106 576 fail and agree on the error (on an error the C leaves its
in-place result unterminated, and OpenDKIM uses neither part then).
judge(): one From: field and one address stay required (moreAfter: only
list or group punctuation may follow); what OpenDKIM cannot read is
refused (mlfi_eoh() would not sign it); the address is lower-cased in
ASCII only, as dkimf_lowercase() and Postfix's tables do. Found reading
dkimf_apply_signtable(): OpenDKIM matches its refile SigningTable against
"user@domain" printed into MAXADDRESS + 1 = 257 bytes, so a longer address
is matched cut short - a quoted local part padded to end there in
"@another.tenant" would be signed with that tenant's key while its real
domain is the application's. Such an address (over 256 bytes) is refused;
one that fits is matched whole. The display-name and comment scan, the
"one @ outside quotes" rule and the SigningTable reader (Signers) go; the
check asks for header values with their leading space as OpenDKIM does
(OptHeaderLeadingSpace; Postfix keeps a v2 milter's event mask, milter8.c
milter8_negotiate()).
L8 closed: refused or passed no longer depends on other domains of the
server; security.md's accepted risk is struck. The shared other-sender
lines are now held to "never signed for the other sender": unit test (a
passed line is read at the application's domain) and e2e (a passed line
arrives signed for the application's domain, none for the other).
Documents: architecture "How the header is read", security's sender
check paragraph, the guide; the help topic per 2cf118c.
Stand (2026-10-15, make e2e, exit 0): sender_check_holds_the_from_line -
another sender's address in the display name now passes; a From: whose
quoted local part is padded so that, cut at 256 bytes, it ends in
"@other.e2e.test" is refused 550 5.7.1 "the address is longer than
OpenDKIM reads whole"; lines_naming_another_sender_never_signed_for_it
and passed_from_lines_signed_for_their_domain_only pass. go vet and go
test ./... pass on the server (24 packages); the differential test ran
there against the compiled C.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit 2026-10-12, stage 2c: the sender check now reads the From: line with
OpenDKIM's own parser, so the help topic says what that means for the
reader - only the address counts; a display name or a comment around it is
not judged; a line with more than one address is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All 2026-10-15; each written beside the code or text it settles.
- A-43: /usr/sbin/postlog is -rwxr-xr-x root:root in the image (postfix
3.7.11-0+deb12u1; dpkg-statoverride lists postdrop and postqueue only):
Debian does not install it set-gid, so it is no use to dmarc-ingest
(cmd/panel/dmarc_ingest.go).
- C-X1: OpenSSL 3.0.22 in the image prints the same public key from
`x509 -pubkey` and `pkey -pubout` for RSA-2048, P-256, P-384 and a SEC1
P-256 key - cert-reload's same_pair holds for certbot's ECDSA keys.
- C-K5, D-guide:933/:233: on the live server (Docker 29.6.1, userland
proxy on 0.0.0.0 and ::, no IPv6 on the network) an outside IPv4 client
and the host's own public IPv4 address are logged as themselves; the
host's IPv6 address and 127.0.0.1 arrive as 172.18.0.1. Over IPv6 every
client is the gateway: one level-1 budget, an application IP list cannot
name them, rspamd sees the gateway. Guide paragraph under level 1, notes
at the milter and allow-list, comments at the anvil setup and authip.go,
and a Security entry in the CHANGELOG (an allow-list naming the gateway
would admit every IPv6 client).
- D-guide:61: a published port nothing listens on (a throwaway container,
-p 20999:25) is refused from outside over IPv4 and from the host's public
IPv4, accepted and closed through 127.0.0.1 - Docker's proxy, which IPv6
goes through too (not probed over IPv6 for this).
- C-M8: the live installation has received one DMARC report, of one
record: nothing to set maxReportRecords by yet - said at the constant.
- D-S7, D-arch:516, D-arch:532: a new database filled through the store to
120 days at 1 900 messages a day (228 000 messages, 1.37 million lines,
464 MB) on the test server (2 cores): the longest write is the line prune
after a 30-day gap, 1.9 s for 342 000 lines (0.4 s with nothing to
delete); the send-log delete 0.6 s; 2 000 lines of a tailer pass 18 ms;
VACUUM INTO 1.7 s; a second connection writing every 50 ms waited 1.8 s
at most, no SQLITE_BUSY - inside busy_timeout's 5 s. architecture.md's
"about a second per million lines" was five times too fast; its "six to
ten lines of 200 bytes" is 6.0 lines of 146 bytes on the live data.
- D-B16: which favicon is fetched, by the probe server's log: Edge 155
the SVG, the desktop app's embedded Chromium 152 the PNG, with or
without sizes="any" - layout.html's "vector wins where supported, raster
catches the rest" holds; Firefox and Safari not measured.
- D-arch:121: the allow/deny trial again, in rspamd/rspamd:3.11.1-3 with
today's multimap.conf: every mix of allow and deny entries matching one
message ended reject; a changed map was used about 10 s later.
- D-sec:424: rspamd runs as _rspamd, uid 11333; the panel is uid 999.
Stand: go vet and go test ./... pass on the server (24 packages); make
e2e exit 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code and config:
- C-M7: a DMARC report's explicit <pct>0</pct> stays 0; only an absent
pct takes RFC 7489 § 6.3's default 100 (Pct *int).
- C-D1: docs/development.md now tables the Debian versions the citations
were read at (postfix 3.7.11-0+deb12u1, opendkim and libopendkim11
2.11.0~beta2-8+deb12u1, libmilter1.0.1 8.17.1.9-2+deb12u2, libsasl2-2 and
sasl2-bin 2.1.28+dfsg-10, supervisor 4.2.5-1, logrotate 3.21.0-1 - read off
the image with dpkg-query, 2026-10-15); the e2e stand's new
checkCitedPackageVersions fails when the image it built differs.
- C-E2: the e2e zone's SOA serial only grows (CoreDNS v1.14.6's file
plugin reloads on a grown serial, not on mtime); Corefile comment says
so; TestZoneSerialGrows.
- C-K8, C-K9: the e2e compose and stage comments say how
--project-directory and .env resolve.
- D-arch:154: postfix-config.sh's "Postfix insists" comment says what
TLS_README advises and that smtpd reads the key as root.
- B-G7: inflight.go's reservationTTL comment: a dropped client does give
ABORT (smtpd_proto() -> mail_reset() -> milter_abort()); the TTL is for
smtpd dying or the milter connection breaking.
- D-guide:301: the nginx fragment's first-certificate command now reaches
certbot (--entrypoint certbot, --standalone, port 80 free), and the
renewal loop passes --webroot -w /var/www/certbot - a standalone
certificate would otherwise renew standalone while nginx holds port 80
(certbot docs, "Renewing certificates": the issuing plugin is used
"unless you specify other plugins or options", and options of a
successful renewal are saved). The register's proposal missed that.
Documents: every D-arch, D-guide and D-sec claim the register's Answers E
found false or partly true, rewritten - by three Sonnet agents, one per
document, then reviewed: two proposals were obsolete after this stage's
fixes and not used (a bounce's From: line - extract_from = "smtp" now;
"Origin: null" - Referrer-Policy is same-origin now); a stand
observation the agent had dated from a commit is now tied to the e2e test
that checks it; the SQLite writers paragraph describes _txlock=immediate;
the log tailer's rotation paragraph describes the minute's grace; the
guide's nginx steps were put in working order (links and modes before
`up`, no reconfigure step). "Deferred" is kept for Postfix's queue in the
guide too. guide.md's CRLF from the agent normalised.
Stand (2026-10-15, make e2e, exit 0, the version gate and zone serial
test included); go vet and go test ./... pass on the server (24 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>