[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).
10 KiB
Plan: inbound-relay (inbound relay)
Status: agreed
Version: target bump 1.x MINOR; 2.x possible — to be settled once
the implementation lands (do not fix a major in advance).
Order: queue-retries shipped in [1.3.1]; this is next as the largest
remaining 1.x+ feature. The 2026-08-13 full-tree review follow-ups closed
in [1.3.0]. The domain-admin role and internal/web split have shipped —
see CHANGELOG [1.2.0].
Goal
The ability to accept mail on port 25 for explicitly configured domains and forward it to a given upstream backend (a backup-MX / relay-forwarder role), as a module disabled by default that changes neither the behaviour nor the attack surface of the base outbound relay.
What it is for (scenarios)
- Backup-MX — accept mail while the domain's primary mail server is temporarily unreachable, and hand it over when it comes back.
- A front for a server without a public IP — the operator runs their own mail server which, for whatever reason, cannot accept mail from the internet itself (no static or public IP, behind NAT, a private address, inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct PTR, acts as the domain's public entry node (the MX points at it) and forwards mail to that internal or otherwise unreachable server.
Scope boundary (critical — what this is NOT)
- IT IS: acceptance on 25 for domains from an explicit list, plus
forwarding (relay/forward) to an upstream (
relay_domains+transport_maps+relay_recipient_maps). Postfix here is a pure forwarder, with no local delivery. - IT IS NOT (out of scope, product.md): local delivery to mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also neither implements nor bundles an anti-spam or anti-virus engine (rspamd/ClamAV) — but, unlike the earlier wording, it does not push filtering onto the backend either (see the "Anti-spam" section below): it provides an attachment point for an external filter.
Why as an option / plugin
- Accepting on port 25 changes the threat model (open relay for inbound,
backscatter, spam ingress). So it is off by default behind the
INBOUND_RELAY_ENABLE=falseenv flag; turning it on is a deliberate step by the operator. - Isolation: separate SQLite tables, separate panel handlers and pages, a separate branch of config generation. With the flag off, the inbound listener, the tables and the UI are absent — the base outbound path is byte-for-byte unchanged.
What to do
- The
INBOUND_RELAY_ENABLEenv flag (default false); whentrue, generate the inbound service and its config from panel state the same way the rest of the config is generated (postfix-config.sh). master.cf: an inboundsmtp ineton 25 for accepting from the internet (today 25 is used only for outbound delivery). Separate from 465/587: on 25 SASL is not offered and sending outwards is not allowed — inbound only, forrelay_domains.- Anti-open-relay for inbound (mandatory): the inbound smtpd's
smtpd_relay_restrictions/smtpd_recipient_restrictionsaccept mail only for domains inrelay_domainsand only for known recipients (relay_recipient_maps); everything else getsreject_unauth_destination/reject_unlisted_recipient. An open relay, or accepting "for anyone", is impossible. - Backscatter: knowing the valid recipients is preferable (reject unknown recipient at RCPT stage) so that bounces to non-existent addresses are never generated.
- The panel manages: the list of inbound domains; for each one the upstream
destination (
host:port, transport), an optional list of valid recipients, and optional TLS to the upstream. Strict validation of domain, host and port (whitelist), injection-safe writing of map files (as withsender_login_mapsin Phase 4),os/execwithout a shell (security.md). - Milters: OpenDKIM is not needed on the inbound path (we do not sign someone else's inbound mail). The journal-milter can optionally be reused for an inbound journal (extra work), or the inbound path can go without it in the first stage; fail-open behaviour is preserved.
- Rate limit / size: a coarse per-client-IP limit (
anvil, as L1) andmessage_size_limiton the inbound smtpd.
Anti-spam (important, but optional)
This is a valuable option, but it is not mandatory: some operators will be
perfectly served by blind forwarding without filtering — when the backend
can filter on content itself, when the upstream is trusted, or when the volume
and risk are low. So the anti-spam hook is off by default (an empty
INBOUND_ANTISPAM_MILTER), and the inbound relay is fully functional without
it.
What matters is something else: where filtering is technically possible. With a "blind" relay the destination backend sees SelfPost's address as the connecting IP, not the original sender's, so everything on the backend that depends on the origin IP breaks (DNSBL and reputation are checked against SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's SPF). The only point where the real client IP is still visible is the inbound hop at SelfPost — so for those who need filtering, it has to be attachable right here, not delegated to a backend that has already lost the information.
The attachment design:
- The anti-spam engine is a separate optional container (rspamd or similar), which the operator runs only if this option is wanted (the same principle as the reverse proxy — a separate container outside the SelfPost image). SelfPost neither contains nor starts it — the image and the "one container, three processes" principle are unchanged, and product.md's out-of-scope list is not violated (SelfPost does not implement anti-spam).
- SelfPost provides the attachment point: a milter hook on the inbound
smtpd. The engine's address is set via env (for example,
INBOUND_ANTISPAM_MILTER=inet:antispam:11332, empty → the hook is off) and is added tosmtpd_miltersfor the inbound path only (not on 465/587). Postfix passes the milter the real client IP, HELO and PTR — the filter sees the true origin.milter_default_actionfor that milter is configurable (fail-open vs tempfail); the default is to be decided during implementation. - A native backstop with no dependencies: on that same inbound hop,
Postfix's own origin-IP facilities are available —
reject_rbl_client(DNSBL) and HELO/PTR checks — and they work even without an external container. Plus preserving authentication results for downstream through ARC orReceived, where part of the filtering does remain on the backend. - docker-compose: document an optional anti-spam sidecar fragment (like the alternative reverse-proxy fragments) — the container comes up with the stack only when the option is enabled.
- Persistence: new tables and map files under
/data— they land in the full backup automatically (Phase 9). Domain export/import can be extended with the inbound configuration — optional, to be flagged. - DNS documentation: an inbound domain needs an
MXrecord pointing at the server (unlike outbound, where no MX is required) — to be reflected in the README's DNS section.
Security
security.md: server-side input validation, escaped writes to
config files, exec without interpolation, no open relay, protection against
backscatter.
Done when
With INBOUND_RELAY_ENABLE=true and a configured domain, mail arriving on port
25 for that domain is forwarded to the given upstream; mail for unconfigured
domains or recipients is rejected (not an open relay, no backscatter); with
INBOUND_ANTISPAM_MILTER set, inbound mail passes through the external filter
with the real origin IP (verified with a sidecar container), and with it empty
the hook stays out of the way; with INBOUND_RELAY_ENABLE=false the inbound
port, tables and UI are absent and the base outbound relay is unchanged;
build/vet/test/image green.
Risks
- open relay / backscatter — removed by
relay_domains+relay_recipient_maps+reject_unauth_destination; - the loss of the origin IP for filtering on the backend when forwarding — removed by the anti-spam milter hook plus native DNSBL on the inbound hop, where the origin IP is still visible;
- port 25 accepting mail widens the attack surface (off by default);
- semver: if the contract turns out incompatible (ports, backup, behaviour with
the flag off) a major
2.xis possible; the decision comes after the implementation.
External deployment dependency: the optional anti-spam container — outside the SelfPost image, brought up by the operator when the option is enabled.
Dependencies
A finished outbound path (already implemented). Agreement obtained — see the status above.
Implementation checklist
Target version cut: 1.4.0 (MINOR). One commit per step; see
development.md § Plan checklists. UI reference:
panel-ui inbound mockups.
- Migration: inbound domain / recipient / transport tables under
/data— Opus INBOUND_RELAY_ENABLE(default false) in entrypoint +postfix-config.sh— Opusmaster.cf: inboundsmtp ineton 25; separate from 465/587 — Opus- Generate
relay_domains,transport_maps,relay_recipient_maps(injection-safe) — Opus smtpd_relay_restrictions/ recipient maps — no open relay, no backscatter — Opusinternal/storeCRUD + validation (domain, host, port) — Opus- Panel: list, add, domain detail, recipients, danger zone (mockups) — Sonnet
- Rate limit +
message_size_limiton inbound smtpd — Opus - Optional
INBOUND_ANTISPAM_MILTER+ compose fragment — Opus - DNS MX copy in README/guide;
.env.example— Sonnet - Backup/export inbound config (per plan optional flag) — Opus
- Unit + handler tests; image build and container smoke — Opus
- guide.md and security.md — Sonnet
- Security review inbound path — Fable
go vet,go test, e2e if applicable — Haiku