fbd0acd03507df38f251fcc68e3526d18ec8a0a1
56
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
89fd0a6116 |
from-header-binding: findings of the technical and security reviews
test / test (push) Canceled after 0s
Both reviews were of
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
eea40bcd49 |
inbound-antispam-panel: filter lists, written as rspamd maps
Step 5/12 of docs/plans/inbound-antispam-panel.md.
Store: AddSpamListEntry, ListSpamListEntries, DeleteSpamListEntry.
internal/inbound/lists.go validates and normalises an entry - sender
address, sender domain, client address or network (canonical text, an
IPv4-mapped address as IPv4, /0 refused) - and writes the lists as six
map files under /data/antispam (ANTISPAM_DIR), each atomically, 0644 in
a 0755 directory: the filter is another user in another container. All
six are written on every change and on every start with the relay on,
empty ones included. An entry whose maps could not be written is taken
back out of the table. The writer refuses a stored value with
whitespace, a newline or # in it.
The plan's allow.map / deny.map became one file per list and entry
type: to rspamd an IP map and a sender map are different map types.
deploy/antispam/rspamd/multimap.conf holds the rules; the fragment
mounts it and ./data/antispam into the sidecar. Deny is a prefilter
reject, allow a symbol weighing -100. Tried in a throwaway rspamd 3.11
container on the test server:
- with allow as a prefilter accept, an address on both lists was
accepted and an IP on both was refused - the rule that ran first
decided; with the weight, deny won in all five mixed cases;
- IPv4, IPv6, address (any case), domain (not its subdomains) match;
- a rewritten file is reread in about ten seconds, no restart;
- files that appear after rspamd started are loaded the same way.
No page edits the lists yet (step 6).
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - with the relay on
/data/antispam is 755 and holds the six maps, 644, empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
27fd355e53 |
inbound-antispam-panel: refused inbound mail from mail.log; retention
Step 4/12 of docs/plans/inbound-antispam-panel.md. The log tailer writes an inbound_spam_log row for mail port 25 refused (internal/logtail/inbound.go): a "reject:" line of the port-25 smtpd is Postfix's own policy; a milter-reject / milter-hold / milter-discard line of cleanup is the filter's verdict. Only a recipient at an inbound domain is a row, so "Relay access denied" for a stranger's domain is not. The row is dated by its log line; the reply text is cut to 255 bytes of printable ASCII; a reply starting with 4 is a tempfail. To tell port 25 from 465 / 587 the listener now logs as postfix/inbound (syslog_name). cleanup logs under one name for every listener, so its verdict counts only for a queue id the port-25 smtpd was seen to open (4096 remembered). Lines are matched from the head, after timestamp and host: their tail is the sender's own text. Retention: PruneInboundSpam every six hours - older than the inbound_spam_log_retention_days setting (seeded from INBOUND_SPAM_LOG_RETENTION_DAYS, 90, range 7-365), then down to 10 000 rows. The setting has no field in the panel yet. Known limits, in architecture.md: a log line names one recipient, so a message refused at end-of-message is one row; a line re-read after an unclean stop would be a second row. Checked on the test server, uncommitted tree: go vet ./... ok, go test ./... ok (22 packages), make e2e ok - the unknown recipient at the inbound domain refused on port 25 has its row (reject by postfix), the stranger's domain has none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
28ec784ae2 |
inbound-antispam-panel: inbound journal milter on port 25
Step 3/12 of docs/plans/inbound-antispam-panel.md. The panel serves a second milter while the inbound relay is on (internal/milter/inbound.go, socket INBOUND_JOURNAL_MILTER_SOCKET, default /run/selfpost/inbound-journal.sock). build/postfix-config.sh puts it last on port 25, after the optional antispam milter, with default_action=accept; postfix-wrapper.sh waits for its socket. At end-of-message it writes one inbound_spam_log row per recipient at an inbound domain - other port-25 recipients (DMARC report addresses) get none - and always accepts. With a filter attached it reads the verdict from X-Rspamd-Action and X-Spamd-Result (action, score, up to six weighted symbols, only what the patterns recognise, 255 bytes); without one those headers are the sender's and are not read. The plan named X-Rspamd-Score, which rspamd does not have. The antispam compose fragment mounts rspamd/milter_headers.conf into the sidecar so that it adds the two headers (checked in rspamd 3.11: both routines exist and remove a sender's own headers of those names). Store: InsertInboundSpam, IsInboundDomain. Checked on the test server, uncommitted tree: go vet ./... ok, go test ./... ok (22 packages), make e2e ok - the relayed message of inbound_relay_delivers_and_refuses has its journal row (accept by selfpost) and the DMARC report sent through the same listener has none. Not yet seen in a real run: a verdict written by rspamd reaching this milter. The e2e stand has no filter, and the test server's only inbound domain has no upstream, so it accepts no mail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d65d632505 |
release: 2.0.1: DMARC ingest drops and bounds; Full backup from the panel
test / test (push) Canceled after 0s
A patch on 2.0.0, all of it found on the test server after the cut:
- a message that is not a report this relay keeps is dropped at once
instead of being retried for days;
- a report is bounded before it is stored, and reports are kept per
domain (security review of the ingest path, nothing Critical or High);
- Full backup from the panel works (it had answered 200 with ten bytes
since 1.3.0);
- no backup holds the Postfix queue, and the panel says why.
Cut: CHANGELOG [Unreleased] -> [2.0.1]; image pin 2.0.1 in compose,
README, guide; version table.
On the test server, on
|
||
|
|
dd0fefdcb7 |
backup: the panel's archive is the command's, private key included
Owner, 2026-10-11: the private key is needed, and a backup from the panel and one from the console have to be the same. The commit that made the panel's backup work skipped a file under certs/ the panel could not open - on the test server the private key, which the certificate hook leaves to root and Postfix's group. That gave an archive with a certificate and no key, and two kinds of archive. - Params.Unprivileged is gone. Nothing is left out on account of who makes the archive; the queue is out for everyone (previous commit). - When the panel cannot read a file under certs/, backup.Check says so before the download and the panel refuses with what to do on the host: chgrp 998 certs/privkey.pem && chmod 640. backup.UnreadableCert tells that case from any other failure. - 998 is the container's selfpost group, which holds the panel and Postfix. Its id was whatever groupadd gave; the Dockerfile now fixes it at the value it has always had, because an operator addresses it by number from the host. - Guide: method 1 is the same archive as method 2, with the permission the key needs and a note for whatever renews the certificate. Help and the box follow the design commit before this one. - e2e: the restore scenario first makes the key unreadable and expects the refusal naming the file and the way out, then takes the backup and expects the key in it; nothing is put back by hand before the restart. go vet ./internal/backup/ ./internal/web/handlers/: ok go test ./internal/backup/ ./cmd/... ./internal/web/...: ok make e2e on the test server: ok Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
21e961cb29 |
backup: no full backup holds the Postfix queue
Owner, 2026-10-11: the queue goes out of the command's archive too. The commit that made the panel's backup work left the queue in the archive of selfpost-backup, which runs as root and can read it. But that copy was never the queue: the archive is made while the instance runs, Postfix moves messages between the queue's directories all the time, and a message on its way could be in the archive twice or not at all. One rule now - QueueDir is left out of every archive - and Params.Unprivileged is only about a certs/ file the panel may not open. What is not carried over is mail still waiting for delivery; a restored instance builds an empty queue (the e2e restore scenario starts one). To keep the queue: stop the instance and tar the project directory, as the guide's methods 3 and 4 already describe. Guide: the description of a full backup, methods 1 and 2 and the comparison table. Help follows the design commit before this one. go vet ./internal/backup/: ok go test ./internal/backup/ ./cmd/... ./internal/web/...: ok Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8c9a71e6a0 |
backup: the panel's full backup works, without the queue
Found by the e2e restore scenario of the next commit: the Full backup button answered 200 with ten bytes. The archive includes the Postfix queue, Postfix keeps every queue file and directory readable by its own user alone (not configurable), and the panel runs as its own user - the walk stopped at /data/postfix/queue/active with "permission denied" after the headers were out. So since the queue moved under /data in 1.3.0. Confirmed on the test server: selfpost-backup as root makes the whole archive, the same command as user panel fails the same way. Owner, 2026-10-11: the panel's archive comes without the queue. - backup.Params.Unprivileged: the queue is left out, and so is a file under certs/ that cannot be opened (the private key on the test server is root and Postfix's group only), named through OnWarn. The selfpost-backup command leaves it unset and takes both, as before. - backup.Check reads through everything Create would archive, writing nothing. The panel runs it before the first byte: a failure is a message on the page, not a download cut short. - The box, Help (design commit before this one) and the guide say what the panel's archive leaves out; the guide's comparison table has rows for the queue and the private key, and tells to put the key back before starting a restored instance. go vet ./internal/backup/ ./internal/web/handlers/: ok go test ./internal/backup/ ./cmd/... ./internal/web/...: ok make e2e on the test server, with the restore scenario: ok Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4dd46ea355 |
dmarc: a report is bounded, kept per domain, and ingested one at a time
Security review of the DMARC report path (Fable, 2026-10-11), which ran for the first time in 2.0.0. No Critical or High finding. Fixed: - Medium: a report was bounded only as 8 MiB of XML - some 160 000 records or one name of megabytes, all of it rows and table cells. At most 5 000 records (more is ErrRejected, dropped), text fields cut to 255 bytes on a character boundary, a record's count capped. - Medium: the cap of 500 reports was for the whole server, so a stranger filing under one domain's address evicted every other domain's reports. PruneDMARCReports counts per domain. - Low: the pipe service ran up to default_process_limit (100) commands at once against the panel's database; maxproc is 1. - Low: the reason logged for a dropped message carried the sender's bytes (header line, zip entry name) as they came; cut to 200 bytes and reduced to printable ASCII, zip entry names quoted. - Low: SELFPOST_DATA_DIR / SELFPOST_DB_PATH with whitespace or "$" would reach the command through export_environment as another path; refused at start when DMARC ingest is on. Left as they are, told to the owner: the parse-failure count is a lifetime figure shown under "This week"; a report larger than the command reads is dropped as unreadable; nested multipart is not searched. The hub's Retention line follows the design commit before this one. security.md: the review, and the accepted-risk entry says what a stranger can and cannot do now. The roadmap no longer calls the review pending. go vet ./cmd/... ./internal/dmarc/ ./internal/store/: ok go test ./cmd/... ./internal/dmarc/ ./internal/store/ ./internal/web/...: ok make e2e on the test server (with the port-25 scenarios of the next commit): ok Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
09d9625f32 |
dmarc-ingest: what is not a report this relay keeps is dropped, not retried
Owner, 2026-10-11: foreign and malformed reports are turned away. The command answered every failure with EX_TEMPFAIL, so Postfix kept a message that could never be stored and offered it again for days, each attempt adding to the parse-failure count. IngestMessage now marks those failures (unreadable as an aggregate report, a domain not sent from here, another domain's report address) with ErrRejected; the command counts one failure, writes the reason where Postfix puts it on the delivery line of the mail log and reports the delivery as done. No bounce: the envelope sender of such a message is whatever its author wrote, and the report addresses are public. A failing store is still EX_TEMPFAIL. go vet ./cmd/... ./internal/dmarc/: ok go test ./cmd/panel/ ./internal/dmarc/ ./internal/web/...: ok Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
403ce93990 |
release: 2.0.0: Panel redesign; new URLs; database from zero
test / test (push) Canceled after 0s
The panel redesign (docs/plans/panel-redesign.md, 27/27). A breaking
release: every panel URL changed and the database starts from a single
baseline - a 1.x /data is not upgraded.
Since the last cut also fixed, all found on the test server:
- the panel can read the mail queue;
- port 25 answers with DMARC report ingest on, also next to the
inbound relay;
- DMARC reports reach the panel (the delivery service never worked
since 1.7.0);
- Postfix start-up warnings about the queue directory.
Cut: CHANGELOG [Unreleased] -> [2.0.0]; image pin 2.0.0 in compose,
README, guide; roadmap, plan and version table.
On the test server, on
|
||
|
|
b951eae58c |
panel-redesign: no report mailbox for the server as a whole
test / test (push) Canceled after 0s
Follows the design commit before it.
- internal/dmarc: the ingest allow-list is the report address of each
domain that is on this host and nothing else;
DefaultHostedReportAddress and DefaultHostedSuggestion are removed.
Since 1.7.0 the general mailbox dmarc-reports@<hostname> was open
while no panel-wide default existed; the three-choice scheme had
left it open always.
- The DMARC hub's Ingest box shows the form of a hosted address,
dmarc-reports+<domain>@<hostname>.
- guide.md: what port 25 accepts.
Evidence: docs/assets/panel-redesign/evidence/dmarc.png re-shot (the
address form wraps after the plus sign in its column at 1440 px).
go test -count=1 ./internal/dmarc/... ./internal/web/... ./cmd/panel: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
ed3cb4aaac |
docs: the panel as it is in 2.0
Stage 3, step 3. Written by Sonnet from the code and the plan, checked
by Opus.
- guide.md: a section on the panel - menu, paths, the roles global and
domain with outbound and inbound assigned separately -, Overview and
Health, Queue and System log, Account and Server > Settings, the
domain page / Domain settings / application form, Help as a page of
topics. The DMARC report address is one of three choices set in
Domain settings. The Spam filter box, the two INBOUND_ANTISPAM_*
variables the panel reads, and the filter's socket on Health.
Sessions; backup and export encrypted unless the box is unticked;
1.x data is not restored into 2.0.
- architecture.md: Panel HTTP surface rewritten from web.go (routes,
the /server/ guard, per-object checks, <page>/fragment, one layout,
typed page data, the kit and its guards, CSP and htmx); Persistence
gains the database - one baseline, a 1.x file refused, the tables as
they are.
- security.md: roles and what each reaches, as built; sessions by user
id and what ends them; state-changing requests; errors shown to
users; the 2.0 review; two accepted risks from it - a delegated
inbound upstream may be any host and port, and Account's profile is
saved without the password.
- CHANGELOG: Removed opens with "2.0 starts from an empty data
directory"; entries that describe the user-default report address
say they were replaced before release; the filter's socket.
- roadmap.md: two review follow-up rows struck as retired by the
redesign (.pair / .split, the two back links); password-reset notes
that changing the account e-mail must then ask for the password.
Progress 25/27.
- development.md: the component kit and its guards; README and
product.md: wording.
Left as they are
- The third follow-up row the plan expected to strike - initShowWhen
is not re-run after an htmx swap - still holds: no polled fragment
carries such a control today, so nothing breaks, but the redesign did
not retire it.
- guide.md embeds no screenshots, so none needed retaking.
- README and guide still name image 1.9.5 - the version cut.
- plans/preflight.md and plans/inbound-antispam-panel.md use pre-2.0
paths; roadmap's template-data-typing is largely done by the
redesign.
go test -count=1 ./cmd/panel (env-doc tables): ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
57c6fddfc3 |
release: 1.9.5: GHCR release fix: keep per-arch images, sweep orphaned versions (CI only)
test / test (push) Canceled after 0s
CI-only cut. The release workflow deleted the X.Y.Z-amd64 / X.Y.Z-arm64
package versions after the manifest merge; those versions are the images the
X.Y.Z manifest points at, so pulling 1.3.0 failed with a missing manifest.
No product code changes.
Fixed:
- release.yml: the per-arch tags stay in GHCR. GHCR cannot drop a tag
without deleting the image behind it.
- release.yml: the merge job now removes only untagged package versions
that no tagged manifest references (leftovers of publishing a version
again); any failed lookup aborts the step before anything is deleted.
- development.md release description and version table, image pin 1.9.5
in compose, README, guide.
Full detail: CHANGELOG.md (1.9.5).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
1e92c5b2a2 |
release: 1.9.4: Panel-redesign plan: component kit, stages, reorder (docs only)
Plan-only cut, owner-requested. The redesign goes first as 2.0.0 and
inbound-antispam-panel follows as 2.1.0 on the 2.0 baseline (migration
0002_inbound_spam_log.sql), built from the redesign's component kit so its
two screens are styled once. No product code changes.
Changed:
- panel-redesign plan: Component kit section and stage 0 (partials with
typed inputs in components.go, one panel.css, /server/components kit
page, acceptance before any page is restyled); staged checklist (5/27);
Health decided inside 2.0, queue pages left as old content in the new
shell.
- panel-redesign plan: Against resurrected pages - shrink-only
legacyPages ratchet in the guard tests enforced by CI, the outline
golden, the old-path test and a history-blob check, so an old template
from git history cannot pass as a redesigned page.
- roadmap: new candidate delivery-log-storage - the Delivery log box on a
message page is empty for anything older than the last daily rotation
because the lines are grepped from the current mail.log on each view
and never stored.
- development.md version table, image pin 1.9.4 in compose, README, guide.
Full detail: CHANGELOG.md (1.9.4).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
6bfaa518d7 |
release: 1.9.3: Panel-redesign prep: mockups, routes, design contract (docs only)
test / test (push) Canceled after 0s
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the accepted header/type-scale/contrast corrections, the account e-mail and inbound-delegation decisions, and the routes/quarantine/preflight roadmap screens. Nothing in `internal/web` changes; the design stays `candidate` (3/21) until the redesign itself ships as `2.0.0`. Added: - Panel redesign, account e-mail and notifications: a user's e-mail is its own - Panel redesign, contrast: a sweep of every text node against its real ground - Panel redesign, header (owner's choice: variant A, with the perforation on a - Panel redesign, type scale: about 40 % of the panel's text measured under - Panel redesign, header comparison sheet … (8 more in CHANGELOG) Full detail: CHANGELOG.md (1.9.3). |
||
|
|
7ef7f75246 |
release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump. Added: - SQLite migration `0010_send_log_app_login_index.sql` — - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration Changed: - README hero line and a **Positioning** block in `docs/product.md` record the Fixed: - The setup account's password is hashed at bcrypt cost `12` like every other - Login/setup rate limiter: protecting blocked buckets from eviction is now - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of - [schema-migrations.md](docs/schema-migrations.md): the schema section was - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second … (11 more in CHANGELOG) Changed: - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code - Extracted `internal/configsafe` — the empty-value and forbidden-character - Build, deploy and migration comments no longer cite the archived - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and … (7 more in CHANGELOG) Removed: - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`, Full detail: CHANGELOG.md (1.9.2). |
||
|
|
7b8685d038 |
release: 1.9.0: Per-application client IP allow-list and rate-limit overrides
Application sending controls: client IP allow-list for authorization, and level-2 rate limits that override the domain ceiling per application. Added: - Per-application **client IP allow-list** on the domain page: when enabled, - SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs Changed: - Application level-2 rate limit **overrides** the domain limit for that login - Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy - Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics - [guide.md](docs/guide.md): full-backup section documents all four capture Full detail: CHANGELOG.md (1.9.0). |
||
|
|
9d8b19d14a |
release: 1.8.0: Built-in operator help drawer and /help page
Built-in operator documentation in the panel: a CSS-only help drawer and Help page so Status checks and domain controls are explained without leaving the panel. Added: - **Help** navigation entry and `/help` index page. - CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine, - «?» entry points on Status and domain card heads open the matching drawer Changed: - [guide.md](docs/guide.md) documents the boundary: in-panel help for card Full detail: CHANGELOG.md (1.8.0). |
||
|
|
20d88e1d9c |
release: 1.7.0: DMARC aggregate report ingest and panel summaries
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse gzip/XML into SQLite, and show summaries in the panel. Added: - Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25 - Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled Full detail: CHANGELOG.md (1.7.0). |
||
|
|
60b743e6c2 |
release: 1.6.0: 30-day send stats and optional auto level-2 rate limits
30-day sending statistics per domain and application, plus optional auto level-2 rate limits derived from average send rate. Added: - Domain page **Sending statistics** card: total messages, peak and average - Level-2 rate limit **Manual** / **Auto** mode on domain and application - Domain export JSON now includes rate limits (`mode`, `auto_multiplier`, Changed: - Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual Full detail: CHANGELOG.md (1.6.0). |