8 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 22ce1bdfbb postfix: long, non-repeating queue ids
The owner, 2026-10-16, of three ways to stop a reused queue id reaching an
older row (found by the technical review of inbound-upstream-result, L6):
"1" - enable_long_queue_ids=yes. A short id is the microsecond of arrival
and the queue file's inode in hex (Postfix 3.10.13, src/global/mail_queue.h,
MQID_SH_*) and repeats once the inode comes round; a later message with no
send-log row of its own (port-25 mail, a bounce Postfix sends) could then
rewrite an older Outbound log row of the same id and recipient, its page
could show the later message's stored lines (security.md's known exposure,
"enable_long_queue_ids ... ends it"), and the inbound journal's rows needed
detaching. A long id adds the time in seconds and does not repeat
(MQID_LG_*; proto/postconf.proto: existing queue files keep their names,
postqueue -p gets a wider Queue ID column). Every pattern of the panel
takes both shapes (queueLineRe's long branch, the [0-9A-Za-z]+ of the
others, queueEntryRe) - checked one by one.

Tests: e2e reads a long id on an outbound send-log row
(testExpiredMessageIsClosed) and on an inbound journal row
(testInboundUpstreamDown). go vet + go test (26 packages) and make e2e on
the test server for this tree.

Docs: security.md (the collision bullet closed for new mail, the upstream
bullet), architecture § Log tailer, delivery-log-storage's note, the
ForgetInboundQueueID comment; CHANGELOG [Unreleased] § Fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:59:18 +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 e174537512 postfix config: port 25's size limit for cleanup too, no local delivery
Port 25 says it takes INBOUND_MESSAGE_SIZE_LIMIT (25 MiB by default) with
smtpd -o message_size_limit, but cleanup writes the queue file under its
own limit, main.cf's - never set, so DEF_MESSAGE_LIMIT, 10240000 (Postfix
3.7.11, src/cleanup/cleanup_init.c, cleanup_post_jail(), set_file_limit();
src/global/mail_params.h). A larger message went through DATA and was
refused 552 (assumptions C-P31). cleanup now gets port 25's limit as a
master.cf -o when it is larger than main.cf's; 465 and 587 keep main.cf's
in smtpd's own DATA/BDAT checks. /etc/postfix outlives a restart, so the
override is removed with postconf -PX first on every start.

An empty mydestination left one local destination: an address literal of
an interface the container listens on (src/global/resolve_local.c,
resolve_local(), own_inet_addr()). local_transport is now
"error:local mail delivery is disabled", as STANDARD_CONFIGURATION_README's
null client has it (assumptions C-P8).

Stand (2026-10-14, make e2e, exit 0): inbound_relay_delivers_and_refuses
sends a 12 MB message through port 25 and finds it at the upstream, and
counts it among the Inbound log's rows; new address_literal_is_not_local -
probe@[127.0.0.1] from an application is refused at RCPT "550 5.1.1 ...
local mail delivery is disabled" (smtpd's check_rcpt_maps() rejects what
resolves to the error transport; before, local(8) took it). go vet and go
test ./... pass on the server (23 packages). Tested together with the next
commit (the log tailer); the two touch no common file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:11:20 +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 599149bf83 build, deploy, inbound, dmarc, backup, e2e: cite the source of what they rely on (assumptions part C)
Comments only, no behaviour changed. Each main.cf/master.cf parameter the
build sets, the wrapper's and the reload hook's reliance on Postfix 3.7.11
(postfix-script, master.5, postconf.5, cleanup_init.c, smtpd_peer.c,
README_FILES), supervisor 4.2.5 (process.py, options.py, events.rst),
logrotate 3.21.0, OpenDKIM's config, Cyrus SASL's sasldb, rspamd 3.11.1
(milter_headers.lua, multimap, the map watcher), Docker Compose, CoreDNS
and certbot now say where that is written. Measurements already there stay
measurements ("date not recorded" where none was); hand-composed fixtures
say so.

Contradictions left for their own commits: C-P31 cleanup's size limit,
C-P42 master outside supervisor's signal, C-V5/V6 startsecs, C-V10,
C-S3/Q3 sasldb written in place, C-Q4/A1 certbot's symlinks, C-R7 rspamd
skips local clients but the signature routine does not, C-P8, C-R4, C-R12,
C-M7, C-E2, C-K8/K9, C-D1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:39:21 +03:00
mixandClaude Opus 5.5 b905f3e560 panel-notifications: Account, Server > Settings, Server > Health, help; e2e
test / test (push) Canceled after 0s
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):

- Account: "Send me notifications" in the Profile form, saved with the
  profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
  mail with the DKIM and SPF records of the server's own name, drawn with the
  record partial of a domain's DNS page and checked in DNS. Sending rate
  limits are box 02 in the left column. A record that is not in place is
  tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
  and a link to Settings, beside Configuration (07); part of the polled
  fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
  account, word for word with help.html.

If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.

e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.

On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:21:48 +03:00
mixandClaude Opus 5.5 815236a607 delivery-log-storage: e2e holds the page to its lines after a rotation
New step delivery_log_survives_rotation: the page of the first delivered
message must list the line that opened its queue id and its delivery line,
then mail.log is rotated with logrotate -f, the current file is checked to
hold no delivery line, and the page must list both lines still. Before this
feature the page searched the current file and would show none.

The step runs after negative_level1_ratelimit: placed right after the first
delivery it rotated away the "connect from" line the level-2 step reads from
the current log, and that step failed.

On the test server, for this tree: go vet clean, go test ./... 22 packages
ok, make e2e ok (126 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:25:21 +03:00