docs: align operator and as-built docs with code after 1.9.1.
test / test (push) Waiting to run

Port 25, DMARC routes, restore Resync vs reload, image pin, and roadmap
index were stale relative to the running panel and Postfix config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 00:54:11 +03:00
parent 4a51db277b
commit 72126b63af
7 changed files with 111 additions and 55 deletions
+13
View File
@@ -5,6 +5,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Changed
- Operator and as-built docs aligned with the code after a full pass —
[architecture.md](docs/architecture.md) (port 25 opens for inbound relay
and/or DMARC ingest; antispam requires inbound relay; DMARC pipe transport
and map paths; `/help` and `/dmarc*` routes; four panel roles including
rate-limit recalc; restore Resync vs manual reload); [guide.md](docs/guide.md)
and [README.md](README.md) (image pin `1.9.1`, port 25 conditions, combined
listener limits, reload vs Resync); [roadmap.md](docs/roadmap.md) (`panel-docs`
closed at `[1.8.0]`, next item inbound-antispam-panel); [development.md](docs/development.md)
version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md)
last-updated note. No behaviour change.
## [1.9.1] - 2026-08-19 ## [1.9.1] - 2026-08-19
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam
+7 -3
View File
@@ -24,6 +24,9 @@ send log and DNS checks in the panel, encrypted backups.
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing - Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default) - Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help drawer and `/help` page for Status and domain controls
- Per-application client IP allow-list for SMTP AUTH (optional)
- Web panel — domains, applications, deliveries, mail queue, system log, backup - Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain - Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
@@ -108,7 +111,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.7.0 ghcr.io/mixeme/selfpost:1.9.1
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
@@ -149,8 +152,9 @@ deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file always publishes **465**, **587**, and **25**; Postfix listens The compose file always publishes **465**, **587**, and **25**; Postfix listens
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 only when on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
`INBOUND_RELAY_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the `INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_ENABLE=true` (see
[Ports](docs/guide.md#ports)). Bump the
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables). variables](docs/guide.md#environment-variables).
+51 -24
View File
@@ -27,7 +27,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
| Program | User | Priority | Role | | Program | User | Priority | Role |
|---|---|---|---| |---|---|---|---|
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter | | `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine | | `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc |
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist | | `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | | `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs | | `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
@@ -54,30 +54,40 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
├─► journal-milter (send log + L2 rate limits, fail-open) ├─► journal-milter (send log + L2 rate limits, fail-open)
└─► outbound MX delivery (port 25 client) └─► outbound MX delivery (port 25 client)
Internet ──► Postfix smtp inet :25 (only when INBOUND_RELAY_ENABLE=true) Internet ──► Postfix smtp inet :25 (when INBOUND_RELAY_ENABLE=true
│ and/or DMARC_REPORTS_ENABLE=true)
├─► optional antispam milter (inbound only) ├─► optional antispam milter (inbound relay only)
─► smtp:[upstream]:port (transport_maps; no local delivery) ─► DMARC aggregate ingest (pipe → dmarc-ingest)
└─► smtp:[upstream]:port (inbound relay only;
transport_maps; no local delivery)
``` ```
The inbound listener is **absent** when the flag is off (`postconf -MX smtp/inet` The smtp/inet listener is **absent** when both flags are off (`postconf -MX
removes Debian's stock smtpd). Outbound delivery still uses the `smtp unix` smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
client; it is not the same service. `smtp unix` client; it is not the same service.
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh)) ### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
- **465/smtps** — implicit TLS, SASL required; primary listener. - **465/smtps** — implicit TLS, SASL required; primary listener.
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with - **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`smtpd_tls_security_level=encrypt`. `smtpd_tls_security_level=encrypt`.
- **25/smtp inet** — only when `INBOUND_RELAY_ENABLE=true`. No SASL, no - **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
OpenDKIM, no journal-milter. Accepts only `relay_domains` + `DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter.
`relay_recipient_maps` (`reject_unauth_destination`, **Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only
`reject_unlisted_recipient`). Maps under `/data/postfix/` `relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`,
(`relay_domains`, `transport`, `relay_recipients`, `tls_policy`), written `reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`,
atomically by [internal/postfix/inbound.go](../internal/postfix/inbound.go). `transport`, `relay_recipients`, `tls_policy`), written atomically by
Domains with an empty upstream host are omitted from the maps. Optional [internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with
`INBOUND_ANTISPAM_MILTER` on this listener only; default an empty upstream host are omitted from the maps. Optional
`milter_default_action` is fail-open (`accept`). `INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
DMARC-only port 25); default `milter_default_action` is fail-open
(`accept`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
on, one listener serves both paths; per-IP rate uses
`INBOUND_RATE_LIMIT_MESSAGES_PER_IP` and message size uses the greater of
`INBOUND_MESSAGE_SIZE_LIMIT` and `DMARC_MESSAGE_SIZE_LIMIT`.
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`; - **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. `smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` / - **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
@@ -93,7 +103,7 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
### Panel binary ([cmd/panel](../cmd/panel)) ### Panel binary ([cmd/panel](../cmd/panel))
One process, three roles: One process, four roles:
1. **HTTP server**`:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse 1. **HTTP server**`:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only. On start it runs `postconf -h` once for the deferred-mail retry proxy only. On start it runs `postconf -h` once for the deferred-mail retry
@@ -116,6 +126,8 @@ One process, three roles:
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
4. **rate-limit recalc** — every six hours (and on demand from the domain
page), recomputes level-2 **Auto** rate limits from send-log statistics.
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
@@ -200,11 +212,13 @@ below is a summary — HTMX fragment endpoints
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) | | `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps | | `/reload` | **Global.** `POST` — reload OpenDKIM tables, Postfix sender map, and inbound relay maps when enabled (not DMARC maps — see § Persistence restore) |
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/help` | In-panel operator help (any authenticated user) |
| `/settings` | Username/password for any user; DMARC report default is **global** only | | `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD | | `/users`, `/users/*` | **Global.** Panel user CRUD |
| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. | | `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. |
| `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}` | **Global** list and report detail; domain roll-up scoped like deliveries. Registered only when `DMARC_REPORTS_ENABLE=true`; otherwise 404. |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
@@ -271,6 +285,7 @@ flowchart TB
domainSvc["internal/domain"] domainSvc["internal/domain"]
appSvc["internal/app"] appSvc["internal/app"]
inboundSvc["internal/inbound"] inboundSvc["internal/inbound"]
dmarcSvc["internal/dmarc"]
end end
subgraph persistence ["Persistence"] subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"] store["internal/store — SQLite, embedded migrations"]
@@ -293,6 +308,7 @@ flowchart TB
web --> domainSvc web --> domainSvc
web --> appSvc web --> appSvc
web --> inboundSvc web --> inboundSvc
web --> dmarcSvc
web --> backupPkg web --> backupPkg
web --> dnscheck web --> dnscheck
web --> health web --> health
@@ -300,15 +316,18 @@ flowchart TB
domainSvc --> store domainSvc --> store
appSvc --> store appSvc --> store
inboundSvc --> store inboundSvc --> store
dmarcSvc --> store
milterPkg --> store milterPkg --> store
logtail --> store logtail --> store
domainSvc --> postfix domainSvc --> postfix
appSvc --> postfix appSvc --> postfix
inboundSvc --> postfix inboundSvc --> postfix
dmarcSvc --> postfix
``` ```
The three roles inside the `panel` process (HTTP server, journal-milter, The four roles inside the `panel` process (HTTP server, journal-milter,
log-tailer goroutine) share one binary and one SQLite handle on purpose — see log-tailer, rate-limit recalc) share one binary and one SQLite handle on
purpose — see
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the [Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
single-connection trade-off that follows from it. single-connection trade-off that follows from it.
@@ -327,6 +346,9 @@ single-connection trade-off that follows from it.
| `postfix/transport` | Inbound next-hop `smtp:[host]:port` | | `postfix/transport` | Inbound next-hop `smtp:[host]:port` |
| `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all | | `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all |
| `postfix/tls_policy` | TLS policy for inbound next hops | | `postfix/tls_policy` | TLS policy for inbound next hops |
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
| `postfix/dmarc_transport` | Pipe transport for report ingest |
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate | | `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) | | `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `manifest.json` | Backup version stamp (consumed on restore) | | `manifest.json` | Backup version stamp (consumed on restore) |
@@ -344,10 +366,12 @@ under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
version check on restore. Requires the project directory mounted read-only at version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
tables, Postfix's sender map, and (when `INBOUND_RELAY_ENABLE=true`) inbound tables, Postfix's sender map, inbound relay maps (when
relay maps are re-derived from SQLite and both daemons are reloaded, so drift `INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
between the extracted archive and the database is healed before mail flows are re-derived from SQLite and both daemons are reloaded, so drift between the
(same step as `POST /reload` on demand). Stopped-container extracted archive and the database is healed before mail flows. Manual
`POST /reload` on the Status page resyncs OpenDKIM, the sender map, and
inbound maps only — not DMARC maps. Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide). `tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads **Optional encryption** of the two secret-bearing downloads
@@ -398,6 +422,9 @@ unsupported rather than as a missing doc:
`POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if `POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if
overridden without matching the panel writer in overridden without matching the panel writer in
[internal/postfix/inbound.go](../internal/postfix/inbound.go), [internal/postfix/inbound.go](../internal/postfix/inbound.go),
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`), `POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
full backups; mount `.:/selfpost-deploy:ro` in compose). full backups; mount `.:/selfpost-deploy:ro` in compose).
+4 -1
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)). [§ Release image](#release-image)).
**Planned version cuts** (from pin `1.7.0`; adjust if semver changes mid-track): **Planned version cuts** (from pin `1.9.1`; adjust if semver changes mid-track):
| Stage | ID | Cut | | Stage | ID | Cut |
|---|---|---| |---|---|---|
@@ -117,6 +117,9 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 4 | domain-stats-auto-ratelimit | `1.6.0` | | 4 | domain-stats-auto-ratelimit | `1.6.0` |
| 5 | dmarc-reports | `1.7.0` | | 5 | dmarc-reports | `1.7.0` |
| 6 | panel-docs | `1.8.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 | inbound-antispam-panel | `1.10.0` (planned) |
Docs-only prep (checklists in plans, no product code) uses the same per-step 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. commit rule but **no** version cut until the next product stage ships.
+23 -17
View File
@@ -52,9 +52,10 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
465 (smtps) is always active. Port **587** is published even when 465 (smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port `SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port
**25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not **25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
accept inbound mail until you set it to `true` (see [Inbound listen on port 25 until you set **`INBOUND_RELAY_ENABLE=true`** and/or
relay](#inbound-relay)). Harmless extra publishes can look like open ports in **`DMARC_REPORTS_ENABLE=true`** (see [Inbound relay](#inbound-relay) and
external scans. [DMARC reports](#dmarc-reports)). Harmless extra publishes can look like open
ports in external scans.
### Local trial ### Local trial
@@ -172,7 +173,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.7.0`. Intermediate deliberately never `:latest`. The current pin is `1.9.1`. Intermediate
CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
@@ -194,12 +195,12 @@ expected to set; defaults match the code exactly.
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` | | `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` | | `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *DMARC*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` | | `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *DMARC*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on (shared listener with inbound relay if both are enabled). | `20` | `.env` | | `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on. When inbound relay is also enabled, **`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`** applies to the shared listener instead. | `20` | `.env` |
| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` | | `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` |
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound listener only (not 465/587). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` | | `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound relay listener only (requires `INBOUND_RELAY_ENABLE=true`; not 465/587 or DMARC-only port 25). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` |
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` | | `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` |
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` | | `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). | `26214400` (25 MiB) | `.env` | | `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). When both inbound relay and DMARC ingest are on, the shared listener uses the **greater** of this and `DMARC_MESSAGE_SIZE_LIMIT`. | `26214400` (25 MiB) | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` | | `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` | | `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of `/data` growth over time. After the first panel start, change retention on **Settings** (global administrator); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` | | `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of `/data` growth over time. After the first panel start, change retention on **Settings** (global administrator); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
@@ -342,7 +343,8 @@ against the PTR record the internet publishes for this server's IP
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
Postfix sender map from the database (and inbound relay maps when Postfix sender map from the database (and inbound relay maps when
`INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel
shows after manual edits under `/data`. shows after manual edits under `/data`. It does **not** rebuild DMARC Postfix
maps; a full restore Resync does (see [Restore](#restore)).
### Mail queue and System log ### Mail queue and System log
@@ -376,8 +378,8 @@ restart). Application SASL logins are separate and are not changed here.
There are two roles: There are two roles:
- **Global administrator** — full access to every page and every domain, - **Global administrator** — full access to every page and every domain,
including Users, Backup, Status, Mail queue, System log, and Inbound (when including Users, Backup, Status, Mail queue, System log, Help, Inbound (when
the inbound relay flag is on). the inbound relay flag is on), and DMARC (when `DMARC_REPORTS_ENABLE=true`).
- **Domain-admin** — scoped to one or more domains assigned by a global - **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and administrator. Sees only those domains' pages, applications, and
Deliveries rows; cannot add or delete domains. `/users`, `/backup`, Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
@@ -547,10 +549,11 @@ refuses to start otherwise and tells you which tag to use. On the first
successful start after restore, `data/manifest.json` from the archive is successful start after restore, `data/manifest.json` from the archive is
**deleted** — it guards only that one boot, so a later in-place upgrade is not **deleted** — it guards only that one boot, so a later in-place upgrade is not
blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM's blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM's
tables and Postfix's sender map are re-derived from SQLite (and inbound relay tables and Postfix's sender map are re-derived from SQLite (inbound relay maps
maps when `INBOUND_RELAY_ENABLE=true`) and both daemons are reloaded, healing when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
any drift between the extracted files and the database (the Status page's and both daemons are reloaded, healing any drift between the extracted files and
*Reload configuration* button runs the same step on demand). This is why the the database. The Status page's *Reload configuration* button resyncs OpenDKIM,
the sender map, and inbound maps only — not DMARC maps. This is why the
compose file pins a fixed tag rather than `:latest`: without a known version, compose file pins a fixed tag rather than `:latest`: without a known version,
there'd be no way to tell which image restoring a given backup actually requires there'd be no way to tell which image restoring a given backup actually requires
(see [Fixed image tag](#fixed-image-tag)). (see [Fixed image tag](#fixed-image-tag)).
@@ -688,8 +691,10 @@ you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never
stores the message locally. stores the message locally.
**Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the **Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the
container. Until then there is no `smtp inet` listener, no Inbound item in container. Until then there is no inbound-relay `smtp inet` listener, no
the nav, and `/inbound` is 404. Outbound 465/587 is unchanged. *Inbound* item in the nav, and `/inbound` is 404. (Port 25 can still listen
when only `DMARC_REPORTS_ENABLE=true` — see [DMARC reports](#dmarc-reports).)
Outbound 465/587 is unchanged.
**Panel** (`/inbound`, global administrator only): add a domain, set the **Panel** (`/inbound`, global administrator only): add a domain, set the
upstream host/port and TLS to that hop (opportunistic / required / off), and upstream host/port and TLS to that hop (opportunistic / required / off), and
@@ -710,7 +715,8 @@ Prefer an explicit recipient list so unknown addresses are refused at RCPT
and never generate a bounce (backscatter). and never generate a bounce (backscatter).
**Anti-spam.** SelfPost does not ship a filter. To attach one, set **Anti-spam.** SelfPost does not ship a filter. To attach one, set
`INBOUND_ANTISPAM_MILTER` (inbound listener only) and merge `INBOUND_ANTISPAM_MILTER` (requires inbound relay enabled; not on DMARC-only
port 25) and merge
[deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml) [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml)
the same way as the nginx/Caddy fragments: the same way as the nginx/Caddy fragments:
+11 -9
View File
@@ -29,18 +29,17 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan | | ID | Topic | Status | Progress | Plan |
|---|---|---|---|---| |---|---|---|---|---|
| contributing | `CONTRIBUTING.md` | candidate | — | — | | contributing | `CONTRIBUTING.md` | candidate | — | — |
| panel-docs | In-panel operator documentation | agreed | 6/6 | [plans/panel-docs.md](plans/panel-docs.md) |
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | agreed | 1/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) | | inbound-antispam-panel | Inbound antispam journal + allow/deny lists | agreed | 1/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) | | inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — | | schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): the next feature is **panel-docs** once **Recommended order** (not binding): the next feature is
agreed. dmarc-reports shipped in **inbound-antispam-panel** (`1.10.0`). panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.7.0]` (security review of the ingest path [CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
pending); domain-stats-auto-ratelimit in `[1.6.0]`; send-log-retention in (security review of the ingest path pending); domain-stats-auto-ratelimit in
`[1.5.0]`; inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the `[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`;
2026-08-13 full-tree review follow-ups are in `[1.3.0]`. Candidates need queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in
explicit agreement before they join the queue. `[1.3.0]`. Candidates need explicit agreement before they join the queue.
After a context reset, pick an item marked `agreed` or `in progress`, then work After a context reset, pick an item marked `agreed` or `in progress`, then work
the **Implementation checklist** in its linked plan. The `Progress` column above the **Implementation checklist** in its linked plan. The `Progress` column above
@@ -69,6 +68,9 @@ priority.
## panel-docs ## panel-docs
**Status:** done — shipped in `[1.8.0]` (2026-08-18). Plan:
[plans/panel-docs.md](plans/panel-docs.md) (checklist complete).
**Goal:** built-in operator documentation in the panel — short pages (or a **Goal:** built-in operator documentation in the panel — short pages (or a
help drawer) that explain what each Status check and other controls mean, help drawer) that explain what each Status check and other controls mean,
without sending the operator out to `docs/guide.md`. without sending the operator out to `docs/guide.md`.
@@ -87,7 +89,7 @@ read the git tree for day-to-day meaning of a card.
**Dependencies / risks:** copy ownership and translation; keeping help in sync **Dependencies / risks:** copy ownership and translation; keeping help in sync
when checks change; not bloating every page with a second column of prose. when checks change; not bloating every page with a second column of prose.
**Version:** `1.x` MINOR; `candidate` until explicitly agreed. **Version:** shipped `1.8.0`.
--- ---
+2 -1
View File
@@ -23,7 +23,8 @@ Documentation.
| Database file | `/data/selfpost.db` (bind mount) | | Database file | `/data/selfpost.db` (bind mount) |
| Compatibility | 1.x MINOR releases must boot a `1.0.0` data directory | | Compatibility | 1.x MINOR releases must boot a `1.0.0` data directory |
Last updated with release **1.9.0** (`0009_application_auth_ips`). Last updated with release **1.9.0** (`0009_application_auth_ips`); **1.9.1**
had no schema change.
--- ---