Commit Graph
55 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 3f5cd9adf0 notification-mail-recheck: Re-check DNS on Server › Settings
test / test (push) Waiting to run
The owner, 2026-10-16: "Да, бери notification-mail-recheck". The records
of the server's own name can be checked again where they are shown: Re-
check DNS in Settings' page head (mockup 98e4dfc) posts to
POST /server/settings/dns-recheck (guard 52a9811), global role, which
runs Health's lookups - the server's address, then serverMail(true) - and
returns to /server/settings?rechecked=1, "DNS re-checked.". The
Notification mail box's end slot says when the records were checked
("The server's own name · checked 3 min ago", from the check's
CheckedAt; the old wording while nothing was looked up). A record that
has turned right comes back folded.

Tests: the page head's form and the end slot, and the Settings form
holding neither the re-check nor the right column
(TestSettingsRechecksTheMailRecords); the re-check skips the cache,
redirects and the page shows both records folded
(TestSettingsRecheckAsksForNotificationMailAgain); a domain
administrator gets 404 (globalOnlyRoutes). go vet + go test (26
packages) and make e2e on the test server for this tree.

Docs: architecture.md routes, the panel-redesign plan's route table,
guide § Settings, help (help.go word for word with the mockup's);
CHANGELOG [Unreleased]; roadmap row done. The roadmap's settings-save-row
note no longer claims the left-column form was needed for this - the
action went to the page head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:29:54 +03:00
mixandClaude Opus 5.5 0cceb549cc sender check: a Sender: line is held to the rule From: is held to
test / test (push) Canceled after 0s
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>
2026-10-16 07:41:42 +03:00
mixandClaude Opus 5.5 2cfd239d16 docs: panel-dkim-signing stage 3 - the documents without OpenDKIM
test / test (push) Canceled after 0s
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>
2026-10-16 01:56:56 +03:00
mixandClaude Opus 5.5 faa3c11ae7 panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Canceled after 0s
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>
2026-10-16 01:05:39 +03:00
mixandClaude Opus 5.5 14de9a05c8 panel-dkim-signing stage 1: the panel's DKIM signer, beside OpenDKIM
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>
2026-10-15 23:49:14 +03:00
mixandClaude Opus 5.5 1c6e04b7d3 base-trixie: the image on Debian trixie, Postfix 3.10.13; Received: hidden on 465/587
test / test (push) Canceled after 0s
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>
2026-10-15 19:31:35 +03:00
mixandClaude Opus 5.5 aaa94120be port 25 ends DATA only at CRLF.CRLF: smtpd_forbid_bare_newline=normalize (port25-bare-newline)
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>
2026-10-15 17:24:29 +03:00
mixandClaude Opus 5.5 6023feb405 audit 6: documents, comments, dead code and staticcheck; the test gaps closed
test / test (push) Canceled after 0s
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>
2026-10-15 14:41:26 +03:00
mixandClaude Opus 5.5 a39eda5a0b audit 5: findings of the two reviews
- 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>
2026-10-15 13:53:14 +03:00
mixandClaude Opus 5.5 dab7715e80 audit 5: the supervisor socket the panel's alone, OpenDKIM logs and oversigns From
test / test (push) Canceled after 0s
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>
2026-10-15 13:07:28 +03:00
mixandClaude Opus 5.5 eaa7de0754 audit 4: findings of the two reviews
test / test (push) Canceled after 0s
- 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>
2026-10-15 12:46:52 +03:00
mixandClaude Opus 5.5 eee089ad4b audit 4: cards ordered by number, a notification owed to each recipient
test / test (push) Canceled after 0s
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>
2026-10-15 11:49:50 +03:00
mixandClaude Opus 5.5 ca876c1631 audit 4: log paging at its edges, NaN refused, a red rate limit, recalculation asks, reload writes all
test / test (push) Canceled after 0s
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>
2026-10-15 11:20:18 +03:00
mixandClaude Opus 5.5 07be0cfb58 audit 4: sign-in timing and sessions, server timeouts, polling that recovers
test / test (push) Canceled after 0s
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>
2026-10-15 11:07:02 +03:00
mixandClaude Opus 5.5 8e3241ef98 audit 3: a restore is ended by its Resync, and half an archive is refused
test / test (push) Canceled after 0s
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>
2026-10-15 09:53:46 +03:00
mixandClaude Opus 5.5 9eddd49602 audit 2c: findings of the two reviews
test / test (push) Canceled after 0s
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>
2026-10-15 09:17:33 +03:00
mixandClaude Opus 5.5 3584adcbc2 sender check: From: read with OpenDKIM's own parser; L8 closed
test / test (push) Canceled after 0s
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>
2026-10-15 02:48:02 +03:00
mixandClaude Opus 5.5 cad1182150 audit 2c: what only the stand could answer, measured and written down
test / test (push) Canceled after 0s
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>
2026-10-15 02:14:58 +03:00
mixandClaude Opus 5.5 fbd0acd035 audit 2c: the rest the sources contradict, and the documents
test / test (push) Canceled after 0s
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>
2026-10-15 01:53:04 +03:00
mixandClaude Opus 5.5 9ca50d33fd web: Referrer-Policy same-origin; a password shown once emptied on pagehide
test / test (push) Canceled after 0s
D-B3. Fetch Standard § 3.2 "append a request `Origin` header": under
Referrer-Policy no-referrer a request that is not GET/HEAD and not CORS -
a form POST, a navigation - gets Origin "null". originAllowed() refuses
"null" whenever Sec-Fetch-Site is absent, and Fetch Metadata § 3 sends
Sec-Fetch-Site only to a potentially trustworthy URL: every form,
sign-in included, failed in Safari before 16.4 and in any browser on a
plain-HTTP panel at a name. same-origin nulls Origin only for a request
not same origin with its URL and still sends no referrer to another
site. The comments of the origin check say so.

D-B13. noStore keeps the page out of HTTP caches (RFC 9111 § 5.2.2.5);
a history mechanism "might honor" it (§ 6), and Pragma in a response
"was never specified" (§ 5.4) - it goes. CopyField.Secret marks the
password of out-app-created data-secret, and panel.js empties such
fields on pagehide (HTML § 7.5.9, "unload a Document", step 10.2: fired
whether or not the document is kept for history traversal).

Measured 2026-10-15 in Chrome 152 (the desktop app's browser), a local
probe page with Cache-Control: no-store and a data-secret field: Back
reloads it (pageshow persisted=false, a new load), so this browser keeps
no-store pages out of its back/forward cache; the handler, fired on the
same page, empties the field. Firefox and Safari not measured.

Tests: the header value; the created page has the password data-secret
and the login not, no Pragma. Stand (2026-10-15, make e2e, exit 0): new
form_post_with_the_browsers_origin - Referrer-Policy: same-origin on a
page; a POST carrying the page's own Origin and no Sec-Fetch-Site, what
a browser sends now, passes; one carrying Origin: null, what it sent
before, is 403. (A first run failed where the e2e client and a view test
scraped the old input markup; both read data-secret now.) go vet and go
test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:04:45 +03:00
mixandClaude Opus 5.5 f2ad699473 supervision as supervisord does it: Postfix stopped, give-ups FATAL, reload's exit read
test / test (push) Canceled after 0s
C-P42. postfix-script's start-fg runs master as its child and master
calls setsid() (Postfix 3.7.11, conf/postfix-script; src/master/master.c);
supervisord signals the pid alone (supervisor 4.2.5, process.py,
Subprocess.kill()). A stop of program postfix left master running - a
restart then found "already running" - and a container stop cut Postfix
off with SIGKILL. The wrapper now stays the parent of "postfix start-fg"
and on TERM/INT runs "postfix stop", asking again while start-fg is still
in its checks (master -t). stopwaitsecs=15.

C-V6. The wrapper's give-up on the milter sockets came after the default
startsecs, so it was EXITED and restarted for ever. startsecs is now
MILTER_WAIT_TIMEOUT + 10, exported by the entrypoint as POSTFIX_STARTSECS
and expanded by supervisord (options.py, processes_from_section(); a
non-number MILTER_WAIT_TIMEOUT stops the start): a give-up is "Exited too
quickly", BACKOFF, FATAL after startretries, crashexit. For those seconds
after each start the program is STARTING (the healthcheck's 90 s start
period covers it; the sender check counts no downtime then).

C-V5. logrotate had startsecs=0, so a refused config restarted it every
second for ever. startsecs=1: FATAL after startretries; crashexit reads
the event's processname and leaves logrotate's FATAL on Server > Health.

B-U2. "supervisorctl start" returns when a startsecs=0 program is RUNNING,
not when it ends, and mapped "already started" to success. The panel now
speaks supervisord's XML-RPC itself (internal/supervisor, RunOnce):
startProcess without waiting, getProcessInfo until it leaves
STARTING/RUNNING, exitstatus 0 or an error; a run in progress is waited
out and one of its own follows. A failed reload while program postfix is
not RUNNING is logged, not an error: the next master reads the files.
C-V10: postfix-reload rests EXITED after a run; the e2e process check
takes STOPPED or EXITED. C-P34: the dead "postfix set-permissions" branch
goes - postfix check's create-missing builds the queue tree.

Stand (2026-10-14, make e2e, exit 0; MILTER_WAIT_TIMEOUT=10 on the stand):
new supervision_stops_postfix_and_gives_up - supervisorctl restart postfix
writes postfix-script's "stopping the Postfix mail system" and leaves one
master; with the panel's sockets removed, start answers "postfix: ERROR
(spawn error)" (finish() sets spawnerr "Exited too quickly", onwait()
reads it first) and Postfix comes up once the panel is back; a
group-writable logrotate config ends FATAL with the container up; the
panel's Reload configuration answers only after mail.log has "refreshing
the Postfix mail system", postfix-reload EXITED. session_survives_restart
sees the stop line for the container's restart. Measured: master's own
"terminating on signal 15" never reaches mail.log (master TERMs its group,
postlogd included, before it logs). go vet and go test ./... pass on the
server (24 packages; new unit tests of RunOnce against a fake supervisord).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:49:25 +03:00
mixandClaude Opus 5.5 c8f669714e journal milter: recipients as Postfix logs them, once each
test / test (push) Canceled after 0s
The row's recipient has to be the address Postfix writes in to=<..> or
orig_to=<..>, or the delivery line finds no row and the sweep closes it as
bounced. Postfix 3.7.11 hands the milter the RCPT argument as the client
wrote it (smtpd.c, milter_argv(); extract_addr() leaves it), go-milter
v0.4.1 trims "<>" and drops the ESMTP parameters (session.go, case
CodeRcpt) - so cleanAddress's cut at the first blank broke a quoted local
part (B-G5). Postfix keeps the address in internal form (tok822: quotes
off, backslash escapes taken) and logs it external
(info_log_addr_form.c, quote_822_local(): quoted again only when not a
dot-string); recipientAsLogged does the same, "ab"@x -> ab@x, "a b"@x
stays. A recipient given twice in any case is one row: cleanup drops the
duplicate, comparing folded (cleanup_out_recipient.c, been_here(),
BH_FLAG_FOLD; A-31). A rewritten recipient is matched by orig_to=, which
log_adhoc() writes only when it differs (A-9). The store's
case-insensitive match stays, now with its reason from the source (D-S20).

Stand (2026-10-14, make e2e, exit 0): new recipients_as_postfix_logs_them
- RCPT TO "x y"@, "ab"@ and "X Y"@ sink.e2e.test give two rows, "x
y"@sink.e2e.test and ab@sink.e2e.test, both sent. go vet and go test ./...
pass on the server; a unit test drives the milter through go-milter's own
client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:30:47 +03:00
mixandClaude Opus 5.5 0ed03b2094 log tailer: expiry, Postfix's time values and refusal lines as Postfix writes them
test / test (push) Canceled after 0s
Expiry (assumptions A-8). Postfix 3.7.11 logs the end of a message's queue
lifetime once per message, "<id>: from=<..>, status=expired, returned to
sender" (src/qmgr/qmgr_active.c, qmgr_active_done_25_generic()), with no
line per recipient; the tailer waited for "to=..., status=expired", which
no Postfix writes, and the sweep closed only queued rows, so such rows
stayed deferred. The tailer now reads qmgr's line (ExpireMessage: each
recipient's newest open row of the id becomes bounced); the sweep closes a
deferred row too when Postfix no longer holds the message, and counts as
missed only a final line (sent, bounced, expiry). The queue listing's '#'
(forced expiry, showq_compat.c showq_message()) is read as a flag (B-PQ1).

Time values (B-PQ10, B-PQ11): conv_time() ported - one number, one
lower-case unit, the parameter's default unit when none (get_def_time_unit:
d for the lifetimes, h for delay_warning_time), the rest ignored; postconf
-h -x expands $name; bounce_queue_lifetime capped at maximal_queue_lifetime
as qmgr_post_init() caps it, so LongestLifetime is the queue lifetime.

Refusals (A-21, A-23, A-24, A-15): smtpd_whatsup() ends the NOQUEUE line
with sasl_username=<login> on an authenticated session; the line is now
matched to its row by client, login and sender, and a line without a login
is no application's. The client= line's login ends at the comma. The
"IPv6:" trim goes (namaddr is built from state->addr). A row recovered from
cleanup's line says that line names the last recipient only.

Stand (2026-10-14, make e2e, exit 0): new expired_message_is_closed -
defer_transports=smtp makes the retry service defer the message, postsuper
-e and postqueue -i force its expiry; mail.log has "<id>:
from=<alerts@sender.e2e.test>, status=force-expired, returned to sender"
and the row goes deferred -> bounced; refusals_show_postfixs_line passes
with the login match. go vet and go test ./... pass on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:51 +03:00
mixandClaude Opus 5.5 55aef6e721 sender check: the sender map read and looked up as Postfix does it
Postfix 3.7.11 takes the owners of the first key mail_addr_find() finds -
the address, then without its extension, then @domain, each tried only
when the one before is absent (src/global/mail_addr_find.c,
mail_addr_find_opt()) - and compares the login with them by
strcasecmp_utf8() (src/smtpd/smtpd_check.c,
reject_auth_sender_login_mismatch()). The check accepted a From: address
when any of the three keys named the application: an application holding
@domain could write an address listed for another application of the
domain, which Postfix refuses it in the envelope, and have it signed
(assumptions B-PS1).

The table is now read as dict_thash_open() reads it: continuation lines,
owners split on commas and blanks (B-PS4), the key ending at the first
blank outside quotes, keys folded; a key given twice makes Postfix refuse
the whole table (no DICT_FLAG_DUP_* flag), and the check then defers too.
The writer folds keys, so two cases of one address cannot be two lines.

e2e on the stand (2026-10-14, make e2e, exit 0): the wildcard application
is refused billing@ - listed for app-list - in From: (550 5.7.1), and in
the envelope by Postfix at RCPT ("553 5.7.1 <billing@...>: Sender address
rejected: not owned by user"). go vet and go test ./... pass on the
server (23 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:03:34 +03:00
mixandClaude Opus 5.5 31c2b50457 antispam: rspamd strips a sender's own verdict for every client
test / test (push) Canceled after 0s
rspamd 3.11.1's milter_headers skips its verdict routines - removes none
of the sender's X-Spamd-Result / X-Rspamd-Action, adds none - for a client
it counts as local (loopback, a unix socket, local_addrs: the private
ranges) or one that authenticated (skip_local, skip_authenticated, both on
by default; milter_headers.lua skip_wanted(), lua_ip.c lua_ip_is_local()).
The custom routine that signs X-SelfPost-Filter is never put through
skip_wanted(), so it signed the sender's own verdict, and Inbound > Log
showed it as rspamd's. Every sender is private when Docker's proxy relays
port 25 from the bridge gateway. Both options set to false.

Tried 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 on the test server
(rspamc --ucl, a message carrying its own verdict headers): old config,
from 10.0.0.5 and as an authenticated user, the milter reply removed and
added neither and added the signature; new config, both removed and
rspamd's own added, as from 203.0.113.9.

Also: the sender rules take the envelope only (extract_from = "smtp"; by
default a bounce's From: header is used - tried: a bounce whose From:
domain is denied was refused before, is not now; a denied sender is
refused either way); the image pinned to 3.11.1-3, the build read (3.11 and
3.11.1 move with each rebuild); the prefilter lines no longer read go.
architecture.md and security.md say so. Assumptions C-R7, B-R1, C-R4,
C-R12, C-R5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:48:07 +03:00
mixandClaude Opus 5.5 af130e268a findings of the reviews of audit stage 2b
Technical review (Opus) and security review (Fable) of 5be109b..5154bba;
reports in the session's scratchpad. Nothing above low from security; one
bug and three risks from the technical review. Migration 0009 is not
deployed, so it was extended.

- Bug: two refusals of one second swapped their NOQUEUE lines (each line
  went to the newest lineless row). AttachRefusalLine now matches the
  client address too (send_log.client_ip, written by the journal milter -
  Postfix's line names the client and the sender, never the login: the
  sasl_username part of the pattern never matched and could be fed by HELO,
  security F1/F2), takes the nearest row in time and the oldest of equals,
  and a line already on a row goes on no other (security F6).
- Waiting lines and recoveries lived only in memory while the saved offset
  moved past them: a stop within 30 s lost them. The tracker now never saves
  an offset past the oldest waiting item in its file (tracker.hold,
  refusalLines.oldest) - for a recovery, the client= line that names its
  login - so they are read again; the "once" guards make that harmless.
- A recovered row whose login the tailer did not see was nobody's: its
  domain_id falls back to the envelope domain's.
- The fingerprint's file identity is the inode alone: the device number can
  change across a host reboot and make the same log look rotated.
- A refused row carries its queue id; UpdateStatus's "newest row" now skips
  refused rows, so a later message with that id does not flip it to sent.
- The alive mark: its age is read first and kept from then on, so asking
  supervisord for 30 s cannot make it stale; the entrypoint removes it with
  the sockets - a container restart is not the check's downtime, and a mark
  from before would have counted the whole outage as "≈".
- Security F3: the sender check prints no ";" of the client's in the From:
  text of its reply, and the tailer reads the envelope after the first ";"
  - Postfix's own text.
- A refused row with a queue id and no stored lines says so (it was told the
  mail.log text of 2.2.0); the fact's em dash is the house hyphen.
- Docs: architecture (client address, no login), guide § Health (warns on
  either count), help topics log and sender-check (design 1ca87f2).
- e2e: refusals_show_postfixs_line wants the refusal's own sender in the
  line; refusal_recovered_from_the_log waits past the tailer's 30 s and
  checks that a refusal the panel wrote got no recovered twin.

Not changed, written down: review 9 (mail.log.1 read to its end at that
moment; postlogd may still write to it until the reload), 10 (a log under
512 bytes has no fingerprint, so places fall back to time briefly after a
rotation), 6 (a 451 at MAIL FROM of another milter reads the same - the
client and sender must match too now), security F5/F12 (info). Review 12:
the refused and closed-by-the-panel variants of out-message were shown to
the owner as pictures of the real templates, not drawn in out-message.html:
the guard ties every drawn screen to a page of the engine, and a variant
has the page's outline.

Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 (refusal_recovered_from_the_log 70 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:38:22 +03:00
mixandClaude Opus 5.5 5154bba61c docs: stage 2b of the audit - the log tailer, refusals, the Sender check card
test / test (push) Canceled after 0s
architecture.md
- § Log tailer: the fingerprint carries the inode; a log rotated while the
  panel was down is read from mail.log.1 to its end, or the gap is logged;
  a refusal's line (the queue id of a From: refusal, the NOQUEUE line put on
  a MAIL FROM refusal's row) and the refusal written from mail.log when the
  panel had not.
- § Sender check, what it costs: supervisord asked for half a minute, then
  the estimate and ≈; connections and messages in two columns; the count's
  saved place in the log.

guide.md
- § Outbound log: a refused message's page shows Postfix's line(s); rows
  from before, and a row written from mail.log, say so.
- § Overview and Health: the two columns and ≈.

schema-migrations row 9 was written with the migration. Audit plan line
ticked: stage 2b's lines are all done. Roadmap 30/62.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:19:05 +03:00
mixandClaude Opus 5.5 60c7b85f84 findings of the reviews of audit stage 1
test / test (push) Canceled after 0s
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>
2026-10-14 15:21:52 +03:00
mixandClaude Opus 5.5 a59e3d4d61 docs: stage 1 of the audit - log tailer, reconcile, sender check, guide
test / test (push) Canceled after 0s
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>
2026-10-14 14:42:02 +03:00
mixandClaude Opus 5.5 7af37654a1 cert-reload: renew Postfix's copy of a renewed certificate (audit H2)
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>
2026-10-14 14:25:20 +03:00
mixandClaude Opus 5.5 c817f0833c from-header-binding: what the reviews had left as found, and address extensions
test / test (push) Canceled after 0s
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>
2026-10-12 21:29:17 +03:00
mixandClaude Opus 5.5 89fd0a6116 from-header-binding: findings of the technical and security reviews
test / test (push) Canceled after 0s
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>
2026-10-12 20:06:02 +03:00
mixandClaude Opus 5.5 c171fb5bc7 from-header-binding: the sender check, what it costs, its pages; guide, architecture, security
test / test (push) Canceled after 0s
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>
2026-10-12 19:35:43 +03:00
mixandClaude Opus 5.5 f09b60aaa2 panel-notifications: findings of the technical and security reviews
test / test (push) Canceled after 0s
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>
2026-10-12 17:00:01 +03:00
mixandClaude Opus 5.5 c9027381e5 panel-notifications: guide, architecture, security; refusals looked for every minute
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>
2026-10-12 16:00:06 +03:00
mixandClaude Opus 5.5 a990e7cc23 delivery-log-storage: findings of the technical and security reviews
test / test (push) Canceled after 0s
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>
2026-10-12 10:41:04 +03:00
mixandClaude Opus 5.5 a6845ae948 delivery-log-storage: architecture and guide
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>
2026-10-12 10:15:47 +03:00
mixandClaude Opus 5.5 d398f488e2 inbound-antispam-panel: the filter signs what it judged
test / test (push) Canceled after 0s
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>
2026-10-12 08:34:29 +03:00
mixandClaude Opus 5.5 89774ab847 inbound-antispam-panel: three things the reviews left as found
test / test (push) Canceled after 0s
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>
2026-10-12 08:17:23 +03:00
mixandClaude Opus 5.5 c731a98a0f inbound-antispam-panel: findings of the technical and security reviews
test / test (push) Canceled after 0s
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>
2026-10-12 07:51:50 +03:00
mixandClaude Opus 5.5 7192c3861e inbound-antispam-panel: Filter lists, kind column, matches, note on a sheet
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>
2026-10-12 01:08:26 +03:00
mixandClaude Opus 5.5 eea40bcd49 inbound-antispam-panel: filter lists, written as rspamd maps
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>
2026-10-11 23:07:10 +03:00
mixandClaude Opus 5.5 27fd355e53 inbound-antispam-panel: refused inbound mail from mail.log; retention
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>
2026-10-11 22:42:25 +03:00
mixandClaude Opus 5.5 28ec784ae2 inbound-antispam-panel: inbound journal milter on port 25
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>
2026-10-11 22:18:14 +03:00
mixandClaude Opus 5.5 a3cc52d6d4 inbound-antispam-panel: migration 0002, journal and filter-list tables
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>
2026-10-11 22:05:20 +03:00
mixandClaude Opus 5.5 ed3cb4aaac docs: the panel as it is in 2.0
Stage 3, step 3. Written by Sonnet from the code and the plan, checked
by Opus.

  - guide.md: a section on the panel - menu, paths, the roles global and
    domain with outbound and inbound assigned separately -, Overview and
    Health, Queue and System log, Account and Server > Settings, the
    domain page / Domain settings / application form, Help as a page of
    topics. The DMARC report address is one of three choices set in
    Domain settings. The Spam filter box, the two INBOUND_ANTISPAM_*
    variables the panel reads, and the filter's socket on Health.
    Sessions; backup and export encrypted unless the box is unticked;
    1.x data is not restored into 2.0.
  - architecture.md: Panel HTTP surface rewritten from web.go (routes,
    the /server/ guard, per-object checks, <page>/fragment, one layout,
    typed page data, the kit and its guards, CSP and htmx); Persistence
    gains the database - one baseline, a 1.x file refused, the tables as
    they are.
  - security.md: roles and what each reaches, as built; sessions by user
    id and what ends them; state-changing requests; errors shown to
    users; the 2.0 review; two accepted risks from it - a delegated
    inbound upstream may be any host and port, and Account's profile is
    saved without the password.
  - CHANGELOG: Removed opens with "2.0 starts from an empty data
    directory"; entries that describe the user-default report address
    say they were replaced before release; the filter's socket.
  - roadmap.md: two review follow-up rows struck as retired by the
    redesign (.pair / .split, the two back links); password-reset notes
    that changing the account e-mail must then ask for the password.
    Progress 25/27.
  - development.md: the component kit and its guards; README and
    product.md: wording.

Left as they are
  - The third follow-up row the plan expected to strike - initShowWhen
    is not re-run after an htmx swap - still holds: no polled fragment
    carries such a control today, so nothing breaks, but the redesign did
    not retire it.
  - guide.md embeds no screenshots, so none needed retaking.
  - README and guide still name image 1.9.5 - the version cut.
  - plans/preflight.md and plans/inbound-antispam-panel.md use pre-2.0
    paths; roadmap's template-data-typing is largely done by the
    redesign.

  go test -count=1 ./cmd/panel (env-doc tables): ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:01:18 +03:00
mix 7ef7f75246 release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
  - SQLite migration `0010_send_log_app_login_index.sql` —
  - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
  - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
  - README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
  - The setup account's password is hashed at bcrypt cost `12` like every other
  - Login/setup rate limiter: protecting blocked buckets from eviction is now
  - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
  - [schema-migrations.md](docs/schema-migrations.md): the schema section was
  - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
  … (11 more in CHANGELOG)
Changed:
  - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
  - Extracted `internal/configsafe` — the empty-value and forbidden-character
  - Build, deploy and migration comments no longer cite the archived
  - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
  - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
  … (7 more in CHANGELOG)
Removed:
  - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
  - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,

Full detail: CHANGELOG.md (1.9.2).
2026-09-22 15:24:53 +03:00
mix 967d3d95de release: 1.9.1: Port-25 Postfix startup with inbound/DMARC/antispam flags
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam milter are enabled. Schema-migration reference and roadmap plans for inbound antispam and quarantine. Upgrading from 1.9.0 is a tag bump; no migration.
Added:
  - [docs/plans/inbound-antispam-panel.md](docs/plans/inbound-antispam-panel.md) —
  - [docs/plans/inbound-quarantine.md](docs/plans/inbound-quarantine.md) — candidate
  - [docs/schema-migrations.md](docs/schema-migrations.md) — living reference for the
Changed:
  - [docs/roadmap.md](docs/roadmap.md) — `inbound-antispam-panel` (agreed, 1/12)
  - [docs/development.md](docs/development.md) — plan checklists may override
  - [docs/development.md](docs/development.md), [docs/roadmap.md](docs/roadmap.md),
Fixed:
  - `build/postfix-config.sh`: enabling inbound relay, DMARC ingest, and/or the

Full detail: CHANGELOG.md (1.9.1).
2026-09-22 15:24:53 +03:00
mix 656c4d4d7f release: 1.4.0: Optional inbound relay (backup-MX) on port 25
Optional inbound relay (backup-MX / forwarder) on port 25, off by default. Mail is accepted only for domains configured in the panel and forwarded to an upstream; there are still no mailboxes. A dedicated security review of the inbound path is still open on the plan (Fable).
Added:
  - `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
  - Panel **Inbound** pages (global administrator): domains, upstream host/port
  - Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
  - Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
Changed:
  - Compose publishes host port 25 even when the flag is off (nothing listens
  - Full-backup restore Resync also rebuilds inbound maps when the flag is on.

Full detail: CHANGELOG.md (1.4.0).
2026-09-22 15:24:52 +03:00
mix 0730b2090e release: 1.3.0: 1.3.0-1.3.1: security pass, queue persist, full backup layout
[1.3.0]
Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration.
Added:
  - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
  - docs: agreed roadmap item **queue-retries** — show this Postfix's retry
Security:
  - The independent security review of the send-log authorization and
Fixed:
  - test (e2e): send-log status scrapers follow the badge markup in
  - security (panel): the Deliveries list is scoped to a domain administrator's
  - mail (level-2 rate limit): the ceiling is no longer overshot by messages that
  - panel (sign-in): a session that cannot be written to the database no longer
  - panel (applications): deleting an application removes its SASL credentials
  … (2 more in CHANGELOG)
Changed:
  - docs: operator and as-built docs aligned with the code after a full
  - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
  - docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7)
  - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
  - ci: the release image is published only for a **published** GitHub Release
  … (9 more in CHANGELOG)
[1.3.1]
Retry policy in the panel, persistent Postfix queue, and self-contained full backups. Mail queue and delivery history show this Postfix's first retry, backoff cap, and queue lifetime. Deferred mail survives container recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; the archive layout breaks 1.3.0 flat backups (unpack those with `tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a tag bump; no schema migration.
Added:
  - Mail queue and a delivery's history show this Postfix's retry policy
  - docs: **Plan checklists** in [development.md](docs/development.md) — format,
  - docs: **Implementation checklists** with model routing in
  - docs: roadmap candidates **send-log-retention** (panel Settings for delivery
  - Postfix queue under `/data/postfix/queue` — deferred and active mail survive
  … (1 more in CHANGELOG)
Changed:
  - **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
  - docs: operator guide, architecture, security, and backup UI updated for
  - docs: HTML mockups for a full panel UI refresh
Changed:
  - docs: panel UI mockups after review — Status restored to live readings
  - docs: panel UI design system and mockups rebuilt as separate pages
  - docs: panel UI mockups — add-domain sits in the list card (Domains and
Fixed:
  - ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped

Squashed CHANGELOG sections: [1.3.0], [1.3.1].

Full detail: CHANGELOG.md (1.3.0, 1.3.1).
2026-09-22 15:24:52 +03:00