docs: the panel as it is in 2.0

Stage 3, step 3. Written by Sonnet from the code and the plan, checked
by Opus.

  - guide.md: a section on the panel - menu, paths, the roles global and
    domain with outbound and inbound assigned separately -, Overview and
    Health, Queue and System log, Account and Server > Settings, the
    domain page / Domain settings / application form, Help as a page of
    topics. The DMARC report address is one of three choices set in
    Domain settings. The Spam filter box, the two INBOUND_ANTISPAM_*
    variables the panel reads, and the filter's socket on Health.
    Sessions; backup and export encrypted unless the box is unticked;
    1.x data is not restored into 2.0.
  - architecture.md: Panel HTTP surface rewritten from web.go (routes,
    the /server/ guard, per-object checks, <page>/fragment, one layout,
    typed page data, the kit and its guards, CSP and htmx); Persistence
    gains the database - one baseline, a 1.x file refused, the tables as
    they are.
  - security.md: roles and what each reaches, as built; sessions by user
    id and what ends them; state-changing requests; errors shown to
    users; the 2.0 review; two accepted risks from it - a delegated
    inbound upstream may be any host and port, and Account's profile is
    saved without the password.
  - CHANGELOG: Removed opens with "2.0 starts from an empty data
    directory"; entries that describe the user-default report address
    say they were replaced before release; the filter's socket.
  - roadmap.md: two review follow-up rows struck as retired by the
    redesign (.pair / .split, the two back links); password-reset notes
    that changing the account e-mail must then ask for the password.
    Progress 25/27.
  - development.md: the component kit and its guards; README and
    product.md: wording.

Left as they are
  - The third follow-up row the plan expected to strike - initShowWhen
    is not re-run after an htmx swap - still holds: no polled fragment
    carries such a control today, so nothing breaks, but the redesign did
    not retire it.
  - guide.md embeds no screenshots, so none needed retaking.
  - README and guide still name image 1.9.5 - the version cut.
  - plans/preflight.md and plans/inbound-antispam-panel.md use pre-2.0
    paths; roadmap's template-data-typing is largely done by the
    redesign.

  go test -count=1 ./cmd/panel (env-doc tables): ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 13:01:18 +03:00
co-authored by Claude Opus 5.5
parent f37424d9fc
commit ed3cb4aaac
9 changed files with 800 additions and 285 deletions
+38 -8
View File
@@ -34,7 +34,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
(`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an (`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an
address of its own or follows a named user's default address of its own or follows a named user's default
(`domains.dmarc_rua_user_id`) — a new domain follows whoever created it, (`domains.dmarc_rua_user_id`) — a new domain follows whoever created it,
and deleting that user leaves it with no report address. and deleting that user leaves it with no report address (replaced before
release — see the entry of 2026-10-10 below).
`user_inbound_domains` and the per-user `all_domains` / `user_inbound_domains` and the per-user `all_domains` /
`all_inbound_domains` flags are in the schema for the inbound delegation `all_inbound_domains` flags are in the schema for the inbound delegation
that follows. Details: `docs/schema-migrations.md`. that follows. Details: `docs/schema-migrations.md`.
@@ -52,8 +53,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
**Server › Settings** (`/server/settings`, global role) replace the one **Server › Settings** (`/server/settings`, global role) replace the one
Settings page. Account has three forms, each its own POST: profile Settings page. Account has three forms, each its own POST: profile
(username, e-mail), password, and the default DMARC report address (username, e-mail), password, and the default DMARC report address
(SelfPost hosted, my account e-mail, another address, none). Saving the (SelfPost hosted, my account e-mail, another address, none) (replaced before
profile no longer asks for the password; changing the password still does. release — see the entry of 2026-10-10 below). Saving the profile no longer
asks for the password; changing the password still does.
- panel-redesign (stage 1): the application form is one POST. Who the - panel-redesign (stage 1): the application form is one POST. Who the
application may send as, its client-IP allow-list and its rate limit application may send as, its client-IP allow-list and its rate limit
("use the domain limit", manual or auto) are validated together and saved ("use the domain limit", manual or auto) are validated together and saved
@@ -125,6 +127,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
are gone, and with them `users.dmarc_default_mode`, are gone, and with them `users.dmarc_default_mode`,
`users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner, `users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner,
2026-10-10; replaces the design of 2026-09-21 described above). 2026-10-10; replaces the design of 2026-09-21 described above).
- panel-redesign (stage 3): with the inbound spam filter on, its socket is one
of the milter sockets on **Server › Health** and counts in Overview's card.
The panel connects to the configured address (`inet:host:port` or
`unix:/path`, one second at most, nothing sent). A filter that does not
answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound
mail is deferred - and a warning otherwise - mail goes through unfiltered.
- panel-redesign (design, stage 3): **New password** and **Delete** are in - panel-redesign (design, stage 3): **New password** and **Delete** are in
each row of a domain's Applications table again, as POST forms that look each row of a domain's Applications table again, as POST forms that look
like the links beside them. like the links beside them.
@@ -151,20 +159,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
export file. export file.
- The panel role `domain_admin` is stored as `domain`. - The panel role `domain_admin` is stored as `domain`.
- On the old domain page, "Same as Settings" makes the domain follow the - On the old domain page, "Same as Settings" makes the domain follow the
default report address of the user who chose it. default report address of the user who chose it (replaced before release —
see the entry of 2026-10-10 below).
- The old panel is untouched on screen but steps aside in the tree: its - The old panel is untouched on screen but steps aside in the tree: its
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
last old page is restyled. The 2.0 menu paths in the new shell do not last old page is restyled. The 2.0 menu paths in the new shell do not
exist before stage 1; only the kit page uses that shell so far. exist before stage 1; only the kit page uses that shell so far (a stage-0
state: both files are deleted and every page is on the shell — see
*Removed*).
- panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0, - panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0,
the component kit. the component kit.
- panel-redesign (stage 3): the operator guide, `architecture.md` (Panel HTTP
surface, Persistence) and `security.md` (roles, sessions, errors shown to
users) describe the 2.0 panel. `security.md` records two accepted risks from
the 2.0 review: a delegated inbound upstream may be any host and port, and
Account's profile is saved without the password.
### Security ### Security
- A panel session belongs to a user by id and ends with them. It used to be - A panel session belongs to a user by id and ends with them. It used to be
keyed by user name: the cookie of a deleted or renamed user stayed valid keyed by user name: the cookie of a deleted or renamed user stayed valid
and became a session of whoever was next created under that name. Signing and became a session of whoever was next created under that name. Signing
in again is required after upgrading. in again is required after upgrading (there is no upgrade from 1.x; this
concerns development builds of 2.0).
- Changing one's password ends one's own other sessions only; it used to - Changing one's password ends one's own other sessions only; it used to
sign every other user out. A password set by an administrator on the user sign every other user out. A password set by an administrator on the user
form ends that user's sessions. form ends that user's sessions.
@@ -180,17 +197,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- Saving a domain's report-address form untouched no longer moves a domain - Saving a domain's report-address form untouched no longer moves a domain
that follows another user's default to whoever saved it: that user's that follows another user's default to whoever saved it: that user's
default is its own choice, and "My default instead" is the change. default is its own choice, and "My default instead" is the change
(replaced before release — see the entry of 2026-10-10 below).
- The auto rate-limit fields follow the limit mode on the domain and - The auto rate-limit fields follow the limit mode on the domain and
application forms (the script applied only the first matching rule). application forms (the script applied only the first matching rule).
- The DMARC page of a domain no longer answers 500 once the domain has a - The DMARC page of a domain no longer answers 500 once the domain has a
reported source. reported source.
- The confirmation before deleting a user names the domains that follow - The confirmation before deleting a user names the domains that follow
that user's default report address and will be left without one; it used that user's default report address and will be left without one; it used
to say the domains were not affected. to say the domains were not affected (replaced before release — see the
entry of 2026-10-10 below).
### Removed ### Removed
- **2.0 starts from an empty data directory.** A 1.x `/data` is not upgraded:
its database is refused at start and left untouched, and a 1.x backup is not
restored; a domain is carried over with its export file. Gone with it, as
built: the 1.x migration chain, every pre-2.0 panel path (listed next), the
help drawer (Help is a page of topics), the default DMARC report address —
the panel-wide one of 1.x (`dmarc_report_email`, "Same as Settings") and the
per-user one with a domain following a user that earlier entries of this
release describe (`users.dmarc_default_*`, `domains.dmarc_rua_user_id`) —
together with the DMARC box on Account, the old stylesheet `legacy.css`, the
old layout `layout_legacy.html`, and the unused `rate_limits.allowed_ips`
column.
- Old panel paths, GET and POST alike, without redirects: `/status`, - Old panel paths, GET and POST alike, without redirects: `/status`,
`/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything `/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`, under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`,
+2 -2
View File
@@ -26,9 +26,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) - Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help drawer and `/help` page for Status and domain controls - In-panel Help: a page of topics, opened from the `?` in the head of a box
- Per-application client IP allow-list for SMTP AUTH (optional) - Per-application client IP allow-list for SMTP AUTH (optional)
- Web panel — domains, applications, deliveries, mail queue, system log, backup - Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
- 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
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
+272 -59
View File
@@ -114,10 +114,10 @@ One process, four roles:
parameters (`queue_run_delay`, `minimal_backoff_time`, parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`, `maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`delay_warning_time`) and caches the snapshot on the handlers config. The `delay_warning_time`) and caches the snapshot on the handlers config. The
Mail queue card and a delivery's `deferred` / `bounced` history print those Outbound queue box and a delivery's `deferred` / `bounced` history print those
numbers; they never call `postconf` per request. If `postconf` is missing, numbers; they never call `postconf` per request. If `postconf` is missing,
the panel logs a warning and uses Postfix 3.x compiled-in defaults the panel logs a warning and uses Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card. (`300s` / `4000s` / `5d` / `0`) with a muted note on the box.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain (`default_action=accept`) so milter failure does not stop mail. Domain
@@ -133,8 +133,8 @@ One process, four 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 4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
page), recomputes level-2 **Auto** rate limits from send-log statistics. the application form), 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).
@@ -159,7 +159,7 @@ panel user cannot read). `follow()` drains the old inode once more before
switching descriptors; the panel treats a missing log file as an empty tail, not switching descriptors; the panel treats a missing log file as an empty tail, not
an error. an error.
**Read offset is persisted** (`logtail_state` table, migration `0003`): the **Read offset is persisted** (`logtail_state` table, in the baseline schema): the
tailer stores its position plus a fingerprint of the log's first 512 bytes, and tailer stores its position plus a fingerprint of the log's first 512 bytes, and
on start resumes from it, parsing the tail written while the panel was down. If on start resumes from it, parsing the tail written while the panel was down. If
the fingerprint no longer matches (rotated or recreated in the meantime) it reads the fingerprint no longer matches (rotated or recreated in the meantime) it reads
@@ -185,8 +185,8 @@ rotated past fourteen files while the panel was down, or deleted.
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both **Two one-shot reads** sit beside the follow loop and are unrelated to it, both
serving panel pages on request: `TailLines` (the last *n* lines, for serving panel pages on request: `TailLines` (the last *n* lines, for
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for `/server/log`) and `QueueLines` (the lines carrying one queue-id, for
`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file — `/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file —
finding a message's lines means reading rather than seeking — and matches the id finding a message's lines means reading rather than seeking — and matches the id
anchored on the character before it, since queue ids are hexadecimal runs and a anchored on the character before it, since queue ids are hexadecimal runs and a
shorter one is regularly the tail of a longer one. Send-log rows outlive the log shorter one is regularly the tail of a longer one. Send-log rows outlive the log
@@ -197,61 +197,227 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface ## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is
unless noted. Routes marked **global** return **404** for domain administrators two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method
(`requireGlobal()` in included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table building them only takes method values, so the route guard test can run it on
below is a summary — HTMX fragment endpoints a bare `Server` and ask which pattern answers a path:
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`.
| Route | Purpose | - **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`),
`/static/`, `/setup/`, `/login`, `/logout`;
- **authenticated** — everything else, behind `RequireAuth`: a request without
a live session is redirected to `/login`.
Both sit inside `secure()`, which sets the security headers and runs the origin
check on every state-changing request (see below).
### Routes
A path reads like the menu: `/<group>/<page>`, an entity under its list, an
action under the thing it changes. Every state change is a `POST`; a `GET`
only reads (the delete confirmation at `GET …/delete` renders a form).
| Group | Routes |
|---|---| |---|---|
| `/healthz` | Liveness (no auth) | | Home | `GET /` → `/overview`, or for a user with the *domain* role to the first list they reach (`/outbound/domains`, or `/inbound/domains` when that is all) |
| `/license` | Embedded `LICENSE` text (no auth) | | Overview | `GET /overview`, `GET /overview/fragment` |
| `/setup/*` | One-time admin bootstrap | | Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` |
| `/login`, `/logout` | Session auth | | Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | | Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network | | Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| `/domains` | Domain list; `POST /domains` (add domain) is **global** | | Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** | | Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` |
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) | | Account | `GET /account` · `POST /account/profile`, `/account/password` |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | | User menu | `GET /help` · `POST /logout` (public mux) |
| `/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 |
| `/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) |
| `/help` | In-panel operator help (any authenticated user) |
| `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD |
| `/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 An application is addressed under its domain, and an application id that does
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in not belong to the domain in the path is a 404. The application form is one
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend `POST`: sender, client IPs and rate limit are validated and saved together or
session idle timeout (only non-`HX-Request` GET and mutating requests count as not at all, also at creation. The page that shows a new password is the
activity). The Mail queue retry-policy card is outside that fragment: it is the response to the `POST` that created or regenerated it; it has no `GET` path and
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)), carries `Cache-Control: no-store`.
not a live re-read.
The pre-2.0 paths are not served and not redirected (`/status`, `/domains/…`,
`/inbound/{id}/…`, `/deliveries`, `/mail-queue`, `/system-log`, `/backup`,
`/users/…`, `/settings`, `/reload`, `/dmarc/…`, and `/account` as a redirect):
each answers 404 once signed in. `TestRoutesFollowNavigation`
([internal/web/guard_routes_test.go](../internal/web/guard_routes_test.go))
holds the table: every path is registered with its methods, every menu entry
points at a registered path, every old path answers 404, and no template
contains an old path.
### Who may open what
Authorization is by tree where the tree is the boundary, and by object where it
is not.
- **`/server/` is guarded once.** Routes under it are registered through
`server()` in `web.go`, which wraps the handler in `globalOnly` (404 for
anyone but the global role — the answer a missing page gets, so the panel does
not confirm the page exists) and panics at start-up for a pattern outside
`/server/`. The handlers also call `requireGlobal()` themselves.
- **Overview and the Outbound queue** are global-only but not under `/server/`,
so their handlers call `requireGlobal()`.
- **Outbound is checked per domain.** `lookupDomain` resolves `{id}` and answers
404 for a missing domain and for one the principal may not reach alike
([handlers/handlers_domains.go](../internal/web/handlers/handlers_domains.go));
application routes go on to check the application belongs to that domain.
Lists and the log are filtered by assignment in the query
(`ListDomainsForUser`), not in the template. Adding and deleting a domain is
`requireGlobal()`.
- **Inbound is checked per inbound domain**, separately from outbound
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
`requireInbound` (the list), `requireInboundDomain` (one domain — page,
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
All three answer 404 alike.
- **The DMARC hub** is open to anyone with outbound reach for the reports of
their own domains; the *Ingest* box (server-wide statistics) is rendered for
the global role only.
- **Account and Help** are open to every signed-in user.
- The principal ([web/auth/principal.go](../internal/web/auth/principal.go))
carries the role, the assigned outbound and inbound domain ids and the two
*All* flags; `CanAccessDomain` and `CanAccessInboundDomain` are the only
predicates. The reach, not the role, decides which menu groups exist.
Routes marked global are tested per path for a user with the *domain* role,
including another tenant's domain id
([handlers/handlers_delegation_test.go](../internal/web/handlers/handlers_delegation_test.go),
[authz_test.go](../internal/web/handlers/authz_test.go)).
### Polled fragments
A polled page answers its refresh at `<page>/fragment` (`/overview/fragment`,
`/outbound/log/fragment`, `/server/health/fragment`, …). The fragment is the
same `{{define}}` block that renders the page's own box, so the first paint and
a refresh cannot differ. HTMX refreshes them every 5 s while the operator is
active on the page, every 30 s when the tab is visible but idle, and not at all
when it is hidden — scheduled in `panel.js` via `data-poll`, not
`hx-trigger="every …"`, because a trigger filter is evaluated with `new
Function`, which the CSP forbids. Polling does not extend the session idle
timeout (only non-`HX-Request` `GET`s and mutating requests count as
activity). The Outbound queue's retry-policy box is outside the polled region:
it is the start-up `postconf -h` snapshot (see [Panel
binary](#panel-binary-cmdpanel)), not a live re-read.
### View engine and component kit
Pages are rendered by [internal/web/view](../internal/web/view/view.go). There
is one layout and one way to render.
- **One layout.** Every page is parsed with `layout.html` (the shell: brick
navbar with the wordmark, the perforated edge, the strip of sibling pages,
user menu, footer) and `components.html` (the partials), then its own file.
`layout_signed_out` is the same document without navigation, used for sign-in
and setup; `Engine.Render` picks it when the page data names no user. The
page's file defines `content`; `pageFiles` in `view.go` lists the files of
each page, and fragments are listed in `fragmentFiles` and rendered by
`RenderFragment` with no layout.
- **Typed page data.** A page's data is a struct that embeds `Meta` (title,
user, the viewer's reach, the menu group and page it sits in) and carries
the page's boxes and rows as fields, built by a constructor in the `view`
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
derived from `Meta` and the feature flags (`SetInboundEnabled`,
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
has is decided once. No template reads a `map[string]any`; the one map left
in the handlers is the health sampler's, which is turned into a typed
`Health` before rendering.
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
holds one `{{define}}` per partial (page head, postmark, box and its head and
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
empty state, confirm list, help topic, form rows) and is the only file that
writes their `sp-` markup. [components.go](../internal/web/view/components.go)
is the typed input of each (`Head`, `Box`, `Record`, `Fact`, `SideMenu`, …),
so a page passes a struct, never a `dict`. Template functions: `status_tag`
(a status → Bulma's tag classes, once), `wbr_at` (a break after the `@` in a
table cell). A page that needs a component the kit lacks is a design change
made in the mockups first
([plans/panel-redesign.md](plans/panel-redesign.md) § The contract).
- **Help.** [help.go](../internal/web/view/help.go) is the Help page's data:
topics grouped by section of the panel. A box's head links to its topic with
`Box.Help` (`/help#<topic>`), and the page lists only the
sections the viewer's menu shows.
- **Assets.** [static/](../internal/web/view/static) is embedded
(`//go:embed`): vendored `bulma.min.css` and a Tabler icon subset (checksummed),
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
and `/static/` does not list its files. The kit page
`GET /server/components` renders every partial in every state from fixtures
(global role only).
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
They read their rules from the accepted mockups
([docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html)) rather
than keep a copy: `TestPanelClassVocabulary` (only the Bulma subset and the
`sp-` classes of `panel.css`; every `sp-` class defined, used and shown on the
components page), `TestPanelCSSIsTheMockupStylesheet`, `TestPanelCSSContract`
(no `!important`, no id or page selector, type floor, widths),
`TestTemplatesUseComponents` (no hand-written component markup in a page),
`TestPanelOutlinesMatchMockups` and `TestPanelPageStructure` (each rendered page
has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
`TestVendoredAssetsArePinned`, `TestStaticHoldsOnlyTheKnownAssets`,
`TestRoutesFollowNavigation`, `TestNoTemplateUsesInlineScriptOrStyle`. The
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
the CI step
[design-first](../.github/scripts/design-first.sh) rejects a commit that
changes a guard together with the code it judges, and a template that is a
byte-for-byte return of one from history. The design contract and the process
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
### Security headers, CSP and scripts
`secure()` ([internal/web/security.go](../internal/web/security.go)) puts these
on every response: `Content-Security-Policy: default-src 'self'; object-src
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie
is `Secure`. Because the policy has no exemptions, no template carries an
inline `<script>`, an event-handler attribute or `style="…"` — the page's
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
`data-confirm` prompt on destructive forms, conditional field visibility,
adaptive polling), both optional: every form works as a native HTML `POST`
with scripts off. htmx is told not to inject its indicator stylesheet
(`htmx-config` meta), which the CSP would otherwise have to allow.
State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed`
first: `Sec-Fetch-Site` must say `same-origin`, or `Origin` must name the
panel's host; a request with neither header is let through (an accepted risk).
There are no CSRF tokens — see [security.md](security.md).
### Errors shown to users
A validation refusal is shown as the text the validator wrote (on the form,
next to the field, or in a notice above the first box). Anything else — a
database error, `saslpasswd2`, a Postfix reload — is logged
and the page gets a fixed sentence saying so; the error text never reaches the
browser. A template error renders a bare `500` rather than a half-written page
(`Engine.Render` buffers).
### Sessions ### Sessions
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random Stored in SQLite (`sessions` table of the baseline, `0001_init.sql`): the cookie
token; the database stores **SHA-256 of the token**, not the token itself — a holds a random token; the database stores **SHA-256 of the token**, not the
stolen DB or backup archive does not alone grant login, but a browser that still token itself — a stolen DB or backup archive does not alone grant login, but a
holds the cookie works after process restart, redeploy, or full backup restore. browser that still holds the cookie works after process restart, redeploy, or
full backup restore.
- **A session belongs to a user by id** (`sessions.user_id`, `ON DELETE
CASCADE`). Deleting a user ends their sessions; renaming one keeps them;
`RequireAuth` loads the user on every request, so a session whose user is
gone is a redirect to `/login`.
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no - **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
absolute cap (regular use keeps the session alive indefinitely). absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour - **Renewal** — the DB `expires_at` and the cookie `Max-Age` update at most once per
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)). hour (`renewThreshold` in
- **Password change on `/settings`** — changing your own password deletes [internal/web/auth/session.go](../internal/web/auth/session.go)).
every other session for that user; the current session stays active - **Password change on Account** — deletes every other session of that user;
the current one stays active
([internal/store/sessions.go](../internal/store/sessions.go), ([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)). [handlers_account.go](../internal/web/handlers/handlers_account.go)).
A global administrator resetting another user's password on `/users` updates - **A password set by a global user on the user form** deletes all of that
the hash but does not delete that user's existing sessions. user's sessions (`DestroyUserSessions`); set for oneself, it keeps the
current one
([handlers_users.go](../internal/web/handlers/handlers_users.go),
`endSessionsAfterPasswordSet`).
Restoring an **older** backup also restores session rows: a session removed Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still holds after that backup was taken can become valid again if the browser still holds
@@ -264,7 +430,7 @@ the cookie and the restored row's `expires_at` has not passed.
Multi-store writes that must land in more than one place (SQLite row, Multi-store writes that must land in more than one place (SQLite row,
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is `sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
also where the rollback of a partial failure lives. Handlers may call also where the rollback of a partial failure lives. Handlers may call
`store` directly for single-table reads and simple writes (sessions, admin, `store` directly for single-table reads and simple writes (sessions, users,
send-log queries); the first-run setup-token file is read and written in send-log queries); the first-run setup-token file is read and written in
`web` itself. The adapters below the services are the only code that knows `web` itself. The adapters below the services are the only code that knows
about Postfix, OpenDKIM, DNS or the log file, which is what makes them about Postfix, OpenDKIM, DNS or the log file, which is what makes them
@@ -295,7 +461,7 @@ flowchart TB
dmarcSvc["internal/dmarc"] dmarcSvc["internal/dmarc"]
end end
subgraph persistence ["Persistence"] subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"] store["internal/store — SQLite, embedded schema"]
end end
subgraph adapters ["Adapters — the only infrastructure-aware code"] subgraph adapters ["Adapters — the only infrastructure-aware code"]
postfix["internal/postfix"] postfix["internal/postfix"]
@@ -344,7 +510,7 @@ single-connection trade-off that follows from it.
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `selfpost.db` | SQLite panel state (domains, apps, users, sessions, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [schema-migrations.md](schema-migrations.md) | | `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) |
| `setup-token` | First-run setup token file | | `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables | | `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials | | `sasl/sasldb2` | Application SASL credentials |
@@ -363,6 +529,49 @@ single-connection trade-off that follows from it.
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
full backups also archive the operator's `./certs` PEM files when present. full backups also archive the operator's `./certs` PEM files when present.
### Database
The schema is one embedded baseline, `internal/store/migrations/0001_init.sql`,
applied by `store.Open` into an empty file; `PRAGMA user_version` is `1` and
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
chain. **2.0 starts from an empty data directory:** a database with a schema
version but without the 2.x `application_id` — what every 1.x file looks like —
is refused at start with `ErrForeignSchema` and left untouched, as is one whose
version is newer than the build knows. A restored 1.x backup fails the same
way. Domains are carried across with a domain export, whose format is not the
database. Later schema changes are new files (`0002_*.sql`, …); once `2.0.0` is
released the baseline is never edited. History and the per-table list:
[schema-migrations.md](schema-migrations.md).
The tables that carry the panel's model:
- **`users`** — `role` is `global` or `domain` (a reach, not a rank); `email` is
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).
- **`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
way round.
- **`sessions`** — `token_hash`, `user_id` (`ON DELETE CASCADE`), `expires_at`.
Keyed by user id, not by name.
- **`domains`** — `dmarc_rua` is the whole of a domain's report-address choice:
`''` is no reports (a new domain), the address SelfPost derives for the
domain and the server's hostname is the hosted choice, anything else is an
address typed for the domain. Nothing refers to a user, and deleting one
changes no domain.
- **`settings`** — key/value, what holds for the instance whoever is signed in
(send-log retention). Nothing that belongs to a user.
- `applications` (with `auth_ip_restrict` / `auth_allowed_ips`),
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
`logtail_state`, `inbound_domains`, `inbound_transports`,
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`.
The panel is the only writer; SQLite runs in WAL mode on one connection (see
[Panel binary](#panel-binary-cmdpanel)), which is why a full backup snapshots
with `VACUUM INTO` instead of copying the file.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 rotated files, check every 6h, rename + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
@@ -377,7 +586,8 @@ tables, Postfix's sender map, inbound relay maps (when
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`) `INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
are re-derived from SQLite and both daemons are reloaded, so drift between the are re-derived from SQLite and both daemons are reloaded, so drift between the
extracted archive and the database is healed before mail flows. Manual extracted archive and the database is healed before mail flows. Manual
`POST /reload` on the Status page resyncs OpenDKIM, the sender map, and `POST /server/health/reload` (the *Reload configuration* button on Server ›
Health) resyncs OpenDKIM, the sender map, and
inbound maps only — not DMARC maps. Stopped-container 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).
@@ -386,9 +596,12 @@ inbound maps only — not DMARC maps. Stopped-container
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header, scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
its counter and an end-of-stream flag (so truncation and reordering fail to its counter and an end-of-stream flag (so truncation and reordering fail to
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
`.json` → `.spde` (SelfPost domain export); the plain forms remain the `.json` → `.spde` (SelfPost domain export). In the panel the *Encrypt with a
default. Domain import detects the envelope by magic bytes; an encrypted full password* box is ticked by default on both downloads and the plain form is
backup is converted back with `selfpost-backup -decrypt` before restore. what an unticked box gives; the `selfpost-backup` CLI stays plain unless it is
given a password. Domain import detects the envelope by magic bytes; an
encrypted full backup is converted back with `selfpost-backup -decrypt` before
restore.
--- ---
+27 -1
View File
@@ -352,6 +352,30 @@ go test ./...
— every new `loadConfig` key must appear in the env lists in [guide.md](guide.md) — every new `loadConfig` key must appear in the env lists in [guide.md](guide.md)
([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)). ([cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go)).
### Panel pages: the component kit and its guards
Every panel page is built from the component kit in
[internal/web/view](../internal/web/view): partials in
`templates/components.html`, their typed input in `components.go`, one stylesheet
`static/panel.css`, one layout `layout.html`. A page template calls partials with
a struct from a `view.New…` constructor and writes no component markup of its
own ([architecture.md § Panel HTTP surface](architecture.md#panel-http-surface)).
The guard tests (`internal/web/guard_*_test.go`, `internal/web/view/guard_*_test.go`)
run in `go test ./...` and read their rules from the accepted mockups under
[docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html): which
classes and icons exist, what `panel.css` may contain, what component skeleton
each page has, which routes exist. A failing guard means the page is wrong, not
the guard.
Changing the design is mockup first: edit the fragment under `panel/src/`, run
`build.py` and `check.py --write`, commit as `design: …`, get it accepted, and
only then touch `internal/web`. CI enforces the order with `design-first`
([.github/scripts/design-first.sh](../.github/scripts/design-first.sh)): a commit
that changes the mockups, a guard or the script touches nothing else under
`internal/web/`, and a template that returns from git history is refused.
[plans/panel-redesign.md](plans/panel-redesign.md) § The contract and
§ Enforcement have the full rules.
### End-to-end (container suite) ### End-to-end (container suite)
Separate Go module `test/e2e/`; **not** included in the main module's Separate Go module `test/e2e/`; **not** included in the main module's
@@ -391,7 +415,9 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
### `test.yml` — every push and PR to `main` ### `test.yml` — every push and PR to `main`
`gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e). `design-first` (needs the full history) → `gofmt -l` → `go vet ./...` →
`go test ./...` (main module, no e2e; includes the panel guard tests, see
[§ Panel pages](#panel-pages-the-component-kit-and-its-guards)).
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer ### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
+327 -185
View File
@@ -6,9 +6,15 @@ overview and quick start, see [README.md](../README.md).
This guide has three parts: **[Installation](#installation)** (getting a This guide has three parts: **[Installation](#installation)** (getting a
container running with a working reverse proxy and TLS), **[Instance container running with a working reverse proxy and TLS), **[Instance
administration](#instance-administration)** (running and maintaining the administration](#instance-administration)** (running and maintaining the
SelfPost server itself — status, backups, users, upgrades), and **[Domain SelfPost server itself — the panel's layout, health, backups, users,
administration](#domain-administration)** (day-to-day work on the sending upgrades), and **[Domain administration](#domain-administration)** (day-to-day
domains hosted on that instance — DNS, deliveries, rate limits, applications). work on the sending domains hosted on that instance — DNS, the outbound log,
rate limits, applications).
SelfPost 2.0 starts from an empty data directory: a `/data` written by 1.x is
refused at start, not upgraded. A domain moves over with its
[export file](#exporting-and-importing-a-single-domain), and every panel path
changed (see the CHANGELOG, *Removed*).
## Table of contents ## Table of contents
@@ -21,8 +27,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Environment variables](#environment-variables) - [Environment variables](#environment-variables)
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory) - [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
- [Instance administration](#instance-administration) - [Instance administration](#instance-administration)
- [Status](#status) - [The panel](#the-panel)
- [Mail queue and System log](#mail-queue-and-system-log) - [Help](#help)
- [Overview and Health](#overview-and-health)
- [Queue and System log](#queue-and-system-log)
- [Account](#account)
- [Settings](#settings) - [Settings](#settings)
- [Users](#users) - [Users](#users)
- [Sessions](#sessions) - [Sessions](#sessions)
@@ -37,11 +46,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Inbound relay](#inbound-relay) - [Inbound relay](#inbound-relay)
- [DMARC reports](#dmarc-reports) - [DMARC reports](#dmarc-reports)
- [Domain administration](#domain-administration) - [Domain administration](#domain-administration)
- [Domains page](#domains-page) - [Outbound domains](#outbound-domains)
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc) - [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
- [IP warmup](#ip-warmup) - [IP warmup](#ip-warmup)
- [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application) - [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application)
- [Deliveries](#deliveries) - [Outbound log](#outbound-log)
- [Exporting and importing a single domain](#exporting-and-importing-a-single-domain) - [Exporting and importing a single domain](#exporting-and-importing-a-single-domain)
## Installation ## Installation
@@ -61,8 +70,8 @@ ports in external scans.
The [README quick start](../README.md#quick-start) runs a single container The [README quick start](../README.md#quick-start) runs a single container
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
reverse proxy, no `./certs` bind mount — Postfix still starts, but the Status reverse proxy, no `./certs` bind mount — Postfix still starts, but Overview
page will report missing TLS material until you mount PEM files at and Health will report missing TLS material until you mount PEM files at
`/etc/postfix/tls/fullchain.pem` and `privkey.pem`. `/etc/postfix/tls/fullchain.pem` and `privkey.pem`.
The one-time setup link is always printed as The one-time setup link is always printed as
@@ -71,8 +80,8 @@ The one-time setup link is always printed as
scheme to `http://127.0.0.1:8080/setup/<token>` — the path token is what scheme to `http://127.0.0.1:8080/setup/<token>` — the path token is what
matters; the hostname in the printed URL is not reachable as written. matters; the hostname in the printed URL is not reachable as written.
**What works:** the full panel — setup, domains, applications, deliveries view, **What works:** the full panel — setup, domains, applications, outbound log,
mail queue, system log. **What does not:** reliable outbound delivery to the queue, system log. **What does not:** reliable outbound delivery to the
public internet (no PTR, no real DNS for your domains, port 25 may be blocked public internet (no PTR, no real DNS for your domains, port 25 may be blocked
on your network, HELO does not match anything receivers trust). on your network, HELO does not match anything receivers trust).
@@ -98,8 +107,9 @@ On first start the one-time setup URL is printed in the container log
container — `./data/setup-token` on the host, mode `0600` — then deleted when container — `./data/setup-token` on the host, mode `0600` — then deleted when
setup completes. The link is `https://<SELFPOST_HOSTNAME>/setup/<token>` (path setup completes. The link is `https://<SELFPOST_HOSTNAME>/setup/<token>` (path
token, not a query string), valid for ten minutes. Open it to choose the token, not a query string), valid for ten minutes. Open it to choose the
administrator username and password — until then the panel has no login. If administrator username and password — until then the panel has no login, and
this host ships container logs to a central aggregator, prefer reading the its sign-in page says there is no administrator yet and points at the setup
link. If this host ships container logs to a central aggregator, prefer reading the
file: file:
```sh ```sh
@@ -164,8 +174,8 @@ cat ./data/setup-token
**4. DNS and sending.** Before sending real mail: **4. DNS and sending.** Before sending real mail:
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status 1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Server ›
page → *Re-check*) — see [Server-level DNS](#server-level-dns-ptrrdns). Health → *Re-check DNS*) — see [Server-level DNS](#server-level-dns-ptrrdns).
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the 2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)).
3. Warm up a new IP gradually ([IP warmup](#ip-warmup)). 3. Warm up a new IP gradually ([IP warmup](#ip-warmup)).
@@ -193,18 +203,18 @@ expected to set; defaults match the code exactly.
|---|---|---|---| |---|---|---|---|
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` | | `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
| `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 › Domains* 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 *Outbound › DMARC reports*. 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. When inbound relay is also enabled, **`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`** applies to the shared listener instead. | `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 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` | 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`. The panel reads it to report on the *Spam filter* box of the inbound pages and to check that the filter answers (Server › Health). | *(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). The *Spam filter* box states which. | `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`). 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` | | `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 under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` | | `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` | | `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` | | `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
@@ -311,98 +321,172 @@ Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
## Instance administration ## Instance administration
After sign-in the panel opens on **Status** — the place to answer "is the After sign-in the global role opens on **Overview** — the place to answer "is
service healthy and will mail be accepted?" the service healthy and will mail be accepted?" A user with the *domain* role
opens on the first list they reach: Outbound › Domains, or Inbound › Domains
when that is all they are assigned.
### In-panel help ### The panel
The panel carries short operator notes so day-to-day work does not require The menu is grouped by mail direction, and a path reads like the menu:
opening this guide. **Help** in the navigation opens an index; each Status or `/<group>/<page>`, an entity under its list, an action under the thing it
domain card's «?» opens the same text in a side drawer (CSS only, no extra changes.
script). That content explains what a check or control *means* — kernel
counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate | Menu | Pages | Path |
limits, and similar. It is not a second copy of this document: installation, |---|---|---|
| Overview | Overview | `/overview` |
| Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` |
| Inbound | Domains | `/inbound/domains` |
| Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` |
| User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` |
A page a user cannot reach, or a feature that is off, leaves no gap in the
menu: a group with nothing in it is absent. *Inbound* appears only with
`INBOUND_RELAY_ENABLE=true` and *DMARC reports* only with
`DMARC_REPORTS_ENABLE=true`. Everything under `/server/`, and Overview and the
queue, answer 404 to a user with the *domain* role — the same answer a missing
page gets.
Pages that used to be one are now several. The old Status page is **Overview**
(one verdict per check) and **Server › Health** (the tables behind them). The
old Settings page is **Account** (your own name, e-mail and password) and
**Server › Settings** (the instance). A domain has its own page, **Domain
settings** under it, and an **application** form of its own under the domain.
### Help
**Help** (user menu) is a page of topics, one box per section of the panel —
Overview, Outbound, Inbound, Server, Account — and a user is shown the sections
their menu shows. The **?** in the head of a box opens the topic about that box
on the Help page, scrolled to it. The topics explain what a check
or control *means* — kernel counters, TLS on port 465, forward-confirmed PTR,
DNS publish hints, rate limits, DMARC report addresses, the spam filter, and
similar. They are not a second copy of this document: installation,
reverse-proxy setup, backup/restore, environment variables, and security reverse-proxy setup, backup/restore, environment variables, and security
assumptions stay here in `docs/guide.md` (linked from the drawer and Help assumptions stay here in `docs/guide.md`.
page).
### Status ### Overview and Health
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS **Overview** (`/overview`, global role) shows the stamp — the worst of its
certificate validity and expiry, milter socket presence, and a short Postfix checks — and one card per check: machine (processor and memory), supervised
queue summary. The **Machine** card adds the resource usage of the host processes (Postfix, OpenDKIM, panel), TLS certificate, queue, milter sockets
underneath — processor (core and thread counts), memory and swap, and and reverse DNS. A check the panel could not run shows as a warning, never as
per-interface network throughput and totals — read from the kernel's fine. Below the cards are the outbound domain list and, with the inbound relay
counters; CPU and throughput are measured between refreshes, so they appear on, the inbound one. Each card leads to its table on **Server › Health**
one refresh after the page opens. A fully busy processor or a machine out of (`/server/health`) — the queue card to the Outbound queue — and Health has the
memory is a warning here, because both delay or kill the mail path; readings:
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS) — see
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
Postfix sender map from the database (and inbound relay maps when
`INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel
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 - **Machine** — processor (core and thread counts), memory and swap, and
per-interface network throughput and totals, read from the kernel's
counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning, because both delay or kill the mail path; throughput is
only reported.
- **Processes**, **TLS certificate** (validity and expiry) and **Milter
sockets** (OpenDKIM and the journal-milter).
- **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR
record the internet publishes for this server's IP (forward-confirmed
reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use
*Re-check DNS* after changing DNS.
- **Configuration** — the **Reload configuration** button re-applies OpenDKIM
tables and the Postfix sender map from the database (and inbound relay maps
when `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the
panel shows after manual edits under `/data`. It does **not** rebuild DMARC
Postfix maps; a full restore Resync does (see [Restore](#restore)).
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still ### Queue and System log
trying to deliver or deferring. A card at the top states this instance's
retry policy — first retry delay, later backoff cap, how long a message - **Outbound › Queue** (`/outbound/queue`, global role) — live view of
stays in the queue — from `postconf -h`, read once when the panel starts. messages Postfix is still trying to deliver or deferring, as `postqueue -p`
A `postconf -e` override inside the container is visible after the next lists them. It only shows: nothing there deletes or forces a message. A box
panel (or container) restart. There is no maximum attempt count: Postfix states this instance's retry policy — first retry delay, later backoff cap,
retries until the message is delivered or the queue lifetime runs out. how long a message stays in the queue — from `postconf -h`, read once when
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and the panel starts. A `postconf -e` override inside the container is visible
related daemon lines). The log rotates daily (14 files kept) with a after the next panel (or container) restart. There is no maximum attempt
`postfix reload` after each rotation; a background loop checks every six count: Postfix retries until the message is delivered or the queue lifetime
hours. It lives in the data volume, so it survives a container recreate along runs out.
with the rest of the state — `./data/log/` on the host — but it is *not* - **Server › System log** (`/server/log`) — tail of `/data/log/mail.log`
included in backups: it is diagnostics, not state. (Postfix and related daemon lines). The log rotates daily (14 files kept)
with a `postfix reload` after each rotation; a background loop checks every
six hours. It lives in the data volume, so it survives a container recreate
along with the rest of the state — `./data/log/` on the host — but it is
*not* included in backups: it is diagnostics, not state.
### Account
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
(username and e-mail) and **Password**. Saving the profile does not ask for the
password; changing the password asks for the current one and is rate-limited
like a sign-in. The e-mail is the user's own address. Nothing is sent to it
yet; it is what a domain's *Use my e-mail* button fills in (see [Domain-level
DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins are separate
and are not changed here.
### Settings ### Settings
`/settings` changes the signed-in user's username and/or password. **Global **Server › Settings** (`/server/settings`, global role) holds what is true for
administrators** also set the panel-wide default DMARC report address (`rua=`) the whole instance: how many days the **Outbound log** keeps its rows before the
offered when a domain doesn't set its own — see background sweep deletes them (7–365 days; takes effect on the next six-hour
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc) — and how many days prune cycle without a container restart), and, read-only, the level-1 rate
**Deliveries** rows are kept before the background sweep deletes them (7–365 limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
days; takes effect on the next six-hour prune cycle without a container There is no panel-wide DMARC report address: each domain has its own.
restart). Application SASL logins are separate and are not changed here.
### Users ### Users
`/users` (global administrator only) creates, edits, and deletes panel users. **Server › Users** (`/server/users`, global role only) creates, edits, and
There are two roles: deletes panel users. Everyone in the panel is an administrator, so the two
roles name a *reach*, not a rank, and are called `global` and `domain` in the
list, the role select and the page headers:
- **Global administrator** — full access to every page and every domain, - **global** — every page and every domain: Overview, Server (Health, System
including Users, Backup, Status, Mail queue, System log, Help, Inbound (when log, Backup, Users, Settings), the Outbound queue, and every outbound and
the inbound relay flag is on), and DMARC (when `DMARC_REPORTS_ENABLE=true`). inbound domain. The only role that adds or deletes a domain and that manages
- **Domain-admin** — scoped to one or more domains assigned by a global users.
administrator. Sees only those domains' pages, applications, and - **domain** — only the domains assigned to the user, outbound and inbound
Deliveries rows; cannot add or delete domains. `/users`, `/backup`, **separately**: the user form has one list for each, and the same name on
`/status`, `/mail-queue`, `/system-log`, `/inbound`, and `POST /reload` are both lists is two assignments. Each list has **All**, which includes domains
not reachable (404). A domain-admin can *export* the added later; with it ticked the rows below are ignored. A *domain* user needs
domains assigned to them — see at least one domain, or *All*, on either list. A group with nothing assigned
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain). is absent from that user's menu.
The panel refuses to remove or demote the **last** global administrator, so What the *domain* role reaches inside what is assigned:
it can never end up with none.
| | `global` | `domain` |
|---|---|---|
| Overview, Server, the Outbound queue | yes | no (404) |
| Outbound: domain page, applications, Domain settings, log | every domain | assigned domains |
| Outbound › DMARC reports | every report, with the ingest statistics | reports of assigned domains, without the ingest box |
| Inbound: domain page, upstream, recipients | every domain | assigned inbound domains |
| Add or delete a domain, either direction | yes | no |
| Export a domain (Domain settings) | yes | assigned domains |
| Import a domain (Server › Backup) | yes | no |
| Account, Help | yes | yes |
A domain that is not assigned, one that does not exist, and a user with no
reach in that direction all get the same 404.
The panel refuses to remove or demote the **last** global user, so it can never
end up with none. A user cannot delete the account they are signed in with.
The user form also sets the user's e-mail, which the users list shows.
### Sessions ### Sessions
A login survives a container restart: sessions live in SQLite, not in A login survives a container restart: sessions live in SQLite, not in
memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default memory. A session belongs to the user it was created for, by id: deleting the
seven days) with no absolute lifetime cap — an admin who keeps using the panel user ends their sessions, and renaming them keeps theirs. Expiry is a sliding
stays signed in indefinitely. HTMX polling on the monitoring screens idle window (`PANEL_SESSION_IDLE_DAYS`, default seven days) with no absolute
(Deliveries, Mail queue, System log, and the Status health fragment) does lifetime cap — a user who keeps using the panel stays signed in indefinitely.
**not** count as activity, so an auto-refreshing tab left open will not keep a HTMX polling on the monitoring screens (Overview, Health, Outbound log and
session alive forever. Changing **your own** password on `/settings` signs out queue, System log) does **not** count as activity, so an auto-refreshing tab
every other session for that user but leaves the current browser signed in. left open will not keep a session alive forever.
Signing out (`POST /logout`) ends only the current session — other browsers or
tabs for the same user keep working until their session rows expire. - Changing **your own** password on **Account** signs out every other session
of yours but leaves the current browser signed in.
- A password that a global user sets for **another** user on the user form
ends all of that user's sessions; set for oneself, it behaves like the
previous item.
- Signing out (`POST /logout`) ends only the current session — other browsers or
tabs for the same user keep working until their session rows expire.
### Upgrading ### Upgrading
@@ -418,7 +502,8 @@ The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
catches a dead mail path that would still leave the HTTP server up, but it catches a dead mail path that would still leave the HTTP server up, but it
does **not** verify TLS certificates, DNS records, or end-to-end delivery — does **not** verify TLS certificates, DNS records, or end-to-end delivery —
use the authenticated [Status](#status) page for that. External monitoring use the authenticated [Overview and Health](#overview-and-health) pages for
that. External monitoring
can use the same endpoint through the reverse proxy if you expose it, or poll can use the same endpoint through the reverse proxy if you expose it, or poll
`docker inspect` health state on the host. `docker inspect` health state on the host.
@@ -429,9 +514,10 @@ its mail hostname. Most receiving mail servers weigh this heavily; get it
from whoever assigns the IP (hosting provider's panel/support), not from from whoever assigns the IP (hosting provider's panel/support), not from
your own DNS zone. your own DNS zone.
The [Status](#status) page verifies the server's hostname against this **Server › Health** verifies the server's hostname against this record
record (forward-confirmed reverse DNS). Results are cached for about one (forward-confirmed reverse DNS), and Overview shows the verdict. Results are
minute; use *Re-check* right after publishing a record. cached for about one minute; use *Re-check DNS* right after publishing a
record.
Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc).
@@ -468,9 +554,9 @@ deploy files — without that mount, *Full backup* refuses with an error.
There are four ways to capture a full backup. They differ in whether the There are four ways to capture a full backup. They differ in whether the
instance keeps running, what goes into the archive, and what happens on restore. instance keeps running, what goes into the archive, and what happens on restore.
**1. Panel — while the container is running (no downtime).** *Backup* → *Full **1. Panel — while the container is running (no downtime).** *Server › Backup*
backup* (optionally tick *Encrypt with a password*). Same archive as the CLI → *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
below. archive. Same archive as the CLI below.
**2. `selfpost-backup` CLI — while the container is running (no downtime).** **2. `selfpost-backup` CLI — while the container is running (no downtime).**
The panel button and this command call the same code path: a consistent SQLite The panel button and this command call the same code path: a consistent SQLite
@@ -552,8 +638,8 @@ blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM
tables and Postfix's sender map are re-derived from SQLite (inbound relay maps tables and Postfix's sender map are re-derived from SQLite (inbound relay maps
when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`) when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
and both daemons are reloaded, healing any drift between the extracted files and and both daemons are reloaded, healing any drift between the extracted files and
the database. The Status page's *Reload configuration* button resyncs OpenDKIM, the database. The *Reload configuration* button on Server › Health resyncs
the sender map, and inbound maps only — not DMARC maps. This is why the 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)).
@@ -619,34 +705,35 @@ the resulting `.tar.gz` — see [Encrypting a backup or
export](#encrypting-a-backup-or-export) for the decrypt command's password export](#encrypting-a-backup-or-export) for the decrypt command's password
options. options.
**Archives from older SelfPost versions** (flat layout: `manifest.json` and **Archives and data directories from 1.x cannot be restored into 2.0.** The
`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore database is refused at start and left untouched — 2.0 starts from an empty data
with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project directory. Carry each domain over with its
that already has `docker-compose.yml` and `.env`. [export file](#exporting-and-importing-a-single-domain) instead.
Restoring an archive taken **before** a session row was removed can bring Restoring an archive taken **before** a session row was removed can bring
that session back: session rows travel with the backup, and a browser that that session back: session rows travel with the backup, and a browser that
still holds the matching cookie is signed in again on the next request if the still holds the matching cookie is signed in again on the next request if the
restored row's idle expiry has not passed. `POST /logout` removes only the restored row's idle expiry has not passed. `POST /logout` removes only the
current session; there is no "logout everywhere". Changing your own password current session; there is no "logout everywhere" — see
on `/settings` deletes your other sessions, but a global administrator [Sessions](#sessions) for what ends a user's sessions.
resetting another user's password on `/users` does not invalidate that user's
existing sessions.
See also [Exporting and importing a single See also [Exporting and importing a single
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
operation that also lives on the *Backup* page (`/backup`). operation: *Export domain* is on the domain's settings page, *Import a domain*
on *Server › Backup* (`/server/backup`).
Both a full backup and a domain export are **secrets** — they contain the Both a full backup and a domain export are **secrets** — they contain the
admin password hash (full backup), TLS private keys and `.env` (full backup), password hashes of the panel's users (full backup), TLS private keys and `.env`
or working application credentials (domain export) in the clear or in (full backup), or working application credentials (domain export) in the clear
directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them or in directly reversible form. Treat them like any other credential material:
around — and encrypt them, which SelfPost can do for you. restrict who can read them, don't email them around — and encrypt them, which
SelfPost does unless you untick the box.
#### Encrypting a backup or export #### Encrypting a backup or export
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the Both download forms carry an **Encrypt with a password** checkbox, ticked by
file that comes down is an encrypted envelope instead of the plain archive: default. Ticked, the file that comes down is an encrypted envelope instead of
the plain archive; unticked, it is the plain archive:
| Artefact | Plain | Encrypted | | Artefact | Plain | Encrypted |
|----------|-------|-----------| |----------|-------|-----------|
@@ -686,27 +773,33 @@ With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
### Inbound relay ### Inbound relay
Optional backup-MX / forwarder: Postfix accepts mail on port **25** for Optional backup-MX / forwarder: Postfix accepts mail on port **25** for
domains you list under *Inbound* and hands each message to the upstream host domains you list under *Inbound › Domains* and hands each message to the
you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never upstream host you configure. It is **not** mailboxes, IMAP, or webmail —
stores the message locally. SelfPost never 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 inbound-relay `smtp inet` listener, no container. Until then there is no inbound-relay `smtp inet` listener, no
*Inbound* item in the nav, and `/inbound` is 404. (Port 25 can still listen *Inbound* group in the menu, and `/inbound/…` is 404. (Port 25 can still
when only `DMARC_REPORTS_ENABLE=true` — see [DMARC reports](#dmarc-reports).) listen when only `DMARC_REPORTS_ENABLE=true` — see [DMARC
Outbound 465/587 is unchanged. reports](#dmarc-reports).) Outbound 465/587 is unchanged.
**Panel** (`/inbound`, global administrator only): add a domain, set the **Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
upstream host/port and TLS to that hop (opportunistic / required / off), and domain's page, set the upstream host/port and TLS to that hop (opportunistic /
choose recipients — an allow-list, or any address at that domain. A domain required / off), and choose recipients — an allow-list, or any address at that
with an empty upstream is kept in the database but is **not** published into domain. Inbound domains are assigned to a user with the *domain* role
Postfix maps, so mail is never accepted with nowhere to send it. separately from outbound ones (see [Users](#users)); such a user sees and
changes the upstream and recipients of the inbound domains assigned to them,
and cannot add or delete one. The upstream may be any host and port, including
loopback and private addresses — see the accepted risks in
[security.md](security.md). A domain with an empty upstream is kept in the
database but is **not** published into Postfix maps, so mail is never accepted
with nowhere to send it.
**DNS.** Unlike sending domains, an inbound domain needs an **MX** record that **DNS.** Unlike sending domains, an inbound domain needs an **MX** record that
points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish
(`10 <hostname>.`) and a check that succeeds when *any* MX host matches this (`10 <hostname>.`) and a check that succeeds when *any* MX host matches this
server — other MX targets (a primary mail server) are fine; this is how server — other MX targets (a primary mail server) are fine; this is how
backup-MX is meant to work. Use *Re-check* after publishing. backup-MX is meant to work. Use *Re-check DNS* after publishing.
**Not an open relay.** The inbound smtpd offers no SASL. It accepts only **Not an open relay.** The inbound smtpd offers no SASL. It accepts only
domains in `relay_domains` and only listed recipients (`relay_recipient_maps`); domains in `relay_domains` and only listed recipients (`relay_recipient_maps`);
@@ -729,10 +822,23 @@ only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
defer instead. defer instead.
**Inbound mail is not in Deliveries.** Port 25 runs its own milter chain: the The panel only reports this setting; it does not change it. Every inbound
optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed domain page has a **Spam filter** box — on or off, what happens to mail while
the filter is down, and that the filter belongs to the server, not to the
domain — and the domain list says in its head whether the filter is on. The
filter's address (`INBOUND_ANTISPAM_MILTER`) is shown to the global role only,
so a user with the *domain* role sees the state without the server's internals.
For the global role the filter is also one of the milter sockets on **Server ›
Health** and in the Overview card: the panel connects to the address (one
second at most, nothing is sent) and, if there is no answer, says whether
inbound mail is being deferred (`tempfail`) or going through unfiltered
(`accept`).
**Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain:
the optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
mail (and a signing outage therefore cannot defer it), and the journal-milter mail (and a signing outage therefore cannot defer it), and the journal-milter
does not file it — [Deliveries](#deliveries) stays a record of what *you* sent. does not file it — the [Outbound log](#outbound-log) stays a record of what
*you* sent.
This also keeps a forged `From:` on inbound mail from counting against a This also keeps a forged `From:` on inbound mail from counting against a
sending domain's [level-2 rate limit](#rate-limiting--level-2-domain-and-application). sending domain's [level-2 rate limit](#rate-limiting--level-2-domain-and-application).
@@ -749,26 +855,42 @@ are not stored. This is separate from [Inbound relay](#inbound-relay) — no
backup-MX, no forwarding upstream. backup-MX, no forwarding upstream.
**Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the **Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the
container. Until then there is no report ingest, no *DMARC* item in the nav, container. Until then there is no report ingest, no *DMARC reports* entry in
and `/dmarc` is 404. Outbound 465/587 is unchanged. the Outbound menu, and `/outbound/dmarc` is 404. Outbound 465/587 is unchanged.
**Addresses.** In *Settings* (global administrator), set the default **Addresses.** A domain's report address (`rua=`) is one of three choices, set
`rua=` mailbox to an address on `SELFPOST_HOSTNAME`, e.g. in that domain's **Domain settings** (Outbound › Domains › the domain › Domain
`dmarc-reports@mail.example.com`. Per domain you can choose **SelfPost hosted** settings) and nowhere else:
(`dmarc-reports+<domain>@<hostname>`) under *Domain settings → DMARC reports*.
Only those allow-listed addresses are accepted on port 25. - **No reports** — the state of a new domain; `rua=` is left out of the DMARC
record.
- **SelfPost hosted** — `dmarc-reports+<domain>@<hostname>`, an address on this
server whose reports are parsed and shown under *DMARC reports*. Offered only
while `DMARC_REPORTS_ENABLE=true`.
- **A specific address** — any mailbox, typed for this domain; it receives the
raw XML. Beside the field, *Use my e-mail* fills it with the e-mail of your
profile (Account); it only fills the field, and saving is the form's own
button. The button is absent when your profile has no e-mail.
Changing the choice changes the DMARC record to publish; the domain page shows
the new value. There is no panel-wide or per-user default. Only addresses on
`SELFPOST_HOSTNAME` are accepted on port 25: the generic
`dmarc-reports@<hostname>` and any address on that host that a domain has
chosen.
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with **DNS.** Publish the usual `_dmarc` TXT on each sending domain with
`rua=mailto:…` pointing at your hosted address. Receivers deliver to the `rua=mailto:…` pointing at the address the domain shows. Receivers deliver to the
address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report
address domain if different) so reports reach this server. If a hub domain address domain if different) so reports reach this server. If a hub domain
authorises external destinations, publish `_report._dmarc` there too; the panel authorises external destinations, publish `_report._dmarc` there too; the panel
checks it on the domain page. checks it on the domain page.
**Panel.** *DMARC* (global administrator) lists recent reports and ingest **Panel.** *Outbound › DMARC reports* lists recent reports. The global role also
health. Open a domain's roll-up from the list or from *View DMARC reports* on sees the *Ingest* box — whether ingest works, the last report received, reports
the domain page. Domain administrators see reports only for domains assigned to kept this week and parse failures. Open a domain's roll-up from the list or
them. Summaries are pruned (500 kept, 90 days max). from *DMARC reports* in the domain page's menu. A user with the *domain* role
sees the reports of the domains assigned to them, without the *Ingest* box.
Summaries are pruned (500 kept, 90 days max).
**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest **Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest
alone, `check_recipient_access` permits only configured report addresses; alone, `check_recipient_access` permits only configured report addresses;
@@ -780,7 +902,7 @@ predictable, so the report XML is treated as untrusted input: a report is
accepted only when the domain it claims in `policy_published` is a sending accepted only when the domain it claims in `policy_published` is a sending
domain configured here, and — when it arrived at a per-domain hosted address — domain configured here, and — when it arrived at a per-domain hosted address —
only when that domain matches the address's `+tag`. Anything else is refused only when that domain matches the address's `+tag`. Anything else is refused
and counted in *parse failures* on the DMARC page, so a stranger cannot file and counted in *parse failures* on the DMARC reports page, so a stranger cannot file
reports under your domain or push genuine ones out through the retention cap. reports under your domain or push genuine ones out through the retention cap.
Reports are accepted as gzip, zip, or plain XML. Reports are accepted as gzip, zip, or plain XML.
@@ -789,17 +911,32 @@ Parsed report data lives in SQLite and is included in a
## Domain administration ## Domain administration
### Domains page ### Outbound domains
`/domains` lists sending domains and hosts the add-domain form (**global **Outbound › Domains** (`/outbound/domains`) lists sending domains with the
administrator only**). Domain administrators see only domains assigned to verdict of each DNS check, the DKIM selector, the number of applications and
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL recent mail, and hosts the add-domain form (**global role only**). A user with
applications. Per-domain rate limits (level 2), per-application limits, and the *domain* role sees only the domains assigned to them. Three pages belong
optional client IP allow-lists are configured here — see [Rate limiting — to a domain:
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads - **The domain page** (`/outbound/domains/{id}`) — the *DNS records* to
one back in (**global administrator only**) — see [Exporting and importing a single publish and what is in DNS now, the *Applications* (SASL logins) with *New
domain](#exporting-and-importing-a-single-domain). password* and *Delete* in their rows, and the *Connection* settings. *Re-check
DNS* and *Add application* are in its head; its menu leads to the domain's
log and DMARC reports.
- **The application form** (`…/applications/new`, `…/applications/{aid}`) —
who the application may send as, its client IP allow-list and its rate limit
are validated and saved together, or not at all, also when the application
is created. A password is shown once, on the page that answers the creation
or *New password*, and is never shown again.
- **Domain settings** (`…/settings`) — the *DMARC report address* (see [DMARC
reports](#dmarc-reports)), the domain's *Rate limit* (see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application)), *Export domain*
and, for the global role, *Delete domain*, which asks once more.
*Export domain* writes a single-domain file; *Import a domain* on *Server ›
Backup* reads one back in (**global role only**) — see [Exporting and importing
a single domain](#exporting-and-importing-a-single-domain).
### Domain-level DNS (SPF, DKIM, DMARC) ### Domain-level DNS (SPF, DKIM, DMARC)
@@ -810,12 +947,14 @@ records; see [Inbound relay](#inbound-relay).
- **SPF** — a TXT record on the domain authorizing this server to send on its - **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup). behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
- **DKIM** — a TXT record with the exact value the panel shows on that - **DKIM** — a TXT record with the exact value the panel shows on that
domain's page (`domain page → DKIM TXT record`), one selector per domain. domain's page (*DNS records* box), one selector per domain.
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring - **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
only, safe to publish immediately). Set `rua=` to receive aggregate reports: only, safe to publish immediately). Set `rua=` to receive aggregate reports
with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address by choosing the domain's report address in *Domain settings*: with [DMARC
from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a reports](#dmarc-reports) enabled, the SelfPost-hosted address; otherwise an
mailbox elsewhere that receives inbound mail. If `rua=` points at another address typed for the domain — a mailbox elsewhere that receives mail (*Use
my e-mail* fills in your profile's) — or no reports, which leaves `rua=` out.
The record the page shows follows that choice. If `rua=` points at another
domain, publish `_report._dmarc` on that hub domain too; the panel checks domain, publish `_report._dmarc` on that hub domain too; the panel checks
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
report destinations. report destinations.
@@ -825,10 +964,10 @@ lands in spam even though SelfPost delivered it correctly — DKIM passing
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
panel, add its DNS records at the same time**, not later. panel, add its DNS records at the same time**, not later.
Each domain's page shows a *DNS status* card comparing the published DKIM Each domain's page has a *DNS records* box comparing the published DKIM
record against the key this server signs with, plus the domain's SPF, record against the key this server signs with, plus the domain's SPF,
DMARC, and (when configured) DMARC report-authorisation records. Results are DMARC, and (when configured) DMARC report-authorisation records. Results are
cached for a few minutes; use *Re-check* right after publishing a record. cached for a few minutes; use *Re-check DNS* right after publishing a record.
The SPF check is deliberately shallow — it looks for a mechanism that The SPF check is deliberately shallow — it looks for a mechanism that
literally covers this server's address and does not follow `include:` or literally covers this server's address and does not follow `include:` or
`redirect=`, so a record that authorizes the server through an include is `redirect=`, so a record that authorizes the server through an include is
@@ -848,12 +987,13 @@ public internet, not something SelfPost's configuration can shortcut.
### Rate limiting — level 2 (domain and application) ### Rate limiting — level 2 (domain and application)
Level 2 is optional, configured on each domain's page, and layers on top of Level 2 is optional, configured in each domain's settings (the domain) and
on each application's form (the application), and layers on top of
the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop). the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop).
Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values
and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix
returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as returns a 4xx and the refusal is recorded in the [Outbound log](#outbound-log)
`rejected`. as `rejected`.
**Level 2 — domain** — a message ceiling and window for **every** client IP **Level 2 — domain** — a message ceiling and window for **every** client IP
sending as that domain. When unset, only level 1 applies for non-privileged sending as that domain. When unset, only level 1 applies for non-privileged
@@ -896,43 +1036,45 @@ accept rather than defer when it cannot reach it. Level 1 is the backstop
that keeps working even when level 2 cannot run, and it is the only one of that keeps working even when level 2 cannot run, and it is the only one of
the two that does not depend on the panel process. the two that does not depend on the panel process.
### Deliveries ### Outbound log
`/deliveries` is a searchable send log with server-side filters by domain **Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
and application. A row identifies its message and nothing more — time, filters by domain and application; a user with the *domain* role sees only the
rows of their domains. It refreshes its rows without resetting a filter being
chosen. A row identifies its message and nothing more — time,
sender, recipient, subject and status `queued` (accepted, not yet sender, recipient, subject and status `queued` (accepted, not yet
delivered), `sent` (handed off successfully), `deferred` (Postfix is delivered), `sent` (handed off successfully), `deferred` (Postfix is
retrying), `bounced` (final failure), or `rejected` (refused — for example retrying), `bounced` (final failure), or `rejected` (refused — for example
by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application)); by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
*Details* opens that row's own page (`/deliveries/{id}`). That page carries *Details* opens that row's own page (`/outbound/log/{id}`). That page carries
the sending domain, the application it was submitted under, the Postfix the sending domain, the application it was submitted under, the Postfix
queue id and the journal id, beside the message's history — when it was queue id and the journal id, beside the message's history — when it was
accepted and what Postfix later reported for the recipient. A `deferred` accepted and what Postfix later reported for the recipient. A `deferred`
or `bounced` row includes this Postfix's retry intervals (first delay, or `bounced` row includes this Postfix's retry intervals (first delay,
backoff cap, queue lifetime), the same numbers Mail queue shows; domain backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
administrators see them here because they cannot open Mail queue. Under with the *domain* role see them here because they cannot open the queue. Under
both sit the `mail.log` lines for its queue id: the connection to the both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed receiving server, the server's reply, and the status that reply was filed
as. Rows outlive `mail.log`, so an older message's lines may have rotated as. Rows outlive `mail.log`, so an older message's lines may have rotated
away; the page says so. Retention is set on **Settings** (global away; the page says so. Retention is set under **Server › Settings** (global
administrator); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
default until it is changed there. is changed there.
### Exporting and importing a single domain ### Exporting and importing a single domain
Domain page → *Export domain* to write the file, *Backup* → *Import a Domain settings → *Export domain* to write the file, *Server › Backup* →
domain* to read it back in. This moves one domain — its DKIM key, its *Import a domain* to read it back in. This moves one domain — its DKIM key, its
applications' **working** SASL passwords, and configured **rate limits** applications' **working** SASL passwords, and configured **rate limits**
(mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost (mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost
instance without regenerating anything, so DNS (the DKIM TXT record) instance without regenerating anything, so DNS (the DKIM TXT record)
doesn't need to change. Unlike a full restore (see [Full backup and doesn't need to change. Unlike a full restore (see [Full backup and
restore](#full-backup-and-restore)), this works across different restore](#full-backup-and-restore)), this works across different
hostnames/instances. *Import* is global-administrator only; *export* is hostnames/instances. *Import* is for the global role only; *export* is
available to any user who can access the domain, **including a domain-admin** available to any user who can access the domain, **including a user with the
for a domain assigned to them — so a domain-admin can walk away with that *domain* role** for a domain assigned to them — so that user can walk away with
domain's working SASL passwords in the clear. Weigh that when deciding which the domain's working SASL passwords, in the clear if they untick the encryption
domains to assign to a domain-admin account. box. Weigh that when deciding which domains to assign to a *domain* user.
A domain export is a secret in the same way a full backup is, and can be A domain export is a secret in the same way a full backup is. The *Encrypt with
encrypted the same way — see [Encrypting a backup or a password* box is ticked by default, and the export is sealed the same way —
export](#encrypting-a-backup-or-export). see [Encrypting a backup or export](#encrypting-a-backup-or-export).
+1 -1
View File
@@ -531,7 +531,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [ ] Technical review of the whole against the mockups, evidence pairs complete — **Opus** - [ ] 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] Security review of the route rename, subtree authorization and inbound delegation — **Fable**
- [ ] 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] 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**
- [ ] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku** - [ ] `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 **After 2.0** — features land in the kit, each from its own plan, mockup first
+4 -4
View File
@@ -59,7 +59,7 @@ Explicitly excluded to prevent scope creep:
- Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes) - Inbound mail (IMAP/POP3, mailboxes, delivery to user inboxes)
- Webmail - Webmail
- Organisations / tenancy beyond global + domain-admin roles; managing - Organisations / tenancy beyond the `global` and `domain` roles; managing
**multiple sending domains** under one global administrator is in scope (see **multiple sending domains** under one global administrator is in scope (see
below) below)
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may - Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
@@ -67,9 +67,9 @@ Explicitly excluded to prevent scope creep:
- A custom MTA — Postfix is used as-is - A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only - Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
The **domain-admin** role ships in the current line (global administrator plus The **`domain`** role ships in the current line (a `global` user plus
domain administrators with assigned domains). The optional **inbound relay** `domain` users with assigned domains, outbound and inbound separately). The
(backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind optional **inbound relay** (backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
`INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log `INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log
retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and
**auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report **auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report
+6 -4
View File
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — | | panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 24/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 25/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
@@ -277,8 +277,8 @@ is in the table below; the rest of it shipped in
| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode | | Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode |
| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` | | Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` |
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not | | Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
| UI | `.pair` (DMARC templates) and `.split` (every other page) are two names for one layout | | UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
| UI | Two `back_link` elements in a row in `dmarc_domain.html` | | UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
| UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does | | UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does |
**Boundary:** no feature work and no behaviour change an operator would notice, **Boundary:** no feature work and no behaviour change an operator would notice,
@@ -397,7 +397,9 @@ requests are rate-limited, and it passes a security review.
**Dependencies / risks:** the account e-mail; an account-takeover surface where **Dependencies / risks:** the account e-mail; an account-takeover surface where
there was none — security review is part of the item, not an afterthought. there was none — security review is part of the item, not an afterthought.
Not drawn yet. Not drawn yet. Changing the account e-mail must then require the current
password (2.0 security review), because the e-mail becomes where a reset link
goes and today Account saves it without the password.
**Version:** `2.x` MINOR; `candidate`. **Version:** `2.x` MINOR; `candidate`.
+123 -21
View File
@@ -16,7 +16,17 @@ whole diff from 1.3.0 (`7052395`) to HEAD past 1.9.1 — the inbound-relay path
limiting, application auth-IP allow-lists, and the new panel surface limiting, application auth-IP allow-lists, and the new panel surface
(handlers, templates, settings): no exploitable findings; two deliberate (handlers, templates, settings): no exploitable findings; two deliberate
behaviours the pass examined are now recorded under Accepted risks behaviours the pass examined are now recorded under Accepted risks
(unauthenticated DMARC ingest, fail-open auth-IP enforcement). (unauthenticated DMARC ingest, fail-open auth-IP enforcement). The 2.0 review
(Fable, 2026-10-10; fixed and re-checked by other models) covered the route
rename, the `/server/` subtree guard, inbound delegation and the new pages: no
Critical or High finding; sessions were keyed by user name (now by user id),
a password change ended other users' sessions, a password set for a user on
the user form did not end that user's sessions, internal errors reached forms a
`domain` user can open, and `/static/` listed its files — all fixed
(see [Authentication and sessions](#authentication-and-sessions) and [Errors
shown to users](#errors-shown-to-users)); two deliberate behaviours are
recorded under Accepted risks (upstream of a delegated inbound domain, profile
saved without the password).
(2) **Accepted risks** — (2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not deliberate departures beyond the mandatory, recorded so the decision is not
lost. lost.
@@ -74,6 +84,72 @@ The panel is exposed to the internet — the items below are **not optional**.
`SameSite`. `SameSite`.
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding - Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
idle timeout (`PANEL_SESSION_IDLE_DAYS`). idle timeout (`PANEL_SESSION_IDLE_DAYS`).
- **A session belongs to a user by id** (`sessions.user_id`, deleted with the
user). The user is loaded on every request and a session is renewed only
after it was found, so the cookie of a deleted user is dead and cannot become
the session of whoever is created under the same name later; renaming a user
leaves their sessions alone.
- **What ends a user's sessions:** deleting the user (all); a password change on
Account (that user's *other* sessions — never another user's); a password set
for them by a global user on the user form (all of theirs, except the current
one when they set their own); signing out (the current one only). There is no
"sign out everywhere", and a restored backup brings back the session rows it
held.
- Polling by the monitoring screens (`HX-Request` `GET`s) does not renew a
session.
### Roles
Everyone who signs in to the panel is an administrator, so the two roles name a
*reach*, never a rank: `global` and `domain` (in the users list, the role
select, the page headers and these documents — not "admin"). A `domain` user is
assigned outbound domains and inbound domains **separately**, each list with
*All* (which includes domains added later and widens the list, not the role).
| | `global` | `domain` |
|---|---|---|
| Overview, Server (Health, System log, Backup, Users, Settings, kit page) | yes | no — 404 |
| Outbound queue | yes | no — 404 (a queue is not filtered by domain) |
| Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains |
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box |
| Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains |
| Add or delete a domain, either direction; import a domain | yes | no |
| Account, Help | yes | yes |
- A path a user may not reach, a domain or application that does not exist,
and one that belongs to another tenant answer the **same 404**, so the panel
does not confirm what exists. A group with nothing assigned is absent from
the user's menu.
- `/server/` is guarded once, by `globalOnly` where its routes are registered
(a route outside the subtree cannot be added through that helper), and every
handler checks again. Outbound and inbound handlers check per object
(`lookupDomain`, `requireInboundDomain`) and lists are filtered by assignment
in the query. Each denied path has a test for a `domain` user, including
another tenant's id.
- The panel will not remove or demote the last `global` user.
- A `domain` user can export an assigned domain, and an export carries the
working application passwords (encrypted unless the box is unticked): assign
domains with that in mind.
### State-changing requests
- Every state change is a `POST`; a `GET` never changes state (the delete
confirmation pages only render a form).
- Every non-read request first passes the origin check
(`originAllowed`, [internal/web/security.go](../internal/web/security.go)):
`Sec-Fetch-Site` must be `same-origin`, or `Origin` must name the panel's own
host; the session cookie is `SameSite` as well.
- There are **no CSRF tokens yet**. The decision, its price and the conditions
for revisiting it are in the accepted risks and the ADR below.
### Errors shown to users
A page shows a validation message (what to fix) and nothing else of an error. A
database, `saslpasswd2` or Postfix-reload failure is logged and the page says
only that the change could not be saved and to check the logs. This holds on
every form a `domain` user can open. The global-only Backup page (full backup,
domain import) still shows the underlying message of an import failure, so the
operator can act on it.
### Output and process ### Output and process
@@ -103,11 +179,12 @@ The panel is exposed to the internet — the items below are **not optional**.
### Backup and domain export ### Backup and domain export
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the - Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
administrator's password hash, `docker-compose.yml`, `.env`, and the TLS panel users' password hashes, `docker-compose.yml`, `.env`, and the TLS
private key from `certs/` when present; a domain export carries the DKIM key private key from `certs/` when present; a domain export carries the DKIM key
and **working** application passwords in the clear (otherwise a transfer and **working** application passwords in the clear (otherwise a transfer
without recreating credentials would be impossible). without recreating credentials would be impossible).
- Both downloads can be encrypted with a password (a checkbox on the form): - Both downloads are encrypted with a password unless the box on the form is
unticked (it is ticked by default):
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
authenticated with the header, the chunk number, and an end-of-stream flag — authenticated with the header, the chunk number, and an end-of-stream flag —
a truncated or substituted file fails to open instead of silently restoring a a truncated or substituted file fails to open instead of silently restoring a
@@ -134,7 +211,7 @@ deferred item from the roadmap.
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.** - **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
A client that sends neither — a genuinely old browser, or a webview with a A client that sends neither — a genuinely old browser, or a webview with a
frozen engine — stays vulnerable to CSRF from any site. Accepted frozen engine — stays vulnerable to CSRF from any site. Accepted
deliberately: every panel user (global or domain-admin) is an operator who deliberately: every panel user (role `global` or `domain`) is an operator who
picks their own browser, not an untrusted party the panel needs to defend picks their own browser, not an untrusted party the panel needs to defend
against, and a strict mode would not "protect" such a client, it would against, and a strict mode would not "protect" such a client, it would
simply break the panel in it. Tightening is one line in `originAllowed` simply break the panel in it. Tightening is one line in `originAllowed`
@@ -144,7 +221,7 @@ deferred item from the roadmap.
neighbouring-subdomain case but depends on browser behaviour; a token does neighbouring-subdomain case but depends on browser behaviour; a token does
not. The price is a hidden field in roughly two dozen forms. The trigger to not. The price is a hidden field in roughly two dozen forms. The trigger to
revisit is a requirement for protection that holds regardless of the browser, revisit is a requirement for protection that holds regardless of the browser,
or a domain-admin population the global administrator does not fully trust or a population of `domain` users the global user does not fully trust
(see the ADR below). A token would not save the panel from XSS inside it (see the ADR below). A token would not save the panel from XSS inside it
either: code executing in the panel's origin sends the request itself — either: code executing in the panel's origin sends the request itself —
against that, `html/template` auto-escaping and CSP do the work, which is why against that, `html/template` auto-escaping and CSP do the work, which is why
@@ -162,20 +239,24 @@ deferred item from the roadmap.
gate every one of these `POST`s whether or not JavaScript ran. Progressive gate every one of these `POST`s whether or not JavaScript ran. Progressive
enhancement means the panel must work with JavaScript off; a enhancement means the panel must work with JavaScript off; a
server-rendered confirmation step would need a second page (or a `?confirm=1` server-rendered confirmation step would need a second page (or a `?confirm=1`
round trip) for every one of these forms, which is what round trip) for every one of these forms, which is what the delete pages
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and ([`user_delete.html`](../internal/web/view/templates/user_delete.html),
`domain_delete.html` already do for the two highest-blast-radius deletes. [`out_domain_delete.html`](../internal/web/view/templates/out_domain_delete.html),
- **Encrypting backups and exports is an option, not the default.** With the [`in_domain_delete.html`](../internal/web/view/templates/in_domain_delete.html))
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an already do for the highest-blast-radius deletes.
operator with nowhere to keep a password would lose the ability to take a - **Encrypting backups and exports can be switched off.** The panel's
backup at all, and a permanently undecryptable archive is worse than an *Encrypt with a password* box is ticked by default, but with it cleared the
unencrypted one: SelfPost does not store the password. The trigger to make file downloads in the clear, and the `selfpost-backup` CLI writes a plain
encryption mandatory is a second administrator (at which point "who archive unless it is given a password. Otherwise an operator with nowhere to
downloaded it" stops being one person). keep a password would lose the ability to take a backup at all, and a
permanently undecryptable archive is worse than an unencrypted one:
SelfPost does not store the password. The trigger to make encryption
mandatory is a second administrator (at which point "who downloaded it"
stops being one person).
- **A journal row left without delivery lines is closed as `bounced` rather - **A journal row left without delivery lines is closed as `bounced` rather
than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to than left as it is.** The "forever `queued`" risk is gone: `mail.log` moved to
`/data/log/` and survives container recreation, and the log tailer keeps its `/data/log/` and survives container recreation, and the log tailer keeps its
read position (`logtail_state`, migration `0003`), so the tail is read after a read position (`logtail_state`), so the tail is read after a
start. What remains are rows whose delivery lines are lost for good (the log start. What remains are rows whose delivery lines are lost for good (the log
rotated past 14 files while the panel was down, or was deleted): the rotated past 14 files while the panel was down, or was deleted): the
reconciliation against `postqueue -p` sees the message is not in the queue and reconciliation against `postqueue -p` sees the message is not in the queue and
@@ -223,14 +304,35 @@ deferred item from the roadmap.
client IPs, but neither message bodies nor headers; it is excluded from client IPs, but neither message bodies nor headers; it is excluded from
backups (`log/` is skipped) so that a dump stays state rather than backups (`log/` is skipped) so that a dump stays state rather than
diagnostics. diagnostics.
- **A `domain` user may point an inbound domain's upstream at any host and
port.** The upstream form checks that the host is a well-formed name or IP
and the port is 1–65535; loopback, private and link-local addresses are
allowed, so a `domain` user with an assigned inbound domain can make this
server's Postfix open SMTP connections to any address it can reach and hand
mail for that domain to it. Accepted deliberately: delegation is within the trusted-operator model — a `domain`
user is an operator the `global` user chose to give that domain, and the
upstream of a backup-MX is by nature a host on someone's private network.
The connection is made by Postfix and carries only mail for that domain; the
panel itself makes no request to the upstream. Trigger to revisit: `domain`
users who are not trusted operators — the same condition as for CSRF tokens
— at which point an allow-list of upstream networks, or restricting the
field to the global role, is the fix.
- **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. Today the e-mail is only the address the *Use my e-mail*
button fills in, so a stolen session gains nothing it did not have. 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.
## ADR: CSRF via origin checking, without tokens ## ADR: CSRF via origin checking, without tokens
**Context.** The panel is forms (`POST`) with a cookie session — the classic **Context.** The panel is forms (`POST`) with a cookie session — the classic
CSRF surface. What is needed is a way to tell a request from the panel's own CSRF surface. What is needed is a way to tell a request from the panel's own
page apart from one initiated by a third-party site in a logged-in user's page apart from one initiated by a third-party site in a logged-in user's
browser. The panel is multi-user since 1.2.0 (a global administrator plus browser. The panel is multi-user since 1.2.0 (a `global` user plus
zero or more domain-admin users, each scoped to their assigned domains), but zero or more `domain` users, each scoped to their assigned domains), but
that is an authorization boundary (who can see or change what), not a change that is an authorization boundary (who can see or change what), not a change
to the CSRF threat: the attacker in scope here is still an external site to the CSRF threat: the attacker in scope here is still an external site
riding a legitimate user's cookie, not one panel user attacking another riding a legitimate user's cookie, not one panel user attacking another
@@ -244,7 +346,7 @@ There are no session-bound tokens embedded in forms. The check applies the same
way regardless of the requesting user's role. way regardless of the requesting user's role.
**Why not tokens.** Cross-user CSRF is not the threat model here: a **Why not tokens.** Cross-user CSRF is not the threat model here: a
domain-admin's browser sending a request still needs that domain-admin's own `domain` user's browser sending a request still needs that user's own
cookie, so a token would not add a boundary between roles that the cookie, so a token would not add a boundary between roles that the
authorization checks (`Principal.CanAccessDomain`, authorization checks (`Principal.CanAccessDomain`,
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route [internal/web/auth/principal.go](../internal/web/auth/principal.go); route
@@ -264,8 +366,8 @@ genuinely old browser, or a webview with a frozen engine) stays vulnerable — s
such a client, at the price of a narrow residual surface. such a client, at the price of a narrow residual surface.
**Revisit if:** a requirement appears for protection that does not depend on **Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or domain-admin accounts stop being trusted operators (for browser behaviour, or `domain` accounts stop being trusted operators (for
example, if a future release lets a global administrator invite domain-admins example, if a future release lets a `global` user invite `domain` users
whose browsers/devices are not vetted) — at that point cross-role request whose browsers/devices are not vetted) — at that point cross-role request
forgery inside the panel would need its own analysis, separate from the forgery inside the panel would need its own analysis, separate from the
external-site case this ADR covers. external-site case this ADR covers.