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
address of its own or follows a named user's default
(`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` /
`all_inbound_domains` flags are in the schema for the inbound delegation
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
Settings page. Account has three forms, each its own POST: profile
(username, e-mail), password, and the default DMARC report address
(SelfPost hosted, my account e-mail, another address, none). Saving the
profile no longer asks for the password; changing the password still does.
(SelfPost hosted, my account e-mail, another address, none) (replaced before
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
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
@@ -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`,
`users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner,
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
each row of a domain's Applications table again, as POST forms that look
like the links beside them.
@@ -151,20 +159,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
export file.
- The panel role `domain_admin` is stored as `domain`.
- 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
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
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,
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
- 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
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
sign every other user out. A password set by an administrator on the user
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
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
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
reported source.
- 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
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
- **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`,
`/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
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
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help drawer and `/help` page for Status and domain controls
- 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)
- 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
- 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
+272 -59
View File
@@ -114,10 +114,10 @@ One process, four roles:
parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`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,
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/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`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
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
4. **rate-limit recalc** — every six hours (and on demand from the domain
page), recomputes level-2 **Auto** rate limits from send-log statistics.
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
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
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
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
@@ -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
serving panel pages on request: `TailLines` (the last *n* lines, for
`/system-log`) and `QueueLines` (the lines carrying one queue-id, for
`/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file —
`/server/log`) and `QueueLines` (the lines carrying one queue-id, for
`/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
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
@@ -197,61 +197,227 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
unless noted. Routes marked **global** return **404** for domain administrators
(`requireGlobal()` in
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
below is a summary — HTMX fragment endpoints
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`.
Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is
two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method
included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and
building them only takes method values, so the route guard test can run it on
a bare `Server` and ask which pattern answers a path:
| 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) |
| `/license` | Embedded `LICENSE` text (no auth) |
| `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/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. |
| 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) |
| Overview | `GET /overview`, `GET /overview/fragment` |
| Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` |
| Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` |
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` |
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` |
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` |
| 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` |
| Account | `GET /account` · `POST /account/profile`, `/account/password` |
| User menu | `GET /help` · `POST /logout` (public mux) |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
session idle timeout (only non-`HX-Request` GET and mutating requests count as
activity). The Mail queue retry-policy card is outside that fragment: it is the
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
not a live re-read.
An application is addressed under its domain, and an application id that does
not belong to the domain in the path is a 404. The application form is one
`POST`: sender, client IPs and rate limit are validated and saved together or
not at all, also at creation. The page that shows a new password is the
response to the `POST` that created or regenerated it; it has no `GET` path and
carries `Cache-Control: no-store`.
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
Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random
token; the database stores **SHA-256 of the token**, not the token itself — a
stolen DB or backup archive does not alone grant login, but a browser that still
holds the cookie works after process restart, redeploy, or full backup restore.
Stored in SQLite (`sessions` table of the baseline, `0001_init.sql`): the cookie
holds a random token; the database stores **SHA-256 of the token**, not the
token itself — a stolen DB or backup archive does not alone grant login, but a
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
absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change on `/settings`** — changing your own password deletes
every other session for that user; the current session stays active
- **Renewal** — the DB `expires_at` and the cookie `Max-Age` update at most once per
hour (`renewThreshold` in
[internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change on Account** — deletes every other session of that user;
the current one stays active
([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
A global administrator resetting another user's password on `/users` updates
the hash but does not delete that user's existing sessions.
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
- **A password set by a global user on the user form** deletes all of that
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
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,
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
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
`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
@@ -295,7 +461,7 @@ flowchart TB
dmarcSvc["internal/dmarc"]
end
subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"]
store["internal/store — SQLite, embedded schema"]
end
subgraph adapters ["Adapters — the only infrastructure-aware code"]
postfix["internal/postfix"]
@@ -344,7 +510,7 @@ single-connection trade-off that follows from it.
| 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 |
| `opendkim/` | DKIM keys + tables |
| `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
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);
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
`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`)
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
`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
`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,
its counter and an end-of-stream flag (so truncation and reordering fail to
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
`.json` → `.spde` (SelfPost domain export); the plain forms remain the
default. Domain import detects the envelope by magic bytes; an encrypted full
backup is converted back with `selfpost-backup -decrypt` before restore.
`.json` → `.spde` (SelfPost domain export). In the panel the *Encrypt with a
password* box is ticked by default on both downloads and the plain form is
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)
([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)
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`
`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
+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
container running with a working reverse proxy and TLS), **[Instance
administration](#instance-administration)** (running and maintaining the
SelfPost server itself — status, backups, users, upgrades), and **[Domain
administration](#domain-administration)** (day-to-day work on the sending
domains hosted on that instance — DNS, deliveries, rate limits, applications).
SelfPost server itself — the panel's layout, health, backups, users,
upgrades), and **[Domain administration](#domain-administration)** (day-to-day
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
@@ -21,8 +27,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Environment variables](#environment-variables)
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
- [Instance administration](#instance-administration)
- [Status](#status)
- [Mail queue and System log](#mail-queue-and-system-log)
- [The panel](#the-panel)
- [Help](#help)
- [Overview and Health](#overview-and-health)
- [Queue and System log](#queue-and-system-log)
- [Account](#account)
- [Settings](#settings)
- [Users](#users)
- [Sessions](#sessions)
@@ -37,11 +46,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Inbound relay](#inbound-relay)
- [DMARC reports](#dmarc-reports)
- [Domain administration](#domain-administration)
- [Domains page](#domains-page)
- [Outbound domains](#outbound-domains)
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
- [IP warmup](#ip-warmup)
- [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)
## Installation
@@ -61,8 +70,8 @@ ports in external scans.
The [README quick start](../README.md#quick-start) runs a single container
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
reverse proxy, no `./certs` bind mount — Postfix still starts, but the Status
page will report missing TLS material until you mount PEM files at
reverse proxy, no `./certs` bind mount — Postfix still starts, but Overview
and Health will report missing TLS material until you mount PEM files at
`/etc/postfix/tls/fullchain.pem` and `privkey.pem`.
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
matters; the hostname in the printed URL is not reachable as written.
**What works:** the full panel — setup, domains, applications, deliveries view,
mail queue, system log. **What does not:** reliable outbound delivery to the
**What works:** the full panel — setup, domains, applications, outbound log,
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
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
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
administrator username and password — until then the panel has no login. If
this host ships container logs to a central aggregator, prefer reading the
administrator username and password — until then the panel has no login, and
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:
```sh
@@ -164,8 +174,8 @@ cat ./data/setup-token
**4. DNS and sending.** Before sending real mail:
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
page → *Re-check*) — see [Server-level DNS](#server-level-dns-ptrrdns).
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Server ›
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
same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)).
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` |
| `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` |
| `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` |
| `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 *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_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_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.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). 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_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_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` |
| `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` |
| `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: 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` |
| `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
After sign-in the panel opens on **Status** — the place to answer "is the
service healthy and will mail be accepted?"
After sign-in the global role opens on **Overview** — the place to answer "is
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
opening this guide. **Help** in the navigation opens an index; each Status or
domain card's «?» opens the same text in a side drawer (CSS only, no extra
script). That content explains what a check or control *means* — kernel
counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate
limits, and similar. It is not a second copy of this document: installation,
The menu is grouped by mail direction, and a path reads like the menu:
`/<group>/<page>`, an entity under its list, an action under the thing it
changes.
| Menu | Pages | Path |
|---|---|---|
| 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
assumptions stay here in `docs/guide.md` (linked from the drawer and Help
page).
assumptions stay here in `docs/guide.md`.
### Status
### Overview and Health
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
certificate validity and expiry, milter socket presence, and a short Postfix
queue summary. The **Machine** card adds the resource usage of the host
underneath — 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 here, because both delay or kill the mail path;
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)).
**Overview** (`/overview`, global role) shows the stamp — the worst of its
checks — and one card per check: machine (processor and memory), supervised
processes (Postfix, OpenDKIM, panel), TLS certificate, queue, milter sockets
and reverse DNS. A check the panel could not run shows as a warning, never as
fine. Below the cards are the outbound domain list and, with the inbound relay
on, the inbound one. Each card leads to its table on **Server › Health**
(`/server/health`) — the queue card to the Outbound queue — and Health has the
readings:
### 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
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
stays in the queue — from `postconf -h`, read once when the panel starts.
A `postconf -e` override inside the container is visible after the next
panel (or container) restart. There is no maximum attempt count: Postfix
retries until the message is delivered or the queue lifetime runs out.
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (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.
### Queue and System log
- **Outbound › Queue** (`/outbound/queue`, global role) — live view of
messages Postfix is still trying to deliver or deferring, as `postqueue -p`
lists them. It only shows: nothing there deletes or forces a message. A box
states this instance's retry policy — first retry delay, later backoff cap,
how long a message stays in the queue — from `postconf -h`, read once when
the panel starts. A `postconf -e` override inside the container is visible
after the next panel (or container) restart. There is no maximum attempt
count: Postfix retries until the message is delivered or the queue lifetime
runs out.
- **Server › System log** (`/server/log`) — tail of `/data/log/mail.log`
(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` changes the signed-in user's username and/or password. **Global
administrators** also set the panel-wide default DMARC report address (`rua=`)
offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc) — and how many days
**Deliveries** rows are kept before the background sweep deletes them (7–365
days; takes effect on the next six-hour prune cycle without a container
restart). Application SASL logins are separate and are not changed here.
**Server › Settings** (`/server/settings`, global role) holds what is true for
the whole instance: how many days the **Outbound log** keeps its rows before the
background sweep deletes them (7–365 days; takes effect on the next six-hour
prune cycle without a container restart), and, read-only, the level-1 rate
limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
There is no panel-wide DMARC report address: each domain has its own.
### Users
`/users` (global administrator only) creates, edits, and deletes panel users.
There are two roles:
**Server › Users** (`/server/users`, global role only) creates, edits, and
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,
including Users, Backup, Status, Mail queue, System log, Help, Inbound (when
the inbound relay flag is on), and DMARC (when `DMARC_REPORTS_ENABLE=true`).
- **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/status`, `/mail-queue`, `/system-log`, `/inbound`, and `POST /reload` are
not reachable (404). A domain-admin can *export* the
domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
- **global** — every page and every domain: Overview, Server (Health, System
log, Backup, Users, Settings), the Outbound queue, and every outbound and
inbound domain. The only role that adds or deletes a domain and that manages
users.
- **domain** — only the domains assigned to the user, outbound and inbound
**separately**: the user form has one list for each, and the same name on
both lists is two assignments. Each list has **All**, which includes domains
added later; with it ticked the rows below are ignored. A *domain* user needs
at least one domain, or *All*, on either list. A group with nothing assigned
is absent from that user's menu.
The panel refuses to remove or demote the **last** global administrator, so
it can never end up with none.
What the *domain* role reaches inside what is assigned:
| | `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
A login survives a container restart: sessions live in SQLite, not in
memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
seven days) with no absolute lifetime cap — an admin who keeps using the panel
stays signed in indefinitely. HTMX polling on the monitoring screens
(Deliveries, Mail queue, System log, and the Status health fragment) does
**not** count as activity, so an auto-refreshing tab left open will not keep a
session alive forever. Changing **your own** password on `/settings` signs out
every other session for that user but leaves the current browser signed in.
Signing out (`POST /logout`) ends only the current session — other browsers or
tabs for the same user keep working until their session rows expire.
memory. A session belongs to the user it was created for, by id: deleting the
user ends their sessions, and renaming them keeps theirs. Expiry is a sliding
idle window (`PANEL_SESSION_IDLE_DAYS`, default seven days) with no absolute
lifetime cap — a user who keeps using the panel stays signed in indefinitely.
HTMX polling on the monitoring screens (Overview, Health, Outbound log and
queue, System log) does **not** count as activity, so an auto-refreshing tab
left open will not keep a session alive forever.
- 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
@@ -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
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 —
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
`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
your own DNS zone.
The [Status](#status) page verifies the server's hostname against this
record (forward-confirmed reverse DNS). Results are cached for about one
minute; use *Re-check* right after publishing a record.
**Server › Health** verifies the server's hostname against this record
(forward-confirmed reverse DNS), and Overview shows the verdict. Results are
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
[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
instance keeps running, what goes into the archive, and what happens on restore.
**1. Panel — while the container is running (no downtime).** *Backup* → *Full
backup* (optionally tick *Encrypt with a password*). Same archive as the CLI
below.
**1. Panel — while the container is running (no downtime).** *Server › Backup*
→ *Full backup*. *Encrypt with a password* is ticked; untick it for a plain
archive. Same archive as the CLI below.
**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
@@ -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
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
the database. The Status page's *Reload configuration* button resyncs OpenDKIM,
the sender map, and inbound maps only — not DMARC maps. This is why the
the database. The *Reload configuration* button on Server › Health resyncs
OpenDKIM, the sender map, and inbound maps only — not DMARC maps. This is why the
compose file pins a fixed tag rather than `:latest`: without a known version,
there'd be no way to tell which image restoring a given backup actually requires
(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
options.
**Archives from older SelfPost versions** (flat layout: `manifest.json` and
`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore
with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project
that already has `docker-compose.yml` and `.env`.
**Archives and data directories from 1.x cannot be restored into 2.0.** The
database is refused at start and left untouched — 2.0 starts from an empty data
directory. Carry each domain over with its
[export file](#exporting-and-importing-a-single-domain) instead.
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
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
current session; there is no "logout everywhere". Changing your own password
on `/settings` deletes your other sessions, but a global administrator
resetting another user's password on `/users` does not invalidate that user's
existing sessions.
current session; there is no "logout everywhere" — see
[Sessions](#sessions) for what ends a user's sessions.
See also [Exporting and importing a single
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
admin password hash (full backup), TLS private keys and `.env` (full backup),
or working application credentials (domain export) in the clear or in
directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them
around — and encrypt them, which SelfPost can do for you.
password hashes of the panel's users (full backup), TLS private keys and `.env`
(full backup), or working application credentials (domain export) in the clear
or in directly reversible form. Treat them like any other credential material:
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
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the
file that comes down is an encrypted envelope instead of the plain archive:
Both download forms carry an **Encrypt with a password** checkbox, ticked by
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 |
|----------|-------|-----------|
@@ -686,27 +773,33 @@ With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
### Inbound relay
Optional backup-MX / forwarder: Postfix accepts mail on port **25** for
domains you list under *Inbound* and hands each message to the upstream host
you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never
stores the message locally.
domains you list under *Inbound › Domains* and hands each message to the
upstream host you configure. It is **not** mailboxes, IMAP, or webmail —
SelfPost never stores the message locally.
**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
*Inbound* item in the nav, and `/inbound` is 404. (Port 25 can still listen
when only `DMARC_REPORTS_ENABLE=true` — see [DMARC reports](#dmarc-reports).)
Outbound 465/587 is unchanged.
*Inbound* group in the menu, and `/inbound/…` is 404. (Port 25 can still
listen when only `DMARC_REPORTS_ENABLE=true` — see [DMARC
reports](#dmarc-reports).) Outbound 465/587 is unchanged.
**Panel** (`/inbound`, global administrator only): add a domain, set the
upstream host/port and TLS to that hop (opportunistic / required / off), and
choose recipients — an allow-list, or any address at that domain. 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.
**Panel** (`/inbound/domains`): the global role adds and deletes domains. On a
domain's page, set the upstream host/port and TLS to that hop (opportunistic /
required / off), and choose recipients — an allow-list, or any address at that
domain. Inbound domains are assigned to a user with the *domain* role
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
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
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
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
defer instead.
**Inbound mail is not in Deliveries.** Port 25 runs its own milter chain: the
optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
The panel only reports this setting; it does not change it. Every inbound
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
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
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.
**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,
and `/dmarc` is 404. Outbound 465/587 is unchanged.
container. Until then there is no report ingest, no *DMARC reports* entry in
the Outbound menu, and `/outbound/dmarc` is 404. Outbound 465/587 is unchanged.
**Addresses.** In *Settings* (global administrator), set the default
`rua=` mailbox to an address on `SELFPOST_HOSTNAME`, e.g.
`dmarc-reports@mail.example.com`. Per domain you can choose **SelfPost hosted**
(`dmarc-reports+<domain>@<hostname>`) under *Domain settings → DMARC reports*.
Only those allow-listed addresses are accepted on port 25.
**Addresses.** A domain's report address (`rua=`) is one of three choices, set
in that domain's **Domain settings** (Outbound › Domains › the domain › Domain
settings) and nowhere else:
- **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
`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 if different) so reports reach this server. If a hub domain
authorises external destinations, publish `_report._dmarc` there too; the panel
checks it on the domain page.
**Panel.** *DMARC* (global administrator) lists recent reports and ingest
health. Open a domain's roll-up from the list or from *View DMARC reports* on
the domain page. Domain administrators see reports only for domains assigned to
them. Summaries are pruned (500 kept, 90 days max).
**Panel.** *Outbound › DMARC reports* lists recent reports. The global role also
sees the *Ingest* box — whether ingest works, the last report received, reports
kept this week and parse failures. Open a domain's roll-up from the list or
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
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
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
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 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
### Domains page
### Outbound domains
`/domains` lists sending domains and hosts the add-domain form (**global
administrator only**). Domain administrators see only domains assigned to
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
applications. Per-domain rate limits (level 2), per-application limits, and
optional client IP allow-lists are configured here — see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads
one back in (**global administrator only**) — see [Exporting and importing a single
domain](#exporting-and-importing-a-single-domain).
**Outbound › Domains** (`/outbound/domains`) lists sending domains with the
verdict of each DNS check, the DKIM selector, the number of applications and
recent mail, and hosts the add-domain form (**global role only**). A user with
the *domain* role sees only the domains assigned to them. Three pages belong
to a domain:
- **The domain page** (`/outbound/domains/{id}`) — the *DNS records* to
publish and what is in DNS now, the *Applications* (SASL logins) with *New
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)
@@ -810,12 +947,14 @@ records; see [Inbound relay](#inbound-relay).
- **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).
- **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
only, safe to publish immediately). Set `rua=` to receive aggregate reports:
with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address
from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a
mailbox elsewhere that receives inbound mail. If `rua=` points at another
only, safe to publish immediately). Set `rua=` to receive aggregate reports
by choosing the domain's report address in *Domain settings*: with [DMARC
reports](#dmarc-reports) enabled, the SelfPost-hosted address; otherwise an
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
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
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
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,
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
literally covers this server's address and does not follow `include:` or
`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)
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).
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
returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
`rejected`.
returns a 4xx and the refusal is recorded in the [Outbound log](#outbound-log)
as `rejected`.
**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
@@ -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
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
and application. A row identifies its message and nothing more — time,
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
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
delivered), `sent` (handed off successfully), `deferred` (Postfix is
retrying), `bounced` (final failure), or `rejected` (refused — for example
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
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`
or `bounced` row includes this Postfix's retry intervals (first delay,
backoff cap, queue lifetime), the same numbers Mail queue shows; domain
administrators see them here because they cannot open Mail queue. Under
backoff cap, queue lifetime), the same numbers the Outbound queue shows; users
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
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
away; the page says so. Retention is set on **Settings** (global
administrator); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial
default until it is changed there.
away; the page says so. Retention is set under **Server › Settings** (global
role); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial default until it
is changed there.
### Exporting and importing a single domain
Domain page → *Export domain* to write the file, *Backup* → *Import a
domain* to read it back in. This moves one domain — its DKIM key, its
Domain settings → *Export domain* to write the file, *Server › Backup* →
*Import a domain* to read it back in. This moves one domain — its DKIM key, its
applications' **working** SASL passwords, and configured **rate limits**
(mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost
instance without regenerating anything, so DNS (the DKIM TXT record)
doesn't need to change. Unlike a full restore (see [Full backup and
restore](#full-backup-and-restore)), this works across different
hostnames/instances. *Import* is global-administrator only; *export* is
available to any user who can access the domain, **including a domain-admin**
for a domain assigned to them — so a domain-admin can walk away with that
domain's working SASL passwords in the clear. Weigh that when deciding which
domains to assign to a domain-admin account.
hostnames/instances. *Import* is for the global role only; *export* is
available to any user who can access the domain, **including a user with the
*domain* role** for a domain assigned to them — so that user can walk away with
the domain's working SASL passwords, in the clear if they untick the encryption
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
encrypted the same way — see [Encrypting a backup or
export](#encrypting-a-backup-or-export).
A domain export is a secret in the same way a full backup is. The *Encrypt with
a password* box is ticked by default, and the export is sealed the same way —
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**
- [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**
**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)
- 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
below)
- 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
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
The **domain-admin** role ships in the current line (global administrator plus
domain administrators with assigned domains). The optional **inbound relay**
(backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
The **`domain`** role ships in the current line (a `global` user plus
`domain` users with assigned domains, outbound and inbound separately). The
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
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
+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 | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | 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) |
**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 | `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 |
| UI | `.pair` (DMARC templates) and `.split` (every other page) are two names for one layout |
| UI | Two `back_link` elements in a row in `dmarc_domain.html` |
| 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`~~ — 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 |
**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
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`.
+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
(handlers, templates, settings): no exploitable findings; two deliberate
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** —
deliberate departures beyond the mandatory, recorded so the decision is not
lost.
@@ -74,6 +84,72 @@ The panel is exposed to the internet — the items below are **not optional**.
`SameSite`.
- Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding
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
@@ -103,11 +179,12 @@ The panel is exposed to the internet — the items below are **not optional**.
### Backup and domain export
- 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
and **working** application passwords in the clear (otherwise a transfer
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
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
@@ -134,7 +211,7 @@ deferred item from the roadmap.
- **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
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
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`
@@ -144,7 +221,7 @@ deferred item from the roadmap.
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
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
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
@@ -162,20 +239,24 @@ deferred item from the roadmap.
gate every one of these `POST`s whether or not JavaScript ran. Progressive
enhancement means the panel must work with JavaScript off; a
server-rendered confirmation step would need a second page (or a `?confirm=1`
round trip) for every one of these forms, which is what
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
`domain_delete.html` already do for the two highest-blast-radius deletes.
- **Encrypting backups and exports is an option, not the default.** With the
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
operator with nowhere to 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).
round trip) for every one of these forms, which is what the delete pages
([`user_delete.html`](../internal/web/view/templates/user_delete.html),
[`out_domain_delete.html`](../internal/web/view/templates/out_domain_delete.html),
[`in_domain_delete.html`](../internal/web/view/templates/in_domain_delete.html))
already do for the highest-blast-radius deletes.
- **Encrypting backups and exports can be switched off.** The panel's
*Encrypt with a password* box is ticked by default, but with it cleared the
file downloads in the clear, and the `selfpost-backup` CLI writes a plain
archive unless it is given a password. Otherwise an operator with nowhere to
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
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
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
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
@@ -223,14 +304,35 @@ deferred item from the roadmap.
client IPs, but neither message bodies nor headers; it is excluded from
backups (`log/` is skipped) so that a dump stays state rather than
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
**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
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
zero or more domain-admin users, each scoped to their assigned domains), but
browser. The panel is multi-user since 1.2.0 (a `global` user plus
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
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
@@ -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.
**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
authorization checks (`Principal.CanAccessDomain`,
[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.
**Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or domain-admin accounts stop being trusted operators (for
example, if a future release lets a global administrator invite domain-admins
browser behaviour, or `domain` accounts stop being trusted operators (for
example, if a future release lets a `global` user invite `domain` users
whose browsers/devices are not vetted) — at that point cross-role request
forgery inside the panel would need its own analysis, separate from the
external-site case this ADR covers.