Step 3/12 of docs/plans/inbound-antispam-panel.md.
The panel serves a second milter while the inbound relay is on
(internal/milter/inbound.go, socket INBOUND_JOURNAL_MILTER_SOCKET,
default /run/selfpost/inbound-journal.sock). build/postfix-config.sh
puts it last on port 25, after the optional antispam milter, with
default_action=accept; postfix-wrapper.sh waits for its socket. At
end-of-message it writes one inbound_spam_log row per recipient at an
inbound domain - other port-25 recipients (DMARC report addresses) get
none - and always accepts. With a filter attached it reads the verdict
from X-Rspamd-Action and X-Spamd-Result (action, score, up to six
weighted symbols, only what the patterns recognise, 255 bytes); without
one those headers are the sender's and are not read. The plan named
X-Rspamd-Score, which rspamd does not have.
The antispam compose fragment mounts rspamd/milter_headers.conf into
the sidecar so that it adds the two headers (checked in rspamd 3.11:
both routines exist and remove a sender's own headers of those names).
Store: InsertInboundSpam, IsInboundDomain.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the relayed message of
inbound_relay_delivers_and_refuses has its journal row (accept by
selfpost) and the DMARC report sent through the same listener has none.
Not yet seen in a real run: a verdict written by rspamd reaching this
milter. The e2e stand has no filter, and the test server's only inbound
domain has no upstream, so it accepts no mail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security review of the DMARC report path (Fable, 2026-10-11), which ran
for the first time in 2.0.0. No Critical or High finding. Fixed:
- Medium: a report was bounded only as 8 MiB of XML - some 160 000
records or one name of megabytes, all of it rows and table cells. At
most 5 000 records (more is ErrRejected, dropped), text fields cut to
255 bytes on a character boundary, a record's count capped.
- Medium: the cap of 500 reports was for the whole server, so a stranger
filing under one domain's address evicted every other domain's
reports. PruneDMARCReports counts per domain.
- Low: the pipe service ran up to default_process_limit (100) commands
at once against the panel's database; maxproc is 1.
- Low: the reason logged for a dropped message carried the sender's
bytes (header line, zip entry name) as they came; cut to 200 bytes and
reduced to printable ASCII, zip entry names quoted.
- Low: SELFPOST_DATA_DIR / SELFPOST_DB_PATH with whitespace or "$" would
reach the command through export_environment as another path; refused
at start when DMARC ingest is on.
Left as they are, told to the owner: the parse-failure count is a
lifetime figure shown under "This week"; a report larger than the
command reads is dropped as unreadable; nested multipart is not searched.
The hub's Retention line follows the design commit before this one.
security.md: the review, and the accepted-risk entry says what a
stranger can and cannot do now. The roadmap no longer calls the review
pending.
go vet ./cmd/... ./internal/dmarc/ ./internal/store/: ok
go test ./cmd/... ./internal/dmarc/ ./internal/store/ ./internal/web/...: ok
make e2e on the test server (with the port-25 scenarios of the next
commit): ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first DMARC report to reach the test server (Google, 2026-10-11
09:41 UTC) was accepted on port 25 and then sat in the queue:
postfix/pipe: fatal: missing user= command-line attribute
postfix/qmgr: warning: private/dmarc-ingest socket: malformed response
Three things stood between an accepted report and the panel, all since
the feature shipped in 1.7.0 - no report had ever been delivered:
- The service was addressed as dmarc-ingest/unix-pipe. Its type is unix:
postconf -P answered "unmatched service_name/type" for flags=, user=
and argv= (the warnings in the container log at every start), which
are arguments of pipe(8) anyway and not -o parameters, and the service
stayed a bare "pipe". For the same reason postconf -MX never removed
it. It is dmarc-ingest/unix now, with the arguments in the service line.
- pipe(8) gives the command export_environment and nothing else, so the
command would have refused to run: it reads DMARC_REPORTS_ENABLE (and
the data directory, when it is not the default) from the environment.
Those are now named in export_environment.
- The command took the recipient from RECIPIENT, which local(8) sets and
pipe(8) does not. With no recipient the check that a report came to the
address of its own domain is skipped. It is now ${recipient} in argv,
one recipient per run.
When DMARC ingest is off the service and both parameters are removed.
e2e: every port-25 combination now asks Postfix for the service and the
two parameters (postconf -M / -h), and for their absence when ingest is
off - which is how the wrong type was found. make e2e on the test
server: ok. go test ./cmd/panel/ ./internal/dmarc/ ./internal/postfix/:
ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by talking SMTP to port 25 of the test server once it answered
again (2026-10-10): RCPT TO:<dmarc-reports+bs.mixdep.ru@selfpost.mixfed.ru>
was refused with "550 5.1.1 User unknown in relay recipient table".
With INBOUND_RELAY_ENABLE and DMARC_REPORTS_ENABLE both true the server's
own name is a relay domain (for the reports) and relay_recipient_maps
lists the relay's recipients only. smtpd_reject_unlisted_recipient
applies after the restriction list, so the OK that
check_recipient_access gives a report address does not help: every
report was refused. The table of report addresses is added to
relay_recipient_maps in that combination.
Checked on the server by setting the parameter in the running container
and reloading Postfix: the domain's report address is answered 250, the
former server-wide mailbox and an unknown local part 550, a foreign
domain and an inbound domain without recipients 554. e2e: the combined
case requires relay_recipient_maps to name both tables.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seen in the System log of the test server (2026-10-10): on every
connection to port 25
postfix/smtpd: fatal: unexpected command-line argument:
texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,...
postfix/master: warning: .../smtpd: bad command startup -- throttling
With DMARC_REPORTS_ENABLE=true the script wrote
-o smtpd_recipient_restrictions=check_recipient_access texthash:...,...
into master.cf. Master splits a service line on whitespace, so smtpd got
the option cut at the space plus a stray argument and died at start:
port 25 greeted nobody - no inbound relay, no DMARC reports. postconf -P
smtp/inet/smtpd_recipient_restrictions showed the cut value,
"check_recipient_access". The server's logs have it under the previous
image too (2026-10-09), so it is not from the redesign; postfix check
does not notice, and the e2e check compared the text of master.cf with
an expectation that had the space in it.
- The restriction list is written with commas only (Postfix splits it
on commas as well as whitespace).
- master_cf_set_smtp_inet_o stops the container on a value with
whitespace instead of writing it.
- e2e: for every option it checks, the port-25 case also asks
postconf -P and requires Postfix to read the same value.
Not fixed here: Postfix's start-time warnings "not owned by root" for
the queue directory and its pid/ - the entrypoint's recursive chown
again; harmless, logged at every start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
- SQLite migration `0010_send_log_app_login_index.sql` —
- DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
- e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
- README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
- The setup account's password is hashed at bcrypt cost `12` like every other
- Login/setup rate limiter: protecting blocked buckets from eviction is now
- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
- [schema-migrations.md](docs/schema-migrations.md): the schema section was
- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
… (11 more in CHANGELOG)
Changed:
- [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
- Extracted `internal/configsafe` — the empty-value and forbidden-character
- Build, deploy and migration comments no longer cite the archived
- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
… (7 more in CHANGELOG)
Removed:
- Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
Full detail: CHANGELOG.md (1.9.2).
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam milter are enabled. Schema-migration reference and roadmap plans for inbound 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-quarantine.md](docs/plans/inbound-quarantine.md) — candidate
- [docs/schema-migrations.md](docs/schema-migrations.md) — living reference for the
Changed:
- [docs/roadmap.md](docs/roadmap.md) — `inbound-antispam-panel` (agreed, 1/12)
- [docs/development.md](docs/development.md) — plan checklists may override
- [docs/development.md](docs/development.md), [docs/roadmap.md](docs/roadmap.md),
Fixed:
- `build/postfix-config.sh`: enabling inbound relay, DMARC ingest, and/or the
Full detail: CHANGELOG.md (1.9.1).
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse gzip/XML into SQLite, and show summaries in the panel.
Added:
- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25
- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled
Full detail: CHANGELOG.md (1.7.0).
Optional inbound relay (backup-MX / forwarder) on port 25, off by default. Mail is accepted only for domains configured in the panel and forwarded to an upstream; there are still no mailboxes. A dedicated security review of the inbound path is still open on the plan (Fable).
Added:
- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
- Panel **Inbound** pages (global administrator): domains, upstream host/port
- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
Changed:
- Compose publishes host port 25 even when the flag is off (nothing listens
- Full-backup restore Resync also rebuilds inbound maps when the flag is on.
Full detail: CHANGELOG.md (1.4.0).
[1.3.0]
Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration.
Added:
- licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
- docs: agreed roadmap item **queue-retries** — show this Postfix's retry
Security:
- The independent security review of the send-log authorization and
Fixed:
- test (e2e): send-log status scrapers follow the badge markup in
- security (panel): the Deliveries list is scoped to a domain administrator's
- mail (level-2 rate limit): the ceiling is no longer overshot by messages that
- panel (sign-in): a session that cannot be written to the database no longer
- panel (applications): deleting an application removes its SASL credentials
… (2 more in CHANGELOG)
Changed:
- docs: operator and as-built docs aligned with the code after a full
- docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
- docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7)
- licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
- ci: the release image is published only for a **published** GitHub Release
… (9 more in CHANGELOG)
[1.3.1]
Retry policy in the panel, persistent Postfix queue, and self-contained full backups. Mail queue and delivery history show this Postfix's first retry, backoff cap, and queue lifetime. Deferred mail survives container recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; the archive layout breaks 1.3.0 flat backups (unpack those with `tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a tag bump; no schema migration.
Added:
- Mail queue and a delivery's history show this Postfix's retry policy
- docs: **Plan checklists** in [development.md](docs/development.md) — format,
- docs: **Implementation checklists** with model routing in
- docs: roadmap candidates **send-log-retention** (panel Settings for delivery
- Postfix queue under `/data/postfix/queue` — deferred and active mail survive
… (1 more in CHANGELOG)
Changed:
- **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
- docs: operator guide, architecture, security, and backup UI updated for
- docs: HTML mockups for a full panel UI refresh
Changed:
- docs: panel UI mockups after review — Status restored to live readings
- docs: panel UI design system and mockups rebuilt as separate pages
- docs: panel UI mockups — add-domain sits in the list card (Domains and
Fixed:
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
Squashed CHANGELOG sections: [1.3.0], [1.3.1].
Full detail: CHANGELOG.md (1.3.0, 1.3.1).
[1.0.0]
Added:
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
- A **History** block on the same page: the journal's two timestamps stated as
Fixed:
- Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
- Postfix config: allow `maillog_file` under `/data` via
- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
… (3 more in CHANGELOG)
Changed:
- Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
- Docs aligned with the code: setup URL is `/setup/<token>` (README and
- Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
- Monitoring screens (status, mail queue, system log, deliveries) use adaptive
- Documentation package consolidated into `docs/development.md`: Documentation
… (4 more in CHANGELOG)
[1.0.1]
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added:
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
Changed:
- The panel's **Account** entry is now called **Settings** — nav link, page
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
- [docs/product.md](docs/product.md) reframes the future line: the inbound
- [docs/security.md](docs/security.md) is now in English, matching the rest of
- The README documentation table now points at `SECURITY.md` for reporting a
… (2 more in CHANGELOG)
Fixed:
- The panel's static assets are served with a content ETag and
Squashed CHANGELOG sections: [1.0.0], [1.0.1].
Full detail: CHANGELOG.md (1.0.0, 1.0.1).
A bounce could be recorded as a successful delivery. The log-tailer's
Fixed:
- A bounce could be recorded as a successful delivery. The log-tailer's
- Log-tailer resumes where it stopped instead of jumping to end-of-file on
- Level-2 rate limit no longer overshoots under concurrency: messages that
Changed:
- The project has a single public home: `github.com/mixeme/selfpost`. Codeberg
- Code comments no longer cite the archived specification. References like
- `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the
- `docs/architecture.md` gained a *Code layers* section: a diagram of
- Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale
… (1 more in CHANGELOG)
Security:
- Pre-release security review (plan § D, model Fable, 2026-08-06): full pass
- `saslpasswd2` argv: the application login is now passed after a `--`
Added:
- Optional password encryption for the two secret-bearing downloads (plan
- Domain import accepts an encrypted export: the envelope is detected by its
- `selfpost-backup` writes encrypted archives and reads them back:
- `TestParseDelivery` covers the exotic mail.log shapes the review asked for
- docs: README *Encrypting a backup or export*; `docs/security.md` §
… (14 more in CHANGELOG)
Removed:
- docs: `security.md` — accepted risk «restore old backup revives session rows»
Changed:
- docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release
- docs: `architecture.md` — sessions (SQLite, idle renew, password change),
- docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow.
- docs: `security.md` — accepted risk for send-log rows stuck at `queued` after
- docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail.
… (10 more in CHANGELOG)
Full detail: CHANGELOG.md (0.5.0).
Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`).
Added:
- Panel (Go, single static binary) with SQLite persistence, one-time
- Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
- Application (sender identity) management: SASL credentials via `sasldb2`,
- Full Postfix relay config generated from env at container start: SMTPS 465,
- Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
… (6 more in CHANGELOG)
Full detail: CHANGELOG.md (0.1.0).