Commit Graph
46 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 98e09fef4a health: sockets dialled, the certificate Postfix serves, "deferred" only for the queue
test / test (push) Canceled after 0s
Audit 2026-10-12, stage 2c, "Health says what is served"; the pages of
ea8b6bf's mockup.

Sockets (A-38). A unix socket's file outlives a killed OpenDKIM or panel
and refuses every connect() (unix(7)); Health said "Listening" from a
stat. CheckSocket and CheckMilter's unix case now dial: refused is
"Nothing listens on the ... socket: the program that made it has
stopped", EACCES is unknown (the panel may not connect), anything else
"does not answer". The probe closes before a byte, which no milter logs:
libmilter takes the EOF as SMFIC_EOF without a line (sendmail
8.17.1.9-2+deb12u2, the image's libmilter1.0.1, libmilter/comm.c
mi_rd_cmd(), engine.c mi_engine() - its source is now in dev/upstream),
OpenDKIM's mlfi_close() has no connection to free, go-milter v0.4.1
returns quietly on io.EOF (session.go, HandleMilterCommands()). The page
polls every 5 s while in view, so this mattered.

Certificate (A-65). Postfix serves build/postfix-config.sh's copy,
refreshed by cert-reload every TLS_RELOAD_INTERVAL_SECONDS. The panel now
reads smtpd_tls_cert_file from postconf -h at its start (as the retry
policy) and TLS_RELOAD_INTERVAL_SECONDS; CheckServedCertificate checks the
served copy and, when the mounted file differs byte for byte (cert-reload's
own cmp -s), names it as Renewal: its expiry and mtime, two facts on the
card. Waiting is fine for the interval: a nearing expiry of the served one
is then no warning (an expiry still an error); past interval + 1 h it is a
warning pointing at cert-reload's lines. The notifier sends no expiry
notice while a renewal waits on time.

Wording (D-P1, D-P3, and the same for the spam filter): Sender check
note, help topics sender-check, upstream, filter, certificate - word for
word as in the mockup; the filter row's detail too.

Tests: stale socket file (Linux) refused for CheckSocket and CheckMilter;
CheckServedCertificate (same, waiting, late, expired + waiting, no served
path); intervalText; the card's facts; the notifier quiet while a renewal
waits, speaking once it is late. Stand (2026-10-15, make e2e, exit 0):
new health_shows_what_is_served - the three sockets dialled ok by the
panel's user; a certificate mounted without its key is refused by
cert-reload ("do not belong together") and the card shows the served
expiry, "Renewal expires" with the mounted one's, "Renewal mounted", and
"Postfix still serves the certificate above."; a matching pair is then
served again. go vet and go test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:44:21 +03:00
mixandClaude Opus 5.5 d20009c0f0 sasldb2 changed through a copy renamed over it, never in place
test / test (push) Canceled after 0s
Cyrus SASL 2.1.28 opens sasldb2 with no Berkeley DB environment, so with
no locks (sasldb/db_berkeley.c, berkeleydb_open(): db_create(mbdb, NULL,
0)); saslpasswd2 puts and deletes in the file itself (_sasldb_putdata()),
while smtpd opens and closes it at every AUTH (_sasldb_getdata()) and the
backup copies it whenever it runs. Either could read a half-written page:
a spurious 535, or a torn sasldb2 in a backup whose restore breaks every
application's AUTH (assumptions C-S3, C-Q3).

SASLDB.Set and Delete now run saslpasswd2 on sasldb2.new - a byte copy of
the database, or no file when there is none yet (saslpasswd2 creates it,
DB_CREATE) - then chmod 0640, fsync, rename over sasldb2; a failed run
removes the copy and leaves the database; a copy a killed panel left is
removed first. A mutex keeps two changes of the panel from copying the
same old file. The backup needs no lock after all (the plan said one): it
reads each file through one descriptor, and a rename leaves that file
whole; the walk now leaves out a file that vanished between listing and
open (a writer's temporary file), while the SQLite snapshot's absence
stays an error. CHANGELOG also gains the entries of f2ad699 (supervision),
written then without them.

Tests: the copy holds the database's bytes, saslpasswd2 never gets the
live path, a reader of the old file keeps reading it (Linux), mode 0640,
nothing left behind; a failed change leaves the database; the first
account creates it. Stand (2026-10-15, make e2e, exit 0): new
sasldb_changed_by_rename - the inode of sasldb2 changes across one
password change (it cannot be the old one's: both exist at the copy),
then 35 AUTHs of app1 from three clients during 104 password changes of
another application, none refused; afterwards 640 panel selfpost, no copy
left in /data/sasl, the last password authenticates. That cannot prove a
race absent; it guards the mechanism. (A first run compared the inode
after 15 changes and found it equal: Linux reuses a freed inode number.)
go vet and go test ./... pass on the server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:19:03 +03:00
mixandClaude Opus 5.5 ee37d16dc7 audit: stage 2c - seven lines built, ticked
31c2b50, 55aef6e, 0ed03b2 (two lines), c8f6697, 463d4ff, e174537 built
the lines without ticking them. Roadmap 38/81.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:13:27 +03:00
mixandClaude Opus 5.5 a658da11f7 audit: stage 2c - the answers, and what they make to fix
test / test (push) Canceled after 0s
docs/assumptions-2026-10-14.md gets its second half: every entry of the
inventory settled in the source of the version the image runs, with the
citation now beside it in the code. 559 verdicts (the documents' claims
that repeat one another answered once): 415 hold, 77 hold in part, 55 do
not hold, 12 need a measurement on the stand.

The plan's heuristics line is done; what the sources contradict becomes
fourteen lines of stage 2c, worst first: rspamd signs a forged verdict for
a client it counts as local; the sender check takes any key of the sender
map where Postfix takes the first; expiry, the forced-expire flag and
Postfix's time units; refusals and recipients matched by what Postfix
really writes; postlogd after a rename; inbound mail over 10 MB refused by
cleanup; master outside supervisord's reach; sasldb written in place;
Health showing a socket file and a certificate Postfix does not serve; the
Origin check refusing every POST where Sec-Fetch-Site is missing; two
processes migrating at once; certbot's symlinks; the documents; and the
measurements. Roadmap 31/81.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:41:07 +03:00
mixandClaude Opus 5.5 0f984ff8d8 development: code relying on another component cites its source; stage 2c: the heuristics review
test / test (push) Canceled after 0s
Owner, 2026-10-14: "нам нужно полное ревью кода на предмет всей этой
эвристики. Её не должно там, где можно посмотреть код и ответить точно и
однозначно", and "в коде таких наших блоков должно быть обоснование со
ссылкой на код компонента".

- development.md § Code that relies on another component's behaviour: the
  rule, with the form of the comment (component, the image's version, file,
  function; or a document's URL and section; or "measured on the stand,
  date" said as such), and fixtures saying whether they were seen or
  composed.
- Audit stage 2c: the line "what was taken on trust" becomes the heuristics
  review - an inventory over the whole tree written down as
  docs/assumptions-2026-10-14.md, then each answered from the source of the
  version the image runs, the comment added, the code fixed where it
  contradicts. Plan still 66 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:44:19 +03:00
mixandClaude Opus 5.5 3c8628074f release: 2.5.1: audit stage 2b - the log tailer and what the panel shows of a refusal
test / test (push) Canceled after 0s
A log rotated while the panel was down is read to its end; the deferred
counter remembers a place in the log, not a second; a refused message's
page shows what Postfix wrote; a refusal the panel did not get to write is
written from mail.log; downtime is estimated (≈) when supervisord is
silent; the Sender check card counts connections and messages apart (the
owner's decision); a message the sweep closed says the panel closed it.
Plus the findings of both reviews of the stage.

Rolled out to the test server as 2.5.0-post.e1ddf12 (the same code):
migration 0009 ran on the live database - schema 9, the new columns in
place, 68/68 send-log rows still tied to their domain, integrity ok.

CHANGELOG cut; pin 2.5.1 in deploy/docker-compose.yml, README (with the
local-build note), guide; development.md "from pin"; roadmap row and the
recommended order; stage 2b marked shipped in the plan. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:43:20 +03:00
mixandClaude Opus 5.5 e1ddf12744 audit: stage 2c - what was taken on trust, read in Postfix's source
test / test (push) Canceled after 0s
Owner, 2026-10-14, after the rule "do not guess how code works when its
documentation or source can be read": "Есть ли ещё у нас места, где мы
угадывали?". The inventory found four: qmgr's expiry against the queue
lifetime (a margin in queued_over_lifetime), postlogd after a rename (the
rotation window), the stale smtpd after a reload (measured, not
understood), and the NOQUEUE line's fields (two fixtures carry a
sasl_username Postfix does not write, one presented as seen on the stand).
A line in stage 2c reads Postfix 3.7.11 for them, records what is read,
fixes the fixtures and the code where they disagree. Plan 66 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:38:56 +03:00
mixandClaude Opus 5.5 5dcff8c2b9 audit: stage 2c - the sender check reads From: as OpenDKIM does (owner's decision)
test / test (push) Canceled after 0s
Owner, 2026-10-14, after reading OpenDKIM's source together
(dkim_mail_parse, called in mlfi_eoh on the first From:; SenderHeaders is
unset in build/opendkim.conf): "Да" to porting the parser so that the check
decides by the address OpenDKIM will take. The scan of the display name and
comments for another sender of the server - a guard against two parsers
parting ways, and the reason L8 is half closed - then goes.

New stage 2c (three lines) after 2.5.1 and before stage 3: the port from
the version the image runs, checked against OpenDKIM on the stand with
generated lines, then docs and wording. Stages 3 and 4 move to 2.5.3 and
2.5.4 (development.md's planned cuts). Plan 65 lines, roadmap 30/65.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:19:34 +03:00
mixandClaude Opus 5.5 57142b21b5 security: L8's remaining probe accepted by the owner
test / test (push) Canceled after 0s
Owner, 2026-10-14, asked whether to refuse every foreign address in a
display name to close what L8 leaves (refused or passed still tells an
application which domains are hosted here): "Ответ 1" - leave it. The
probe is a trusted application of this server guessing names one at a
time, as it already had to before L8, when the reply said in words what the
line itself had named. security.md (Mail path and accepted risks, with the
trigger to revisit: untrusted tenants, or the display-name rule decided
anew) and the audit plan record it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:06:05 +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 415334c2cc message page: a row the sweep closed says the panel closed it (stage 2b)
The History step of every bounced row said the receiving server refused
the message permanently or Postfix gave up, "the reason is in the delivery
log below" - for a row the reconcile sweep closed (reason "reconcile")
neither is known and the log has nothing (review of stage 1). Such a row's
step is now "Closed by the panel - Postfix no longer held the message and
mail.log never said what became of it ... rather than show a delivery it
cannot evidence." The help topic log names the case (design df96690).
Pictured for the owner with the real templates (dev/shots); the variant has
no mockup screen of its own, as the refused one.

Test: the page of a row the sweep closed says "Closed by the panel" and not
"the receiving server refused".

CHANGELOG [Unreleased], audit plan line ticked, roadmap 29/62.
Evidence: the message-page tests of ./internal/web/handlers ok locally; the
server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:18:15 +03:00
mixandClaude Opus 5.5 ac985d03a8 sender check: connections and messages counted apart; downtime estimated when supervisord is silent (stage 2b)
Following the design commit df96690.

- Two columns (owner, 2026-10-14): sender_check_days.messages (migration
  0009, not deployed) holds the messages deferred mid-message, from
  cleanup's warning; deferred stays the connections smtpd turned away.
  AddSenderCheckDeferred takes which; the counter passes cleanup's as a
  message. The card has "Connections turned away" and "Messages deferred";
  it warns on either within 24 hours (one mark, as before).
- Supervisord silent at start: postfixStarted asks again every 2 s for 30 s
  (postfixState: started, running, known). With no answer the downtime is
  the time since the alive mark - an upper bound - booked with estimated = 1
  (sender_check_days.estimated, MAX over a day); the card prefixes such a
  period's time with "≈ " and its note says what ≈ means. Before, such a
  start counted nothing and the number the owner decides by read low.
- Help topic sender-check and the log topic word for word as the mockup
  (help.go).

Tests: store (connections and messages apart, a period with an estimated
day); logtail (cleanup's warnings are messages); cmd/panel (supervisord
silent: the time since the mark, estimated); view fixture and the Health
page at four columns with ≈; e2e reads the row's two counts (panel down:
connections; panel hung: messages). Schema at 0009 with the columns.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan lines ticked
(two), roadmap 28/62.
Evidence: go vet ./... clean; go test of store, view, cmd/panel ok locally,
logtail but for the Windows-only test; the server run follows before the
push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:16:02 +03:00
mixandClaude Opus 5.5 65d916f26a logtail: a refusal the panel did not get to write is written from mail.log (stage 2b)
test / test (push) Canceled after 0s
The sender check answers 550 5.7.1 and writes the refused row through a
buffer a moment later; a panel stopped in between (a rollout, a kill) left
an application refused and a log that did not know it. A refusal at MAIL
FROM needs nothing: the journal milter writes its row before it answers.

- logtail.refusalLines remembers each queue id's login from smtpd's
  "<id>: client=..., sasl_username=<login>" (4096 ids at most) and, on
  cleanup's "<id>: milter-reject: END-OF-MESSAGE ...: 5.7.1 From: <line> -
  <why>; from=<...> to=<...>", waits 30 s and hands it to
  store.RecoverRefusal - an INSERT ... WHERE NOT EXISTS a refused row of that
  queue id within two minutes: the row the panel wrote, or one recovered
  before, means nothing is written. It is read from the saved offset on, so
  a kill is covered as well as a clean stop - which a flush of the buffer at
  shutdown would not.
- Migration 0009 (not deployed yet) gains send_log.recovered; the row is
  written with the line's time, the login's domain id, reason "from", the
  From: line as the check printed it (to the last " - " before ";").
- The message page: a fact "Written: from mail.log after a restart: the
  panel stopped before it wrote the refusal".

Tests: store (not written when the panel wrote it; written once however
often); logtail (the login from client=, the From: line with a " - " in it,
nothing before the wait is over); view (the fact). e2e
refusal_recovered_from_the_log: a From: refusal, the panel stopped, its row
deleted and the tailer's saved place moved before the message's first line
(python3 in the container, as the panel's user - a kill cannot be timed to
that moment from outside); started again, the row is back, recovered, once.

Also fixed: handlers TestMessagePageOfARefusedMessage still expected the
old sentence; it failed on the server after e166a2e, whose run showed only
the schema failures - now it wants the new one.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 26/62.
Evidence on the test server for this tree but the handler-test fix: make e2e
exit 0 (the new case 37 s); go test 22/23 ok, the 23rd that test - fixed,
passes locally; the full run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:09:05 +03:00
mixandClaude Opus 5.5 e166a2ebd6 refused messages: the page shows what Postfix wrote (stage 2b, 0009)
The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.

- Wrong From: cleanup gives the message a queue id before the headers are
  judged; the sender check reads {i} at end-of-headers and writes it on the
  refused row. Its lines - stored under the id like any other's - are on the
  page, but only those from 10 minutes before to 1 minute after the refusal
  (store.SendLogLines for a rejected row): Postfix frees the id at once, and
  a later message with it lends nothing. No mail.log fallback for a refused
  row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
  0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
  "NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
  CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
  of that sender (and login, when the line names it) within two minutes; a
  line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
  "none - refused at MAIL FROM, before one is given"; the box's caption is
  "The line from mail.log for this refusal" for the latter; a row from
  before says the line was not kept. LogLineLevel colours a refusal by its
  reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
  not muted as in the picture: the kit has no muted inline.

Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:56:29 +03:00
mixandClaude Opus 5.5 c452606175 logtail: the deferred counter remembers a place in the log, not a second (stage 2b)
test / test (push) Canceled after 0s
The counter passed over, while catching up after a start, every warning no
later than the last one counted - by time; mail.log stamps whole seconds,
so a second warning of that second, later in the file, was lost.

- The tailer gives each line its place (Place: the file's fingerprint and
  the offset just past the line): follow tracks the bytes the reader hands
  over, readRotated likewise in mail.log.1. Run hands tr.line to the
  counter.
- store.AddSenderCheckDeferred(at, n, place) counts, moves the
  last-deferral mark (never back - an upsert WHERE) and saves the place
  (settings sender_check_counted_place) in one transaction.
- Catching up, a warning in the file of the last one counted is passed over
  by place (end <= counted end); in another file - a rotated log read again
  - by time, as before. The fingerprint carries the inode, so the place
  stays true across a rotation.

Tests: two warnings of one second, an unclean stop between them, all lines
read again - each counted once; the store saves the place with the count
and keeps the latest deferral time. Existing counter tests pass a zero
place (time rule).

CHANGELOG [Unreleased], audit plan line ticked, roadmap 24/62.
Evidence: go vet ./... clean; go test ./internal/store, ./internal/logtail ok
(but for the Windows-only test); the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:28:13 +03:00
mixandClaude Opus 5.5 4678015bbd logtail: a log rotated while the panel was down is read to its end (stage 2b)
At start, a saved offset whose fingerprint was not the current file's meant
"start on the new file": what Postfix wrote between the panel's stop and the
rotation was never read - delivery statuses, stored lines, refusals, inbound
journal rows, the deferred counter.

- tracker.resume marks the case (behind, prev); follow calls readRotated
  before the new file: it opens mail.log.1 (delaycompress leaves it plain),
  checks by fingerprint that it is the file the place belongs to, reads it
  from the saved offset to its end through handle (catching up: the
  once-protections apply) and flushes the stored lines.
- Not found, not that file, or the offset past its end (rotated twice):
  logged, with the saved place's time - from when nothing was read.
- The fingerprint gains the file's identity (device.inode, fileid_unix.go;
  empty elsewhere): two logs that begin alike are two files - the
  review-2026-08-followups row on collisions. A fingerprint saved before
  (hash alone) still matches by its hash (sameLog), so the first start after
  the upgrade resumes where it was.
- store.LogtailState reads back updated_at.

Tests: stop, append, rotate, append, start - both halves read, once each,
the earlier line not again; a mail.log.1 that is another file is not read
from the place; sameLog with an old and a new fingerprint.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 23/62.
Evidence: GOOS=linux go vet ./internal/logtail clean; go test ./internal/logtail
ok on Windows but for TestFollowTailsAndRotates; the server run follows
before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:25:29 +03:00
mixandClaude Opus 5.5 5be109b871 release: 2.5.0: audit stages 1 and 2 - the delivery log and the tenant boundary
test / test (push) Canceled after 0s
Stage 1: a cut queue listing no longer bounces held mail; the reconcile
sweep closes only queued rows, by id, marked "reconcile", and its fate is
measured (delivery_log_days, migration 0007); delivery lines are read at
the head of the line; a renewed TLS certificate is served without a
restart; Health counts the message a hung panel deferred.
Stage 2: logins folded to lower case and unique without regard to case;
the last global administrator kept inside the transaction, users saved
whole; a domain import held to the forms' rules; log rows by domain id
(migration 0008); the 550 reply names no other domain; which key OpenDKIM
signs with, tested. Plus the findings of both reviews of each stage.

Rolled out to the test server as 2.4.2-post.ef18ef2 (the same code):
migrations 0007 and 0008 ran on the live database - schema 8, 68/68 send-log
and 10/10 inbound rows tied to their domain, integrity ok.

CHANGELOG cut; pin 2.5.0 in deploy/docker-compose.yml, README (with the
local-build note), guide; development.md "from pin"; roadmap row and the
recommended order; stages 1 and 2 marked shipped in the plan. No tag (the
owner's command only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:22:04 +03:00
mixandClaude Opus 5.5 ef18ef21a0 findings of the reviews of audit stage 2
test / test (push) Canceled after 0s
Technical review (Opus) and security review (Fable) of a59e3d4..cb5a4a2;
reports in the session's scratchpad. Nothing critical, high or medium; no
outright bug. Fixed:

- F2, F3 (0008 backfill, before it runs on the live database): rows are
  filled by the rule new rows follow - the application's domain, then the
  name's - and only by a domain and application that existed when the row
  was written (created_at); the inbound log likewise. Test: the migration's
  UPDATEs re-run on prepared rows (before the domain, after, null sender,
  application gone, a stranger's name). Dry run on a copy of the live
  database: 68/68 and 10/10 rows filled, none disagreeing with its login's
  domain, integrity ok.
- V2, F4: an application's statistics and level-2 count are its login's
  rows of its domain (appByLogin), and StatsWindow starts no earlier than
  the entity's creation; the message page's "may send as" only for a login
  still of the row's domain. Test: a login taken again in another domain
  starts at zero.
- F7: MAIL FROM:<> is held to the domain limit of its login
  (ApplicationByLogin fills DomainName).
- V3: an imported DKIM selector is held to DNS labels (validate.Selector).
- F5, F6: an imported auto limit's numbers are dropped and recalculated
  right after the import; its mode is stored trimmed.
- V6: the store refuses a role that is neither global nor domain.
- F9: SaveUser keeps the stored hash when no new password is given (the
  form no longer writes back the hash it read). F10: a reach naming a
  domain deleted meanwhile is ErrNoSuchDomain, answered on the form.
- V4, F12: from-lines-other.txt - lines naming another sender ({other}) in
  a quoted string, a quoted pair, capitals, a comment, an encoded word, a
  quoted local part; the unit test wants each refused, the e2e
  (lines_naming_another_sender_never_signed_for_it) wants each refused and
  none signed for it. The passed list gained a.{DOMAIN}, a comment after
  the angle address, a quoted local part, and "b@ {other}" - passed by
  design (security.md), and the e2e shows it signed for the own domain.
- F13: e2e login_in_other_capitals_does_not_authenticate - AUTH with the
  login upper-cased gets 535.

Not changed, written down: V1/F1 - L8 is half closed (refused or passed
still tells an application which domains are hosted here); security.md's
accepted risks and the CHANGELOG say so, and the owner is asked whether to
refuse every foreign address in a display name (changes the 2.4.0 rule).
F11 - deleting a domain rewrites its rows' domain_id (seconds at 1e5 rows),
accepted at this size. F8 (test cannot tell write-first from read-first;
the reviewer verified it with two stores, 30/30), V5, V7, P2: noted.

Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 with the three new cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:17:57 +03:00
mixandClaude Opus 5.5 8b53d63160 audit: the owner's decision on the Sender check columns (stage 2b)
test / test (push) Canceled after 0s
Owner, 2026-10-14: show connections turned away and messages deferred
mid-message apart, not mixed. The stage 2b step now says: smtpd's warning
to the existing column (Connections turned away), cleanup's to a new column
of sender_check_days in the stage's migration (Messages deferred), both on
the card, the help topic saying what each counts; mockup first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:57:53 +03:00
mixandClaude Opus 5.5 cb5a4a22e1 docs: stage 2 of the audit - roles, the guard, logs by id, import, logins
test / test (push) Canceled after 0s
security.md
- § Roles: the last-global guard is inside the transaction that deletes or
  demotes (write, then count); setup is one statement; a user is saved in
  one transaction. A log row, outbound or inbound, belongs to a domain by
  id; a domain added again starts with an empty log. The Inbound › Log row of
  the table says by id.
- § Mail path, sender check: the agreement of the check and OpenDKIM is
  tested (the shared list of passed From: lines); the refusal does not say
  which other sender a line names.
- Accepted risks: "a row of the inbound log belongs to a domain by name"
  struck (L12).

guide.md
- Domain export and import: the file is checked like the forms before
  anything is created; a login keeps its case and a case clash is refused.
- The application form: a new login is stored in lower case.

schema-migrations.md was updated with 0008 in its commits. Audit plan line
ticked; stage 2's lines are all done. Roadmap 22/62.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:45:46 +03:00
mixandClaude Opus 5.5 5747f0b25c sender check: the reply names no other domain (L8); which key signs, tested (stage 2)
L8: a From: line naming another sender of the server was refused with
"it names another sender of this server, <domain>" - a confirmation, to
whoever tries names, that the domain is hosted here. The reason now stops
before the domain; the line as written stays in the reply (the client's own
text) and on the refused message's page (header_from). Unit test: the reason
of every refusal names none of the server's other domains; e2e: the reason
of each sender-check refusal does not contain other.e2e.test.

Which key OpenDKIM signs with (owner, 2026-10-13): the check and OpenDKIM
read From: with two parsers, and their agreement was argued, never run.
internal/milter/testdata/from-lines-passed.txt holds the lines the check
passes for an application - bare and bracketed, quoted names with a comma,
escaped quotes and an address inside, an extension, three encoded charsets,
two folded lines, comments (nested too), a stranger's address in the name -
as Go-quoted strings with {domain}. The unit test wants each passed; the new
e2e case passed_from_lines_signed_for_their_domain_only sends each as app1
and wants every DKIM signature at the sink to be for the sender domain,
with one valid. All fifteen were. "(the shop) a@domain" - a comment before
the address, valid RFC 5322 - is refused by the check ("it must be one
address"); strict, not a hole, so it is not in the file. Groups are refused
too.

Audit plan lines ticked (two) with what was found, CHANGELOG [Unreleased],
roadmap 21/62.
Evidence on the test server for this tree (with a618ffb): go vet clean, go
test ./... 23 packages ok, make e2e exit 0 - the new case passed in 4 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:44:57 +03:00
mixandClaude Opus 5.5 a618ffb0e4 send log: rows belong to a domain by id (audit L12, 0008)
A domain user's log, the message page, a domain's statistics, the level-2
count and the notifier's refusal count picked send-log rows by domain name,
so a domain deleted and re-added under its old name showed its earlier mail
to the next assignee and went on counting it.

Migration 0008 (not run on the live database yet, so extended here):
send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ...
ON DELETE SET NULL, filled once by name from the registry as it is at
migration time, each with an (id, created_at) index.

- Writes: InsertQueued / InsertRejected set domain_id to the domain of the
  row's login (loginDomainID) - the journal milter and the sender check's
  refusal rows both go through them; the inbound journal sets
  inbound_domain_id from the inbound domain's name at insert.
- Reads: SendLogFilter.DomainIDs (was Domains, by name) and
  InboundSpamFilter.DomainIDs are the scope; the global role still reads
  everything. The message page checks row.DomainID against the user's
  domains. DomainSendStats and CountMessages keep their name argument and
  select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier
  joins send_log.domain_id to the domain and its application.
- DMARC reports stay by name on purpose (a report is about the DNS name).

Tests: store - scope by ids; a re-added domain's log, level-2 count and
statistics hold its own row only, the global log both; inbound scope by
ids. Handlers - the new assignee gets 404 for the old row's page and does
not see it in the log, the global role sees both. Tests that wrote rows for
a login that is no application now register it (a row is a domain's through
its application). Schema at 0008 with the two columns and indexes.

schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 19/62.
Evidence: go vet ./... clean; go test ./... ok locally but for the two
Windows-only tests; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:37:26 +03:00
mixandClaude Opus 5.5 e469034477 import: the export file held to the forms' rules before anything is created (audit M10)
test / test (push) Canceled after 0s
domain.Import trusted the file for everything but the domain name, the
selector and the password: DMARCRua was not validate.Email'ed, client IPs
not parsed, a rate limit's mode, window and count not checked - a window of
0 counted nothing, a garbage IP made the application refuse every client.

handlers.checkDomainImport runs in HandleImportDomain after the domain name
check and before Import, and refuses the file with "Invalid <what> in export
file: <the form's own text>": the report address (validate.Email), each
rate limit (parseRateLimitMode; auto - checkAutoMultiplier, 0 meaning the
default as an empty field does; manual - checkManualLimit, which the form
now uses too: count > 0, <= the level-1 backstop, window > 0; both 0 is an
export without a limit of its own), each application's client addresses
and the legacy ones on its rate limit (parseIPList, written back as the
form stores them; allow-list on with none is refused). Login, sending
addresses and password stay checked in app.ImportApplication.

Tests: a good export passes and its addresses come out normalised; nine
broken fields each refused with their message; a refused file posted to the
handler creates no domain.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 18/62.
Evidence: go vet clean; the import, rate-limit and application tests of
./internal/web/handlers ok; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:28:29 +03:00
mixandClaude Opus 5.5 f9e75de8d1 store: users saved in one transaction, the last global kept inside it (audit M7, M9)
M7: DeleteUser counted the global users and then deleted; the handler's
demotion and CreateGlobalUser checked and then wrote. MaxOpenConns(1)
serialises statements, not pairs: two globals deleting or demoting each
other at once could leave none. Now:
- DeleteUser: a transaction that deletes first (taking the write lock) and
  then requires a global left (requireAGlobal) - ErrLastGlobal rolls back.
- SaveUser: likewise for a demotion, the count inside the transaction after
  the write. The handler's early count stays only to answer before hashing a
  password; SaveUser decides, and ErrLastGlobal maps to the same message.
- CreateGlobalUser: INSERT ... SELECT ... WHERE NOT EXISTS (SELECT 1 FROM
  users), one statement.

M9: submitUserUpdate made five writes in a row (UpdateUser, sessions,
SetUserRole, ClearUserReach with its error only logged, SetUserReach).
store.SaveUser(UserChange) writes name, hash, e-mail, role and reach in one
transaction (a global user's reach cleared); sessions end after it commits.
The create path had the same flaw - user, reach and then e-mail in three
writes - and now goes through AddUser, one transaction; CreateUser stays as
a wrapper for its many test callers. SetUserRole is gone; writeUserReach is
the reach write inside a transaction.

Tests: store - two globals demoting, and deleting, each other from two
goroutines: exactly one refused, one global left; a refused SaveUser (taken
name, a reach to no domain, nothing assigned) changes nothing; promoted to
global loses the reach; AddUser with a bad reach leaves no user; four setups
at once create one user. Handlers (the users pages, direct): the whole
change saved, back to domain with a reach; the last global not demoted and
the typed rename not written; a domain user with nothing assigned refused,
nothing changed; create with e-mail and reach.

CHANGELOG [Unreleased], audit plan lines ticked (two), roadmap 17/62.
Evidence: go vet ./... clean; go test ./internal/store ok; the users tests of
./internal/web/handlers ok; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:25:41 +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 f51f5db245 app: logins folded to lower case, unique without regard to case (audit H4, 0008)
Postfix's reject_sender_login_mismatch compares the SASL login with the
sender map without regard to case - seen on the e2e stand this time (a
one-off probe, not committed: map rewritten to "APP1", client authenticated
as "app1", RCPT accepted). The panel stored logins as typed under a BINARY
UNIQUE, so "Ops" and "ops" were two applications to the panel and one sender
to Postfix: a tenant could send with another tenant's envelope sender.

- app.foldLogin: Service.Create lower-cases the login (owner: fold, do not
  refuse); CreateWithSettings rolls back the stored (folded) login.
- ImportApplication keeps the login as written: folding it would rename the
  SASL account and break the imported application's working credentials;
  the index below refuses a case clash there too.
- Migration 0008 (DDL): CREATE UNIQUE INDEX idx_applications_login_nocase ON
  applications (login COLLATE NOCASE). The column's own BINARY UNIQUE stays
  (SQLite cannot change a collation without a table rebuild, which a
  migration transaction cannot do with foreign keys on). Checked on the live
  installation first: 4 logins, no case clash; "Quieter3C" keeps its case.
  0008 is not run anywhere yet; send_log.domain_id joins it later in stage 2.

Tests: "Ops" created as ops, SASL account and map binding in lower case,
OPS in another domain and an imported oPs refused; the store refuses ops
beside Ops; schema at user_version 8 with the index.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 15/61.
Evidence on the test server for this tree (with the probe): go vet clean,
go test ./... 23 packages ok, make e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:55:24 +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 db36aa2c0e logtail: count the message a hung sender check defers in cleanup (audit M13)
Seen on the e2e stand, the panel frozen with SIGSTOP once DATA was
answered: the client gets 451 4.7.1 after 30 s, and the warning that names
the socket is cleanup's, not smtpd's:

  postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sender.sock:
    can't read SMFIC_HEADER reply packet header: Connection timed out
  postfix/cleanup[1339]: 7BCAF3C75DA0: milter-reject: END-OF-MESSAGE ...
    4.7.1 Service unavailable - try again later; ...

The counter read smtpd lines only, so Health's "messages deferred" missed
the one case that tells a hung panel from a stopped one. milterLineRe now
takes smtpd and cleanup; cleanup's "milter unix:<socket>:" warning counts a
message each time (one cleanup process serves message after message, and the
first warning ends the message); smtpd keeps once per connection. The
composed fixture is replaced by the seen lines, with two messages through one
cleanup process and OpenDKIM hung in cleanup (not counted).

e2e panel_hung_mid_message_is_counted: freezes the panel after DATA's 354,
wants a 4xx at the end of DATA, logs what mail.log says, and waits for
Health's today count to grow. kill through sh -c: the image has no kill(1).

CHANGELOG [Unreleased], audit plan line ticked, roadmap 13/61.
Evidence on the test server for this tree (with the next commit's docs):
go vet clean, go test ./... 23 packages ok, make e2e exit 0 -
renewed_certificate_picked_up 1.2 s, panel_hung_mid_message_is_counted
31.7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:42:01 +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 63520f1678 logtail: deliveryRe anchored at the head of the line (audit M12)
The pattern that writes a row's status matched anywhere in a line: a
port-25 reject line ending helo=<ID: to=<victim@...>, status=sent> parsed
as (ID, victim, sent). parseDelivery now takes the stamp off with
SplitTimestamp and matches `^host program[pid]: <id>: to=<...>, ...
status=`, as queueLineRe and the inbound patterns do.

Tests: the probed HELO line, the same text in a quoted sender on a qmgr
line, the text after an smtpd line's queue id - none parse; a syslog-stamped
line as Postfix 3.7 writes it parses. The existing cases unchanged.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 11/61.
Evidence: go test ./internal/logtail ok but for TestFollowTailsAndRotates
(Windows); the server run of the stage follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:22:03 +03:00
mixandClaude Opus 5.5 ffefa9d6df delivery log: measure the sweep before deciding its fate (audit H3, 0007)
Migration 0007 (DDL only): delivery_log_days, a row per UTC day, kept a
year, shown nowhere - read off a copy of the database after a month by the
decision rule written in the audit plan.

- reconcile_closed: the sweep adds the rows it actually closed.
- reconcile_contradicted: store.UpdateStatus, in one transaction, counts the
  rows with reason "reconcile" a delivery line now speaks of, writes the
  log's status and clears the reason - so a re-read line counts once.
- tailer_blind_seconds: follow() tracks stretches in which the file is gone
  or reopening fails (logtail.blindness); stored when a stretch ends and
  every minute while it lasts; under 3 s not counted (the rotation's tick
  between rename and create loses nothing).
- queued_over_lifetime: every sweep overwrites the day's level with the rows
  queued past LongestLifetime + MaximalBackoff (a message Postfix still
  works on stays queued until its first retry after the lifetime).
- retentionLoop prunes days older than a year.

Tests: store (sums per day, the level overwritten, under a second nothing,
prune; a contradicted sweep counted once and its reason cleared; count of
queued rows); logtail (the sweep counts what it closed; countStuck records
the level; blindness - a one-tick gap not counted, 75 s stored as 60 + 15
from where the last part ended). Schema tests at user_version 7.

schema-migrations.md: chain head 7 (it still said 5 since 2.4.0), row 7.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 10/61.
Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail
ok but for TestFollowTailsAndRotates (Windows); the server run of the stage
follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:20:44 +03:00
mixandClaude Opus 5.5 9adb44845c logtail: the sweep closes only queued rows, with reason "reconcile" (audit H3)
reconcile() closed a row through UpdateStatus (WHERE queue_id AND to_addr),
which could overwrite a "sent" the tailer wrote between the listing and the
close, and rewrite an old row of a reused queue id. store.CloseUnreported
sets status bounced and reason "reconcile" (store.ReasonReconcile; the
0006 column, no schema change) WHERE ... AND status = 'queued'; the sweep
counts rows actually closed.

Tests: store - a queued row is closed with the reason, a sent row is left
alone (0 rows); logtail - the sweep's close goes through CloseUnreported.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 9/61.
Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail
ok but for TestFollowTailsAndRotates (Windows rename of an open file, fails
here always); the server run of the stage follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:16:26 +03:00
mixandClaude Opus 5.5 dfe10f6c27 postfix: a truncated postqueue -p listing is no listing for the sweep (audit H3)
QueueIDs parsed Queue()'s text, which caps postqueue -p at 4 MiB and
appends a note, and returned the ids before the cut with no error: the
reconcile sweep then closed every queued row past the cut as bounced while
Postfix still held the mail. listQueue() now returns the text and whether
it was cut; QueueIDs refuses a cut listing with an error (reconcile already
treats an error as no information), the way postqueueJSON does for the
notifier. Queue() keeps showing the cut listing with its note.

postqueueP is a var so tests run a fake postqueue (the test binary as a
helper process): 5 MiB -> QueueIDs errors, Queue ends with the note; 1000
bytes -> ids read.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 8/61.
Evidence: go test ./internal/postfix ok (Windows); the server run of the
stage follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:13:34 +03:00
mixandClaude Opus 5.5 9efc9c7005 release: 2.4.2: audit stage 0, release hygiene
test / test (push) Canceled after 0s
Stage 0 of docs/audit-2026-10-12.md, 7/7: the release image says which
release it is (H1), the e2e module is compiled on every push (M20),
test/ is out of the build context (L33), the documents say the 2.x image
is not published and show the local build (M6), dependencies raised,
staticcheck clean, the negative e2e cases held to their own refusals
(which showed the level-1 rate-limit case had never seen the limit).

CHANGELOG cut; pin 2.4.2 in deploy/docker-compose.yml, README (with the
local-build note), guide; development.md "from pin" and the planned cuts
of the audit's stages (2.5.0 ... 2.6.0); roadmap row in progress and the
recommended order; stage 0 marked shipped in the plan. No tag (the
owner's command only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:11:25 +03:00
mixandClaude Opus 5.5 96425dd515 e2e: a negative case passes only on its own refusal (audit stage 0)
sendResult.refusedAt(stage, code, text) and replyIs(err, code, text):
nil only for the server's reply with that code and text at that stage;
a reset, a timeout or another reply is described for the failure.

- level 1: only anvil's "450 4.7.1 Error: too much mail from", only after
  a message of the loop was accepted. Held to that, it failed twice on
  the stand, each time for a real reason:
  1. waitForSMTPSReady took a TCP connect for readiness; Docker's port
     proxy accepts before Postfix listens and then resets - the reset
     once taken for the limit. It now waits for the TLS 220 greeting.
  2. the first message then got "553 ... not owned by user l1app": an
     smtpd idle from before the panel's postfix reload took the
     connection with the old sender_login_maps. The level-1 limit had
     never been what the case saw. The first message now waits (15 s at
     most) for the map, failing at once on any other refusal.
  Now: "level-1 limit tripped on attempt 51/60: 450 4.7.1 Error: too
  much mail from 10.77.0.1".
- level 2: 451 4.7.1 at MAIL, and both MAIL FROM of one connection.
- sender mismatch: 553 at RCPT, "not owned by user".
- no AUTH and the relay case: 554 "Client host rejected: Access
  denied" at RCPT; the relay case's comment claimed
  reject_unauth_destination, which on 465 comes after the client
  restriction - port 25 holds "554 Relay access denied".
- port 25: 550 5.1.1 for unknown recipients and report addresses.
- panel down: a 4xx reply (textproto), not just text starting with 4.

The product side (a second after an application is saved when its first
message can get 553) is written in the audit plan, not changed. Audit plan
line ticked with what was found, roadmap 7/61. No CHANGELOG line: tests only.

Evidence: make e2e on the test server for this tree, exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:10:47 +03:00
mixandClaude Opus 5.5 8b9798884a staticcheck: the four items of the audit (stage 0)
test / test (push) Canceled after 0s
- handlers/authz.go: domainIDSet removed, nothing called it (U1000).
- handlers_outbound_pages_test.go: two refusals whose status code was
  assigned and never read (SA4006) now check it is 400 - a custom DMARC
  report mode without an address, export passwords that differ.
- view/pages_test.go: !strings.Contains for a missing box (S1003).
- Audit plan line ticked, roadmap 6/61. No CHANGELOG line: nothing an
  operator sees changes.

staticcheck ./... clean afterwards. Evidence on the test server: see the
previous commit (same tree run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:55:12 +03:00
mixandClaude Opus 5.5 80bab92c8c deps: modernc.org/sqlite 1.60.1, x/crypto 0.58.0, x/sys 0.49.0 (audit stage 0)
- go get ...@latest + go mod tidy: sqlite 1.53.0 -> 1.60.1 (libc
  1.77.1, memory 1.12.1), x/crypto 0.54.0 -> 0.58.0, x/sys 0.47.0 ->
  0.49.0, go-isatty 0.0.24. The go line reads 1.26.0 (tidy's spelling of
  1.26); no dependency asks for a newer Go.
- development.md: golang:1.26-bookworm floats to the newest patch, and
  the standard library's security fixes arrive that way - rebuild the image
  after each Go patch release; dependencies are raised by hand.
- CHANGELOG [Unreleased], audit plan line ticked, roadmap 5/61.

Evidence on the test server (Go 1.26.5) for this tree plus the next
commit's staticcheck fixes: go vet clean, go test ./... 23 packages ok,
make e2e exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:55:12 +03:00
mixandClaude Opus 5.5 937b41ddb7 docs: say the 2.x image is not published; show the local build (audit M6)
README, guide and deploy/docker-compose.yml pinned
ghcr.io/mixeme/selfpost:2.4.1, which does not exist: ghcr.io holds 1.0.0
and 1.3.0 only. The owner decided (2026-10-12) that old tags and images
stay untouched and the pin points forward, made true by the next
publication. Until then:

- README Quick start and guide Fixed image tag say the 2.x line is not on
  ghcr.io and show `docker build ... --build-arg VERSION=2.4.1 -t
  ghcr.io/mixeme/selfpost:2.4.1 .`; with the image present locally,
  docker run and Compose (pull_policy missing) use it without pulling.
- compose header says the same in two lines.
- development.md release steps: bump the version in those notes at each
  cut, remove them at the first publication of the line.
- CHANGELOG [Unreleased], audit plan line ticked, roadmap 4/61.

Documents only; no test reads the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:53 +03:00
mixandClaude Opus 5.5 cc2ca3c968 ci: compile the e2e on every push; test/ out of the build context (audit M20, L33)
test / test (push) Canceled after 0s
- test.yml: after go test, `go vet ./...` and `go test -c -o /dev/null .`
  in test/e2e - compiled and linked, not run. Not `go test -run xxx` as
  the plan said: TestMain would still run and bring up the stand. The Go
  cache now keys on test/e2e/go.sum too.
- .dockerignore: test (e2e leftovers in test/e2e/.stage - DKIM keys,
  sasldb2 - no longer reach the builder; the sink image is built from
  test/e2e/sink as its own context, so it is unaffected).
- development.md (test.yml line), CHANGELOG [Unreleased], audit plan
  lines ticked, roadmap 3/61.

Evidence on the test server for this tree: make e2e (no
SELFPOST_E2E_VERSION, so the "e2e" default path) exit 0, including
image_stamped_with_version and the logrotate-mode build; in test/e2e
go vet ./... and go test -c -o /dev/null . clean; docker build of
build/Dockerfile with a file under test/e2e/.stage succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:24:48 +03:00
mixandClaude Opus 5.5 70b4eb5585 release: stamp the image with the release's version (audit H1)
test / test (push) Canceled after 0s
The e2e gate rebuilt selfpost:e2e through compose.override.yml with its
own VERSION=e2e, and that rebuild is the image release.yml pushed: every
published image printed "e2e", and the backup/restore version check
compared a backup's version with "e2e".

- compose.override.yml: VERSION: ${SELFPOST_E2E_VERSION:-e2e}.
- release.yml: the e2e step gets SELFPOST_E2E_VERSION = the release's
  X.Y.Z; a new step before the push checks panel -version and
  selfpost-backup -version of selfpost:e2e against it.
- e2e: image_stamped_with_version checks both binaries against
  expectedVersion() (the variable, or "e2e" when unset).
- development.md, CHANGELOG [Unreleased], audit plan line ticked,
  roadmap 1/61.

Evidence: make e2e on the test server for this tree with
SELFPOST_E2E_VERSION=2.4.2-stamp - exit 0, TestE2E/image_stamped_with_version
PASS, and `docker run --rm --entrypoint <bin> selfpost:e2e -version` prints
2.4.2-stamp for panel and selfpost-backup. GOOS=linux go vet in test/e2e
clean. Published images are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:18:20 +03:00
mixandClaude Opus 5.5 86665f08e6 audit: decisions of 2026-10-13/14 on what 2.4.0 left undone
test / test (push) Canceled after 0s
Agreed with the owner, one question at a time; documents only, no code.

From from-header-binding's Not done list, each a step of the audit plan:
the Sender check column says "Connections turned away"; a stand test of
which key OpenDKIM signs with; Health connects to a milter socket instead
of stat-ing it; the cards layout orders a phone by the box's number;
a refusal the panel did not write is recovered from mail.log; a log
rotated while the panel was down is read to its end; the deferred counter
keeps a place in the log; downtime is counted when supervisord is silent.
"Today, UTC" became the roadmap item local-time (agreed): one instance
zone, no labels beside a time, days counted in that zone, with the
conditions for per-user zones and offsets, and the trap of mail.log's own
stamps written down.

The six steps on the log tailer are split off stage 1 into stage 2b, a
cut of its own (2.5.1); stages 3 and 4 move to 2.5.2 and 2.5.3.

Also: negative e2e cases pass only on their own refusal (stage 0);
notifications are owed per recipient (stage 4); a DNS outage is unknown,
not wrong (M14, M15 had no step); DMARC reports tested where they come in.
review-2026-08-followups is closed, its rows placed in stages or dropped
with a reason. Certificate warnings by lifetime: left as is, with the
condition for return.

The audit's checklist is 61 lines (was 48).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:29:13 +03:00
mixandClaude Opus 5.5 c3d99b1f15 roadmap: decisions of 2026-10-13 on the candidates
test / test (push) Canceled after 0s
Agreed with the owner, one question at a time; documents only, no code.

- csrf-tokens -> origin-strict (agreed): no tokens, the origin check
  refuses a request with neither header. security.md's ADR records the
  decision and when tokens are built after all.
- structured-logging, contributing: dropped, each with what brings it
  back (new roadmap section "Dropped, with the condition for return").
- template-data-typing: done by 2.0.0; its last map (healthChecks) is a
  step of the audit's stage 4.
- preflight -> health-setup-checks (certificate name, Postfix's greeting,
  trusted proxy as lines on Health) and test-email, both agreed. The port
  check is dropped with its reason; plans/preflight.md removed.
- password-reset: agreed; a reset link goes only to a confirmed e-mail,
  so the item builds confirmation first.
- phone-desktop-mode: agreed, after stages 1 and 2 of the audit.
- backup-reauth (agreed): the account password before a backup or a
  domain export, out of review-2026-08-followups.
- from-header-binding, Not done: the Delivery log box is required on a
  refused message's page, with the line Postfix wrote, for all three
  kinds of refusal (audit stage 1); the Outbound log gets a filter by
  status and by day (audit stage 4).

The audit's checklist is 48 lines (was 45).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:13:04 +03:00
mixandClaude Fable 5.1 b9353db507 audit: the plan as a checklist, with how to resume it
test / test (push) Canceled after 0s
45 steps as '- [ ]' lines per development.md § Plan checklists; roadmap
progress 0/45; a resume note naming the active stage and the working rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 23:21:30 +03:00
mixandClaude Fable 5.1 7fdf09839f audit: the four items below the line are decisions, and say so
test / test (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 23:18:56 +03:00
mixandClaude Fable 5.1 e3bcafb6d9 audit: decisions of 2026-10-12 written into the plan
Stage 0 then 1+2 agreed; H4 folds logins; H3 measured without a screen
(delivery_log_days, decision rule, no card); L5 validated at save; L12 the
send log gets domain_id; L8 the reply names no other domain; L9 local
comment and test only; stage 5 taken whole. Migrations renumbered
0007-0009.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 23:15:34 +03:00
mixandClaude Fable 5.1 2a96008803 release: 2.4.1: code audit of 2.4.0 and its plan
test / test (push) Canceled after 0s
Docs only. docs/audit-2026-10-12.md: findings of a full-tree audit (four
high, twenty-one medium, the rest), doc drift, test gaps, what was checked
and found sound, and a six-stage plan; roadmap index row; CHANGELOG cut and
pin bump. Old tags and images stay as they are (owner): fixes go forward.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 22:57:08 +03:00