docs: clear shipped plans and closed work out of the documents
test / test (push) Waiting to run

Owner, 2026-10-18: "Надо почистить документы от устаревшего и сделанного."

- Deleted, shipped: plans/delivery-log-storage, from-header-binding,
  inbound-antispam-panel, inbound-upstream-result, panel-dkim-signing,
  panel-notifications, and the own-mta-builds analysis (its three rows
  shipped as 2.6.1, 2.7.0, 2.8.0). What outlived them moved first: when a
  filter-list counter is worth a migration, and the notifications' "when"
  table, into architecture.md; the sender check's own-process trigger into
  security.md. Code comments that cited them point at architecture.md or
  security.md; CHANGELOG keeps their names, unlinked.
- Kept, marked closed: audit-2026-10-12, assumptions-2026-10-14 and
  security-audit-2026-10-17 - the code cites their finding ids in 50-84
  files. Their file:line refs are of the tree each audited.
- roadmap.md: open work only - stage H of the security audit (the owner's),
  go-milter-upstream (the owner posts), and queue-table, which the
  redesign left undecided and only its own shipped section still named;
  Dropped and Ideas as they were. 2.x versioning instead of 1.x.
- development.md: the 47-row planned-version-cuts table (history, in the
  CHANGELOG) gone; a stage's last step now removes its roadmap item and
  plan. Docs map lists the closed audits.
- panel-redesign.md: the done checklist out; Room for roadmap items says
  which drawn screens are unbuilt and why.
- Stale facts fixed: schema-migrations.md and architecture.md at schema 14
  (0013's users columns and user_links, 0014's secure TLS mode);
  security.md's accepted risk "profile saved without the password" - the
  e-mail asks for it since 2.14.0.

Tests: go build, go vet; go test of cmd and the touched internal packages
pass except logtail's three Windows-bound rotation tests (rename of an open
file; CI is Linux). Only comments changed in Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-18 22:37:22 +03:00
co-authored by Claude Opus 5.5
parent f20c942d64
commit 30fcf978e0
33 changed files with 211 additions and 2685 deletions
+10 -10
View File
@@ -377,7 +377,7 @@ Nothing for an operator to do.
### Added
- **Inbound › Log shows what the upstream answered** (inbound-upstream-result,
[the plan](docs/plans/inbound-upstream-result.md)): an *Upstream* column
the plan, since removed): an *Upstream* column
beside the decision - *queued*, *held*, *sent*, *deferred* (a greylisting, an
upstream that cannot be reached), *bounced* (refused, or expired in the
queue) - opening the last reply, the host that gave it and when. Read from
@@ -456,7 +456,7 @@ schema 11.
### Added
- **The panel signs outgoing mail with DKIM itself** (panel-dkim-signing,
[the plan](docs/plans/panel-dkim-signing.md)): a milter of the panel's on
the plan, since removed): a milter of the panel's on
two sockets, one for applications' mail - signed with the key of the
domain the login belongs to - and one for the panel's own, signed with the
server's name. `relaxed/simple`, RFC 6376 § 5.4.1's headers and `From`
@@ -643,7 +643,7 @@ No migration; schema 11.
The listener now sets `normalize` - the setting Postfix recommends and 3.9
made the default: DATA ends only at the standard `<CR><LF>.<CR><LF>`, a
bare newline elsewhere is read as a line end. Found while reading for
[own-mta-builds](docs/own-mta-builds-2026-10-15.md) (§ 5); `test/e2e`
own-mta-builds (§ 5, since removed); `test/e2e`
sends the sequence and finds one message at the upstream with all of it
inside. Only installations with `INBOUND_RELAY_ENABLE=true` or
`DMARC_REPORTS_ENABLE=true` have the listener.
@@ -1359,7 +1359,7 @@ Documentation only; no product code changes.
held to - any address of the application's domain, or the addresses of its
list, with or without a `+extension` - and refuses anything else with
`550 5.7.1`, a domain this server does not sign for included
([plans/from-header-binding.md](docs/plans/from-header-binding.md)).
(plans/from-header-binding.md, since removed).
- **The check is strict.** It is the first milter of the chain, on a socket
of its own (`SENDER_CHECK_SOCKET`), attached with
`default_action=tempfail`: while the panel does not answer, Postfix defers
@@ -1413,7 +1413,7 @@ Documentation only; no product code changes.
a user's own switch, `users.notify`, on for everyone; `notify_state`, what
the panel has already written to its users about. The instance switch is a
settings key, on unless turned off
([plans/panel-notifications.md](docs/plans/panel-notifications.md)).
(plans/panel-notifications.md, since removed).
- **The panel writes to its users when something needs them.** Four things,
each said once: a sending domain's DKIM, SPF or DMARC record stops matching
(after two hourly checks in a row, and once more when it matches again); a
@@ -1463,7 +1463,7 @@ Documentation only; no product code changes.
The table that will hold what Postfix wrote about a message for as long as
its send-log row lives, with the store methods to write a line, to write it
once, to read the lines of a row and to prune
([plans/delivery-log-storage.md](docs/plans/delivery-log-storage.md)).
(plans/delivery-log-storage.md, since removed).
- **The log tailer keeps what Postfix writes about queued messages.** A
`mail.log` line headed by a queue id is stored with the line's own time, one
transaction per pass over the log; after an unclean stop a line read twice
@@ -2063,7 +2063,7 @@ screens. Nothing in `internal/web` changes; the design stays `candidate`
scrolling at 1280px and up. Documented on the components page and in the
plan (§ Type and tables).
- Panel redesign, header comparison sheet
([docs/assets/panel-redesign/header/](docs/assets/panel-redesign/header/index.html)):
(`docs/assets/panel-redesign/header/`, since removed):
at nav size the stamp dissolves into the brick bar and the perforated edge
was drawn inverted (round bumps instead of round holes). Three replacements
beside the current header — **A** wordmark with the stamp's real hole-to-tooth
@@ -2137,7 +2137,7 @@ screens. Nothing in `internal/web` changes; the design stays `candidate`
grammar (`measure` / `fill` plus Web Awesome layout CSS), no JS component
library, new mockups rather than `docs/assets/panel-ui/`. Status: candidate.
- Roadmap item and plan for `/preflight` installation check page
([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level
(`docs/plans/preflight.md`, since removed) — instance-level
infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers,
DKIM socket, test email). Status: candidate.
@@ -2311,7 +2311,7 @@ antispam and quarantine. Upgrading from 1.9.0 is a tag bump; no migration.
### Added
- [docs/plans/inbound-antispam-panel.md](docs/plans/inbound-antispam-panel.md) —
- `docs/plans/inbound-antispam-panel.md` (since removed) —
agreed plan for inbound antispam journal (inbound-journal milter + mail.log
rejects) and instance-wide allow/deny lists synced to rspamd maps; target
`1.10.0`; Composer → Opus → Fable workflow.
@@ -2511,7 +2511,7 @@ tag bump; no schema migration.
self-contained backups and persistent queue.
- docs: HTML mockups for a full panel UI refresh
([docs/assets/panel-ui/](docs/assets/panel-ui/index.html)) — current screens,
(`docs/assets/panel-ui/`, since removed) — current screens,
agreed and candidate roadmap surfaces (queue-retries, inbound-relay,
dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms
keep a reading measure), and a 390px emergency layout. The stamp and brick
+1 -1
View File
@@ -44,7 +44,7 @@ send log and DNS checks in the panel, encrypted backups.
| [Architecture](docs/architecture.md) | As-built technical design |
| [Security design](docs/security.md) | Mandatory requirements, accepted risks, the CSRF ADR |
| [Development](docs/development.md) | Building, testing, docs rules, model routing, commits |
| [Roadmap](docs/roadmap.md) | Open work (1.x+) — direction, not commitments |
| [Roadmap](docs/roadmap.md) | Open work — direction, not commitments |
| [CHANGELOG](CHANGELOG.md) | Release history |
Found a vulnerability? Do not open an issue — [SECURITY.md](SECURITY.md) has
+1 -1
View File
@@ -57,7 +57,7 @@ ENV DEBIAN_FRONTEND=noninteractive
# test say whether it does.
# No DKIM package is installed: the panel signs itself (internal/milter,
# sign.go) and makes the keys in Go (internal/domain/dkim.go); OpenDKIM left
# the image in 2.8.0 (docs/plans/panel-dkim-signing.md).
# the image in 2.8.0 (docs/architecture.md § DKIM signing).
# systemd-standalone-sysusers is named so that cron-daemon-common, which
# logrotate brings (logrotate 3.22.0-1, debian/control: "Depends: cron |
# anacron | cron-daemon | systemd-sysv"), creates its group with it rather
+1 -1
View File
@@ -111,7 +111,7 @@ find /data -mindepth 1 -maxdepth 1 ! -name log ! -name postfix ! -name sasl ! -n
# serves (cmd/panel/signer.go). Until 2.8.0 the keys were under
# /data/opendkim, shared with OpenDKIM; 2.8.x copied them to /data/dkim at its
# first start and kept the old tree for a rollback to 2.7.x until the next
# release, this one (docs/plans/panel-dkim-signing.md, Q2). It is removed
# release, this one (CHANGELOG [2.9.0]). It is removed
# once /data/dkim holds a key. A /data that never ran 2.8.x - keys under the
# old tree only - stops the start instead: the panel would make the server's
# own name a new key, and its published record would no longer match; 2.8.x
+2 -2
View File
@@ -426,7 +426,7 @@ postconf -e \
# deferred (docs/security.md).
#
# The panel's DKIM signer follows, strict too: unreachable, it defers rather
# than send unsigned (docs/plans/panel-dkim-signing.md). It signs with the
# than send unsigned (docs/architecture.md § DKIM signing). It signs with the
# key of the login's domain. The journal-milter is monitoring only and is
# fail-open (default_action=accept): its failure must never block the relay.
# Per-milter settings use Postfix 3.0+ brace syntax.
@@ -756,7 +756,7 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
# only here (above), so no client is spared. The submission ports take
# the default, normalize as well since this Postfix: an application whose
# SMTP library ends DATA with a bare <LF>.<LF> is no longer read to its
# end there (own-mta-builds, § 5: their smuggled transaction would still
# end there (docs/security.md § Mail path: their smuggled transaction would still
# be the authenticated session's, so they were left to the default).
postconf -P \
"smtp/inet/smtpd_forbid_bare_newline=normalize" \
+1 -1
View File
@@ -24,7 +24,7 @@ const aliveEvery = 2 * time.Second
// try again later"), so while this role is not serving, mail is deferred:
// what that costs is counted here and
// by the log tailer, and shown on Server > Health
// (docs/plans/from-header-binding.md § Statistics).
// (docs/architecture.md § Sender check).
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
ln, err := listenMilter(cfg.senderSocket)
if err != nil {
+28 -14
View File
@@ -177,9 +177,15 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
off the journal on each view of Filter lists - the rows a list decided (the
deny rule's reply, the allow rule's symbol, which the journal milter keeps
ahead of its six-symbol cap) matched against the entries as rspamd matches
them. When that stops being good enough is written in
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Filter
list usage.
them. So a count reaches back as far as the journal does, a row counts for
every fitting entry of the deciding list, and a count is of messages,
roughly. A counter kept with each entry (hits and last hit on
`inbound_spam_list`, a migration) is worth it when any one of these holds
(owner, 2026-10-12): the journal is cut by its 10 000-row cap rather than by
age (the tailer's prune keeps removing rows while the oldest row of
Inbound › Log is younger than the retention); the retention is set short,
towards its 7 days; counts are wanted that outlive the journal; or the page
is slow - past a few hundred entries, measure it.
**Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only
`relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`,
`reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`,
@@ -235,8 +241,7 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
The panel signs outgoing mail itself, in a role of its own, `dkim-signer`
([cmd/panel/signer.go](../cmd/panel/signer.go),
[internal/milter/sign.go](../internal/milter/sign.go)), since `2.8.0`; until
then OpenDKIM did, in a program of its own
([plans/panel-dkim-signing.md](plans/panel-dkim-signing.md)).
then OpenDKIM did, in a program of its own (CHANGELOG `[2.8.0]`).
- **Keys** — `/data/dkim/keys/<name>/<selector>.private`, PKCS#1 PEM, one
for each sending domain and one for the server's own name
@@ -353,7 +358,7 @@ milter, § Postfix above, port 25):
(`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
2. **sender check** — unix socket `SENDER_CHECK_SOCKET`, first in the chain
and **strict** (`default_action=tempfail`): § Sender check below.
3. **DKIM signer** (`2.8.0`, [plans/panel-dkim-signing.md](plans/panel-dkim-signing.md))
3. **DKIM signer** (`2.8.0`, § DKIM signing)
— unix sockets `DKIM_SIGN_SOCKET` (applications' mail on 465/587, signed
with the key of the domain the SASL login belongs to) and
`DKIM_SIGN_LOCAL_SOCKET` (the panel's own mail, `non_smtpd_milters`,
@@ -397,8 +402,8 @@ generates pass no milter.
### Sender check
[internal/milter/sender.go](../internal/milter/sender.go) holds a message's
`From:` header against the application that sends it
([plans/from-header-binding.md](plans/from-header-binding.md)). Postfix holds
`From:` header against the application that sends it (since `2.4.0`;
`Sender:` too since `2.8.0`). Postfix holds
the envelope sender against the SASL login, and the signer signs with the key
of the login's domain (§ DKIM signing): the signature's `d=` is that domain,
and a receiver holds it to the domain of `From:` (DMARC, RFC 7489 § 3.1.1).
@@ -504,10 +509,15 @@ the hole it closed was the same.
[internal/notify](../internal/notify/notify.go) reports four things to the
users of the panel: a sending domain's DNS record that stopped matching, a
level-2 rate limit that refused mail, the TLS certificate about to expire, a
message more than three days in the queue. The rules of when a message goes
out are the owner's and are written in
[plans/panel-notifications.md](plans/panel-notifications.md) § When a message
is sent; the operator's view is guide § Notifications.
message more than three days in the queue. The operator's view is guide
§ Notifications. When a message goes out is the owner's rule (2026-10-12):
| Subject | Sent | "Cleared" message |
|---|---|---|
| DNS mismatch | when it has held for an hour (two checks, below); a resolver that did not answer is not a mismatch | the same way back |
| Certificate | 14 days before expiry, and 3 days before if it is still the same certificate | none |
| Level-2 limit refused mail | at the first refusal for a sending domain, then at most once a day for it | none |
| Queue older than three days | when such a message appears | one, when none is left |
- **Readings.** Once an hour, starting three minutes after the process does:
every sending domain's records through a DNS checker of the notifier's own,
@@ -1179,7 +1189,7 @@ full backups also archive the operator's `./certs` PEM files when present.
### Database
The schema is an embedded baseline, `internal/store/migrations/0001_init.sql`,
and the migrations after it (`0002` to `0012` today; the next is `0013`),
and the migrations after it (`0002` to `0014` today; the next is `0015`),
applied by `store.Open` in file order; `PRAGMA user_version` counts the files
applied and
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
@@ -1198,7 +1208,11 @@ The tables that carry the panel's model:
the user's own address, edited on Account and set by the global role on the
user form; `all_domains` / `all_inbound_domains` widen a *domain* user to
every domain of that direction, including ones added later (with a flag set
the assignment rows are ignored).
the assignment rows are ignored); `email_confirmed_at` is when `email` was
confirmed, `''` while it is not (v13).
- **`user_links`** — the one-time links that confirm an address and reset a
password (v13): the token's SHA-256, `purpose`, the address it went to, its
expiry; one live link per user and purpose.
- **`user_domains`** and **`user_inbound_domains`** — the outbound and inbound
domains assigned to a *domain* user. Separate tables: the same name on both
is two assignments, and a sending domain gives no inbound access or the other
+7
View File
@@ -1,5 +1,12 @@
# What SelfPost assumes of other programs - the register of 2026-10-14
> **Closed 2026-10-14** (stage 2c of [audit-2026-10-12](audit-2026-10-12.md),
> shipped `2.5.2`): every entry answered, the citations written into the
> code, what the sources contradicted fixed. The file stays because the code
> names its entries (`assumptions C-V5`, `A-43`); its `file:line` references
> are of the tree at `0f984ff`, not of today's, and OpenDKIM, named
> throughout, left the image in `2.8.0`.
The heuristics review of audit-2026-10-12, stage 2c. The owner's rule
(development.md § "Code that relies on another component's behaviour"):
what Postfix, OpenDKIM, Cyrus SASL, rspamd, supervisord, logrotate, Docker,
+6 -1
View File
@@ -1,5 +1,10 @@
# Code audit — 2026-10-12
> **Closed 2026-10-15.** Every stage shipped (`2.4.2` to `2.6.0`; stage 6,
> documents and tests, without a cut). The file stays because the code cites
> its finding ids (`H4`, `M2`, `L9`...); its `file:line` references are of the
> tree at `8f226bd`, not of today's.
**What this is.** A full-tree audit of the code and infrastructure at `8f226bd`
(release `2.4.0`): leftovers, unfinished work, defects, performance, doc drift
and test gaps — explicit ones (there are no `TODO`/`FIXME` markers anywhere in
@@ -1570,7 +1575,7 @@ tested (the count and the spared are).
sleeps said where they are); roadmap.md's closing paragraph. Already right
by then: the `.alive` mark, `dmarc-ingest` as a second writer, the chain
head, M1's wording, the version stamp.
- [ ] roadmap.md: done ahead on 2026-10-13/14 with the owner -
- [x] roadmap.md: done ahead on 2026-10-13/14 with the owner -
`template-data-typing` struck, `structured-logging` dropped,
`review-2026-08-followups` closed with each row placed in a stage or
dropped with its reason. What is left: check that every *Test gaps* entry
+12 -58
View File
@@ -1,7 +1,7 @@
# SelfPost — development
**What this file is.** How to build, test, document, and ship changes. Open
work after 1.0 (1.x+) lives in [roadmap.md](roadmap.md) and linked
work lives in [roadmap.md](roadmap.md) and linked
[plans/](plans/). Product boundaries: [product.md](product.md). As-built layout:
[architecture.md](architecture.md).
@@ -90,7 +90,7 @@ checklist changes; update the numerator when steps are checked off.
### Roadmap track: commit and version
For the current 1.x+ roadmap queue ([roadmap.md](roadmap.md) index), stricter
For the roadmap queue ([roadmap.md](roadmap.md) index), stricter
than the default «meaningful step» rule in [§ Commits and release build](#commits-and-release-build):
**After each checklist step**
@@ -106,58 +106,10 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
[deploy/docker-compose.yml](../deploy/docker-compose.yml).
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)).
**Planned version cuts** (from pin `2.15.0`; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
| 1 | queue-retries | `1.3.1` (PATCH) |
| 2 | inbound-relay | `1.4.0` |
| 3 | send-log-retention | `1.5.0` |
| 4 | domain-stats-auto-ratelimit | `1.6.0` |
| 5 | dmarc-reports | `1.7.0` |
| 6 | panel-docs | `1.8.0` |
| 7 | application auth IPs | `1.9.0` |
| 8 | port-25 Postfix startup fix | `1.9.1` (PATCH) |
| 9 | 2026-08-19 code review pass | `1.9.2` (PATCH) |
| 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) |
| 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) |
| 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) |
| 13 | panel-redesign | `2.0.0` (MAJOR, no compatibility) |
| 14 | DMARC ingest drops and bounds, full backup from the panel, no queue in a backup | `2.0.1` (PATCH) |
| 15 | inbound-antispam-panel | `2.1.0` (was `1.10.0`, reordered 2026-10-09) |
| 16 | delivery-log-storage | `2.2.0` |
| 17 | panel-notifications | `2.3.0` |
| 18 | from-header-binding | `2.4.0` |
| 19 | code audit 2026-10-12 and its plan (docs only, owner-requested cut) | `2.4.1` (PATCH) |
| 20 | audit-2026-10-12 stage 0: release hygiene | `2.4.2` (PATCH) |
| 21 | audit-2026-10-12 stages 1 and 2 | `2.5.0` |
| 22 | audit-2026-10-12 stage 2b: the log tailer | `2.5.1` |
| 23 | audit-2026-10-12 stage 2c: the sender check reads From: as OpenDKIM does | `2.5.2` |
| 24 | audit-2026-10-12 stage 3: persistence and restore | `2.5.3` |
| 25 | audit-2026-10-12 stage 4: panel behaviour | `2.5.4` |
| 26 | audit-2026-10-12 stage 5: image and Postfix hardening | `2.6.0` |
| 27 | port25-bare-newline | `2.6.1` (PATCH) |
| 28 | base-trixie | `2.7.0` |
| 29 | panel-dkim-signing | `2.8.0` |
| 30 | dns-record-collapse, settings-save-row, notification-mail-recheck; `/data/opendkim` removed | `2.9.0` |
| 31 | log panes wrap on a phone | `2.9.1` (PATCH) |
| 32 | inbound-upstream-result | `2.10.0` |
| 33 | long, non-repeating queue ids | `2.10.1` (PATCH) |
| 34 | phone-desktop-mode | `2.10.2` (PATCH) |
| 35 | backup-reauth | `2.10.3` (PATCH) |
| 36 | origin-strict | `2.10.4` (PATCH) |
| 37 | health-setup-checks | `2.11.0` |
| 38 | test-email | `2.12.0` |
| 39 | local-time | `2.13.0` |
| 40 | docs-audit | `2.13.1` |
| 41 | password-reset | `2.14.0` |
| 42 | overview-head | `2.14.1` |
| 43 | dmarc-day, health-sockets | `2.14.2` |
| 44 | label-tag | `2.14.3` |
| 45 | security-audit-2026-10-17, stages 0-6 | `2.14.4` |
| 46 | etrn-silent | `2.14.5` |
| 47 | server-dmarc-hint, upstream-tls-verify | `2.15.0` |
3. The item leaves [roadmap.md](roadmap.md) and its plan file is deleted; what
it did is in the CHANGELOG, the rest in git history. A report whose
finding ids the code cites (the audits, the assumptions register) stays,
marked closed.
Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships.
@@ -308,7 +260,8 @@ question, the comment says so and what was measured instead - "measured on
the e2e stand, 2026-10-14: ..." - and a measurement is never written as if
it were read. A test fixture copied from a real log says where and when it
was seen; one composed by hand says that it was composed. The heuristics
review of 2026-10-14 (audit-2026-10-12, stage 2c) brought the code that
review of 2026-10-14 ([audit-2026-10-12](audit-2026-10-12.md), stage 2c; the
[assumptions register](assumptions-2026-10-14.md)) brought the code that
existed then up to this rule.
---
@@ -623,7 +576,8 @@ There is no `docs/archive/` directory.
| Development process (this file) | [development.md](development.md) |
| Security requirements and accepted risks | [security.md](security.md) |
| SQLite schema and migrations | [schema-migrations.md](schema-migrations.md) |
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
| Roadmap | [roadmap.md](roadmap.md) |
| Closed audits, cited by finding id from the code | [audit-2026-10-12.md](audit-2026-10-12.md), [assumptions-2026-10-14.md](assumptions-2026-10-14.md), [security-audit-2026-10-17.md](security-audit-2026-10-17.md) |
| Active design plans | [plans/](plans/) |
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
@@ -640,8 +594,8 @@ There is no `docs/archive/` directory.
| [deploy/.env.example](../deploy/.env.example) | Public env template; full reference in [guide.md](guide.md) |
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog |
Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
(candidates in [roadmap.md](roadmap.md)).
Out of scope: `CONTRIBUTING.md` (dropped, with its condition, in
[roadmap.md](roadmap.md)), man pages, a separate docs site.
### Maintaining documentation
-276
View File
@@ -1,276 +0,0 @@
# Own builds of Postfix and OpenDKIM - the analysis of 2026-10-15
**Question (owner, 2026-10-15; roadmap [own-mta-builds](roadmap.md#own-mta-builds)):**
is it worth patching Postfix and OpenDKIM and shipping our own builds, to
optimise something in SelfPost or to lift a limit we work around?
**Answer:** no to carrying patched builds of either - nothing we work around
needs a patch, and a patch set makes one maintainer the security team of
two MTAs. The question does point at two real problems, each with a cheaper
answer:
1. **The image's base is out of date.** `debian:bookworm-slim` left regular
security support on 2026-07-12; its Postfix `3.7.11` is code of mid-2024,
and the three Postfix security batches of 2026 (June, August, September)
carry no CVE ids, so nothing will bring them to bookworm's LTS. The
answer is Debian trixie (`postfix 3.10.13`, the August batch included),
not a build of our own: proposed as roadmap row
[base-trixie](roadmap.md#base-trixie).
2. **OpenDKIM has no upstream left.** The package is a beta of 2018; three
CVEs of 2026 are open in every Debian suite with "the vendor did not
respond"; Debian's bug on them has had no answer since 2026-10-03. Every
limit we have worked around is OpenDKIM's. A patched OpenDKIM of our own
would make us its maintainer; signing in the panel's own milter, which
already runs in the mail path, removes the program and most of what was
built around it: proposed as roadmap row
[panel-dkim-signing](roadmap.md#panel-dkim-signing).
A side finding of the reading: port 25 of the inbound relay accepts bare
newlines - Postfix 3.7's default for the 2023 SMTP-smuggling parameter -
one line fixes it: [port25-bare-newline](roadmap.md#port25-bare-newline).
Everything below is the evidence and the prices. Versions are those of the
image (`docs/development.md`, the cited-package table); upstream source is
`dev/upstream/`; web sources are linked where a fact came from one.
---
## 1. What the image runs, and who maintains it now
| Component | In the image (bookworm) | In Debian trixie | Upstream |
|---|---|---|---|
| Debian | 12 bookworm, LTS since 2026-07-12, until 2028-06-30 ([debian.org](https://www.debian.org/News/2026/20260712.en.html)) | 13 trixie, stable since 2025-08-09 | — |
| Postfix | `3.7.11-0+deb12u1` (unchanged since the image was pinned; [tracker](https://tracker.debian.org/pkg/postfix)) | `3.10.13-0+deb13u1` (trixie-security) | `3.11.7`, stable branches 3.8–3.11 ([3.11.7](https://postfix.cs.utah.edu/announcements/postfix-3.11.7.html)) |
| OpenDKIM | `2.11.0~beta2-8+deb12u1` | `2.11.0~beta2-9.1` (same code; a gcc-14 build fix, a path move, one verifier-side header patch) | `2.11.0-Beta2`, 2018; no release since ([releases](https://github.com/trusteddomainproject/OpenDKIM/releases), [issue #422](https://github.com/trusteddomainproject/OpenDKIM/issues/422), unanswered) |
| libmilter | `8.17.1.9-2+deb12u2` | `8.18.1-6` | — |
| Cyrus SASL | `2.1.28+dfsg-10` | `2.1.28+dfsg1-9` (same upstream) | — |
| supervisor | `4.2.5-1` | `4.2.5-3` (same upstream) | — |
| logrotate | `3.21.0-1` | `3.22.0-1` | — |
| Go build stage | `golang:1.26-bookworm` | `golang:1.26-trixie` exists (Docker Hub, 2026-10-10) | — |
**Postfix in bookworm is 2024 code with the 2026 fixes missing.** Upstream
issued three security batches this year, all without CVE ids:
- June 2026: unbounded reads of SMTP commands, responses and BDAT data
("large SMTP inputs"); a TLSA-parsing crash (DANE only - not us).
- August 2026 ([3.11.6](https://openwall.com/lists/oss-security/2026/08/20/5)):
sixteen items, among them a remote memory exhaustion by many small BDAT
commands (smtpd, since 3.4), a remote smtpd crash by DATA without MAIL
FROM after a BDAT error, a policy bypass after an
`smtpd_end_of_data_restrictions` reject (we set none), and the Milter
left in "accept" state by the same bug.
- September 2026 ([3.11.7](https://postfix.cs.utah.edu/announcements/postfix-3.11.7.html)):
two SMTP-smuggling cases behind `smtpd_proxy_filter` (we use none), a
crash under `strict_smtputf8` (not set), a multi-line response over-read
reachable by a malicious Milter, a `postqueue` use-after-free.
Legacy releases 3.7.22 and 3.7.23 exist upstream but, as the announcements
say, "do not include the patches for out-of-support Postfix versions" of
June. Debian's tracker lists one 2026 CVE for bookworm's Postfix
([CVE-2026-43964](https://security-tracker.debian.org/tracker/CVE-2026-43964),
a one-byte over-read, `no-dsa`) and no bookworm upload at all in 2026; the
LTS team works from CVE ids, and these batches have none. The exposed
listeners are ours: 465 and 587 to the Internet, 25 when the inbound relay
is on.
**OpenDKIM has three open CVEs and nobody to fix them.** Debian bug
[#1149875](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1149875)
(2026-10-03, severity important, no reply from maintainer or security team
as of 2026-10-15) covers
[CVE-2026-100888](https://security-tracker.debian.org/tracker/CVE-2026-100888)
(`dkim_canon_selecthdrs`, out-of-bounds write),
[CVE-2026-100889](https://security-tracker.debian.org/tracker/CVE-2026-100889)
(`dkim_qp_decode`, off-by-one) and
[CVE-2026-101277](https://security-tracker.debian.org/tracker/CVE-2026-101277)
(`dkim_process_set`, the tag tokenizer); all three "vendor was contacted
early but did not respond in any way". Read in `dev/upstream/opendkim-2.11.0~beta2`
for our configuration (`Mode s`, attached to 465/587 and to the panel's
local submission only; never to port 25):
- `dkim_qp_decode` is called from `dkim_sig_domainok()`,
`dkim_sig_getreportinfo()` and `dkim_sig_getidentity()`
(`libopendkim/dkim.c:1718, 7712, 7732, 7801`) - verifier-side functions,
reached from `dkim_eoh_verify()`. Not on our path.
- `dkim_process_set` runs on every `DKIM-Signature:` header of a message
**in either mode**: `dkim_header()` (`dkim.c:6447`, the call at `:6625`)
has no mode gate. On our path that header comes only from an authenticated
application; the exposure is a tenant's, not the Internet's.
- `dkim_canon_selecthdrs` runs when signing as well as when verifying
(`dkim_canon_runheaders()`, `dkim-canon.c:1194, 1280`). The write past
the array is `lhdrs[shcnt] = NULL` (`dkim-canon.c:1052`) once every
header of the message has been matched and a further name follows. When
verifying, the names are the signature's `h=`, the attacker's. When
signing, OpenDKIM composes the list itself - the headers present that
are in `SignHeaders` (by default RFC 6376 § 5.4's SHOULD list,
`dkim_should_signhdrs[]`, `dkim.c:223`) plus `OversignHeaders`, our
`From` - so the condition is a message whose every header is in that
list. Whether Postfix's own `Received:` keeps it out of reach I did not
run; it is one ASan run with `miltertest` on the stand to know, and the
answer changes only how soon § 4's row is wanted, not whether.
A patch for each exists in the researchers' write-ups (for `dkim_qp_decode`:
[weitongli.com](https://weitongli.com/share/opendkim-qp-off-by-one.html)).
Nobody upstream will take them; Debian may yet carry them - the bug is
twelve days old - but the project behind the package is gone, and the
next finding meets the same silence.
## 2. The limits met so far, and what would lift each
| Limit (where it is worked around) | Own patched build | Debian trixie | Signing in the panel |
|---|---|---|---|
| OpenDKIM logs through syslog(3) only (`internal/syslogrelay`, a supervisord program, `/dev/log` in the entrypoint; audit L30) | yes, a stderr patch | no | yes - the program goes, and the relay with it (nothing else in the container uses syslog(3)) |
| The panel reads Postfix's state from `mail.log`, `postconf -h -x`, `postqueue -p`/`-j` | no: these *are* Postfix's interfaces; a patch would invent a private one to maintain | partly: 3.11 adds `postconf -j` (JSON) - not in trixie's 3.10 | no (unchanged) |
| The sender check reads `From:` the way OpenDKIM does: `dkim_mail_parse()` ported (`internal/milter/mailparse.go`, 327 lines), the 256-byte `MAXADDRESS` refusal, two diff/probe scripts against the C | no: the port exists because two programs parse the line; a patch does not merge them (OpenDKIM's `SenderMacro` could pick the key by the envelope instead - it changes nothing, since the check already pins `From:` to the login's senders) | no | yes: one parser, ours, and the key chosen by the login's domain, not by `From:` - the class "signed with another tenant's key" is gone by construction; `From:` still has to be the application's (DMARC alignment), with `net/mail` |
| The image held to bookworm's versions (Postfix 3.7.11, OpenDKIM 2.11.0~beta2) | yes, at the price in § 3 | yes for Postfix (3.10.13, security-tracked to 2028 and beyond in trixie LTS); OpenDKIM's version is the same everywhere | OpenDKIM's version stops mattering |
| A socket file outlives a crashed OpenDKIM (`health/socket.go`, assumptions #38) | yes, with a patch | no | yes - no second milter process |
| Keys group-readable, `RequireSafeKeys no`, the `selfpost` group between panel and OpenDKIM | no | no | yes - the panel reads its own keys; the group stays for Postfix's sasldb and maps |
| `SIGUSR1` through supervisord to reload the tables, which is what needs `CAP_KILL` in the compose file (cross-uid kill) | no | no | yes - no tables, no signal; `CAP_KILL` can probably go (to verify on the stand: supervisord then signals only root's wrapper and the panel's own uid) |
| `smtpd_forbid_bare_newline` defaults to `no` on Postfix < 3.9 (`src/smtpd/smtpd.c:807`); our port 25 does not set it | yes | yes - the default is `normalize` since 3.9 | no (it is Postfix's) - see § 5 |
Nothing in the Postfix column needs a patch. The limits are OpenDKIM's.
## 3. The options, priced
Prices are estimates in stages of the kind the 2026-10-12 audit used (a
stage: write, tests, two reviews, e2e, rollout, commit). Nothing here was
measured on the stand; where a number matters it says so.
### A. Patched builds of both, carried by us - **not recommended**
- *Gives:* the stderr patch for OpenDKIM, the three CVE fixes, any option
compiled in or out, the newest Postfix on the day we rebuild.
- *Costs, once:* a build stage per program (`apt-get source`, quilt, a
patch series, `dpkg-buildpackage`), its build-dependencies, the e2e
`checkCitedPackageVersions` rewritten for our version strings, every
citation of `3.7.11` (331 lines in 65 files) and `2.11.0~beta2` (55 in
14) re-pointed at our tree. Image build time is not measured; from the
size of the sources it is minutes, not seconds, on every rebuild.
- *Costs, for ever:* Postfix issued seven releases in 2026; each is ours
to notice, rebase the series on, rebuild and roll out, with no Debian
security team between the announcement and the image. For OpenDKIM there
is no upstream to rebase on at all: the series *is* the program, and
the next CVE is ours to find and fix. One maintainer, two MTAs.
- *Why not:* § 2 shows no Postfix limit a patch lifts, and every OpenDKIM
limit lifted by option E without taking OpenDKIM on.
### B. Unpatched upstream build where the distribution's version is the limit - **not recommended**
- *Gives:* Postfix 3.11.7 today instead of trixie's 3.10.13 - the
September batch two to four weeks earlier (trixie-security carried 3.10.11
→ 3.10.12 → 3.10.13 between July and August; 3.10.14 is upstream now).
For OpenDKIM there is no newer upstream to build.
- *Costs:* A's build stage and watching duty for Postfix, minus the patch
series; Debian's own patches (paths, SASL and LMDB build options)
become ours to reproduce.
- *Why not:* the gap to trixie-security is weeks, the duty is permanent.
### C. A patch sent upstream instead of carried - **nothing to send**
- Postfix: no code change is needed (§ 2); Wietse Venema's project is
responsive, but we have no patch for it.
- OpenDKIM: the project does not answer CVE reporters, the Debian
maintainer or its own issue tracker. A patch sent there is a patch
carried - option A.
### D. The base image to Debian trixie - **recommended, next code row** → [base-trixie](roadmap.md#base-trixie)
- *Gives:* Postfix 3.10.13 with the June and August batches and the
September one when trixie-security ships 3.10.14; Debian's security team
until 2028, then trixie LTS; `smtpd_forbid_bare_newline=normalize` by
default; `smtpd_hide_client_session` (3.10: a `Received:` header without
the application's address - a product question of its own, not decided
here); OpenSSL 3.5; libmilter 8.18.1.
- *Costs:* one stage, one MINOR release. The work is the re-reading: the
331 citation lines against the 3.10.13 tree (most will hold word for
word; `src/` paths are stable), with attention to what 3.8–3.10 changed
on our paths - reject logging now carries `sasl_method`, `sasl_username`,
`sasl_sender` (3.10, 20241023: `refusals.go`, `inbound.go` regexes), the
queue id or `NOQUEUE` on an abnormal connection end and cleanup's
`canceled` line (3.10: new line shapes for the log tailer), `postfix(1)`
logging to stderr when it is not a terminal (3.8: the wrapper's output
under supervisord), DNS results capped at 100 records and stray CR/LF
replaced by default (3.9), the removed parameters (3.9:
`permit_naked_ip_address`, `reject_maps_rbl`, `check_relay_domains` - we
set none). Then: `dev/upstream` refetched from trixie, the cited-package
table and `checkCitedPackageVersions`, the e2e sink image, the build
stage tag, `docs/assumptions-2026-10-14.md`'s header, the guide's
"what the image contains". OpenDKIM's citations do not change (same
upstream code).
- *Risk:* a log line we parse changing shape unnoticed - the e2e suite
and the fixtures labelled "seen" are the net; a day on the test server
before the cut.
### E. DKIM signing in the panel's own milter; OpenDKIM removed - **recommended, after D** → [panel-dkim-signing](roadmap.md#panel-dkim-signing)
- *Gives:* every OpenDKIM row of § 2; about 1,500 lines gone (the relay
and its tests, the parser port and its tests, the probe scripts, the
table renderer and its reload, `opendkim.conf`), two programs fewer
under supervisord, two packages fewer in the image, no CVE watch on a
dead project, probably `CAP_KILL` dropped. Keys, selectors and the
published DNS records are unchanged: the panel already generates the
keys (`internal/domain/dkim.go`, PKCS#1 PEM) - nothing for an operator
to do.
- *How:* `github.com/emersion/go-msgauth/dkim` (v0.7.0, 2025-04, MIT; the
author of the `go-milter` we run; the e2e suite already verifies with it).
Read for this analysis: `SignOptions` gives `relaxed`/`simple` per side,
`HeaderKeys` with over-signing as RFC 6376 § 5.4.2 wants it (a name
listed once more than present is kept in `h=` and nothing is hashed for
it, `sign.go`), last-instance-first header picking (`header.go`), RSA and
Ed25519, and a streaming `NewSigner` (the body is hashed as it passes,
not buffered). The milter: headers and body chunks in, `InsertHeader(0,
"DKIM-Signature", …)` at end of message - the shape of go-msgauth's own
`cmd/dkim-milter`, some 450 lines. The key: by the login's domain on
465/587 (`Bindings.DomainOf`, which the sender check has), by the
server's name for the panel's local submission (`non_smtpd_milters`
pointed at the same socket - strict, as OpenDKIM is today). The header
set: `dkim_should_signhdrs[]`, `From` over-signed once; canonicalization
`relaxed/simple`, as now.
- *Costs:* two to three stages, one MINOR release: the signer and its
tests; the removal (code, image, compose, supervisord, entrypoint,
Health's liveness set, Resync's steps, docs: architecture § OpenDKIM,
security, guide, development's table, assumptions § 3); e2e with an
**independent verifier as the oracle** - go-msgauth must not check its
own work: `opendkim-testmsg` in the sink image or rspamd's verifier, the
way `mailparse-diff.sh` holds the port to the C today - and a message to
a real mailbox before the cut.
- *Risk:* a signing defect fails DMARC for every tenant at once; the
oracle and the canary are the answer. go-msgauth is a small library with
a quiet release pace - vendorable if it ever matters.
- *Why after D:* D's exposure is remote and unauthenticated (465/587),
E's is a tenant's; D is one stage, E is three; neither waits on the
other (E re-reads no Postfix citation, D no OpenDKIM one).
## 4. Recommendation
In this order, each a roadmap row of its own, none of them an own build:
1. **[port25-bare-newline](roadmap.md#port25-bare-newline)** - one line in
`build/postfix-config.sh`, a PATCH release, now (§ 5).
2. **[base-trixie](roadmap.md#base-trixie)** - option D, one stage, MINOR.
3. **[panel-dkim-signing](roadmap.md#panel-dkim-signing)** - option E, two
to three stages, MINOR.
Options A, B and C are declined with the arguments above; what would bring
A back is a limit of Postfix's that its interfaces cannot answer, and none
is known.
## 5. Side finding: bare newlines on port 25
Postfix 3.7.11 has the 2023 SMTP-smuggling parameter
(`smtpd_forbid_bare_newline`, `src/global/mail_params.h:4249`) with the
default `no` "for Postfix < 3.9" (`src/smtpd/smtpd.c:807`); its
`RELEASE_NOTES` recommend `normalize`, which 3.9 made the default. The
inbound relay's listener (`build/postfix-config.sh`, the `smtp/inet`
block) does not set it, so with `INBOUND_RELAY_ENABLE=true` the relay is
the receiving side the smuggling research described: a bare `<LF>.<LF>`
inside DATA ends the message early and what follows is read as the next
transaction. Ports 465 and 587 are not the case that matters - the
smuggled transaction is still the authenticated session's, under its
sender bindings and milters. The fix is
`smtp/inet/smtpd_forbid_bare_newline=normalize` with an e2e check on port
25 (a bare-LF DATA, one message delivered); a PATCH release. Trixie's 3.10
would set it by default, but the relay should not wait for that row.
A smaller one: `opendkim-tools` is installed in the image and used by
nothing at run time (the key is generated in Go; the probe script runs on
the server from source). It goes with option E, or with D if E waits.
-185
View File
@@ -1,185 +0,0 @@
# Plan: delivery-log-storage
**Status:** shipped in `2.2.0` (2026-10-12); agreed the same day (owner: option 1 of the roadmap item, "store the lines")
**Date:** 2026-10-12
**Version:** `2.2.0` MINOR (one DDL migration; a 2.1.0 data directory boots unchanged).
---
## Goal
A message's page (`/outbound/log/{id}`, mockup `out-message`) shows what
Postfix wrote about the message for as long as its row is in the send log.
Today the *Delivery log* box is filled by a search of the current `mail.log`
on every page view, so it is empty for anything sent before the last daily
rotation, while the row itself lives for ninety days.
## Context (as-built)
- The log tailer ([internal/logtail](../../internal/logtail/logtail.go))
already reads every `mail.log` line once, from a persisted offset, to
advance delivery statuses.
- `logtail.QueueLines` searches the last 4 MB of the current file for one
queue id; `Handlers.deliveryLog` calls it per page view.
- A message with several recipients is several send-log rows under one queue
id; the page of each shows every line of the message, as the search does
today.
## Decisions
### What is stored
A `mail.log` line whose head is a queue id - after the timestamp, the host
name and `program[pid]:` - goes into a new table with that id, the line's own
time (`lineTime`, the same reading the inbound journal uses) and the line as
written, cut to 4096 bytes on a character boundary. The head is matched from
the start of the line: the tail is the far end's words. `NOQUEUE` and
`warning` are not queue ids - the pattern takes Postfix's short form
(hexadecimal) and its long form (no vowels) and nothing else.
The tailer does not ask whether the id belongs to a send-log row before it
writes. The first lines of a message (`client=`, `message-id=`) are logged
before the journal milter writes the row at end-of-message, so such a check
would drop exactly them.
**Mail that came in on port 25 is left out** (security review, 2026-10-12). A
queue id that `postfix/inbound/smtpd` opened is a stranger's message; it never
gets a send-log row, and what a stranger can make Postfix log - his own
`message-id`, up to the cut - is not stored to be pruned later. The tailer
remembers such ids (4096 at most) until qmgr's `removed` or until another
listener opens a message under the id. What is still stored without a row is
little: the bounces Postfix generates, and port-25 mail met in mid-flight
after a restart.
Lines are written in one transaction per pass over the log, 500 at most
(technical review): a commit per line is a disk sync per line on the one
connection the journal milter writes through.
### Which lines are a row's
A line is shown on a row's page when the queue id is the same **and** the
line's time lies between one hour before the row was written and the queue
lifetime (the larger of `maximal_queue_lifetime` and `bounce_queue_lifetime`,
as `postconf` reports them) plus a day after it. Postfix reuses a short queue
id once the message has left the queue - the id is the microsecond of arrival
and the inode of the queue file - so a row that lives ninety days can meet a
later message under its id. The window does not rule that out, it limits it
to the days the row's own message could still be in the queue; this is the
bound the roadmap item asked for. The search of the current file that the
page did before had the same exposure for a day. `enable_long_queue_ids`
would end it and is a change to the mail path, not part of this item.
*It was made in `2.10.1` (owner, 2026-10-16).*
### Retention
Lines live as long as a send-log row with their queue id does. One rule covers
both ends: a line older than an hour whose queue id has no send-log row is
deleted. That removes the lines of a row the retention sweep has just deleted,
and the lines of messages that never had a row. It runs at start and after
every send-log sweep, six-hourly. It asks the send log once per queue id; at a
million lines that is still a scan of the table, of the order of a second,
during which the one connection is taken.
There is no cap of its own: with port-25 mail left out, the table is bounded
by what authenticated applications send, which level 1 and level 2 limit.
### After an unclean stop
The tailer saves its place every few seconds, so after a crash it reads some
lines twice. While it is catching up at start a line is written only if the
same line is not there already - the rule the inbound journal follows.
### What is not kept
- Lines the tailer never reads are not stored: the tail of a file that was
rotated away while the panel was down, as with delivery statuses
(architecture.md § Log tailer).
- A message in the queue across the upgrade to 2.2.0 has only its later lines
stored, and the page then shows those alone: the search of the file runs
only for a row with nothing stored. The only installation is the owner's
test server; no merge of the two sources is built for one moment of it.
### The page
The page reads the table: the latest 200 lines of the row, oldest first. When
the table has none, it still searches the current `mail.log` as before - that
is what shows a message accepted before 2.2.0 while its lines are in today's
file. Lines in rotated files are not imported: the only installation is the
owner's test server.
Nothing in the page's layout changes; the `out-message` mockup already draws
the box as a `log_pane`. Two texts change, mockup first: the *Outbound log*
help topic (it says an old message may show no lines) and the note shown when
there are none.
### Size
Six to ten lines of about 200 bytes per message: 1-2 KB next to a row of a few
hundred bytes. The table becomes the larger part of what the send log costs in
`/data` and in a backup; the retention setting bounds both together.
## Schema (migration `0003_send_log_lines.sql`)
```sql
CREATE TABLE send_log_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
queue_id TEXT NOT NULL,
logged_at TEXT NOT NULL,
line TEXT NOT NULL
);
CREATE INDEX idx_send_log_lines_queue_id ON send_log_lines (queue_id, logged_at);
```
No foreign key: a line is written before its row exists, and one queue id is
several rows.
## Scope
In: the table, the tailer writing and pruning it, the page reading it, the two
texts, docs.
Out: the inbound journal (it decides for itself, roadmap § delivery-log-storage);
importing rotated files; `enable_long_queue_ids`; any change to `mail.log`
rotation or to the Server › Mail log page.
## Security
- The lines carry the far end's text (replies, HELO names). They are shown
today from the file through the same escaping; storing them changes where
they are read from, not what is shown or to whom. A domain administrator
sees the lines of messages of their own domains, as now.
- They now enter the database and so a full backup, which `log/` does not:
submitting clients' addresses and SASL logins, message ids, receivers'
replies. [security.md](../security.md) says so.
- A line is matched from its head, so a reply cannot make itself a line of
another message; Postfix does not log raw line breaks, and `postlogd` is the
file's only writer.
- A stranger on port 25 does not write to the table (above).
## Done when
A message sent before the last rotation shows its lines on its page; the lines
disappear with the row; the page's wording no longer blames rotation for an
ordinary case.
## Model routing (this plan)
| Role | Model |
|------|-------|
| All implementation (code, tests, docs) | **Opus** |
| Technical review (reviewer ≠ author: a separate read-only agent) | **Opus** |
| Security review | **Fable** |
## Implementation checklist
Target version cut: **`2.2.0`** (MINOR). One commit per step;
[development.md](../development.md) § Plan checklists.
- [x] Migration `0003_send_log_lines.sql`, store methods (insert, insert once, lines of a row, prune) and their tests — **Opus**
- [x] Log tailer: store queue-id lines, write once while catching up, prune with the send-log sweep — **Opus** (hourly as first built; six-hourly after the reviews)
- [x] The two texts, mockup first: the *Outbound log* help topic and the empty note (`design:` commit) — **Opus** (the help topic is in the mockups; the empty note is not drawn there - the `out-message` mockup shows a message with lines - and changes with the page)
- [x] Message page reads the table, falls back to the current `mail.log`; help text in `help.go` — **Opus**
- [x] [architecture.md](../architecture.md), [guide.md](../guide.md), [schema-migrations.md](../schema-migrations.md) — **Opus** (schema-migrations.md went in with the migration)
- [x] `go vet`, `go test`, e2e: a message's page still lists its lines after a forced rotation — **Opus** (on the test server: vet ok, 22 packages ok, e2e ok with the new step `delivery_log_survives_rotation`)
- [x] Technical review — **Opus** (2026-10-12; no Critical or High. Fixed: one transaction per pass instead of a commit per line, the prune asks the send log once per queue id and runs six-hourly, the cut keeps characters whole, tests for what the comments claimed. Left as found and written down under *What is not kept*: lines the tailer never read, and a message in the queue across the upgrade)
- [x] Security review — **Fable** (2026-10-12; no Critical or High. Fixed: port-25 mail is not stored, so a stranger cannot grow the table; [security.md](../security.md) states what a backup now carries. The window against queue-id reuse is described as the limit it is)
-260
View File
@@ -1,260 +0,0 @@
# Plan: from-header-binding
**Status:** shipped in `2.4.0` (2026-10-12); agreed the same day - the design questions were closed with the owner one at a time and he took it into work ("Да")
**Date:** 2026-10-12
**Version:** `2.4.0` MINOR (one migration; it refuses mail that was accepted before).
---
## Goal
An application sends only as itself - in the `From:` line a mail program
shows and OpenDKIM chooses the key by, not only in the envelope.
Until now Postfix held the envelope sender against the SASL login and nothing
held the header. An application of one sending domain could write another
registered domain, or the server's own name, into `From:` and have the message
signed with that name's key (security review of panel-notifications,
2026-10-12).
## Decisions (owner, 2026-10-12)
### The rule
**`From:` is held to what the envelope is held to.** An application set to
any address of its domain may write any address of that domain; one with a
list of addresses, only those. Anything else is refused: another registered
domain, the server's name, an address at a domain this server knows nothing
of, no `From:` at all.
I proposed the narrower rule - refuse only a `From:` that names a domain this
server signs for, and let a stranger's address through unsigned as before -
and, for an application with a list, to check the domain alone. The owner
chose the wider one both times. What it stops working: a form that puts its
visitor's address into `From:`, and an application with a list whose envelope
address and header address differ, until the second is added to the list.
When the hostname is itself a sending domain its applications write
`…@<hostname>` by right, as the guide already says.
### Where it is checked, and what happens when it cannot be
**The panel checks, and the check is strict:** while the panel does not
answer, Postfix replies with a temporary error and accepts nothing unchecked.
I first proposed the opposite (fail-open, as the journal and the level-2 limit
are) and then a small process of its own. The owner's objection to the first:
a check that is absent while the panel is down does not close the hole, and
the panel only has to be slow - 15 seconds - for Postfix to go on without it.
His answer to the second: not yet; measure first.
So the check is a role of the panel process on a socket of its own, attached
with `default_action=tempfail`. The journal milter keeps its socket and stays
fail-open: a slow database still does not defer mail. The check reads no
database - it reads the file of sender bindings Postfix itself reads for the
envelope, so the two cannot disagree - and moving it into a process of its own
later is a change of where it is started.
### Statistics, to decide whether the check needs its own process
Per day, kept a year:
- **for how long the panel did not answer while Postfix was running.** The
panel marks itself alive every few seconds; at start the gap since the last
mark is its downtime, counted when Postfix was not restarted within it (a
rollout or a reboot stops mail for another reason);
- **how many messages were deferred because the check did not answer**, read
from `mail.log`. This one also sees a panel that is running and not
answering, which the mark does not.
Shown on **Server › Health** in a card of its own, *Sender check*: today, the
last 30 days, the last 12 months. The card warns when a message was deferred
in the last 24 hours; downtime that deferred nothing warns of nothing.
### What a refusal looks like
- **To the application:** `550 5.7.1`, permanent, naming the address. A
level-2 limit answers 4xx because the message will pass in an hour; a wrong
`From:` will not, and a 4xx would have the application send it again for
days.
- **In the Outbound log:** a row with the reason in the status tag -
`wrong From`, beside `rate-limited` and the tag of a refused client address.
The built panel wrote `rejected` for all of them; it follows the mockup now.
(The mockup's status filter is not built - the log filters by domain and
application - so there is nothing to keep in step with the tags.)
- **On the message's page:** a fact *From: line* with the address written in
the header, and a History step that says which address was refused, for
which application, what that application may write, and that it was told
`550 5.7.1`.
### Health, re-laid
The owner on the first picture of the new card: too many holes on the screen,
the numbers should follow importance, and the boxes should read left to right
and top to bottom - with stacked columns the third box was below the fold and
the fourth was not.
- Order: 01 Processes, 02 Milter sockets, 03 Sender check, 04 TLS
certificate, 05 Hostname and reverse DNS, 06 Notification mail, 07 Machine,
08 Configuration.
- Layout `cards` (new, on the components page): two stacks, odd numbers on the
left and even on the right, each closed up, so a short box leaves no gap
beside a tall one and the eye still goes 01, 02, 03. On a phone the boxes
come in the order of their numbers.
## Settled while building
- **The header is read twice.** One `From:` field holding one address, by
Go's parser; then every `@name` of the raw value is held against OpenDKIM's
own `SigningTable`. The first build took the names for the second look from
the sender bindings, and the e2e stand let a line through that named a
domain with a key and no application yet: a domain is a signing name from
the moment it is added, not from its first application.
- **What Postfix writes when the check does not answer** (seen on the stand):
a warning naming the socket, then `milter-reject: CONNECT … 451 4.7.1
Service unavailable` and the same for `EHLO`; the client is told `454 4.3.0
Try again later` at `AUTH` and leaves. No `MAIL` is ever offered, so what
is counted is the connection turned away, once. The journal milter's
level-2 refusal is written in the same words, which is why a reject line
counts only after the warning.
- **When Postfix started** is asked of supervisord (`supervisorctl status`,
the uptime of `postfix`).
- **The reply** is a milter reply code, `550 5.7.1 From: <line> - <why>`; what
the client wrote is reduced to printable ASCII and cut first.
- **The Delivery log box stays on a refused message's page**, with the one
sentence it already had for a message that never reached the queue; the
picture shown to the owner had left the box out.
## To settle while building (as first written)
- How the header is read so that the check and OpenDKIM cannot disagree: one
`From:` field, one address, and a cruder second look at the raw value.
- What Postfix writes in `mail.log` when a strict milter does not answer, and
how a line is told to be this milter's and not OpenDKIM's.
- How the panel learns when Postfix started.
- Schema: the reason of a refusal and the header address on a send-log row;
the table of days.
- That a refusal for `From:` is not taken for a refusal under a limit by the
notifier.
## Implementation checklist
- [x] Mockups: Health card and layout, the log row, help topic; accepted by the owner 2026-10-12 (`design:` commit)
- [x] Migration and store: refusal reason and header address, days of the sender check (`0006_sender_check.sql`)
- [x] The check: a role of the panel on its own socket, bindings read from Postfix's file; Postfix attaches it strictly; start order
- [x] Statistics: the alive mark and downtime, deferred mail from `mail.log`
- [x] Panel: Health (card, sockets row, layout), Outbound log tags, the message's page, help
- [x] guide.md, architecture.md, security.md (the accepted risk goes), schema-migrations.md
- [x] `go vet`, `go test`, e2e: refused for another domain's `From:`, for the hostname's and for an address off the list; accepted for its own (on the test server: vet ok, 23 packages ok, e2e ok - `sender_check_holds_the_from_line`, `panel_down_defers_mail`)
- [x] Technical review — **Opus** (2026-10-12; one Critical, fixed: a folded `From:` line - what a mail library makes of a long display name - was refused for good. Also fixed: a display name in a charset Go does not convert was refused; the statistics ran before the check served, and a hung `supervisorctl` would have kept it from serving; the count of deferred mail missed a check that stops answering in the middle of a message, and is now taken at Postfix's warning; a card whose figures could not be read said ok; a killed container's stale socket satisfied the wrapper's wait; outside quoted strings the line may now hold one `@` only. Three were first left as found and fixed the same evening at the owner's question: downtime is cut at the UTC midnights it crossed; the row of a message with the null envelope sender takes the domain the login is bound to; a log read again after an unclean stop does not count again what it counted before)
- [x] Security review — **Fable** (2026-10-12; no Critical or High. The same folded line, as its one Medium. Fixed: another sender's name in an encoded display name passed the second look; a client could arm the deferred counter by writing the socket's name where Postfix quotes it in a warning; a refused message wrote a row per recipient, a thousand at most - now one. Recorded: what a display name looks like to a person is not promised. The review also found the check stricter than Postfix for an address with an extension (`invoices+x@`, allowed in the envelope and refused in the header); the owner asked for it to work, and the check now makes Postfix's second try, with `recipient_delimiter` set explicitly. Confirmed from the code: no path to the signer around the check, and every failure of the check defers)
## Not done
Written at the owner's word when he had the version cut ("Запиши все
недоделки в план", 2026-10-12). Nothing here was promised and dropped in
silence; each line says what is missing and what it would take.
### Decided to wait
- **A process of its own for the check.** The owner: measure first. Server ›
Health now has the numbers; the decision is his when they have had time to
say something. Moving the role out is a change of where it is started -
a subcommand of the panel binary and a `[program:]` in supervisord - and
the downtime figure then needs a new meaning.
### Shown to the owner one way and built another
- **The Delivery log box is on a refused message's page.** The picture he
approved had left it out; the built page keeps it, with one sentence. And
the sentence is less than the truth for a `From:` refusal: cleanup did
write a line about it under a queue id (`milter-reject: END-OF-MESSAGE ...
5.7.1 From: ...`), but the check answers before the queue id reaches it, so
the row keeps none and the page cannot show that line. **Decided
2026-10-13 (owner):** the box is required - he had overlooked its absence
from the picture, it was never a decision to drop it. The tailer attaches
the line to the row, for all three kinds of refusal; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
- **The Outbound log has no status filter.** The mockup draws one; the built
log filters by domain and application. With three kinds of refusal now
told apart by tag, a filter by status and reason would be worth having.
**Decided 2026-10-13 (owner):** a filter by status, without the split by
reason, and a filter by day as the Inbound log has; a step of [audit-2026-10-12](../audit-2026-10-12.md) stage 4.
- **"Messages deferred" counts connections Postfix turned away.** Postfix
refuses from the connection on, before a message is offered, so a monitor
that only connects during an outage is counted. The guide says so; the
card's column head and the help topic still say "messages". **Decided
2026-10-13 (owner):** the column is renamed *Connections turned away*; a
step of [audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
### Known and small
- **"Today" on the card is the UTC day**, and the card does not say so.
**Decided 2026-10-13 (owner):** not a label - the local day; it became the
roadmap item `local-time`, for the whole panel.
- **A deferral in the very second of the last one counted before an unclean
stop is passed over** at the restart: `mail.log` stamps whole seconds.
Chosen over counting a stretch of the log twice. **Decided 2026-10-14
(owner):** counted exactly - the counter keeps a place in the log, saved
with the count; a step of [audit-2026-10-12](../audit-2026-10-12.md)
stage 2b.
- **Deferrals are lost when `mail.log` is rotated while the panel is down**:
the tailer starts on the new file. **Decided 2026-10-13 (owner):** the
tailer reads the rotated file to its end first - not the deferrals alone,
everything the panel takes from the log; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
- **A start at which `supervisorctl` fails counts no downtime**; it is
logged. **Decided 2026-10-13 (owner):** the question is repeated, and
without an answer the downtime is counted from the alive mark and the day
marked as estimated; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
- **Logins are compared as written**, Postfix compares them without regard
to case. The panel writes logins one way, so nothing differs today.
- **The phone order of the `cards` layout is written for four boxes to a
stack** and takes every child of a stack for a box (said at the partial).
The rule the technical review suggested instead - the order from the
box's own number - changes the component and would be drawn first.
**Decided 2026-10-13 (owner):** that rule is built; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 4.
- **Health's socket check looks at the file, not at who listens**: a socket
nobody answers on would read "ok". The start now removes stale sockets,
which was the one way to get such a file. **Decided 2026-10-13 (owner):**
the check connects to the socket; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 4.
- **The kit page's table of status tags does not list the new ones**
(`wrong From`, `wrong client`, `rate-limited`), and `rate-limited` is grey
beside two red tags - as the mockup has it, not as a decision.
- **Refusal rows still in the buffer when the panel stops are not written**;
the refusals themselves were answered. **Decided 2026-10-13 (owner):**
the tailer writes such a row from the refusal's line in `mail.log`; a step
of [audit-2026-10-12](../audit-2026-10-12.md) stage 2b.
### Accepted, in security.md
- **The panel's downtime is mail's.** The price of the strict check.
- **What a display name looks like to a person is not promised.** A name of
another sender written without an `@` the scan can find passes, signed
with the application's own key.
- **A malformed packet on the check's socket ends the panel** (the milter
library panics on an empty one). The socket is reachable only inside the
container, by the group Postfix is in; the journal milter always had this,
and what is new is that the price is deferred mail.
### Not verified
- **OpenDKIM's reading of a `From:` line** was reasoned about, not run: its
parser is not in this repository. The two rules that do not depend on it -
no other signing name after any `@`, one `@` outside quotes - are what the
claim rests on. A test that feeds the stand's OpenDKIM the lines the check
passes and reads which key it signs with would settle it. **Decided
2026-10-13 (owner):** that test is built; a step of
[audit-2026-10-12](../audit-2026-10-12.md) stage 2.
- **The pages were not looked at in a browser on the deployed panel**: I
cannot sign in there. They were rendered from the fixtures and
screenshotted, the polling and the phone order read from the code and
exercised by e2e only as far as the Health numbers.
- **No refusal has been seen on the test server itself**, only on the e2e
stand: there is no application there I can send as.
- **A check that stops answering in the middle of a message** is counted
from a warning composed from Postfix's source; the stand only produced the
case of nobody listening.
-325
View File
@@ -1,325 +0,0 @@
# Plan: inbound-antispam-panel
**Status:** shipped in `2.1.0` (2026-10-12)
**Date:** 2026-08-19
**Version:** `2.1.0` MINOR (opt-in; no change to outbound-only or inbound-without-filter paths). Was `1.10.0`; on 2026-10-09 the owner put the panel redesign first, so this ships on the 2.0 baseline and its screens are built from the redesign's component kit ([panel-redesign.md](panel-redesign.md) § Component kit).
---
## Goal
Give the operator a **panel view of inbound anti-spam decisions** and **editable
allow/deny lists** that tune filter behaviour — without SelfPost shipping or
starting an anti-spam engine ([product.md](../product.md) out-of-scope list
unchanged).
Typical journal row:
| When | From | To | Subject | Decision |
|------|------|----|---------|----------|
| 2026-08-19 09:14 | `spammer@evil.example` | `user@inbound.example` | Cheap pills | reject (rspamd: BAYES_SPAM +0.95) |
Lists let the operator correct false positives/negatives (e.g. always allow
`billing@vendor.example`, block `192.0.2.0/24`).
## Context (as-built)
- Inbound relay ships in `[1.4.0]` (`INBOUND_RELAY_ENABLE`); optional
`INBOUND_ANTISPAM_MILTER` attaches an **external** filter (documented rspamd
sidecar in [deploy/antispam/docker-compose.antispam.yml](../../deploy/antispam/docker-compose.antispam.yml)).
- The inbound smtpd has **no journal-milter** today — only outbound 465/587
writes `send_log` ([architecture.md](../architecture.md)).
- Inbound UI and handlers are **global-administrator only** today
(`requireInbound` → `requireGlobal`).
## Quarantine
**Not in this plan.** Held-mail review/release is an idea kept outside the roadmap,
[inbound-quarantine](inbound-quarantine.md). The journal may still
record a `quarantine` **decision label** when the external filter reports it,
without SelfPost storing the message.
**DMARC `p=quarantine`** remains unrelated (outbound policy for receivers).
## Decisions (closed 2026-08-19)
### Capture mechanism
**Inbound journal-milter in `panel` only** — no rspamd history API polling in v1.
A separate unix socket (`INBOUND_JOURNAL_MILTER_SOCKET`, default
`/run/selfpost/inbound-journal.sock`) on the inbound smtpd only; fail-open
(`default_action=accept`).
**Milter order on port 25** (when antispam is enabled):
```text
smtpd_milters = { antispam, inbound-journal }
```
Antispam runs first; inbound-journal runs at end-of-message on the **accept**
path and reads rspamd-added headers (`X-Rspamd-Action`, and `X-Spamd-Result`
for the score and symbols - rspamd has no `X-Rspamd-Score`; the sidecar adds
both through `deploy/antispam/rspamd/milter_headers.conf`). As built they are
read only next to rspamd's signature of the message, `X-SelfPost-Filter`
(owner, 2026-10-12: "А rspamd не может дописывать какой-то заголовок, по
которому мы будем знать, что это заголовки нашего спам фильтра?") - an HMAC
of the queue id, since a fixed mark could be written by a sender as easily as
the verdict itself.
**Antispam rejects** do not reach end-of-message on the journal milter. v1 also
tails `mail.log` for inbound `postfix/smtpd` **`milter-reject`** / **`reject`**
lines and inserts journal rows (client IP, envelope from/to when logged, reason
text, subject empty if rejected before DATA). DMARC-ingest pipe traffic is out
of scope for this tailer.
### Journal without `INBOUND_ANTISPAM_MILTER`
Journal stays **on** whenever `INBOUND_RELAY_ENABLE=true` (not tied to the
antispam hook). Without an external filter:
- **accept** rows — message relayed (engine `selfpost`, detail empty);
- **reject** / **tempfail** rows — from the mail.log tailer (Postfix policy,
unknown recipient, a sender domain that does not exist. As built, a refusal
whose log line names no recipient - the size limit answered at MAIL FROM -
and the per-client rate limit, which Postfix logs as a warning, make no row).
Panel copy explains that rspamd symbols appear only when the antispam milter is
configured.
### RBAC
**Global administrator only** for journal and lists — same as inbound relay. No
domain-admin access in v1.
*As built (2026-10-11):* the lists are the global role's; the **log follows
the delegation of Inbound** that 2.0 introduced after this was written - a
user with the `domain` role sees the rows of the inbound domains assigned to
them and nothing else, without the *Allow / Deny sender* links. That is what
the accepted `in-log` mockup draws (no "global role" in its head). The owner
was told and can turn it back into a global-only page.
**Inbound › Log accepted by the owner on 2026-10-12** ("Этот экран
утверждаем"), from screenshots of the page built from its fixture, after his
changes to it: the sender column is headed *From*; *Why* is not a column but
a sheet over the page, opened by the decision tag, holding a centred table of
who decided, the verdict and symbols or the reply, and the subject whole
(kept on the sheet at his word: the table clips it); a message Postfix itself
refused offers no *Deny sender*. Inbound › Filter lists was redrawn at his word
the same day - kind in a column of its own, the note on the entry's sheet,
matches counted (§ Filter list usage) - and **accepted by him on 2026-10-12**
("Да"), after a deny entry he added in the panel refused a test message with
the list's reply and the journal recorded it.
### Retention
Mirror send-log retention:
- Settings key `inbound_spam_log_retention_days` on `/server/settings` (global admin).
- Default **90** days; validation **7–365**; seed from env
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
- Prune every **6 hours** (same cadence as send-log retention).
- Hard cap **10 000** rows after age prune (drop oldest) — busier than DMARC
reports, lighter than unbounded growth.
### Allow/deny lists
| Topic | Decision |
|-------|----------|
| Entry types | sender address, sender domain (`@domain` suffix), client IP / CIDR |
| Precedence | **deny overrides allow** |
| Scope | instance-wide (not per inbound domain in v1) |
| Where applied | **rspamd map files** under `/data/antispam/`, one per list and entry type (`allow_ip.map`, `allow_from.map`, `allow_domain.map`, `deny_…`) - an IP map and a sender map are different map types to rspamd; documented compose fragment mounts them into the sidecar. Deny is a prefilter reject, allow a weight of -100 (as built: two prefilters do not give "deny overrides allow") |
| SelfPost pre-check | **out of v1** — lists live in rspamd only |
| Edit access | global administrator only |
| When inactive | lists editable in the panel but UI warns that sync applies only when `INBOUND_ANTISPAM_MILTER` is set; `postfix reload` / rspamd reload documented in guide |
List change → atomic map rewrite (same pattern as Postfix maps). No reload:
rspamd rereads a changed map file by itself (about ten seconds on 3.11).
### Filter list usage (owner, 2026-10-12)
Each entry shows how often it decided a message (*Matched*, or *never*) and,
on its sheet, when it last did - so that entries nothing matches any more can
be removed. **The count is read off the journal on every view of the page;
nothing is stored for it.** A journal 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
(`SELFPOST_ALLOW_IP` / `_FROM` / `_DOMAIN`, which the journal milter always
keeps) - and the entry fits the row's sender or client address the way rspamd
matches it (`inbound.Lists.Usage`).
What follows from counting this way, and is accepted:
- the count reaches back as far as the journal does: its retention (90 days by
default), which is what the sheet says;
- a row counts for every fitting entry of the deciding list, since the journal
does not say which of two the filter met first;
- a message refused at end-of-message is one row whatever its recipient count,
so a count is of messages, roughly.
**When to move to a counter kept with each entry** (a migration adding hits
and last-hit to `inbound_spam_list`, bumped where the journal row is written;
the owner asked for the condition to be written down). Any one of:
1. **The journal is cut by its 10 000-row cap and not by age.** Then it holds
fewer days than its retention, the sheet's "in the last 90 days" is no
longer true, and *never* stops being evidence that an entry is stale. The
sign: the log tailer's prune line keeps reporting rows removed while the
oldest row of Inbound › Log is younger than the retention.
2. **The retention is set short** (towards its minimum of 7 days) for reasons
of its own, privacy or disk. A week without a match does not make an entry
stale.
3. **Counts are wanted that outlive the journal** - since the entry was added.
4. **The page is slow.** A view reads every journal row a list decided and
tries it against every entry. With the cap that is at most 10 000 rows
times the number of entries; past a few hundred entries, measure it.
Until then no migration is spent on it.
### Activation
| Condition | Behaviour |
|-----------|-----------|
| `INBOUND_RELAY_ENABLE=false` | no inbound journal milter, no UI, no tables written |
| inbound on, antispam empty | journal + UI; lists visible but marked inactive for filter sync |
| inbound on + antispam set | full journal (symbols on accept) + list sync |
No separate feature flag beyond inbound relay + existing antispam env vars.
### UI
The journal is **Inbound › Log** and list management is a new **Inbound ›
Filter lists** page (global-only: the lists are instance-wide) — see
[panel-redesign.md](panel-redesign.md) § Room for roadmap items. The journal
table has filters (decision, domain, date range). Mockups are drawn first, in
`docs/assets/panel-redesign/panel/src/` under that plan's design contract —
not in the superseded `docs/assets/panel-ui/` (removed 2026-10-16).
## Schema (migration `0002_inbound_spam_log.sql`, the first after the 2.0 baseline)
**`inbound_spam_log`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `inbound_domain` | TEXT NOT NULL | recipient domain (from `relay_domains`) |
| `client_ip` | TEXT NOT NULL | |
| `from_addr` | TEXT NOT NULL | envelope from |
| `to_addr` | TEXT NOT NULL | envelope to (one row per recipient) |
| `subject` | TEXT NOT NULL | truncated with `mailhdr.SubjectMaxRunes` (200) |
| `decision` | TEXT NOT NULL | `accept`, `reject`, `tempfail`, `quarantine` |
| `engine` | TEXT NOT NULL | `rspamd`, `postfix`, `selfpost` |
| `detail` | TEXT NOT NULL | symbol/score summary or log-line reason; may be empty |
| `created_at` | TEXT NOT NULL | RFC3339 UTC |
Index on `(created_at)`, `(inbound_domain, created_at)`.
**`inbound_spam_list`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `list_type` | TEXT NOT NULL | `allow` or `deny` |
| `entry_type` | TEXT NOT NULL | `address`, `domain`, `ip` |
| `value` | TEXT NOT NULL | normalized entry |
| `note` | TEXT NOT NULL | operator comment; default `''` |
| `created_at` | TEXT NOT NULL | |
Unique on `(list_type, entry_type, value)`.
## Scope
**In:**
- Schema, store CRUD, retention loop, mail.log tailer extension for inbound
rejects.
- Inbound journal-milter (fail-open) + Postfix `postfix-config.sh` wiring.
- rspamd map sync under `/data/antispam/`.
- Panel journal + list UI (global admin).
- Full backup includes `/data/antispam/` and new SQLite tables.
- Tests; [guide.md](../guide.md); [architecture.md](../architecture.md).
**Out:**
- Shipping rspamd, ClamAV, or any filter binary inside the SelfPost image.
- Outbound spam filtering.
- [inbound-quarantine.md](inbound-quarantine.md) storage/release.
- rspamd rule editing, Bayes training UI, antivirus.
- MIME replay from the panel (metadata only).
- Per-domain lists; domain-admin RBAC.
- rspamd history API polling.
## Architecture
```mermaid
flowchart LR
internet["Internet :25"] --> inbound["Postfix inbound smtpd"]
inbound --> antispam["External antispam milter optional"]
antispam --> journalM["inbound-journal milter fail-open"]
journalM --> upstream["smtp upstream transport"]
journalM --> sqlite["inbound_spam_log"]
mailog["mail.log tailer"] --> sqlite
listsUI["Panel allow/deny lists"] --> maps["/data/antispam/*.map"]
maps --> antispam
sqlite --> journalUI["Panel inbound spam journal"]
```
## Security
- Same validation whitelists as inbound relay ([security.md](../security.md)).
- Journal rows contain PII — retention, and access by reach: the global role
reads all of it, a `domain` user the rows of assigned inbound domains (as
built; see § RBAC).
- Inbound journal milter fail-open must not block mail (backup-MX role).
- List map writes injection-safe; deny wins over allow.
## Done when
- With inbound relay + antispam sidecar, panel shows accept rows with symbols
and reject rows from mail.log tailing.
- Operator can add/remove list entries; a test message reflects allow/deny.
- With antispam hook off, journal still shows Postfix-level decisions; list UI
shows inactive sync notice.
- `go vet`, `go test`, image build green; guide and architecture updated.
## Risks
- rspamd header/symbol format drift — thin parser, pin sidecar tag in compose.
- Journal volume — retention + 10k cap.
- mail.log parse brittleness — unit tests on sample lines; inbound smtpd only.
## Dependencies
- Inbound relay, shipped in `[1.4.0]` — as built in
[architecture.md](../architecture.md) § Mail path. Its plan file was removed
once it shipped.
## Model routing (this plan)
| Role | Model |
|------|-------|
| All implementation (code, tests, docs) | **Composer** |
| Technical review (reviewer ≠ author) | **Opus** |
| Security review | **Fable** |
Composer ships each step; Opus reviews the accumulated diff once implementation
steps are done (fix loop: Composer addresses Opus findings, Opus re-checks).
Fable runs after Opus sign-off.
## Implementation checklist
Target version cut: **`2.1.0`** (MINOR), after `2.0.0`. One commit per step;
[development.md](../development.md) § Plan checklists.
- [x] Agree journal fields, retention, and RBAC (this plan § Decisions) — **Composer**
- [x] Migration `0002_inbound_spam_log.sql` on the 2.0 baseline — **Composer**
- [x] Inbound journal-milter + Postfix wiring — **Composer**
- [x] mail.log tailer: inbound reject rows — **Composer**
- [x] List CRUD + validation + atomic rspamd map sync — **Composer**
- [x] Panel: Inbound › Log and Inbound › Filter lists from the kit partials, mockups `in-log` / `in-filter-lists` (add client IP and engine columns mockup-first), evidence pairs — **Composer**
- [x] Backup includes `/data/antispam/` — **Composer** (it always did: a backup walks all of `/data`; now held by a test, and the guide says the `antispam/` fragment directory is not in it)
- [x] Unit + handler tests — **Composer** (written with each step: store, milter, tailer, lists and their usage, view fixtures held to the outlines, handlers for both roles)
- [x] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** (kept up with each step; [security.md](../security.md) follows the security review)
- [x] `go vet`, `go test`, e2e if applicable — **Composer** (on the test server for every step; last on `7192c38`: vet ok, 22 packages ok, e2e ok, which relays, refuses, reads the log page and edits a list through the panel)
- [x] Technical review of inbound antispam changes — **Opus** (2026-10-12; findings fixed: client address of a connection's later messages, the lists' writer under a lock, the tailer's queue-id memory, hold rows written twice, the cap by time; and, outside the feature, the level-2 limit and client-address list skipped after a connection's first message)
- [x] Security review inbound antispam path — **Fable** (2026-10-12; no Critical or High; HELO-forged journal rows and the six-hourly row cap fixed; a sender's own verdict while the filter is down closed by the filter's signature, the owner's idea; accepted risks recorded in [security.md](../security.md))
+5 -7
View File
@@ -1,8 +1,8 @@
# Plan: inbound-quarantine
**Status:** an idea to think about, not on the roadmap (owner, 2026-10-12) — design TBD; needs explicit agreement before any code
**Version:** TBD (`1.x` MINOR if opt-in and backward-compatible; may warrant `2.x`
if it changes inbound storage or backup contract).
**Version:** TBD (a 2.x MINOR if opt-in; a major only if it breaks the
inbound storage or backup contract).
---
@@ -17,8 +17,8 @@ so we can compare approaches before committing.
## Why it is separate from inbound-antispam-panel
[inbound-antispam-panel.md](inbound-antispam-panel.md) covers **metadata**
(journal + lists). Quarantine implies **storing message bodies** (or a durable
inbound-antispam-panel (shipped `2.1.0`; [architecture.md](../architecture.md)
§ Mail path) covers **metadata** (journal + lists). Quarantine implies **storing message bodies** (or a durable
reference to them) under `/data`, retention, release workflow, and a wider
attack/backup surface — closer to "mini-mailbox" than pure relay.
@@ -68,9 +68,7 @@ to the upstream, 14 days, headers only in the panel); it does not settle them.
- Inbound relay, shipped in `[1.4.0]` — as built in
[architecture.md](../architecture.md) § Mail path.
- Sensible to decide **after** or **alongside**
[inbound-antispam-panel.md](inbound-antispam-panel.md) (journal/lists), but
not blocked on it for design discussion.
- Builds on inbound-antispam-panel (journal and lists, shipped `2.1.0`).
## Done when (placeholder)
-100
View File
@@ -1,100 +0,0 @@
# inbound-upstream-result
**Status:** shipped `2.10.0` (2026-10-16).
## Problem
The owner, 2026-10-16: "Нам не хватает в журнале для Inbound ответа
upstream. Не понятна судьба письма: принято, отклонено, хост не доступен и
пр."
Inbound › Log shows what port 25 decided - accept, reject, tempfail - and
nothing after it. Its help says so ("Accept says what this server did, not
what the upstream did with the message afterwards"). The answer is in
`mail.log`: Postfix's `smtp` client logs every attempt to hand a message to
its upstream as `<id>: to=<…>, relay=<host>[ip]:port, …, dsn=…, status=sent
(250 …)` - or `deferred (451 4.7.1 Greylisted …)`, `deferred (connect to …:
Connection refused)` with `relay=none`, `bounced (… said: 550 …)` (Postfix
3.10.13, `src/global/log_adhoc.c`; `relay=none` when there was no session,
`src/smtp/smtp_trouble.c:273, 421`). The tailer reads those lines for the
outbound log and, finding no send-log row for a port-25 queue id, drops
them; the journal row does not even hold the queue id.
## Decision
The owner, asked whether the result goes in the row or on a page of its own
per message: "Пока только результат. Отдельная страница в план в случае
появления потребности." So: the row carries the result and the last reply;
no stored `mail.log` lines for port-25 mail (delivery-log-storage left them
out on purpose: a stranger sets the rate). A page per inbound message, with
every line, is for later, if the result is not enough.
## Design
- **Schema, migration 0012:** `inbound_spam_log` gains `queue_id`,
`upstream_status` (`''`, `queued`, `sent`, `deferred`, `bounced`),
`upstream_relay`, `upstream_reply` and `upstream_at`; an index on
`queue_id` for rows that have one. Rows written before have none and show
"—".
- **The milter** writes an accepted row with the queue id it already reads
at end-of-message (`{i}`, for the filter's signature) and `queued`. The
row is in the database before the message is queued, so before any
delivery line.
- **The tailer:** a delivery line whose queue id has no send-log row is
offered to the journal: the rows of that queue id and recipient take the
status, the relay (empty for `none`), the reply (printable, at most 255
bytes) and the line's time. One update per attempt, no new row; the
journal's caps stand. qmgr's `status=expired, returned to sender` makes a
queued or deferred row `bounced`, its reply saying it expired after the
last one.
- **The page:** an Upstream column after Decision (mockup `5348330`): a
plain `queued` tag, or a tag opening an "Upstream" sheet - host, reply,
when, and what follows for deferred and bounced. Refused rows "—".
- **Docs:** help (word for word with the mockup), guide § Inbound log,
architecture (journal and tailer), security (what is stored from the
upstream and at what rate), CHANGELOG.
## Reviews (2026-10-16)
A technical and a security review of the tree before the commit; what each
finding became:
- *A quoted RCPT TO* (both): the milter kept `"owner"@x` as given, every line
says `owner@x` - the row never matched. Now `recipientAsLogged`, as the
outbound milter does.
- *A forged line through a quoted local part* (security, medium): `to=<…>`
was read up to its first `>`; a local part in quotes may hold `>`, `,`,
`status=`. Port 25 refuses the one that would reach another row (an `@` in
the local part is a routed address: `src/trivial-rewrite/resolve.c:467`,
`src/smtpd/smtpd_check.c:1724`, `allow_untrusted_routing=no`), and the
pattern now reads addresses by Postfix's quoting all the same
(`loggedAddress`).
- *Held mail* (technical): a message the filter had held showed `queued`,
"not tried yet", for as long as it stayed held. Now `held`, a status of its
own (the CHECK of 0012, not shipped yet).
- *Rows that never close, and queue ids that repeat* (both): before writing
a message's rows the milter detaches older rows of its queue id; cleanup's
`removed (canceled)` detaches rows of a message never queued; an empty
`{i}` writes no result.
- *A bounce of null-sender mail* (technical): "The sender was sent a
bounce" is wrong there - Postfix does not bounce a bounce. The sheet says
so.
- Accepted, said in security.md: a row whose insert waited past the milter's
30 s timeout can miss its first line and stay `queued`. Not of this row:
an old send-log row of a reused queue id and the same recipient takes the
line first (sendlog.go, as before).
## Steps
- [x] Migration 0012, store: write the queue id, update by queue id and
recipient, read the result back.
- [x] Milter: the queue id and `queued` on accepted rows.
- [x] Tailer: delivery and expiry lines of port-25 mail to the journal.
- [x] View, template, help; tests (store, milter, tailer, view).
- [x] e2e: a relayed message's row shows `sent` with the sink's `250`; an
upstream that refuses connections shows `deferred` with the reason.
- [x] Docs. *Done (2026-10-16), with the reviews' findings above.*
- [x] Roll out, read the server's journal; cut `2.10.0`. *Done (2026-10-16): on the
test server as 2.9.1-post.d515493, a message to selfpost@dev.mixdep.ru through
port 25 - its row sent, mc-ent.mixfed.ru's "250 2.0.0 Ok: queued as
04F0D1023EF" on it.*
-257
View File
@@ -1,257 +0,0 @@
# Plan: panel-dkim-signing
**Status:** shipped `2.8.0` (2026-10-16); agreed 2026-10-15 - the row is agreed (owner, 2026-10-15, the
order port25-bare-newline -> base-trixie -> panel-dkim-signing); three
questions below were settled one at a time the same day.
**Date:** 2026-10-15
**Version:** `2.8.0` MINOR (after base-trixie, shipped `2.7.0`). No migration;
nothing for an operator to do.
---
## Goal
The panel signs outgoing mail itself, in its own milter, and OpenDKIM
leaves the image. The analysis ([own-mta-builds](../own-mta-builds-2026-10-15.md)
§ 3 E) and the roadmap section give the reasons: OpenDKIM is a 2018 beta
with three open 2026 CVEs and nobody upstream; every OpenDKIM limit
SelfPost works around goes with it (the syslog relay, the From: parser
port and its 256-byte refusal, the tables reloaded by a cross-uid signal
and `CAP_KILL`, keys shared through a group).
What stays the same for an operator: the keys, the selectors, the DNS
records, the panel's pages (one label on Health excepted, Q3).
## What the signer does
- **Library:** `github.com/emersion/go-msgauth/dkim` v0.7.0 (MIT; the
author of the go-milter v0.4.1 we run; the e2e suite verifies with its
v0.6.8 today). Read for this plan, `dkim/sign.go`: `NewSigner` hashes the
body as it is written (an `io.Pipe` into a goroutine), so a message is
not buffered; `HeaderKeys` is written to `h=` in the order given, each
name picked last instance first (`header.go`, `newHeaderPicker`), and a
name listed once more than it occurs is kept in `h=` with nothing hashed
for it - the over-signing RFC 6376 § 5.4.2 describes; RSA-SHA256; the
signature is the caller's to insert.
- **What is signed:** `relaxed/simple`, as `build/opendkim.conf` signs
today. `h=`: every header of the message whose name is in OpenDKIM's
`dkim_should_signhdrs[]` (`libopendkim/dkim.c:223-246`: from, reply-to,
subject, date, to, cc, the five resent-*, in-reply-to, references, the
seven list-*), each occurrence, in message order, plus `From` once more
(`OversignHeaders From` today). Absent names are not listed, as
OpenDKIM does not list them.
- **Which key:** on 465/587 the key of the domain the SASL login belongs
to - `{auth_authen}`, then `Bindings.DomainOf(login)`, the table the
sender check already reads (`internal/milter/sender.go:262-269`). For the
panel's own notifications (`sendmail(1)`, `non_smtpd_milters`) the key of
the server's name, as `EnsureServerSigned` provides it today. The sender
check has already held `From:` to that same domain, so the signature's
`d=` is the `From:` domain DMARC aligns on.
- **What Postfix hands a milter,** read in Postfix 3.10.13,
`src/milter/milter8.c:2329-2400`, `milter8_header()`: Postfix's own
`Received:` is not shown (and an inserted header at index 0 lands above
it, "relative to the message as delivered"); a multi-line header comes
as text separated by `\n`; one space after the colon is eaten unless the
milter asks for `SMFIP_HDR_LEADSPC` (`OptHeaderLeadingSpace`, which the
sender check already asks for); body records end in `\r\n`
(`milter8_body()`, :2470-2500). The signer rebuilds each header as
`Name:` + value with `\n` made `\r\n` - go-msgauth's own
`cmd/dkim-milter` writes `name + ": " + value + "\r\n"` and would sign a
folded header wrongly. A unit test signs folded, tab-led and
space-free headers and an independent verifier checks them.
- **When it cannot sign** (no key file, a login with no domain, a read
error): temporary failure, `default_action=tempfail`, as OpenDKIM is
attached today. A message is never sent unsigned by accident.
- **8-bit mail:** `disable_mime_output_conversion=yes`. Postfix converts an
8-bit message to 7-bit for a server that does not announce 8BITMIME
(default `no`, `src/global/mail_params.h:3088`), which breaks any body
hash - OpenDKIM's today as well. Not set until now; set with this row.
## Decisions settled before this plan
From the roadmap section and the analysis, agreed with the row: the
library; `relaxed/simple`; the header set and `From` over-signed; the key
by the login's domain and by the server's name for local mail; strict;
the removals below; keys, selectors and records unchanged; the e2e oracle
an independent verifier; a message to a real mailbox before the cut.
Decided here (technical, the owner may overrule):
- **The oracle is dkimpy** (`python3-dkim` 1.1.8-2 in trixie,
`/usr/bin/dkimverify`, seen in `debian:trixie` 2026-10-15) - a separate
implementation in another language, maintained, packaged by Debian; not
OpenDKIM's `opendkim-testmsg` (the code this row removes) and not
go-msgauth (the signer's own library). It runs in the e2e sink's
network against the stand's CoreDNS.
- **A cross-check before OpenDKIM goes:** on the stand of stage 1 both
sign the same messages for a while (OpenDKIM after the panel in the
chain) and the two signatures' `bh=` and `h=` are compared on every
e2e message; they must agree before stage 2 removes OpenDKIM.
- **The From: parser port goes.** It exists so that the sender check
judges the address OpenDKIM chooses the key by. With the key chosen by
the login, the check keeps `net/mail` (one RFC 5322 address at an
allowed domain) and the 256-byte refusal goes; the refusal texts that
name OpenDKIM are rewritten.
## Open questions (to the owner, one at a time)
- **Q1. Where the signer runs.** (a) Its own role in the panel process on
two sockets of its own: `sign.sock` for 465/587 (the key by the login)
and `sign-local.sock` for local submission (the server's key only), in
the chain where OpenDKIM is now: sender check, signer, journal. (b)
Inside the sender check's milter: one socket fewer, but the check would
receive every body, and local mail - which has no login - would need the
same milter to tell "no login" from "local", deciding a key by the
absence of something. **Recommended: (a)** - the socket a message comes
through, not a missing login, decides that the server's key may sign
it; the sender check stays a header-only milter; and moving the signer
into a process of its own later is a change of where it is started.
**Decided (owner, 2026-10-15): (a).**
- **Q2. The key directory's name.** `/data/opendkim/keys/<domain>/<sel>.private`
stays as it is, or becomes `/data/dkim/...` (the entrypoint moves the
tree once; KeyTable and SigningTable are deleted). A backup restores
only into the image of its own version, so no older archive meets the
new name. **Recommended: rename** - the cost is the entrypoint's move,
the paths in the code and the backup tests; a directory named after a
program the image no longer has would mislead whoever reads `/data`.
**Decided (owner, 2026-10-15): rename to `/data/dkim`.**
- **Q3. Health.** The Overview's socket check and the process list name
OpenDKIM and the syslog relay. **Recommended:** the socket row becomes
"DKIM signer" (the panel's `sign.sock`, checked the same way), the two
programs leave the process list. A mockup first, as for any page
change.
**Settled 2026-10-15:** shown to the owner as a picture; it follows from
the card's own rule (a row per milter socket Postfix needs, and the
signer is strict), so it is not his choice - the two rows as drawn.
## Stages
### Stage 1 - the signer, beside OpenDKIM (no release) - done `14de9a0`
- [x] `internal/milter/sign.go`: the session (Connect, MailFrom with
`{auth_authen}`, Header, Headers, BodyChunk, Body), `OptHeaderLeadingSpace`,
`InsertHeader(0, "DKIM-Signature", ...)` (the server's `Actions` gain
add-header), the key read per message from the key file (a small cache
keyed by path and mtime), the selector from the store as `Record` reads
it.
- [x] Unit tests: sign and verify with go-msgauth; folded and tab-led
headers; a header absent and present twice; an empty body; a body
without a final newline; a login with no domain and a missing key file
answer tempfail; the server's socket never signs with a domain's key.
- [x] `cmd/panel`: the role, its two sockets (`listenMilter`, group
selfpost, 0660), the wrapper waits for them.
- [x] `build/postfix-config.sh`: `smtpd_milters` = sender, sign, OpenDKIM,
journal (OpenDKIM kept for the cross-check); `non_smtpd_milters` =
sign-local, OpenDKIM; `disable_mime_output_conversion=yes`.
- [x] e2e: dkimpy in the stand; every positive send and the notification
verified by dkimpy against the panel's signature; the panel's and
OpenDKIM's `bh=`/`h=` compared.
*Done (2026-10-15, `14de9a0`):* e2e exit 0 on the server; on an
application's message and on a notification the panel's and OpenDKIM's
signatures agree on `h=`, `bh=`, `c=`, `s=`, `a=` and dkimpy verifies both.
Two findings while writing it: a folded header reaches a milter
`
`-separated (the signer rebuilds CRLF), and go-milter calls no Abort on a
dropped connection (a one-hour watchdog closes the signer).
### Stage 2 - OpenDKIM removed (no release) - done
- [x] Chain: OpenDKIM out of both milter lists.
- [x] Go: `internal/domain/opendkim.go` reduced to key storage (tables,
`Rebuild`, `assertConfigSafe`, the USR1 reload go), `internal/supervisor`
(its only caller is the reload), `internal/syslogrelay`,
`internal/milter/mailparse.go` and its tests and testdata, the
sender-check texts; Resync's "OpenDKIM's tables" step; Health's
liveness set and socket row (Q3); `OPENDKIM_SOCKET`/`OPENDKIM_DIR`.
- [x] Image: the `opendkim` package, `opendkim.conf`, the two supervisord
programs, the `/dev/log` link and `/run/opendkim`, `usermod -aG selfpost
opendkim`, crashexit's spared list; `deploy/docker-compose.yml`'s `KILL`
capability (its only use was the reload) - **kept, see below**.
- [x] Keys: `/data/opendkim/keys` -> `/data/dkim/keys` (Q2): the entrypoint
copies the tree once and leaves the old one for a rollback to 2.7.x until
the next release; KeyTable and SigningTable go with the new layout.
*2.9.0:* the entrypoint removes the old tree once `/data/dkim` holds a
key, and stops on a `/data` with keys under the old tree only; the copy
and the backup exclusion are gone.
- [x] Tests: `test/upstream/*.sh`, e2e's OpenDKIM log and process checks,
the 256-byte test rewritten for the new rule.
*Done (2026-10-16):* `internal/domain/keys.go` (`Keys`, `NewKeys`,
`DKIM_DIR`) keeps the keys and writes a selector table,
`/data/dkim/selectors`, a line `<name> <selector>` each, which the signer
reads instead of the KeyTable: the signer reads no database, as the sender
check reads none - the panel's one SQLite connection is shared with the
pages, and a long query would hold up mail. The table is written before the
signer serves (`EnsureServerSigned`, moved there from the notifier), so the
first start of 2.8.0 needs nothing of the operator. Keys and table are
0600, directories 0700, owned by the panel; the entrypoint copies the old
keys as the panel user (`setpriv`), into `/data/dkim.new`, renamed when
whole, and stops the start if the copy fails - else the server's name would
get a new key. Three findings: `KILL` stays - measured on the test server,
root without it cannot signal a process of another uid, and supervisord
stops the panel and Postfix's master its children that way (the plan's
"only use" was wrong); the sender check's `Allowed` took the domain through
`strings.ToLower`, which makes the Kelvin sign a "k" - the comparison with
OpenDKIM's reading had hidden it, it now folds ASCII only; the Milter
sockets card told the spam filter by its row having a note, which the
signer's rows have too. The e2e drops the OpenDKIM-only cases
(`testNeverSignedForAnother`) and checks the one signature, the tree's
modes and the cited packages without opendkim, libopendkim11 and
libmilter1.0.1.
### Stage 3 - documents, the canary, the cut
- [x] architecture § OpenDKIM -> § DKIM signing; security.md (the
mail-path bullets, the accepted risks that were OpenDKIM's); guide;
development's cited-package table (opendkim and libopendkim11 leave);
the assumptions register's header notes what left; help (design commit
first) and Health's label (Q3, mockup first).
- [x] Reviews: technical and security.
*Done (2026-10-16):* help and Health went with stage 2 (design `005922b`);
the reviews ran on stages 1-2 and their findings are fixed in `c2f63d2`,
with the first rollout's incident (the queue's owner numbers); the
documents in the commit after it. `Sender:` is held to the login like
`From:` (security review F1; the owner's decision, 2026-10-16). go-milter
v0.4.1 kept every header in a map of its own per session, on 465/587 and on
port 25 alike: the panel builds it patched not to
(`third_party/go-milter/PATCHES.md`; the owner's decision, 2026-10-16).
- [x] On the test server: a day of the owner's mail; one message from an
application through 465 and one notification to a real mailbox, the
headers read (dkim=pass with the panel's `d=`).
- [x] Cut `2.8.0`.
*Done (2026-10-16):* the owner's message through 465 (`dev/send-check.ps1`)
arrived at Gmail with `dkim=pass header.i=@carrel.mixfed.ru
header.s=selfpost`, `spf=pass`, `dmarc=pass`, and at Yandex with
`dkim=pass` and `X-Yandex-Spam: 1`; its `h=` over-signs the shown headers.
His `dev/from-check.ps1` sent thirteen `From:`/`Sender:` cases: four
accepted and delivered, nine refused as expected, each a send-log row of
reason `from` or `sender`. The notification to a real mailbox was not
sent - nothing raised one; its signature is verified by dkimpy on every e2e
run, and the server name's key, unchanged by the move (digests compared),
matched its published record on 2026-10-15. The day of mail was cut short
at the owner's word ("Выпускай", 2026-10-16), the test server having run
the stage's code since 2026-10-15 22:48 UTC. Cut as `2.8.0`, no tag.
## Risks
- **A signing defect fails DMARC for every tenant at once.** The answer is
the oracle (another implementation), the cross-check against OpenDKIM on
the same messages before it goes, and the canary before the cut.
- **go-msgauth is small and released slowly.** It can be vendored if it
ever matters; the signing path is about 300 lines of it.
- **Rollback:** until stage 2 ships, the chain change is one line; after
the cut, the previous image (2.7.x) still has OpenDKIM and reads the keys
from the old path, which the move leaves as a copy until the next release
- the keys as they were at the upgrade: a domain added under 2.8.x is not
in that tree, and 2.7.x sends its mail unsigned; one re-added under 2.8.x
has another key there. 2.7.x reads the queue as its own postfix (uid
101) before it hands it over, and 2.8.0 left it to uid 100: the guide
(§ Upgrading) gives the hand-over by name to run in the 2.7.x image first,
measured on a copy of the test server's queue. Going forward again after
a rollback, a domain added under 2.7.x has its key only in the old tree:
copy its directory into `/data/dkim/keys` (removing `/data/dkim` instead
copies the old tree again and loses the keys of domains added under
2.8.x).
-119
View File
@@ -1,119 +0,0 @@
# Plan: panel-notifications
**Status:** shipped in `2.3.0` (2026-10-12); agreed the same day - the design questions were closed with the owner and he took it into work ("Делаем")
**Date:** 2026-10-12
**Version:** `2.3.0` MINOR (one migration; opt-out by a switch).
---
## Goal
The panel writes to its administrators when something needs them, instead of
waiting to be opened.
## Decisions (owner, 2026-10-12)
### Events of the first version
Four, each of them something the panel can already tell and a person has to
act on:
| Event | Who is told |
|---|---|
| A sending domain's DNS record stopped matching (SPF, DKIM, DMARC) | global users, and `domain` users the domain is assigned to |
| A level-2 rate limit refused mail | the same, for the domain of the limit |
| The TLS certificate is about to expire | global users |
| A message has been in the queue for more than **three days** | global users |
Three days, not one (owner: "one day is not that much"): Postfix keeps a
message five, so two are left to act before it bounces. The threshold is a
constant, not a setting.
Left for later: a process or milter socket down (a relay in that state often
cannot send), an inbound upstream unreachable, DMARC failures appearing
(noisy). Mail held in quarantine is not an event: quarantine is an idea
outside the roadmap.
### Sender and path
From `postmaster@<SELFPOST_HOSTNAME>`, submitted locally. Not from one of the
sending domains (owner): with several domains the choice is arbitrary, and a
message about the certificate has no business coming from a domain.
These messages have to arrive (owner: "we need the best deliverability for
them"), so the server's own name gets what a sending domain has:
- a **DKIM key for the hostname**, generated by the panel, with which
notifications are signed - no domain has to be chosen for it. An SPF record
alone would pass DMARC, but not after the user's mailbox forwards the
message on, and large receivers treat unsigned mail more strictly;
- an **SPF record** for the hostname, `v=spf1 a -all`.
The records themselves are under **Server › Settings** (owner: "the DNS
records go to Settings, Health has only the status"), in a box of their own
drawn with the record component of a domain's DNS page: host, value to
publish, copy buttons, state. **Server › Health** has a card of its own,
*Notification mail*, with the state of each record and a link to Settings.
A DMARC record for the hostname is not asked for: the name inherits its
parent domain's policy, and with SPF and DKIM aligned it passes any.
Without the records notifications are still sent, and the card warns.
Notifications are the panel's own mail: they are not rows of the Outbound log
and count against no level-2 limit.
A relay that cannot send cannot say so; none of the four events is "the relay
is down", and that is why.
### Who chooses what
- One switch for the instance under **Server › Settings**.
- One checkbox per user under **Account**, "send me notifications" - on when
the profile has an e-mail. No choice per event: four events, none of them
noise. Per-event choice comes when there are more and some are.
- A user without an e-mail gets nothing.
### When a message is sent
A background check once an hour for DNS, certificate and queue.
| Event | Sent | "Cleared" message |
|---|---|---|
| DNS mismatch | after two checks in a row found it (it has held for an hour). A resolver that did not answer is not a mismatch | after two good checks in a row |
| Certificate | 14 days before expiry, and again 3 days before if it is still the same certificate | none |
| Level-2 limit refused mail | at the first refusal of a sending domain's applications, then at most once a day for that domain (one subject per domain, as built: what the refused row says of its domain is the client's own word, so the registry is asked) | none |
| Queue older than three days | when such a message appears | one, when none is left |
Event state is kept in the database, so a restart announces nothing a second
time.
## To settle while building
- How the panel submits: `sendmail` as the unprivileged panel user, and what
that needs of the image.
- Whether a notification carries a link to the panel - there is no setting
for the panel's own address.
- Where the refusal of a level-2 limit is noticed: in the journal milter as
it refuses, or from the `rejected` rows of the send log on the hourly check.
- Schema: the user's checkbox, the instance switch, the event state table.
## Screens
Not drawn. The Notifications box was taken out of the `account` and
`settings` mockups on 2026-10-10 (owner: 2.0 ships no box for a feature that
is not built). Drawing them again - one checkbox on Account, one switch on
Server › Settings, the SPF check on Server › Health - is the first step, shown
to the owner before any code ([panel-redesign.md](panel-redesign.md)
§ Enforcement).
## Implementation checklist
- [x] Mockups: Account checkbox, Settings switch and records, Health status card, help topic; accepted by the owner 2026-10-12 (`design:` commit)
- [x] Migration and store: user flag, instance switch, event state
- [x] Sending: local submission from `postmaster@<hostname>`, message text (`sendmail` as the panel user; signed through `non_smtpd_milters` with a key of the server's own name; no link in the text - the panel does not know its own address)
- [x] Background checker: DNS, certificate, queue; the rules of *When a message is sent*
- [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute)
- [x] Panel: Account, Settings, Health card, help
- [x] guide.md, architecture.md, security.md, schema-migrations.md
- [x] `go vet`, `go test`, e2e: a level-2 refusal reaches a mailbox as a signed notification (on the test server: vet ok, 23 packages ok, e2e ok; "once" and "clears" are held by the unit tests of `internal/notify`)
- [x] Technical review — **Opus** (2026-10-12; no Critical. Fixed: refusals counted by the registered domain and only under a limit, with an index of their own; account e-mails may be at public mail hosts; "said" means sent; DNS checks must be 45 minutes apart; the notifier's own mail is not a queue subject and a cut or unreadable listing is no listing; the tables must name the selector the panel shows; an unanswered `a`/`mx` lookup is not an SPF mismatch; a pass stops between domains at shutdown; days are rounded as a person counts. Left as found: a recipient whose copy failed while another's went out is not retried (since 2.5.4 it is: audit 2026-10-12, stage 4); a certificate that lives under 14 days is reported at each renewal)
- [x] Security review — **Fable** (2026-10-12; no Critical. Fixed: the limit notification was built on the domain a client writes in `MAIL FROM` - a mail cannon and a way to write to another tenant's users; a ceiling of 60 messages an hour; [security.md](../security.md) corrected on who can submit locally and on what the account e-mail now is. Recorded as accepted, with a roadmap candidate: an application can have a message signed as the server by writing its name into `From:` - the header is not held against the login, for sending domains either)
+21 -92
View File
@@ -1,15 +1,16 @@
# Plan: panel-redesign
**Status:** shipped in `2.0.0` (2026-10-11)
**Date:** 2026-09-21 (replaces the 2026-09-08 givens)
**Version:** `2.0.0` MAJOR — a breaking release by the owner's decision (§ No compatibility).
**Status:** the design contract of the panel; built and shipped in `2.0.0`
(2026-10-11), a breaking release by the owner's decision (§ No compatibility).
Its implementation checklist (27/27) is in the git history.
**Date:** 2026-09-21 (replaces the 2026-09-08 givens)
---
## What this file is
The design contract for the control panel and the plan to implement it. The
design itself is drawn and accepted:
The design contract for the control panel. The design itself is drawn and
accepted:
| Artefact | Role |
|---|---|
@@ -72,9 +73,9 @@ and the migration chain is deleted to zero.** The redesign is `2.0.0`.
- **Schema.** `internal/store/migrations/` is emptied and replaced by one
baseline, `0001_init.sql`, equal to the 2.0 schema (including the new
assignment table below). No historical `admin` table, no create-then-drop.
This is [schema-squash](../roadmap.md#schema-squash) with its trigger now
decided and without the upgrade gate that item planned: 2.0 starts from an
empty data directory.
This was the roadmap item schema-squash, with its trigger decided and
without the upgrade gate that item planned: 2.0 starts from an empty data
directory.
- **Docs.** `schema-migrations.md` shrinks to the 2.0 baseline; the 1.x chain
stays in git history.
@@ -145,8 +146,7 @@ shown another user's login name, and three explicit choices are simpler.
- The 2.0 baseline has `users.email`: the user's own address, edited on
**Account**, set by the global administrator on the user form and shown in
the users list. It is what the panel writes to — event notifications
([panel-notifications](../roadmap.md#panel-notifications)) and a reset link
([password-reset](../roadmap.md#password-reset)) — and what the fill button
(`2.3.0`) and a reset link (`2.14.0`) — and what the fill button
offers. **Account** is Profile and Password; Server › Settings keeps nothing
of DMARC.
- Baseline: `domains.dmarc_rua` alone (`''` = no reports). The hosted choice is
@@ -226,38 +226,24 @@ shell, and its menu entry is hidden if the page does not exist.
## Room for roadmap items
The mockups draw today's features, the four rows above, and the screens of
the open roadmap items — none of them needed a new component or layout. A
mockup of a feature is its target look, not a decision to build it: each item
keeps its own status and plan. From here on a feature's screens are drawn
The mockups drew the 2.0 pages and the screens of the roadmap items of the
time; none of them needed a new component or layout. A mockup of a feature is
its target look, not a decision to build it. A feature's screens are drawn
mockup-first in this system (`panel/src/`, `build.py`, `check.py --write`) as
the first step of that feature's plan — not invented during implementation.
the first step of that feature's work, not invented during implementation.
| Roadmap item | Where it lands | Built from | Drawn |
|---|---|---|---|
| inbound-antispam-panel (agreed, `2.1.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + two tables, `status_tag`, filter form in `box_head`; the log links *Allow / Deny sender* to the lists | `in-log`, `in-filter-lists` |
| inbound-quarantine (an idea, off the roadmap since 2026-10-12) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane` for headers only, Release beside a `sp-danger-zone` Discard | `in-quarantine`, `in-quarantine-message` — the open questions of its plan (where mail lives, what release means, retention, RBAC) stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
| preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag` and a *What to do* column, one form box for the test e-mail | `preflight` |
| panel-notifications (candidate) | one checkbox per user on **Account** (no choice per event, owner 2026-10-12); one switch under **Server › Settings** | a checkbox in a box, `help` | **no** — drawn once, taken out of `account` and `settings` on 2026-10-10 (owner): 2.0 ships no box for a feature that is not built; redrawn as the first step of its plan |
| password-reset (candidate) | a link on sign-in, two signed-out screens (request, set new password) | the signed-out layout | **no** |
| delivery-log-storage (candidate) | the *Delivery log* box of **Outbound › Log › message**, filled from a table instead of a grep of today's `mail.log` | `log_pane`, unchanged | `out-message` |
| csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a |
| template-data-typing | no screen; the component partials are the natural first users of typed data | — | n/a |
| structured-logging, contributing, review follow-ups | none | — | n/a |
Drawn and not built (`deferredScreens` in the guard tests):
| Screen | Item | State |
|---|---|---|
| `in-queue` | [queue-table](../roadmap.md#queue-table): the queue as a table from `postqueue -j`, and an Inbound › Queue | undecided; Outbound › Queue shows postqueue's listing in a log pane (`outlineExempt`) |
| `in-quarantine`, `in-quarantine-message` | inbound-quarantine, an idea off the roadmap ([its notes](inbound-quarantine.md)) | its open questions stay open; the mockup assumes SelfPost-held mail, release to upstream, 14 days, delegated like the rest of Inbound |
| `preflight` | preflight as a page of its own, dropped 2026-10-13 ([roadmap](../roadmap.md#dropped-with-the-condition-for-return)); its checks went to Server › Health, the test e-mail to its own form | a leftover of a dropped item |
The sibling strip under the navbar holds five or six entries per group without
wrapping; Inbound would reach five (Domains · Log · Queue · Quarantine ·
Filter lists), Server six. Past that a group is split rather than squeezed.
Ordering with 2.0 (owner, 2026-10-09: feature order is not fixed, take
whatever suits development): **the redesign ships first as `2.0.0`**, and
inbound-antispam-panel follows as `2.1.0`, built from the component kit on
the 2.0 baseline. The other way round would have styled two screens twice and
written a 1.x migration only to dissolve it into the baseline. The antispam
back end (journal milter, log tailer, list CRUD, rspamd map sync) touches
nothing under `internal/web/view` and may be built alongside stages 0–1 below
if time allows; its two screens wait for the kit.
## Three layers
1. **Bulma, as shipped.** Vendored `bulma.min.css`, never edited. Only the
@@ -485,60 +471,3 @@ reviewed.
- Dark scheme and the narrow-screen shell: Bulma ships both; they are drawn
and agreed as a `design:` change before anyone implements them.
- Weakening CSP; a JS bundler; a front-end framework.
## Implementation checklist
Roadmap status: **agreed** (owner, 2026-10-09). Stages are ordered for
development, not by feature priority (owner, 2026-10-09). Stage 1 touches no
file under `internal/web/view` and may run alongside stage 0; stage 2 starts
only when both are done. Every stage-2 step ends with the evidence of
§ Evidence for "done".
**Decided**
- [x] Owner decision recorded here: keep today's paths or rename with redirects — **owner** (2026-09-21: rename, § Routes)
- [x] Owner decision: no compatibility, migrations to zero, `2.0.0` — **owner** (2026-09-21, § No compatibility)
- [x] Owner decision: inbound is delegated per user, separately from outbound — **owner** (2026-09-21, § Who sees what)
- [x] Owner decision: the redesign ships before inbound-antispam-panel; feature order follows development convenience — **owner** (2026-10-09, § Room for roadmap items)
- [x] Decision inside 2.0: **Server › Health** is built with the restyle (the tables that leave Overview need a home, § Routes); **queue as a table** and the **inbound queue** stay roadmap decisions — both queue pages ship old content in the new shell, the Inbound › Queue entry hidden — (2026-10-09)
**Stage 0 — foundation and component kit** (§ Component kit)
- [x] Vendor Bulma 1.0.4 and the icon subset (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku**
- [x] Guard tests from § Enforcement and `TestRoutesFollowNavigation`, red against today's panel where expected, with the `legacyPages` ratchet listing every page; `design-first` CI step including the ratchet and the history-blob check (§ Against resurrected pages) — **Opus**
- [x] `panel.css` from `panel/theme.css` + `shared/brand.css`; `components.html` partials with typed inputs in `components.go`; `status_tag`, `copy_field`, `wbr_at` — **Sonnet**
- [x] `layout.html`: navbar, perforated edge, sibling strip, user menu, footer, visibility flags; the signed-out shell — **Sonnet**
- [x] `GET /server/components` behind the global role rendering every partial from fixtures; kit acceptance (§ Component kit) with evidence — **Sonnet**, reviewed by **Opus**
**Stage 1 — data and routes** (no template work; parallel with stage 0)
- [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus**
- [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus**
- [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
- [x] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus**
- [x] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus**
**Stage 2 — pages, in groups** (each step: partial calls only, page off the ratchet list, evidence pairs)
- [x] Signed-out pages: login, setup — **Sonnet**
- [x] Overview, Health, and the Account / Settings split — **Sonnet**
- [x] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
- [x] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [x] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet**
- [x] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet**
- [x] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet**
- [x] Remove the old `panel.css`, the drawer code in `panel.js` and every class nothing references; `legacyPages` empty, vocabulary test green with no allow-list — **Sonnet**
**Stage 3 — reviews, docs, 2.0.0**
- [x] Technical review of the whole against the mockups, evidence pairs complete — **Opus**
- [x] Security review of the route rename, subtree authorization and inbound delegation — **Fable**
- [x] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet**
- [x] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku**
**After 2.0** — features land in the kit, each from its own plan, mockup first
(§ Room for roadmap items): inbound-antispam-panel `2.1.0`; then, in whatever
order suits development, preflight, delivery-log-storage, panel-notifications,
password-reset (two screens to draw first), queue as a table with the inbound
queue, csrf-tokens
(one hidden field in the form partial once security.md is settled).
inbound-quarantine is an idea outside the roadmap.
+1 -1
View File
@@ -1,6 +1,6 @@
# SelfPost — product boundaries
**What this file is.** Stable product definition for SelfPost v1.0: purpose,
**What this file is.** Stable product definition of SelfPost: purpose,
deployment assumptions, explicit out-of-scope items, and the multi-domain
model. User-facing overview in [README.md](../README.md); install and operations
in [guide.md](guide.md); as-built technical detail in
+78 -937
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -17,8 +17,8 @@ When you add or review a migration, update this file in the same change.
| Field | Value |
|---|---|
| Chain head | `user_version = 13` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql`, `0012_inbound_upstream.sql` |
| Chain head | `user_version = 14` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql`, `0006_sender_check.sql`, `0007_delivery_log_days.sql`, `0008_tenant_boundary.sql`, `0009_refusal_line.sql`, `0010_send_log_indexes.sql`, `0011_notify_addressees.sql`, `0012_inbound_upstream.sql`, `0013_email_links.sql`, `0014_upstream_tls_secure.sql` |
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
| Database file | `/data/selfpost.db` (bind mount) |
| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
@@ -91,6 +91,7 @@ audit (`2.5.0`), `0009_refusal_line.sql` (`2.5.1`) and
| 11 | `0011_notify_addressees.sql` | 2.5.4 | DDL | `notify_addressees (key, address, told_at)`: who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its `notify_state` row |
| 12 | `0012_inbound_upstream.sql` | 2.10.0 | DDL | `inbound_spam_log` gains `queue_id`, `upstream_status` (`''`, `queued`, `held`, `sent`, `deferred`, `bounced`; CHECK), `upstream_relay`, `upstream_reply` and `upstream_at`, and the partial index `idx_inbound_spam_log_queue_id` on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none |
| 13 | `0013_email_links.sql` | 2.14.0 | DDL | `users.email_confirmed_at` (`''` for every address saved before it: all unconfirmed) and `user_links`, the one-time links that confirm an address and reset a password, with `idx_user_links_user` (password-reset; owner, 2026-10-13 and 2026-10-17) |
| 14 | `0014_upstream_tls_secure.sql` | 2.15.0 | DDL + data | `inbound_transports.tls_mode` may be `secure` (Verified: Postfix's level that checks the upstream's certificate against its name; upstream-tls-verify, security audit 2026-10-17, M7). SQLite cannot alter a CHECK, so the table is made anew with the wider one and its rows copied |
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 2.x images.
@@ -113,7 +114,7 @@ for operators upgrading from older 2.x images.
| `send_log` | Delivery journal. `reason` says why a row was refused (`limit`, `client`, `from`, and `sender` since 2.8.0) or that the sweep closed it (`reconcile`, `reconcile-contradicted`), `header_from` what the From: or Sender: line of a message refused for it said (v6), `refusal_line`, `client_ip` and `recovered` (v9), and `domain_id` (v8: the row belongs to a domain by id, `ON DELETE SET NULL`) |
| `logtail_state` | Log-tailer read position |
| `inbound_domains` | Inbound relay domains |
| `inbound_transports` | Upstream host / port / TLS per inbound domain |
| `inbound_transports` | Upstream host / port / TLS per inbound domain; `tls_mode` is `may`, `encrypt`, `secure` (v14) or `none` |
| `inbound_recipients` | Allow-list when `recipient_mode = list` |
| `user_inbound_domains` | Inbound domains assigned to a `domain` user — granted separately from the outbound ones |
| `dmarc_reports` | Parsed aggregate report summaries |
+6
View File
@@ -1,5 +1,11 @@
# Security audit — 2026-10-17
> **Stages 0-6 shipped in `2.14.4` (2026-10-17)**, the product side of S3
> and the fix of M7 in `2.15.0` (2026-10-18). Stage H, the owner's host and
> DNS, is open and tracked in [roadmap.md](roadmap.md#security-audit-2026-10-17).
> The file stays because the code cites its finding ids (`A2`, `M7`...); its
> `file:line` references are of the tree at `44bf09d`.
**What this is.** A full security audit of SelfPost at `44bf09d` (release
`2.14.3`): the code tree, the image and its process model, the deployment
fragments, CI/CD and the supply chain, the dependencies, and the live
+14 -22
View File
@@ -365,8 +365,8 @@ operator can act on it.
MIME boundary and the signature still verified), then once more each of the shown ones present
(Subject, Date, To, Cc, Reply-To, Sender) and `From` last, so one of those
added after signing breaks the signature. Until `2.8.0` OpenDKIM signed - a
2018 beta with three 2026 CVEs open and nobody upstream to fix them
([own-mta-builds](own-mta-builds-2026-10-15.md) § 1) - and chose the key
2018 beta with three 2026 CVEs open and nobody upstream to fix them - and
chose the key
by the `From:` header; it left the image, and with it the syslog relay and
`/dev/log`, the port of its `From:` parser, its tables and their reload by
a signal across uids. Architecture § DKIM signing.
@@ -445,8 +445,7 @@ operator can act on it.
and later; `src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`), so
they end DATA the same way; the port-25 setting stays explicit. Before
`2.7.0` a smuggled transaction on a submission port was still the
authenticated session's, under its sender bindings and milters
([own-mta-builds](own-mta-builds-2026-10-15.md) § 5).
authenticated session's, under its sender bindings and milters.
- **The panel's milters judge every message of a connection.** Postfix
introduces a client to a milter once per connection, and the milter library
replaces the milter's session after any reply that is not "continue". Until
@@ -651,7 +650,7 @@ deferred item from the roadmap.
templates must contain no inline scripts and no inline styles. Decided by
the owner on 2026-10-13: no tokens; the roadmap item that asked for them is
dropped and the origin check is made strict instead
([origin-strict](roadmap.md#origin-strict)). This bullet and the ADR below
(origin-strict, `2.10.4`). This bullet and the ADR below
are the single record of the decision, and the ADR's **Revisit if** says
when tokens are built after all.
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
@@ -872,7 +871,8 @@ deferred item from the roadmap.
seconds or the whole instance; Server › Health counts the
downtime and the mail deferred for it. Trigger to revisit: those numbers
not staying at nothing - then the check moves into a process of its own
([plans/from-header-binding.md](plans/from-header-binding.md)).
(a subcommand of the panel binary and a `[program:]` of supervisord; the
downtime figure then needs a new meaning).
- **A malformed packet on a milter socket of the panel ends the panel.** The
milter library indexes into an empty packet and nobody recovers the panic,
so the process exits and supervisord starts it again. The sockets are
@@ -956,21 +956,13 @@ deferred item from the roadmap.
uses with another mode is refused and told that mode - not the domain -
so the form tells whether some domain forwards to a given host and port.
Accepted with the rest of this item.
- **Account's profile (username, e-mail) is saved without the password.**
Saving the profile does not ask for the current password, as changing the
password does. Since 2.3.0 the e-mail is where notifications go, so a stolen
session can turn them to another address: the thief then learns what they
say - which domain's DNS is wrong, which application is refused, when the
certificate expires, sender addresses of old queued mail - and the user
stops hearing it. Nothing in a notification gives access. A `domain` user
can likewise point signed mail of this server at a third party's address,
a dozen messages a day per assigned domain at most. The profile form also
says when a username is taken, so a signed-in user can tell which
usernames exist; signing in does not (`2.5.4`). It
becomes a requirement to ask for the current password when changing
`users.email` as soon as [password-reset](roadmap.md#password-reset) mails a
link to it — from then on whoever can change the address could take the
account over.
- **Account's username is saved without the password.** Changing the
e-mail on Account asks for the current password since `2.14.0`, on the
password change's limit: it is where a reset link goes, so whoever could
change it could take the account over. The username and the notification
switch are saved without it. The profile form says when a username is
taken, so a signed-in user can tell which usernames exist; signing in does
not (`2.5.4`).
## ADR: CSRF via origin checking, without tokens
@@ -1023,7 +1015,7 @@ external-site case this ADR covers.
**Decision of 2026-10-13 (owner).** Asked to choose between this ADR, tokens,
and a strict origin check, the owner chose the ADR with the strict check
([origin-strict](roadmap.md#origin-strict)) and no tokens. Session-bound tokens
(origin-strict, `2.10.4`) and no tokens. Session-bound tokens
are the answer to return to - a hidden field in every state-changing form and
htmx path, checked against the session, on top of the origin check - when
either condition above comes true, and also when: a browser the panel must
+3 -3
View File
@@ -340,9 +340,9 @@ type Usage struct {
// counts for every entry of the deciding list that fits it: the journal does
// not say which of two fitting entries the filter met first.
//
// A counter kept with each entry would not depend on the journal; the plan
// (docs/plans/inbound-antispam-panel.md, Filter list usage) says when that is
// worth a migration.
// A counter kept with each entry would not depend on the journal;
// docs/architecture.md (the antispam filter lists) says when that is worth a
// migration.
func (l *Lists) Usage() (map[int64]Usage, error) {
entries, err := l.store.ListSpamListEntries()
if err != nil {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
// The sender check is attached to Postfix strictly: while the panel does not
// answer on its socket, mail is deferred (docs/plans/from-header-binding.md).
// answer on its socket, mail is deferred (docs/architecture.md § Sender check).
// How many submissions that happened to is read here, from what Postfix
// writes about it, and shown on Server > Health. It is the figure that also
// sees a panel which is running and not answering.
+1 -1
View File
@@ -22,7 +22,7 @@ import (
// The sender check: a message's From: line is held against the application
// that sends it, under the rule Postfix holds the envelope sender to
// (docs/plans/from-header-binding.md). The signer signs with the key of the
// (docs/architecture.md § Sender check). The signer signs with the key of the
// domain the login belongs to (sign.go), and a receiver holds that signature
// to the domain of this line (DMARC, RFC 7489 § 3.1.1), so without the check
// an application could send under the server's signature as another domain
+1 -1
View File
@@ -1,6 +1,6 @@
package milter
// The DKIM signer (docs/plans/panel-dkim-signing.md): the panel's own milter
// The DKIM signer (docs/architecture.md § DKIM signing): the panel's own milter
// that signs what applications send on 465/587 with the key of the domain
// their login belongs to, and what the panel itself sends with the key of
// the server's name. Each of the two is a socket of its own, so the socket a
+1 -1
View File
@@ -5,7 +5,7 @@
//
// Each subject has a row of state in the database, so a thing is said once and
// a restart says nothing again. The rules of when a message goes out are the
// owner's (docs/plans/panel-notifications.md § When a message is sent) and
// owner's (docs/architecture.md § Notifications) and
// live in this file; what the messages say is in text.go, how they leave in
// mailer.go.
package notify
@@ -13,7 +13,7 @@ import (
"github.com/mixeme/selfpost/internal/web/auth"
)
// The panel pages of notifications (plans/panel-notifications.md): the user's
// The panel pages of notifications (docs/architecture.md § Notifications): the user's
// checkbox on Account, the instance's switch and the DNS records of the server's
// own name on Server › Settings, and the state of those records on Server ›
// Health. What is sent and when is internal/notify's and tested there.
+3 -2
View File
@@ -88,8 +88,9 @@ func verifyDKIM(raw []byte, domain string) error {
return nil
}
// checkPanelSignature is the oracle of docs/plans/panel-dkim-signing.md: the
// message carries one DKIM signature, the panel's, for domain - signed as
// checkPanelSignature is the oracle of the panel's DKIM signer
// (docs/architecture.md § DKIM signing): the message carries one DKIM
// signature, the panel's, for domain - signed as
// OpenDKIM signed until 2.8.0 (relaxed/simple, rsa-sha256, From over-signed)
// - and dkimpy (python3-dkim in the sink image), an implementation that is
// not go-msgauth, the panel's library, verifies it. Stage 1 compared it with
+1 -1
View File
@@ -54,7 +54,7 @@ var port25Cases = []port25Case{
// The SMTP-smuggling defence. Postfix 3.10.13 has it by default
// (src/global/mail_params.h, DEF_SMTPD_FORBID_BARE_LF "normalize");
// set explicitly since 2.6.1, when the image ran Postfix 3.7.11,
// whose default was "no" (own-mta-builds, § 5), and kept so the
// whose default was "no" (docs/security.md § Mail path), and kept so the
// listener does not depend on the default. testInboundBareNewline
// drives it.
{param: "smtpd_forbid_bare_newline", contains: "normalize", wantExact: true},
+1 -1
View File
@@ -4,7 +4,7 @@
# the panel's signer added) straight off disk. smtp-sink ships in the postfix
# package itself. python3-dkim (dkimpy 1.1.8-2 in trixie, /usr/bin/dkimverify)
# is the independent DKIM verifier the suite holds the panel's signer to
# (docs/plans/panel-dkim-signing.md); it looks the keys up through the
# (docs/architecture.md § DKIM signing); it looks the keys up through the
# stand's CoreDNS (compose.override.yml, sink's dns).
FROM debian:trixie-slim