Commit Graph
76 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 ac21180d58 release: 2.8.0: panel-dkim-signing - the panel signs, OpenDKIM gone
test / test (push) Waiting to run
Cut at the owner's word (2026-10-16, "Выпускай").

The panel signs outgoing mail in a milter of its own (go-msgauth,
relaxed/simple, the login's key on 465/587 and the server's name's on
local mail); OpenDKIM, the syslog relay, the USR1 reload and the From:
parser port are gone. Keys move to /data/dkim (copied once; /data/opendkim
kept for a rollback, left out of backups). The sender check holds Sender:
like From:. go-milter is built patched not to keep every header. The queue
is handed to Postfix's users by name before "postfix check". Commits
005922b..ca3a183 (005922b, faa3c11, c2f63d2, f0dd9f9, 2cfd239, d9d9af3,
0cceb54, 80faf1a, f1d90a2, ca3a183).

On the test server since 2026-10-15 22:48 UTC (2.7.0-post.c2f63d2, now
2.7.0-post.f1d90a2, the same code as this cut): healthy, keys unchanged.
The first rollout (faa3c11) crash-looped five minutes on the queue's owner
numbers and was rolled back; fixed in c2f63d2. The owner's canary: Gmail
dkim/spf/dmarc pass with d=carrel.mixfed.ru, Yandex dkim/spf pass, not
spam; his From:/Sender: check, thirteen cases, all as expected.

CHANGELOG [2.8.0]; pins in deploy/docker-compose.yml, README and the guide
(the guide's rollback command keeps naming 2.7.0 on purpose); development's
"from pin"; roadmap row and recommended order; plan stage 3 closed. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:36: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) Waiting to run
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 a556a13f67 release: 2.7.0: base-trixie - Debian trixie, Postfix 3.10.13
Cut without asking, as the owner said on 2026-10-15.

The image runs debian:trixie-slim with Postfix 3.10.13, libmilter
8.18.1, OpenSSL 3.5; every citation re-read against the trixie sources.
smtpd_hide_client_session=yes on 465/587 (owner's decision). The
selfpost group is 10998 and the panel 10999:10999, outside the system
range; the entrypoint re-owns /data by name. The panel's database is
0600; the entrypoint removes symbolic links the panel could leave under
/data. Commits 68ec8b2, 1c6e04b, a3050af.

Rolled out to the test server as 2.6.1-post.a3050af (the same code),
2026-10-15 17:07 UTC: healthy, every program RUNNING, no restarts, three
messages sent, the queue empty, no warning but outside password guessing.
The canary: one message from an application through 465 to Gmail, Yandex
and the test mailbox - Received: in the short form everywhere; Gmail
dkim=pass spf=pass dmarc=pass; Yandex spf=pass dkim=pass, not spam.
No migration, schema 11.

CHANGELOG cut; pin 2.7.0 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the rows of
base-trixie and panel-dkim-signing; roadmap row and recommended order.
No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:14: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 b76f1ef1a0 release: 2.6.1: port 25 ends DATA only at CRLF.CRLF
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15.

The inbound relay's smtp/inet listener sets
smtpd_forbid_bare_newline=normalize, the SMTP-smuggling defence Postfix
3.7.11 leaves off (aaa9412, port25-bare-newline, the first of the three
rows from own-mta-builds).

Rolled out to the test server as 2.6.0-post.aaa9412 (the same code): the
container healthy, every program RUNNING; the server runs the inbound
relay, and postconf -P shows smtp/inet/smtpd_forbid_bare_newline =
normalize next to syslog_name postfix/inbound; no warning or fatal in
docker logs. No migration, schema 11.

CHANGELOG cut; pin 2.6.1 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the row;
roadmap row shipped and the recommended order. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:30:33 +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 dc78b0ed76 release: 2.6.0: audit stage 5 - image and Postfix hardening
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15.

The supervisord socket is root:supervisor, the panel the group's one member
(M17). Postfix takes TLS 1.2 at least, the client too where TLS is
required; VRFY off; the banner without the distribution; the start refuses
a rate, size or milter timeout that is not a number, under its minimum or
past an int (L31). OpenDKIM logs through the syslog relay to the container
log and oversigns From: (L30). crashexit takes the container down after
five unexpected exits of a mail-path program in ten minutes (L32).
logrotate keeps its state under /data/log. extract-cert.sh refuses what is
not PEM and renames whole files (M21). Plus the findings of both reviews
(a39eda5). Commits dab7715, a39eda5, b09e525.

Rolled out to the test server as 2.5.4-post.95af363 (the same code): the
container healthy, every program RUNNING, syslog-relay among them;
OpenDKIM's start line in docker logs under "syslog: "; the postconf values
as set; /run/supervisor.sock root:supervisor 0770 and the panel reaches it;
/data/log/.logrotate.status written. No migration, schema 11.

CHANGELOG cut; pin 2.6.0 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 5 marked shipped in the plan. No
tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:58:10 +03:00
mixandClaude Opus 5.5 d6ed6bcb30 release: 2.5.4: audit stage 4 - panel behaviour
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15.

Signing in costs the same for an unknown name (M8) and ends the session the
browser presented (L16); the Account password check has its own limit
(L15). The HTTP server bounds the request, the answer and idle connections;
the full backup moves its own deadline (L14). Monitoring refreshes are
scheduled from htmx:afterRequest with a back-off and a 30 s timeout, and a
lost session sends htmx to sign-in (M11). The Outbound log clamps its page
(L10) and filters by status and day; the multiplier refuses NaN (L11); a
rate-limited refusal is red (L18); Recalculate asks before it drops an
unsaved edit (L19); Reload configuration writes what a restore writes and
removes the keys of deleted domains (L13, L17). The cards layout orders by
number. A notification is owed to each recipient (migration 0011). A DNS
outage is unknown, each resolver with its own time (M14, M15). DMARC
reports too large are refused for their size. An inbound upstream has one
TLS mode, changed for all its domains (L5). Plus the findings of both
reviews (eaa7de0). Commits 07be0cf, 643892a, ca876c1, 80e3979, 2b8e0c0,
b90a2f0, eee089a, 006bed7, 32dfe4a, eaa7de0.

Rolled out to the test server as 2.5.3-post.eaa7de0 (the same code):
migration 0011 applied, schema 11; all programs RUNNING, the container
healthy. Resync's key cleanup checked against the live data: every key
directory is a domain's or the hostname's, nothing to remove.

CHANGELOG cut; pin 2.5.4 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 4 marked shipped in the plan. No
tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:51:50 +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 006bed7022 audit 4: DNS outages unknown, DMARC limits said, typed Health, one TLS mode per upstream
test / test (push) Canceled after 0s
M14, M15: queryEach gives each resolver 2 s within the check's context and
asks the one that answered last first; an address or PTR lookup not
answered is Unknown, an answered mismatch beside it still an error;
Checker.Server keeps the last answer's addresses, so SPF does not go Unknown
with it. Notifications report faults only, now tested with SPF unknown
alone and beside a wrong DKIM.

DMARC reports: tests of malformed XML, gzip and zip bombs, a message over
the limit, the service's Resync, hosted suggestion and validation. The
finding: both limits cut silently and an oversized report failed as
damaged; readAtMost refuses it for its size (MaxMessage 10 MiB, moved into
IngestMessage).

healthReadings replaces map[string]any for Health and Overview.

L5: an inbound upstream has one TLS mode - a second domain to the same
[host]:port with another mode is refused, naming the first; the TLS policy
map holds each next hop once.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 631 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:15:58 +03:00
mixandClaude Opus 5.5 2b8e0c086d audit 4: a deleted domain says so, the Outbound log filters by status and day
test / test (push) Canceled after 0s
L17: past DeleteDomain both rebuilds run, their errors joined under
domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables
were rebuilt (they may still name it otherwise); the domain list says the
domain is deleted and that Reload configuration finishes it, not "internal
error". Tests in domain (the tables failing, the sender map failing) and
handlers.

The Outbound log's status and day filters (owner, 2026-10-13; the mockup
first, 80e3979): view.OutLogFilter carried by the poll, the pages, the rows'
links and the message's way back; SendLogFilter.Status, Since, Until (whole
UTC days, as the Inbound log's); rejected is the three refusals together; a
status not in the list is no narrowing. Tests: the filters through the
handler, the poll and the way back; the day boundaries in store.

The plan's milter-socket step is ticked: done in 2.5.2 (98e09fe).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 642 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:34:24 +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 48832d223a release: 2.5.3: audit stage 3 - persistence and restore
test / test (push) Canceled after 0s
Cut without asking, as the owner said on 2026-10-15: "Вопросы про резку не
задавай, просто режь."

A restore is ended by its Resync: the manifest stays until the first start
has regenerated the maps (M1). Every archive ends with data/contents.json and
the first start after a restore compares /data with it before it opens the
database: half an archive, a damaged file, a journal left beside the
database is refused, named. The entrypoint hands every map under
/data/postfix to the panel (a restore unpacked as root left the DMARC maps
unreadable - found by the review). Migration 0010: the send log's sweep,
refusal-line check, level-2 count and pages each by its index, pinned by
EXPLAIN tests on the statements the store runs (M2). The backup CLI refuses
a missing database (M3) and a password under 12 (M5), writes -o whole,
checks -decrypt's password before the output (L1), warns of what it leaves
out and refuses an empty certs/ (L2). An encrypted file may ask scrypt for
128 MiB at most, r <= 64, p <= 2, and nothing may follow its last chunk
(M4, L3). TRUSTED_PROXY_CIDR's bare ::ffff:a.b.c.d is one host (L4). Maps
are durable across a power loss: the directory is synced (L7). Commits
8e3241e, 508ac96, 242515c, 76b21b3.

Rolled out to the test server as 2.5.2-post.76b21b3 (the same code):
migration 0010 applied, schema 10, the four new indexes present; all
programs RUNNING, Postfix with its three milters, nothing in the logs. On
the live container: a CLI backup ends with data/contents.json, a wrong
SELFPOST_DB_PATH is refused and creates nothing, a short password is
refused.

CHANGELOG cut; pin 2.5.3 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin" and the stage's row;
roadmap row and recommended order; stage 3 marked shipped in the plan. No
tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:44:08 +03:00
mixandClaude Opus 5.5 242515c305 audit 3: backup CLI, envelope budget, directory sync, docs; findings of the reviews
Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one
commit: the review fixes sit in the same hunks.

Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list):
- backup.snapshotDB refuses a database that is not there and opens the
  source mode=ro (measured: mode=ro creates no file).
- selfpost-backup holds an encrypting password to
  validate.MinSecretFilePasswordLen; -o of both directions writes a
  temporary file beside the name and renames it whole; -decrypt peeks the
  first chunk (password and type authenticated) before the output exists.
- what a backup leaves out (a link under data/, a link under certs/ to a
  directory, a socket...) is told to OnWarn; certs/ with no file is
  backup.ErrNoCerts, refused by Check and Create, named by the panel.
- parseTrustedProxies: a bare address takes /128 when written with a
  colon (::ffff:10.0.0.1/32 was ::/32).
Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2;
bytes after the last chunk are ErrCorrupt.
Step 5 (L7): atomicfile.SyncDir after the rename, used by Write,
replaceSASLDB and the CLI; atomicfile_test.go.
Step 6: security.md and guide on backup and restore.

Reviews (technical, security):
- H1: a restore of a server with DMARC on was refused on every start - the
  archive records no owner, tar as root left the DMARC maps root's, and the
  entrypoint only chowned the maps it named. It now hands every file beside
  queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map
  before the backup and finds it panel:selfpost 640 after a root unpack.
- the comparison reads regular files only (Lstat, size, LimitReader); a
  record unpacked again wins over the earlier check's mark; ConsumeRestore
  removes both; the mark is synced; a journal file beside a freshly unpacked
  database is refused.
- AttachRefusalLine refuses an empty line as the old statement did; skipped
  entries named in words; -decrypt of an empty payload is no failure; guide
  wording on the shell's '>' and a killed CLI; the in-place restore's
  'mv .' (older than this stage) fixed.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 643 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:38:39 +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 68b3a7b23f release: 2.5.2: audit stage 2c - what SelfPost relies on, read in the sources
test / test (push) Canceled after 0s
The owner's word, 2026-10-15: "Да, режем."

Every place SelfPost relies on another program was read in that program's
source, of the version the image runs, and is cited in the code (621
entries, docs/assumptions-2026-10-14.md); what the sources contradicted is
fixed: rspamd signing a sender's own verdict, the sender map's first key,
queue expiry and Postfix's time values, refusal and recipient lines as
Postfix writes them, the rotated log read until postlogd lets it go,
inbound mail over 10 MB, local delivery to an address literal, supervision
(Postfix stopped, give-ups FATAL, the reload's exit read), sasldb2 through
a copy, Health dialling sockets and showing the certificate Postfix
serves, Referrer-Policy same-origin, migrations under BEGIN IMMEDIATE,
certbot's links followed, about forty sentences of the documents, and ten
measurements on the stand. The sender check reads From: with OpenDKIM's
own parser, ported and checked against the C and against OpenDKIM's
signing; L8 is closed; an address OpenDKIM would cut short is refused.
Plus the findings of both reviews of the stage (9eddd49).

Rolled out to the test server as 2.5.1-post.9eddd49 (the same code): no
migration, schema 9; all programs RUNNING, the container healthy,
Postfix with its three milters, nothing in the logs.

CHANGELOG cut; pin 2.5.2 in deploy/docker-compose.yml, README, guide (and
its sample restore message); development.md "from pin"; roadmap row and
recommended order; stage 2c marked shipped in the plan. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:22:18 +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 2e1544dac5 certbot: certs/ links into /etc/letsencrypt, mounted at the same path; the backup follows them
test / test (push) Canceled after 0s
C-A1, C-Q4, D-guide:258, :291, :722. certbot's live/<host>/*.pem are
relative links into ../../archive/<host>/ (eff-certbot docs, "Where are
my certificates?": point a server at them "or create symlinks"). The
guide had `ln -s /etc/letsencrypt/live/<host> certs` and the nginx
fragment mounted live/<host> alone: inside the container the links led
into a directory it did not have - postfix-config.sh's [ -f ] false, no
copy, Postfix without a certificate - and the panel's backup, reading
certs/ through /selfpost-deploy, skipped links silently.

Now certs/fullchain.pem and certs/privkey.pem are links to
/etc/letsencrypt/live/<host>/, and /etc/letsencrypt is mounted read-only
at the same path: a commented line in deploy/docker-compose.yml for the
Apache scenario, mounted in the nginx fragment (./nginx/certbot-etc). The
guide's steps add certbot's own commands for a server that is not root -
chmod 0755 live archive, chgrp 998 + chmod 0640 the key, which certbot
keeps on renewal - so the panel reads them (Health, backup). Postfix's
side follows links already ([ -f ], cp, cmp, openssl). The vhost comment
says the same. The backup table: links are followed by the panel's
backup; a stopped tar stores them as links unless -h (GNU tar).

internal/backup: addTree follows, for certs/ only, a link to a regular
file and archives that file under the link's name; a link to nothing
reachable is an error naming it and the mount.

Tests: TestCertsLinksAreFollowed (certs/ -> live/ -> archive/, the
archive holds the files; the tree gone, Check names the dangling link;
Linux). Stand (2026-10-15, make e2e, exit 0): the stand mounts
./letsencrypt at /etc/letsencrypt; new certbot_layout_followed puts a new
pair into letsencrypt/archive/, relative links in live/, absolute links
in certs/, recreates the container: 465 serves the new serial, Health
names no renewal waiting, and the full backup holds certs/*.pem as
regular files with the archive's bytes. go vet and go test ./... pass on
the server (24 packages); the Linux-only tests of this session run there
and pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:34:19 +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 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 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 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 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 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 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 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 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
mixandClaude Opus 5.5 8f226bd82f release: 2.4.0: an application signs only as itself
test / test (push) Canceled after 0s
from-header-binding (docs/plans/from-header-binding.md, 9/9), at the owner's
word (2026-10-12, "Да").

  - A message's From: line is held to what its envelope sender is held to:
    any address of the application's domain, or the addresses of its list,
    with or without a +extension. Anything else is refused with 550 5.7.1 -
    another registered domain, the server's own name, a domain this server
    does not sign for. Until now an application could have mail signed with
    another domain's key by writing that domain into From:.
  - The check is the panel's, first in the milter chain and strict: while
    the panel does not answer, Postfix defers mail.
  - Server > Health counts what that costs - the panel's downtime under a
    running Postfix and the submissions turned away - and is laid out anew,
    boxes numbered by importance.
  - A refused row of the Outbound log says why in its tag.
  - Migration 0006.

Upgrading: mail whose From: is not the application's own stops being
accepted - another of your domains, an address off an application's list, a
visitor's address a form puts there. The guide says what to do about each.
A stopped panel now stops submission.

Not done, written into the plan at the owner's word: the Delivery log box on
a refused message's page, the log's status filter, "messages" that are
connections, and what was reasoned about rather than run - OpenDKIM's own
parser above all.

Cut: CHANGELOG 2.4.0 written (nothing had been put under [Unreleased]
while building); image pin 2.4.0 in compose, README, guide; version table,
roadmap, plan status. security.md: a malformed packet on a milter socket,
as an accepted risk. No tag: the owner tags when he decides to, so there is
no image of this version until then.

On the test server, on c817f08: go vet ./... ok, go test ./... ok
(23 packages), make e2e ok. This commit changes documents and the pin only.

Full detail: CHANGELOG.md (2.4.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:00:29 +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 ce7b09a65c release: 2.3.0: the panel writes to its users
test / test (push) Canceled after 0s
panel-notifications (docs/plans/panel-notifications.md, 10/10), at the
owner's word (2026-10-12, "Да").

  - Four things are reported by e-mail, each once: a sending domain's
    DNS record that does not match, a level-2 rate limit refusing mail,
    the TLS certificate about to expire, a message more than three days
    in the queue. Global users are told all of it, domain users about
    their own domains.
  - Sent from postmaster@<SELFPOST_HOSTNAME> and signed with a DKIM key
    of the server's own name, which the panel generates.
  - Account has the user's switch, Server > Settings the instance's and
    the two DNS records to publish, Server > Health their state.
  - An account e-mail may now be at a public mail host.
  - Migrations 0004 and 0005.

Upgrading: publish the two records Server > Settings shows for the
hostname (a DKIM key and "v=spf1 a -all"); until then notifications are
sent and may be filed as spam. Every user with an e-mail starts with
notifications on. non_smtpd_milters now names OpenDKIM.

Accepted and recorded in security.md: an application can have a message
signed as the server's own name by writing it into From: (roadmap
candidate from-header-binding).

Cut: CHANGELOG [Unreleased] -> [2.3.0]; image pin 2.3.0 in compose,
README, guide; version table, roadmap, plan status. No tag: the owner
tags when he decides to (2026-10-11), so there is no image of this
version until then.

On the test server, on f09b60a: go vet ./... ok, go test ./... ok
(23 packages), make e2e ok.

Full detail: CHANGELOG.md (2.3.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:08:37 +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 972a9f825c release: 2.2.0: a message's page keeps its delivery log
test / test (push) Canceled after 0s
delivery-log-storage (docs/plans/delivery-log-storage.md, 8/8), at the
owner's word (2026-10-12, "Да").

  - The log tailer stores what Postfix writes about queued outbound
    messages; a message's page reads it from the database, so the
    Delivery log box no longer empties when mail.log rotates. The lines
    live as long as the send-log row and go with it.
  - Mail that came in on port 25 is not stored.
  - Migration 0003 (send_log_lines), DDL only.

What an operator should know: the stored lines are 1-2 KB a message
and are in a full backup, which mail.log is not (security.md). A
message accepted before this version shows its lines only while they
are in the current file.

Cut: CHANGELOG [Unreleased] -> [2.2.0]; image pin 2.2.0 in compose,
README, guide; version table, roadmap, plan status. No tag: the owner
tags when he decides to (2026-10-11), so there is no image of this
version until then.

On the test server, on a990e7c: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:59:10 +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 ae06d8b47e release: 2.1.0: Inbound log and filter lists; a milter security fix
test / test (push) Canceled after 0s
inbound-antispam-panel (docs/plans/inbound-antispam-panel.md, 12/12),
at the owner's word (2026-10-12, "Да").

  - Inbound mail is recorded: accepted mail by a second, read-only
    milter on port 25 with the spam filter's verdict, refused mail from
    the mail log. The filter signs what it judged, and a verdict is
    shown only next to its signature.
  - Inbound > Log, with a sheet over the page that says who decided
    and why; Inbound > Filter lists, allow and deny entries written as
    rspamd maps, each with how often it decided a message.
  - Migration 0002 - the first after the 2.0 baseline.

Security, outside the feature and in every release since 1.9.0: the
level-2 rate limit and an application's client IP list held only for
the first message of a connection. Fixed; e2e sends two MAIL FROM on
one connection.

Upgrading an installation with the antispam sidecar: copy
deploy/antispam/ again (the fragment mounts two new things) and
restart the sidecar. Port 25 now logs as postfix/inbound.

Cut: CHANGELOG [Unreleased] -> [2.1.0]; image pin 2.1.0 in compose,
README, guide; version table, schema-migrations, roadmap, plan status.
No tag: the owner tags when he decides to (2026-10-11), so there is no
image of this version until then.

On the test server, on 1f44e40: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.1.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:12:16 +03:00
mixandClaude Opus 5.5 1f44e40382 inbound-antispam-panel: the filter reads its signing key for every message
test / test (push) Canceled after 0s
The routine kept the key after its first read. SelfPost makes a new key
when the file is lost or damaged, or a backup brings back another, and
rspamd would then have gone on signing with the old one - every verdict
unrecorded - until someone restarted the sidecar. The file is one short
line; reading it per message costs nothing worth keeping a copy for.

The guide says what never needs doing: nothing of rspamd's is cleared
after a change of the lists or the key; a restart is for a change of
its own configuration only.

Prompted by the owner's question whether rspamd's cache needs clearing
(2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:42:16 +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 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 c1da784193 inbound-antispam-panel: the backup holds the lists; Inbound log accepted
Step 7/12 of docs/plans/inbound-antispam-panel.md. A full backup walks
all of /data, so data/antispam/ was in it from the day the directory
existed; TestCreateIncludesStateExcludesTransient now holds that. On
the first start after a restore the maps are written again from the
database (mailStack.Resync, already there). The guide says what is not
in a backup: the antispam/ directory with the compose fragment and the
rspamd configuration, which is installation and not state.

The plan records the owner's acceptance of Inbound > Log (2026-10-12,
"Этот экран утверждаем") and what he changed on the way to it.

go test ./internal/backup/ ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:36:59 +03:00
mixandClaude Opus 5.5 56c5dfee40 inbound-antispam-panel: Inbound > Log and Inbound > Filter lists
Step 6/12 of docs/plans/inbound-antispam-panel.md. Written by a Sonnet
subagent from the mockups, reviewed and corrected by me.

GET /inbound/log - the journal a page at a time (50), filtered by
domain, decision and day; QueryInboundSpam / CountInboundSpam with the
viewer's inbound domains as the scope. The global role reads all of it
and gets "Deny sender" / "Allow sender" links into the lists; a user
with the domain role reads the rows of the inbound domains assigned to
them, without the links. The plan said global-only for the journal;
the accepted mockup and 2.0's delegation of Inbound say otherwise -
recorded in the plan as built.

GET, POST /inbound/filter-lists and POST .../{id}/delete - global role,
404 otherwise. Add form (prefilled from the query), the two lists with
Remove as the kit's row action, and "Where the lists go" with the
filter's state and when the maps were last written
(Lists.LastWritten). A refused or duplicate entry is 422 with the
message and what was typed.

Server > Settings: "Keep inbound log rows, days" with the relay on.
Help: Inbound log, Filter lists. Navigation: Log for everyone who sees
Inbound, Filter lists for the global role (Shell.ShowFilterLists).
Status tags accept / reject / tempfail join the kit's mapping.

Tests: page fixtures for both screens, held to outlines.json; view and
handler tests (both roles, filters, paging, relay off, add / duplicate
/ invalid / delete, map file content, settings field, help). e2e reads
the journal from the page instead of the database and adds and removes
a deny entry through the panel, reading deny_domain.map after each.

Seen: both pages and Settings rendered from their fixtures and compared
with the mockups side by side (headless Edge, 1360 px) - same layout
and wording.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:50:34 +03:00