diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..9d019d3 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "mockups", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1"], + "port": 8765 + } + ] +} diff --git a/.cursor/rules/panel-design.mdc b/.cursor/rules/panel-design.mdc new file mode 100644 index 0000000..5d04090 --- /dev/null +++ b/.cursor/rules/panel-design.mdc @@ -0,0 +1,39 @@ +--- +description: The panel's design is fixed by accepted mockups; implement it, do not reinterpret it. +globs: internal/web/view/**,docs/assets/panel-redesign/** +alwaysApply: false +--- + +# Panel design contract + +Source of truth, in this order: the mockups in +[docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html), +the [components page](docs/assets/panel-redesign/panel/components.html), +`panel/outlines.json`, then +[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md). Read the plan's +**The contract** and **Enforcement** sections before touching a template. + +You are implementing a finished design. You are not designing. + +- Build pages only from Bulma classes in the `BULMA` list of `panel/check.py` + and from the `sp-` components on the components page, through their + partials. No other class, no hand-written component markup, no inline style. +- No selector in `panel.css` that names a page or an id, uses `!important`, or + sets a width outside `WIDTH_OK`. If a page looks wrong, the layout choice is + wrong — do not patch it locally. +- If a screen seems to need a component, state or layout that does not exist: + **stop and ask.** Do not approximate it, and do not substitute a look of + your own. +- Never edit a mockup, `outlines.json`, `check.py`, the vendored Bulma or a + guard test to make your work pass. A failing guard means the implementation + is wrong. Design changes go mockup-first in a separate `design:` commit on + the owner's explicit instruction. +- "Done" is evidence, not a description: passing `go test ./internal/web/...` + output plus a screenshot of each restyled page from the running container, + saved under `docs/assets/panel-redesign/evidence/` beside its mockup's name. + Report every difference from the mockup you could not remove; do not + describe a different result as the requested one. + +After editing a fragment in `panel/src/`: `python panel/build.py`, then +`python panel/check.py` (add `--write` only when the structure change is the +point of the commit). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 256c55b..8c3eba6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,19 @@ name: release -# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). +# Publishes an immutable, version-tagged image on ghcr.io. # Ordinary commits and bare git tag pushes do not publish anything. A published # GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub). # You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z. # -# Native per-architecture builds (see docs/development.md), not qemu: -# running the full Postfix/OpenDKIM stack under emulation for the e2e gate -# below is impractically slow. Each arch builds, e2e-gates and pushes its own -# tag on its own native runner; a merge job then combines them into the one -# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not -# push-by-digest then test) is deliberate: it means the bytes that get tagged -# are exactly the bytes that passed e2e. Per-arch tags are pushed only so -# imagetools can assemble the multi-arch manifest; merge removes them from GHCR -# so operators see a single version tag (spec 10.1). +# Native per-architecture builds (see docs/development.md), not qemu: running +# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is +# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its +# own native runner; a merge job then combines them into the one manifest tag +# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest +# then test) is deliberate: it means the bytes that get tagged are exactly the +# bytes that passed e2e. Per-arch tags are pushed only so imagetools can +# assemble the multi-arch manifest; merge removes them from GHCR so operators +# see a single version tag. on: release: types: [published] diff --git a/CHANGELOG.md b/CHANGELOG.md index ddad24f..affa1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,303 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Added + +- Panel redesign, account e-mail and notifications: a user's e-mail is its own + field (2.0 baseline `users.email`, today `dmarc_report_email`) — edited under + **Account**, seen and set by the global administrator on the user form and in + the users list; DMARC may *use* it, as one choice beside the hosted address, + another address or none (Settings and domain settings). **Account** gains a + Notifications box and **Settings** a switch for the new roadmap candidate + `panel-notifications`; `password-reset` is added as a candidate too. The + inbound spam filter becomes visible: a *Spam filter* box on every inbound + domain, a tag on the inbound domains list, its socket among the milter + sockets. Preflight loses a box that only filled space — the test form now + shares its box with its result — and the plan's layout rule and contract say + so: what stands beside a form must be real. The role is labelled by its reach, + `global` or `domain` — everyone in the panel is an administrator — and each + assignment list on the user form gains **All**, a per-user flag that covers + domains added later. The DMARC default report address is a **user's** + setting, not the server's: it moves from Server › Settings to **Account** + with its report-authorization record, and a domain follows the default of a + *named* user, so two people sharing a domain never pull it two ways. +- Panel redesign, contrast: a sweep of every text node against its real ground + found the solid red button at 3:1 (Bulma derives a solid button's text from + the fill's lightness; brick had been patched, red had not), the muted grey + at 4.1:1, the amber postmark at 4.0 and Bulma's menu labels at 4.2. Solid + buttons are white on colour, muted is `#5c6774`, the postmark uses the warn + token; all 33 screens now measure 4.5:1 or better. The sweep is kept as + `panel/audit.js` — text size, contrast, overflow, clipped values, missing + icons — and its clean output joins the evidence a restyle step must show. +- Panel redesign, header (owner's choice: variant A, with the perforation on a + light edge as on the mark): the navbar carries the **wordmark** instead of + the stamp — `docs/assets/selfpost-wordmark.svg` and `-ink`, the stamp's own + outlines, recorded in `docs/product.md` — and under the brick bar runs the + stamp's paper margin with the perforation along its outer edge: round holes, + flat teeth, the ink hairline (`panel/perf-edge.svg`). All 33 screens rebuilt; + the components page documents the shell; the comparison sheet is removed. + The six health cards now split into six, three or two columns, so one card + is never left alone on a row. Three corrections after the owner's review: + the paper edge is 8px under the 60px bar — it had been scaled from the hole + size to 16px, where the mark's margin is a tenth of the brick field; the + health row is compact again (71px: smaller icons, each line a block with its + own line height, a verdict never wraps and stretches the row); and a + read-only value is as tall as the Copy button joined to it. `check.py` no + longer counts `min-width: 0` as setting a width. Text sizes: the filter + controls in a box head and the navbar's dropdown entries are 15px like the + bar itself (they had Bulma's `is-small` and its .875rem dropdown); `check.py` + now rejects `is-small` on anything but a progress bar. +- Panel redesign, type scale: about 40 % of the panel's text measured under + 13px (labels 11.7, table heads 10.5, fact labels 10.2, tags and help 11.3). + Body is now 16px with two tokens under it — `--sp-fs-s` 14px for supporting + text and `--sp-fs-xs` 13px for upper-case mono labels and tags; Bulma's + small sizes are overridden or dropped (`sp-small` replaces `is-size-7`), and + `check.py` fails any font size under the floor. Every table now sits in a + `table-container` (enforced), addresses in tables break after the `@`, the + inbound log folds the client IP under the sender; all screens fit without + scrolling at 1280px and up. Documented on the components page and in the + plan (§ Type and tables). +- Panel redesign, header comparison sheet + ([docs/assets/panel-redesign/header/](docs/assets/panel-redesign/header/index.html)): + at nav size the stamp dissolves into the brick bar and the perforated edge + was drawn inverted (round bumps instead of round holes). Three replacements + beside the current header — **A** wordmark with the stamp's real hole-to-tooth + perforation bitten into the bar, **B** wordmark on a plain bar, **C** a + stamp-paper header with an ink hairline and brick only behind the name. The + wordmark is cut from the stamp's own outlines. Pending the owner's choice; + the mockups are unchanged. `.claude/launch.json` serves the mockups locally + (`python -m http.server 8765` from the repository root). +- Panel redesign, the roadmap's screens: **Inbound › Filter lists** and the + adjusted **Inbound › Log** (client, decision, engine and reason; *Allow / + Deny sender*) for inbound-antispam-panel, **Inbound › Quarantine** (list and + held message with Release / Discard) for inbound-quarantine, and **Server › + Preflight** for preflight — all from existing components; 33 screens. Their + paths join § Routes; the quarantine and preflight plans link their mockups. + A mockup is the target look, not a decision to build: each item keeps its + status, and quarantine's open questions stay open. +- Panel redesign, two owner decisions recorded in + `docs/plans/panel-redesign.md`: **no compatibility** — the redesign is + `2.0.0`, old paths are removed without redirects, the SQLite migration chain + is replaced by one baseline and 2.0 starts from an empty data directory + (schema-squash ships as a step of this plan, without its upgrade gate; the + only live installation is the owner's); and + **inbound delegation** — a domain administrator manages assigned inbound + domains, granted separately from outbound (`user_inbound_domains`, a second + list on the user form, per-domain authorization under `/inbound/`). Mockups `users`, `user`, + `user-delete` redrawn for the two lists. Checklist `3/21`. +- Panel redesign, routes: the owner decided that paths follow the new names + and structure. `docs/plans/panel-redesign.md` § Routes maps every screen to + its new path (`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`, + `/account`) with the rules that come along — one `/fragment` name, the + application form as a single POST, 301s from old GET paths for one MINOR, + subtree `requireGlobal()` under security review, and + `TestRoutesFollowNavigation`. Mockups gained what the first pass missed: the + *Reload configuration* box (now on Server › Health), the `flash` component + and the `sp-help` link that replaces the help drawer. Checklist `1/17`. +- Panel redesign, design contract: + [docs/plans/panel-redesign.md](docs/plans/panel-redesign.md) rewritten for + the accepted direction — three layers (Bulma as shipped, adapters, own `sp-` + components), three page layouts, what an implementer must not do, the guard + tests that land before any template, a `design-first` CI step and + evidence-based acceptance. A + [components page](docs/assets/panel-redesign/panel/components.html) shows + every building block in every state; `panel/check.py` lints the mockups + against the contract and writes `panel/outlines.json`, the per-screen + component skeleton an implementation is compared with. Own modifier classes + renamed to carry the `sp-` prefix so the vocabulary is machine-checkable. + Agent rule `.cursor/rules/panel-design.mdc`; roadmap row updated (`0/15`). +- Panel redesign, stage 2: the accepted direction drawn on every screen — + 29 pages under [docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html). + Direction C (Bulma) is the base, with A's postal signature (postmark verdict, + numbered mono section heads, the stamp's perforated edge) and B's icons. + Overview keeps verdicts only and the tables move to a new **Server › Health** + page; the own password becomes **Account**; queues are drawn as tables + (needs `postqueue -j`) and **Inbound** gets its own Log and Queue. Pages are + built from fragments by `panel/build.py`; the built HTML is committed. + Mockups only; nothing in `internal/web` changes. +- Panel redesign, stage 1: three directions drawn on the three key screens + (Overview, an outbound domain, sign-in) under + [docs/assets/panel-redesign/](docs/assets/panel-redesign/index.html) — + **A** Pico CSS ("Blank"), **B** Tabler ("Console"), **C** Bulma ("Counter"), + with a cover sheet comparing size, JavaScript, CSP fit and own CSS. All three + share a navigation grouped by mail direction (Overview · Outbound · Inbound · + Server, with a symmetric Log and Queue on both sides), one block per DNS + record instead of separate "status" and "records to publish" cards, and + applications as a table. Mockups only; nothing in `internal/web` changes. + The brief behind them supersedes the "no component library" decision in + `docs/plans/panel-redesign.md`; that file is rewritten once a direction is + accepted. +- Roadmap item and givens for a panel visual redesign + ([docs/plans/panel-redesign.md](docs/plans/panel-redesign.md)) — layout + grammar (`measure` / `fill` plus Web Awesome layout CSS), no JS component + library, new mockups rather than `docs/assets/panel-ui/`. Status: candidate. +- Roadmap item and plan for `/preflight` installation check page + ([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level + infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers, + DKIM socket, test email). Status: candidate. + +## [1.9.2] - 2026-09-22 + +Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission +milter chain, DMARC reports are validated before they are stored, and the auto +rate limit is correct for a non-default level-1 window. Schema migration +`0010` (index only). Upgrading is a tag bump. + +A full-tree code review on 2026-08-19 (architecture, code quality, security, +GUI, tests, documentation, AGPL compliance; 38 findings) produced most of what +follows. Its four open items live in [roadmap.md](docs/roadmap.md); its working +document is not part of the release. + +### Added + +- SQLite migration `0010_send_log_app_login_index.sql` — + `idx_send_log_app_login_created_at`. The journal-milter counts an + application's messages on every `MAIL FROM`, and the domain page computes + per-application statistics on every render; both were scanning the + `created_at` range because `app_login` was unindexed. +- DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML + (several reporters ship a single-entry zip; those reports previously counted + as parse failures). +- e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration + inside the container for all four combinations of `INBOUND_RELAY_ENABLE` / + `DMARC_REPORTS_ENABLE` / `INBOUND_ANTISPAM_MILTER` and requires + `postfix check` to pass. The `1.9.1` crash-loop shipped in `1.4.0` and + survived three releases because the suite only ever exercised the flags-off + path. + +### Changed + +- README hero line and a **Positioning** block in `docs/product.md` record the + tagline (`YOUR SMTP. YOUR RULES.`) and hero copy; the stamp subtitle stays + `SELF-HOSTED SMTP RELAY`. `docs/assets/selfpost-proof.html` updated to match. + +### Fixed + +- The setup account's password is hashed at bcrypt cost `12` like every other + panel password; it was still being written at `bcrypt.DefaultCost` (10). The + work factor now has one definition, `auth.BcryptCost`. bcrypt records the + cost in the hash, so existing passwords keep verifying unchanged. +- Login/setup rate limiter: protecting blocked buckets from eviction is now + bounded. Blocked buckets are still never evicted to make room, but past + `maxBuckets × 4` they are evicted too (nearest expiry first) — otherwise an + attacker with many source addresses could grow the bucket map without limit + by making each address fail its way to blocked. The overflow is logged at + most once per window. +- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of + the listing, and therefore the panel's allocation, up to the queue). +- [schema-migrations.md](docs/schema-migrations.md): the schema section was + still headed `v9` while the chain head is `v10`, and three links pointed at + plan files deleted when those features shipped. +- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second + context timeout so a stuck queue command cannot hang the queue panel path + indefinitely. +- DMARC panel layout classes now exist in + `internal/web/view/static/panel.css` (`.pair`, `.attn`, `.desk-only`, + `.phone-only`, `.phone-list`, `.check-row`), so desktop/mobile DMARC views and + attention highlighting render as intended. +- `dmarc-ingest` now bounds stdin with `io.LimitReader` (10 MiB) before parsing. +- User-role update no longer contains a duplicate "last global admin" demotion + guard; one check now returns a context-specific message for self-demotion. +- Settings flash text now reflects what actually changed and only mentions + session sign-out when the password changed. +- Help page/drawer links now use `SourceURL` (`{{.SourceURL}}/blob/main/docs/...`) + instead of a hardcoded repository URL. +- Help drawer close controls include accessibility attributes (`role="button"`, + `aria-label`) for the scrim and close action. +- Test coverage expanded for DMARC map token injection rejection and email + validation edge cases (ASCII/local-part/domain/freemail cases). +- **Port 25 no longer runs the submission milter chain.** `smtpd_milters` is + now always overridden on `smtp/inet` — to the optional antispam milter, or + to nothing. Previously, with `INBOUND_ANTISPAM_MILTER` unset, inbound mail + inherited main.cf's chain: OpenDKIM (strict, `tempfail`) could defer relayed + mail during a signing outage, and the journal-milter filed inbound mail into + the outbound send log under the **sender's** domain — so Deliveries showed + other people's mail, and a forged `From:` on an inbound message consumed that + sending domain's level-2 rate-limit budget. [architecture.md](docs/architecture.md) + already described the intended behaviour; the code now matches it. +- **DMARC reports are validated before they are stored.** A report is accepted + only when the domain in `policy_published` is a sending domain configured + here and, for a per-domain hosted address, matches that address's `+tag`. + The rua address form is predictable, so anyone able to reach it could + previously file reports under any domain, skew the panel's alignment figures + and the tighten-`p=` hint, and evict genuine reports through the 500-report + retention cap. +- Auto level-2 rate limit: the ceiling is now scaled to the level-1 window + before the multiplier is applied. `ceil(avg msg/h × multiplier)` was stored + as the ceiling for a window of `RATE_LIMIT_WINDOW_SECONDS`, so any window + other than the default `3600` shifted the effective allowance by + `window/3600` (a 1800s window allowed twice the intended rate). A domain + that has sent anything now also gets a floor of 1. +- Single-domain export no longer drops rate limits silently when the store + errors — the error is returned instead of being swallowed by `err == nil && ok`. + +### Changed + +- [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code + review left unimplemented, so no finding lives only in a plan file: + `csrf-tokens` (the recorded §4.4 decision that never became a task), + `template-data-typing`, `structured-logging`, and + `review-2026-08-followups` for the small remainder (backup re-auth, the + missing service/handler/error-page tests, and nine minor findings that were + never carried into the review's own phase tables). Each roadmap row stands on + its own — file, symptom, and what "done" means — and the declined finding + (`//go:build linux` for the `/proc` reader) is recorded as declined so it is + not raised again. The review's working document is gone from `docs/plans/` + now that nothing depends on it; git history keeps it. +- Extracted `internal/configsafe` — the empty-value and forbidden-character + check every generated config file shares. Each writer keeps its own forbidden + set next to the file it protects (an OpenDKIM table and a Postfix map break on + different characters); only the check itself is shared. +- Build, deploy and migration comments no longer cite the archived + specification. The `spec N` references CHANGELOG `[0.5.0]` said were gone + survived in `build/`, `deploy/`, the first two migrations and the release + workflow; they are removed and the affected comment paragraphs re-wrapped. + Comments only — no build or configuration behaviour changes. +- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through + wrappers left behind when the two parsers were merged: nothing but their own + tests still called them. +- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and + `internal/supervisor` (supervisorctl start/signal wrappers), and reused them in + domain/postfix code paths. +- `docs/roadmap.md`: shipped `panel-docs` now references CHANGELOG history + instead of a removed plan file. +- [schema-migrations.md](docs/schema-migrations.md) notes that migration `0009` + changed what the IP list *means* — permissive (those IPs got the application + ceiling) before 1.9.0, restrictive (every other IP refused) after. Its + conversion branch only fires for a database that carried application-scope + `allowed_ips` from before 1.9.0. +- Panel help, [guide.md](docs/guide.md) and [architecture.md](docs/architecture.md): + the client IP allow-list is a **sending** control, not an authentication + boundary — the SASL login still succeeds, the check runs in the + journal-milter at `MAIL FROM`, and it is fail-open like the rate limits. + architecture.md's rate-limit description still documented the pre-1.9.0 + trusted-IP semantics. +- Removed unused duplicates of the inbound validators (`Host`, `Port`, + `TLSMode`, `RecipientMode`, `MailboxInDomain`, `NormalizeHost`) from + `internal/web/validate` — the live path uses `internal/inbound/validate.go`; + the copies were reachable only from their own tests. +- `gofmt` across `cmd/` and `internal/` (10 files had drifted); dead branch + removed from `postfix.inboundNexthop`. +- The planned inbound-antispam migration is renumbered `0011_inbound_spam_log.sql`. +- Operator and as-built docs aligned with the code after a full pass — + [architecture.md](docs/architecture.md) (port 25 opens for inbound relay + and/or DMARC ingest; antispam requires inbound relay; DMARC pipe transport + and map paths; `/help` and `/dmarc*` routes; four panel roles including + rate-limit recalc; restore Resync vs manual reload); [guide.md](docs/guide.md) + and [README.md](README.md) (image pin `1.9.1`, port 25 conditions, combined + listener limits, reload vs Resync); [roadmap.md](docs/roadmap.md) (`panel-docs` + closed at `[1.8.0]`, next item inbound-antispam-panel); [development.md](docs/development.md) + version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md) + last-updated note. No behaviour change. + +### Removed + +- Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs + and `internal/web/static/favicon.png`; nothing referenced it). +- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`, + `dmarc-reports`, `inbound-relay`, `panel-docs`, `queue-retries`, + `send-log-retention`. Only unimplemented plans remain in `docs/plans/`. + ## [1.9.1] - 2026-08-19 Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam @@ -239,6 +536,14 @@ tag bump; no schema migration. parsed aggregate report (aligned vs third-party fail), not a hub-only summary. +### Fixed + +- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped + after the manifest merge via the GitHub Packages API. The `[1.3.0]` merge job + called `docker buildx imagetools rm`, which is not a valid subcommand — cleanup + failed with a warning and the side-effect tags stayed in the registry until + this fix. + ## [1.3.0] - 2026-08-14 Security and quality after 1.2.5: domain-admin send-log authorization, @@ -256,7 +561,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration. - docs: agreed roadmap item **queue-retries** — show this Postfix's retry policy (first delay, backoff cap, queue lifetime) on Mail queue and on a delivery's history, reading `postconf -h` once at panel start so a manual - override is visible. Plan: [docs/plans/queue-retries.md](docs/plans/queue-retries.md). + override is visible. Plan: `docs/plans/queue-retries.md` (removed once shipped). Explanation only; no attempt counter and no panel knobs. Not yet implemented. @@ -323,11 +628,6 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration. the same Resync on demand. The `internal/backup` package comment now matches this behaviour. -- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped - after the manifest merge via the GitHub Packages API. The merge job had called - `docker buildx imagetools rm`, which is not a valid subcommand — cleanup failed - with a warning and the side-effect tags stayed in the registry. - ### Changed - docs: operator and as-built docs aligned with the code after a full @@ -377,15 +677,13 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration. (`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a bare git tag push no longer starts the build. `release.yml` listens for `release: published`, checks out that tag (not `main` HEAD), e2e-gates each - native arch build, merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one manifest, - then removes the per-arch tags from GHCR via the GitHub Packages API so - operators see only `ghcr.io/mixeme/selfpost:X.Y.Z` (what - `deploy/docker-compose.yml` pins). A dispatch whose version input is missing - or not `X.Y.Z` fails in `prepare`. [development.md](docs/development.md) - documents draft vs published releases, why deleting a release tag converts - it back to draft, and Gitea → GitHub tag-mirror pitfalls (do not prune release - tags on GitHub; a mirrored `v1.0.0` still runs that tag's `on: push: tags` - workflow). + native arch build, and merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one + manifest. Reliable removal of the per-arch side-effect tags from GHCR landed + in `[1.3.1]`. A dispatch whose version input is missing or not `X.Y.Z` fails + in `prepare`. [development.md](docs/development.md) documents draft vs + published releases, why deleting a release tag converts it back to draft, and + Gitea → GitHub tag-mirror pitfalls (do not prune release tags on GitHub; a + mirrored `v1.0.0` still runs that tag's `on: push: tags` workflow). - test: the authorization and sign-in surfaces that had no tests now have them. The login limiter is covered for its ceiling, its per-address scope, the reset @@ -455,7 +753,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration. RBAC in the doc-alignment pass above). Corrected stale `admin.dmarc_report_email` references in [roadmap.md](docs/roadmap.md) and - [docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's + `docs/plans/dmarc-reports.md` (removed once shipped) to the setting's actual home after migration `0005`. No behaviour change. - panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is @@ -482,7 +780,9 @@ Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration. carry muted leads and matched control height; trusted-IP help sits under the IP field. Domain settings pairs DMARC reports with the level-2 rate limit using CSS subgrid so titles, fields, and Save / Remove buttons line up across - columns. + columns. Domain page Export and Danger cards stack naturally again — a + shared-baseline flex pin on `.split` cards made mismatched body lengths look + worse than a plain stack. ## [1.2.4] - 2026-08-12 @@ -1289,9 +1589,10 @@ database, or the on-disk layout. Upgrading is a tag bump. Bookmarks to the old paths stop working. - panel: each entry in the navigation bar now carries an icon beside its label, - so the bar is scannable at a glance instead of a row of similar-length words. - The icons are inline SVG drawn in the entry's own colour — no extra request, - no exemption from the panel's Content-Security-Policy — and are hidden from + so the bar is scannable at a glance instead of a row of similar-length words — + Backup uses a filing-cabinet mark; Sign out has its own icon too. The icons + are inline SVG drawn in the entry's own colour — no extra request, no + exemption from the panel's Content-Security-Policy — and are hidden from screen readers, which still announce the label alone. - panel: the navigation bar is laid out as two rows on purpose — the signed-in @@ -1431,6 +1732,11 @@ database, or the on-disk layout. Upgrading is a tag bump. login/password and on the sending server name. - panel: the *Addresses* field is hidden while an application's address mode is *Any address of the domain*, where the server ignores it. +- deploy: `cap_add` gains `CAP_FOWNER` and `CAP_FSETID` so the entrypoint can + chmod and set the setgid bit on `/data` directories it has just chowned under + `cap_drop: ALL`, and `CAP_KILL` so supervisord can signal OpenDKIM across uids + when domains are added or removed. Production boot under the hardened compose + file exposed both gaps after Phase 10. - ci: disable provenance attestation on release image push, so the ghcr.io manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`). - ci: run `go vet` and `go test ./...` on every push to `main` and every pull diff --git a/Makefile b/Makefile index f8ab028..abb39de 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ # SelfPost build. # # The version is stamped into both binaries and MUST match the Docker image tag -# (spec 7.5.A: backup/restore compatibility check). Override on the command line: +# (architecture.md § Persistence: backup/restore compatibility check). Override +# on the command line: # # make build VERSION=1.3.0 # # CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure -# Go, so no cgo is required) — see spec 7.1. +# Go, so no cgo is required). VERSION ?= dev MODULE := github.com/mixeme/selfpost diff --git a/README.md b/README.md index 96d72d1..c6cd0fa 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,16 @@ # SelfPost -Self-hosted outbound SMTP relay with a web control panel, shipped as a single -Docker image. Postfix, OpenDKIM, and a small Go panel run together under -`supervisord`; you configure domains, DKIM keys, and SASL applications once, -then point your apps at the SMTP endpoint. +Self-hosted SMTP relay with a web panel — one Docker image, no provider. -SelfPost sends mail straight to the internet from **your own IP**, with per-domain -DKIM signing. It is outbound by default — no mailboxes or webmail. An optional -inbound relay (backup-MX / forwarder on port 25) can be turned on; it forwards -to an upstream, it does not store mail. +Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you +configure domains, DKIM keys, and SASL applications once, then point your apps +at the SMTP endpoint. + +SelfPost sends mail straight to the internet from **your own IP**, with +per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay +(backup-MX / forwarder on port 25) forwards to an upstream; it does not store +mail. **For:** operators who run their own VPS or home server and want a simple relay they control, without a third-party SMTP provider. @@ -24,6 +25,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 +- Per-application client IP allow-list for SMTP AUTH (optional) - Web panel — domains, applications, deliveries, mail queue, system log, backup - Multi-domain relay — each SASL application is bound to one sending domain - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check @@ -108,7 +112,7 @@ docker run --rm -d --name selfpost-try \ -e SELFPOST_HOSTNAME=mail.local.test \ -e PANEL_COOKIE_SECURE=false \ -v selfpost-try-data:/data \ - ghcr.io/mixeme/selfpost:1.7.0 + ghcr.io/mixeme/selfpost:1.9.2 ``` **Get the setup URL** (pick one): @@ -149,8 +153,9 @@ deployment](docs/guide.md#full-deployment) section, with proxy-specific commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). The compose file always publishes **465**, **587**, and **25**; Postfix listens -on 587 only when `SUBMISSION_ENABLE=true`, and on 25 only when -`INBOUND_RELAY_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the +on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when +`INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_ENABLE=true` (see +[Ports](docs/guide.md#ports)). Bump the pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment variables](docs/guide.md#environment-variables). diff --git a/build/Dockerfile b/build/Dockerfile index 177b6d6..25ba6f1 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # # SelfPost — single Debian-slim image running postfix + opendkim + panel under -# supervisord (spec 4). Build from the repository root: +# supervisord. Build from the repository root: # # docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . @@ -10,7 +10,7 @@ FROM golang:1.26-bookworm AS build WORKDIR /src -# Version stamped into both binaries; MUST match the image tag (spec 7.5.A). +# Version stamped into both binaries; MUST match the image tag. ARG VERSION=dev # Module metadata first for layer caching. @@ -52,13 +52,13 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \ curl \ && rm -rf /var/lib/apt/lists/* -# Unprivileged user for the panel process (spec 7.6.8). +# Unprivileged user for the panel process. RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel -# Shared group bridging the unprivileged services (spec 5.1, 6): the panel -# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the -# Postfix sender map, while OpenDKIM and Postfix (different users) must read -# them. Membership in this group — plus setgid dirs under /data (set up in +# Shared group bridging the unprivileged services: the panel generates +# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix +# sender map, while OpenDKIM and Postfix (different users) must read them. +# Membership in this group — plus setgid dirs under /data (set up in # entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read # the sasldb2/sender map, and lets the panel reach the supervisor control socket # to signal OpenDKIM/Postfix reloads without any process running as root. @@ -67,15 +67,15 @@ RUN groupadd --system selfpost \ && usermod -aG selfpost opendkim \ && usermod -aG selfpost postfix -# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the -# status page's queue card). postqueue is setgid postdrop, which normally gives -# it the group needed to reach Postfix's showq socket — but the documented -# deployment runs with `no-new-privileges`, which disables setgid transitions, -# so the panel would always see "Permission denied". Making `panel` a real -# member of postdrop grants the same access without relying on a setgid -# escalation the hardening deliberately forbids. postdrop membership is -# read-side only: it does not let the panel bypass any Postfix restriction that -# a local user does not already have through the world-executable sendmail. +# The panel reads the mail queue with `postqueue -p` (and the status page's +# queue card). postqueue is setgid postdrop, which normally gives it the group +# needed to reach Postfix's showq socket — but the documented deployment runs +# with `no-new-privileges`, which disables setgid transitions, so the panel +# would always see "Permission denied". Making `panel` a real member of postdrop +# grants the same access without relying on a setgid escalation the hardening +# deliberately forbids. postdrop membership is read-side only: it does not let +# the panel bypass any Postfix restriction that a local user does not already +# have through the world-executable sendmail. RUN usermod -aG postdrop panel # Runtime directories: milter sockets and the consolidated persistent root. @@ -118,5 +118,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 # The entrypoint fixes /data ownership (bind mount) as root, then execs -# supervisord, which becomes PID 1 and owns process supervision (spec 4). +# supervisord, which becomes PID 1 and owns process supervision. ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/build/crashexit.py b/build/crashexit.py index 368d2d3..d7f2c5e 100644 --- a/build/crashexit.py +++ b/build/crashexit.py @@ -4,7 +4,7 @@ If any managed program exhausts its restart retries and enters FATAL, this listener signals supervisord (its parent) to terminate, so the whole container exits and Docker's `restart` policy can recreate it cleanly — rather than -lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4). +lingering "alive" with a dead Postfix/OpenDKIM/panel. Communication with supervisord uses the event listener protocol over stdin and stdout, so stdout must carry only protocol tokens. diff --git a/build/entrypoint.sh b/build/entrypoint.sh index 1155e4f..ca5b0a0 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -9,14 +9,14 @@ set -e # SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must # simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the -# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and -# postfix-config.sh each fall back independently when it's unset — to -# `localhost` and to the container hostname respectively — so accounts get -# written under one realm and looked up under another and authentication -# silently fails for every application, while HELO also stops matching the -# PTR record and mail that does go out lands in spam. No fallback can be -# correct, so fail loudly here, before either side of that split has a chance -# to run, rather than leave a green panel with broken mail. +# Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each +# fall back independently when it's unset — to `localhost` and to the container +# hostname respectively — so accounts get written under one realm and looked up +# under another and authentication silently fails for every application, while +# HELO also stops matching the PTR record and mail that does go out lands in +# spam. No fallback can be correct, so fail loudly here, before either side of +# that split has a chance to run, rather than leave a green panel with broken +# mail. if [ -z "$SELFPOST_HOSTNAME" ]; then cat >&2 <<'EOF' FATAL: SELFPOST_HOSTNAME is not set. @@ -52,11 +52,11 @@ case "$SELFPOST_HOSTNAME" in ;; esac -# The persistent root /data is a host bind mount (spec 9), so it arrives owned -# by the host user (typically root), not by the unprivileged panel user that -# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8). -# Fix its ownership here — the one place still running as root — before handing -# off to supervisord, which starts the panel as the panel user. +# The persistent root /data is a host bind mount, so it arrives owned by the +# host user (typically root), not by the unprivileged panel user that actually +# writes the SQLite database, setup token and DKIM keys. Fix its ownership here +# — the one place still running as root — before handing off to supervisord, +# which starts the panel as the panel user. # # Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their # trees under /data as other users. Go's testing.TempDir is 0700, and a bare @@ -71,7 +71,7 @@ chmod 755 /data # normalised on its own below. find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} + -# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes +# DKIM key tree. The panel (user `panel`) generates keys and writes # the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the # tree on every start so it is correct whether /data is fresh, restarted, or # just restored from a backup: @@ -89,7 +89,7 @@ find /data/opendkim -type d -exec chmod 2750 {} + chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + -# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the +# Application SASL accounts. The panel (user `panel`) writes the # sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP # clients. Share it through the `selfpost` group the same way as the DKIM tree: # setgid directory so new files inherit the group, and the database itself @@ -99,8 +99,8 @@ chown -R panel:selfpost /data/sasl chmod 2750 /data/sasl [ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 -# Postfix state under /data (spec 5.1, architecture.md § Persistence). The panel -# writes sender_login_maps; Postfix owns the on-disk queue tree under queue/. +# Postfix state under /data (architecture.md § Persistence). The panel writes +# sender_login_maps; Postfix owns the on-disk queue tree under queue/. mkdir -p /data/postfix/queue [ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps # Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true). @@ -132,14 +132,14 @@ chown -R postfix:selfpost /data/log chmod 2750 /data/log find /data/log -type f -exec chmod 0640 {} + -# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must -# actually CONNECT to both milter sockets — OpenDKIM's and the panel's -# journal-milter — not just probe them at start-up. The sockets are -# created by the opendkim and panel users respectively, so bridge them to -# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean -# each socket created inside inherits group `selfpost`, and group-traversable -# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and, -# because signing is strict (default_action=tempfail), rejects all mail. +# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to +# both milter sockets — OpenDKIM's and the panel's journal-milter — not just +# probe them at start-up. The sockets are created by the opendkim and panel +# users respectively, so bridge them to `postfix` through the shared `selfpost` +# group: group-owned + setgid dirs mean each socket created inside inherits +# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without +# this, smtpd cannot talk to OpenDKIM and, because signing is strict +# (default_action=tempfail), rejects all mail. mkdir -p /run/opendkim /run/selfpost chown opendkim:selfpost /run/opendkim chown panel:selfpost /run/selfpost diff --git a/build/logrotate-loop.sh b/build/logrotate-loop.sh index 4a16970..ed59572 100644 --- a/build/logrotate-loop.sh +++ b/build/logrotate-loop.sh @@ -1,13 +1,13 @@ #!/bin/sh -# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the -# file, recreates it (`create 0640 postfix selfpost`, matching a cold container +# Periodic logrotate for /data/log/mail.log. Rotation renames the file, +# recreates it (`create 0640 postfix selfpost`, matching a cold container # start), then runs `postfix reload` (the same mechanism `postfix logrotate` # uses): postlogd keeps writing to the renamed inode until reload, and the -# panel's log-tailer holds its own descriptor on that inode, so nothing -# written before the reload is lost. `create` (rather than `nocreate`) matters -# here beyond timing: a postlogd-triggered recreate lands the file at 0600 -# owned by postfix, which the unprivileged panel process cannot read — -# confirmed on a live container — so logrotate must be the one to create it. +# panel's log-tailer holds its own descriptor on that inode, so nothing written +# before the reload is lost. `create` (rather than `nocreate`) matters here +# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by +# postfix, which the unprivileged panel process cannot read — confirmed on a +# live container — so logrotate must be the one to create it. # # logrotate itself only rotates once the configured "daily" period has elapsed # (tracked in /var/lib/logrotate/status), so it is safe to invoke this more diff --git a/build/opendkim.conf b/build/opendkim.conf index 4a6898a..82ac531 100644 --- a/build/opendkim.conf +++ b/build/opendkim.conf @@ -1,9 +1,9 @@ -# OpenDKIM — per-domain signing (spec 6). +# OpenDKIM — per-domain signing. # # Signing is strictly per-domain: each sending domain has its own key pair and # selector. The panel generates keys under /data/opendkim/keys// and # keeps KeyTable/SigningTable in sync as domains are added or removed, then asks -# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5). +# supervisord to send this process SIGUSR1 to reload the tables. # # Both table files are created (empty) by entrypoint.sh before OpenDKIM starts, # so the daemon comes up cleanly on a fresh /data with no domains yet and simply diff --git a/build/postfix-config.sh b/build/postfix-config.sh index 0c3075f..f0aaa01 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -1,16 +1,16 @@ #!/bin/sh -# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2). +# Generate the outbound-relay Postfix configuration. # # Run as root from entrypoint.sh on every container start, BEFORE supervisord # starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is # re-derived from the environment on each start rather than persisted, so a # fresh, restarted or restored container always ends up with the same config # (the only panel-edited Postfix file, sender_login_maps, lives under /data and -# is generated separately by the panel — spec 5.1). +# is generated separately by the panel). # # main.cf is written with `postconf -e`, the master.cf submission services with # `postconf -M`/`-P`. No user input is interpolated: every value here comes from -# a fixed literal or a container environment variable (spec 7.6.3). +# a fixed literal or a container environment variable. set -eu # postconf -P refuses master.cf -o values that contain whitespace; inbound port @@ -44,16 +44,16 @@ master_cf_set_smtp_inet_o() { mv /etc/postfix/master.cf.new /etc/postfix/master.cf } -# --- environment knobs (spec 8) ---------------------------------------------- +# --- environment knobs ------------------------------------------------------- # Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the # sasldb2 accounts are looked up under. The panel creates accounts under realm # $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication # fails. Fall back to the container hostname only outside a real deployment. HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" -# TLS material supplied by the reverse-proxy through a read-only bind mount -# (spec 5.2). The relay requires TLS on 465; if these files are absent the -# master still starts but TLS handshakes on 465 fail until they appear. +# TLS material supplied by the reverse-proxy through a read-only bind mount. The +# relay requires TLS on 465; if these files are absent the master still starts +# but TLS handshakes on 465 fail until they appear. # # Postfix insists the private key is root-owned and mode 0600. The bind mount # is often :ro and owned by the host user (e2e TempDir / CI runner UID), so @@ -75,8 +75,8 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem" fi -# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative -# defaults, sensible during IP warm-up (spec 10). +# Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible +# during IP warm-up. RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}" @@ -85,16 +85,16 @@ RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}" OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}" JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" -# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so -# the unprivileged panel can rewrite it and just ask for a reload. +# Persistent panel-managed sender map; texthash needs no postmap, so the +# unprivileged panel can rewrite it and just ask for a reload. SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" # Transit mail queue under /data so deferred/active messages survive container # recreate (architecture.md § Persistence). Distinct from sender_login_maps. QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}" SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}" -# Optional submission service on 587 (spec 5 p.1: off by default, enabled only -# when a client library needs STARTTLS on 587 instead of implicit TLS on 465). +# Optional submission service on 587 (off by default, enabled only when a client +# library needs STARTTLS on 587 instead of implicit TLS on 465). SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" # Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does @@ -153,13 +153,13 @@ postconf -e \ "alias_maps=" \ "alias_database=" -# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2). +# Outbound delivery: straight to the recipient MX, opportunistic TLS. postconf -e \ "smtp_tls_security_level=may" \ "smtp_tls_note_starttls_offer=yes" -# TLS server material shared by every inbound service (spec 5.2). auth_only -# guarantees credentials are never accepted before TLS is up on any port. +# TLS server material shared by every inbound service. auth_only guarantees +# credentials are never accepted before TLS is up on any port. postconf -e \ "smtpd_tls_cert_file=${TLS_CERT}" \ "smtpd_tls_key_file=${TLS_KEY}" \ @@ -167,11 +167,11 @@ postconf -e \ "smtpd_tls_auth_only=yes" \ "smtpd_tls_loglevel=1" -# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm -# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name -# Postfix uses for sender_login_maps is the BARE login the panel writes into the -# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname -# as the server realm, which equals the realm the accounts were created under. +# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left +# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix +# uses for sender_login_maps is the BARE login the panel writes into the map; +# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as +# the server realm, which equals the realm the accounts were created under. postconf -e \ "smtpd_sasl_auth_enable=yes" \ "smtpd_sasl_type=cyrus" \ @@ -181,43 +181,42 @@ postconf -e \ "smtpd_sasl_tls_security_options=noanonymous" \ "broken_sasl_auth_clients=yes" -# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash -# resolves the full address first, then the "@domain" wildcard, so both address -# modes work from the same map. +# Sender binding (the critical anti-spoofing control). texthash resolves the +# full address first, then the "@domain" wildcard, so both address modes work +# from the same map. postconf -e \ "smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \ "queue_directory=${QUEUE_DIR}" # Restrictions: authenticated clients only, no relay to foreign destinations, # and every authenticated sender address must be owned by its login. NO -# permit_mynetworks anywhere — authorisation is by credentials, never by network -# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible. +# permit_mynetworks anywhere — authorisation is by credentials, never by +# network. This is what makes an open relay impossible. postconf -e \ "smtpd_helo_required=yes" \ "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ "smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ "smtpd_sender_restrictions=reject_sender_login_mismatch, permit" -# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even -# if the journal-milter (level 2) is down. +# Level-1 rate limit by client IP. Backstop that keeps working even if the +# journal-milter (level 2) is down. postconf -e \ "smtpd_client_message_rate_limit=${RATE_MSGS}" \ "anvil_rate_time_unit=${RATE_WINDOW}s" -# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly -# (default_action=tempfail: if it is unreachable, defer rather than send -# unsigned). The journal-milter is monitoring only and is fail-open -# (default_action=accept): its failure must never block the relay. Per-milter -# settings use Postfix 3.0+ brace syntax. +# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail: +# if it is unreachable, defer rather than send unsigned). The journal-milter is +# monitoring only and is fail-open (default_action=accept): its failure must +# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax. postconf -e \ "milter_protocol=6" \ "milter_default_action=tempfail" \ "smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \ "non_smtpd_milters=" -# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never -# replies) must fail open just like a crash, not stall mail acceptance until the -# Postfix defaults (300s content) elapse. With default_action per milter, a +# Bounded milter timeouts: a *hung* milter (socket accepts but never replies) +# must fail open just like a crash, not stall mail acceptance until the Postfix +# defaults (300s content) elapse. With default_action per milter, a # journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, # but within seconds rather than minutes. Values are well above any healthy # response time (signing/DB insert are sub-second), so they never fire in normal @@ -228,8 +227,8 @@ postconf -e \ "milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}" # --- master.cf: inbound submission services ---------------------------------- -# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service -# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data +# smtps (465, implicit/wrapper TLS) — the primary, always-on submission +# service. chroot=n so smtpd can read the sasldb2 and sender map under /data # and the Cyrus config outside any chroot. postconf -M "smtps/inet=smtps inet n - n - - smtpd" postconf -P \ @@ -237,10 +236,10 @@ postconf -P \ "smtps/inet/smtpd_sasl_auth_enable=yes" \ "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" -# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits; -# the only difference is TLS is negotiated via STARTTLS, so require encryption -# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a -# restart after disabling it does not leave the port listening. +# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only +# difference is TLS is negotiated via STARTTLS, so require encryption before +# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart +# after disabling it does not leave the port listening. if [ "${SUBMISSION_ENABLE}" = "true" ]; then postconf -M "submission/inet=submission inet n - n - - smtpd" postconf -P \ @@ -301,6 +300,13 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true "smtp_tls_policy_maps=" fi + # Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is + # for the SUBMISSION path only: OpenDKIM must not gate someone else's + # inbound mail (a signing outage would defer it), and the journal-milter + # would file inbound mail into the outbound send log under the *sender's* + # domain — polluting Deliveries and letting a forged From: consume that + # domain's level-2 rate-limit budget. So smtpd_milters is always overridden + # here: either the optional antispam milter, or nothing at all. INBOUND_MILTERS="" if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then case "${INBOUND_ANTISPAM_MILTER}" in @@ -346,9 +352,7 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true "smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \ "smtp/inet/message_size_limit=${PORT25_SIZE}" master_cf_set_smtp_inet_o smtpd_recipient_restrictions "${RECIPIENT_RESTRICTIONS}" - if [ -n "${INBOUND_MILTERS}" ]; then - master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}" - fi + master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}" else postconf -MX "smtp/inet" 2>/dev/null || true postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true @@ -359,18 +363,18 @@ else "smtp_tls_policy_maps=" fi -# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery -# agent and others chrooted to /var/spool/postfix, where they cannot read -# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail -# never leaves. Inside a container the chroot buys little (the container is the -# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off -# uniformly. Our own smtps/submission services are already n; this covers the -# delivery agents and the rest. +# Disable chroot for every service. Debian ships the smtp delivery agent and +# others chrooted to /var/spool/postfix, where they cannot read /etc/resolv.conf +# — so outbound MX lookups fail with "Host not found" and mail never leaves. +# Inside a container the chroot buys little (the container is the isolation +# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our +# own smtps/submission services are already n; this covers the delivery agents +# and the rest. postconf -F "*/*/chroot=n" # --- Cyrus SASL app config for smtpd ----------------------------------------- # Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify -# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN +# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN # only — both are safe because TLS is mandatory before auth on every port. mkdir -p /etc/postfix/sasl cat > /etc/postfix/sasl/smtpd.conf < :443 redirect vhost, and renews in the background via its own timer. -# The resulting PEM files land at -# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host -# — bind-mount that directory read-only into the SelfPost container (see +# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to +# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443 +# redirect vhost, and renews in the background via its own timer. The resulting +# PEM files land at +# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host — +# bind-mount that directory read-only into the SelfPost container (see # ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix # can present the very same certificate on 465/587. One certificate, two -# consumers (spec 10 p.4) — no separate cert just for the panel. +# consumers — no separate cert just for the panel. ServerName mail.example.com @@ -26,9 +25,9 @@ ProxyPass "/" "http://127.0.0.1:8080/" ProxyPassReverse "/" "http://127.0.0.1:8080/" - # The panel is plain HTTP behind the proxy; it never terminates TLS - # itself (spec 10 p.2). Cookies are still marked Secure by the panel - # (spec 7.6.6) because the browser only ever sees this HTTPS vhost. + # The panel is plain HTTP behind the proxy; it never terminates TLS itself. + # Cookies are still marked Secure by the panel because the browser only ever + # sees this HTTPS vhost. diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml index 4ea8377..7cef432 100644 --- a/deploy/caddy/docker-compose.caddy.yml +++ b/deploy/caddy/docker-compose.caddy.yml @@ -1,10 +1,10 @@ -# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully -# automatic ACME with no separate certbot container. Caddy writes certificates -# as plain PEM under its own data directory, which this fragment bind-mounts -# to the host so SelfPost can read the same files. +# Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME +# with no separate certbot container. Caddy writes certificates as plain PEM +# under its own data directory, which this fragment bind-mounts to the host so +# SelfPost can read the same files. # -# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes +# NOTE: Caddy's on-disk cert path includes # the ACME CA's name as a path segment, e.g. # /caddy/certificates/acme-v02.api.letsencrypt.org-directory//.crt # This has been stable across recent Caddy releases but is an internal diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 474d1e7..70fea17 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,12 +1,12 @@ -# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5). +# SelfPost — default deployment, reverse-proxy = Apache. # -# This file only runs SelfPost itself. Apache is assumed to already be -# installed on the HOST (the target audience for this project typically runs -# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel; -# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready -# vhost fragment, and docs/guide.md "Reverse proxy" for the -# certbot steps that produce the PEM files this compose file mounts. +# This file only runs SelfPost itself. Apache is assumed to already be installed +# on the HOST (the target audience for this project typically runs Apache there +# already) and reverse-proxies HTTPS for the panel; it is not containerised +# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and +# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files +# this compose file mounts. # # Usage: # 1. Copy this file (and .env.example as .env) next to your own ./data and @@ -15,23 +15,22 @@ # TLS paths are fixed in this file to match that bind mount, not .env. # 3. docker compose up -d # -# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup -# restore compares the manifest version against the running binary's version, -# so ":latest" would make that check meaningless. Bump the tag deliberately -# when you want to upgrade. +# The image tag below is FIXED on purpose: backup restore compares the manifest +# version against the running binary's version, so ":latest" would make that +# check meaningless. Bump the tag deliberately when you want to upgrade. services: selfpost: - image: ghcr.io/mixeme/selfpost:1.9.1 + image: ghcr.io/mixeme/selfpost:1.9.2 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" # Path Postfix reads inside the container — matches the certs bind mount - # below. Point these at your reverse-proxy's PEM output (spec 10 p.2). + # below. Point these at your reverse-proxy's PEM output. TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem TLS_KEY_FILE: /etc/postfix/tls/privkey.pem - # Set to true to also publish RFC 6409 submission (587/STARTTLS) - # alongside the primary 465/smtps listener (spec 5). + # Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside + # the primary 465/smtps listener. SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}" DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}" @@ -41,14 +40,14 @@ services: INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}" INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" - # Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app - # limits (level 2) are configured later from the panel itself. + # Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are + # configured later from the panel itself. RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}" # How long the send log keeps rows before the background sweep deletes - # them (spec 7.3, 9) — the main driver of /data growth over time. + # them — the main driver of /data growth over time. SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}" - # Sliding idle timeout for the panel login session, in days (spec 7.6.6). + # Sliding idle timeout for the panel login session, in days. PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}" # Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they # report what the internet sees rather than what this host's own stub @@ -59,27 +58,27 @@ services: # client spoof its rate-limit key. TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}" volumes: - # All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, - # sasldb2, sender map, setup token. Back this up (panel button or the - # selfpost-backup CLI) before you touch it directly. - # Postfix's mail.log is here too (./data/log, rotated daily, 14 files - # kept) so the delivery lines that resolve a queued send-log row survive - # a container recreate; it is diagnostics and is left out of backups. + # All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2, + # sender map, setup token. Back this up (panel button or the + # selfpost-backup CLI) before you touch it directly. Postfix's mail.log is + # here too (./data/log, rotated daily, 14 files kept) so the delivery + # lines that resolve a queued send-log row survive a container recreate; + # it is diagnostics and is left out of backups. - ./data:/data # Read-only project root for full backups: docker-compose.yml, .env, and # certs/ are archived alongside /data (architecture.md § Persistence). - .:/selfpost-deploy:ro - # Read-only: SelfPost only ever reads certificates, never manages them - # (spec 10 p.2). Point this at wherever your reverse-proxy/certbot - # writes PEM files, e.g. /etc/letsencrypt on the host. + # Read-only: SelfPost only ever reads certificates, never manages them. + # Point this at wherever your reverse-proxy/certbot writes PEM files, e.g. + # /etc/letsencrypt on the host. - ./certs:/etc/postfix/tls:ro ports: # 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are - # published directly — mail traffic bypasses Apache entirely, it only - # ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself - # (8080) is intentionally NOT published here: Apache reaches it over - # the host network at 127.0.0.1:8080 (see the vhost fragment), so the - # panel is never directly reachable from the internet without TLS. + # published directly — mail traffic bypasses Apache entirely, it only ever + # proxies the panel's HTTP(S). The panel itself (8080) is intentionally + # NOT published here: Apache reaches it over the host network at + # 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly + # reachable from the internet without TLS. - "465:465" # 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only # when the variable is true — see docs/guide.md "Published ports". @@ -88,7 +87,7 @@ services: # when the variable is true. - "25:25" - "127.0.0.1:8080:8080" - # Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as + # Hardening. SelfPost's entrypoint still needs to run as # root very briefly to fix /data ownership and normalise permissions # under the shared `selfpost` group (see build/entrypoint.sh) before # supervisord drops the panel to an unprivileged user — so this cannot be diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml index 07bc567..1bc15a3 100644 --- a/deploy/nginx/docker-compose.nginx.yml +++ b/deploy/nginx/docker-compose.nginx.yml @@ -1,7 +1,7 @@ -# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host -# exactly like the Apache+certbot scenario — nginx and certbot are close -# cousins here, both writing/reading plain PEM under /etc/letsencrypt. +# Alternative reverse-proxy: nginx. PEM files land on the host exactly like the +# Apache+certbot scenario — nginx and certbot are close cousins here, both +# writing/reading plain PEM under /etc/letsencrypt. # # This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it # adds an nginx + certbot pair and removes the panel's host port publish (nginx @@ -29,9 +29,9 @@ services: volumes: !override - ./data:/data - .:/selfpost-deploy:ro - # Same host directory certbot below writes into — plain bind mount, - # no named volume, so the PEM files are as directly inspectable as in - # the Apache scenario (spec 10.3). + # Same host directory certbot below writes into — plain bind mount, no + # named volume, so the PEM files are as directly inspectable as in the + # Apache scenario. - ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro nginx: diff --git a/deploy/nginx/nginx.conf.example b/deploy/nginx/nginx.conf.example index a5e9ee3..4ca40a7 100644 --- a/deploy/nginx/nginx.conf.example +++ b/deploy/nginx/nginx.conf.example @@ -1,6 +1,6 @@ -# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel -# over the compose network; mail (465/587) is published directly by the -# selfpost container and never touches nginx. +# nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the +# compose network; mail (465/587) is published directly by the selfpost +# container and never touches nginx. server { listen 80; diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml index 7674ebb..b2b6455 100644 --- a/deploy/traefik/docker-compose.traefik.yml +++ b/deploy/traefik/docker-compose.traefik.yml @@ -1,7 +1,7 @@ -# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain -# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost -# needs separate PEM files for Postfix, so an extraction step is required; see +# Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files — +# everything (cert + key) lives bundled inside acme.json. SelfPost needs +# separate PEM files for Postfix, so an extraction step is required; see # extract-cert.sh in this directory and the cron/systemd-timer note below. # # Merge with the base file — run this from the deploy/ directory (Compose diff --git a/deploy/traefik/extract-cert.sh b/deploy/traefik/extract-cert.sh index 4eb1bf4..e942512 100644 --- a/deploy/traefik/extract-cert.sh +++ b/deploy/traefik/extract-cert.sh @@ -1,9 +1,9 @@ #!/bin/sh -# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json -# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction -# step is required"). Run this on the host, after Traefik has issued or -# renewed the certificate, and again on a schedule (cron/systemd timer) since -# acme.json is not itself watched by SelfPost/Postfix. +# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json — +# Traefik keeps its certificates there, so a PEM extraction step is required. +# Run this on the host, after Traefik has issued or renewed the certificate, and +# again on a schedule (cron/systemd timer) since acme.json is not itself watched +# by SelfPost/Postfix. # # Requires jq. Usage: ./extract-cert.sh set -eu diff --git a/docs/architecture.md b/docs/architecture.md index d99ee35..3a99cca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)): | Program | User | Priority | Role | |---|---|---|---| | `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter | -| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine | +| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc | | `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist | | `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | | `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs | @@ -54,30 +54,44 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission) ├─► journal-milter (send log + L2 rate limits, fail-open) └─► outbound MX delivery (port 25 client) -Internet ──► Postfix smtp inet :25 (only when INBOUND_RELAY_ENABLE=true) +Internet ──► Postfix smtp inet :25 (when INBOUND_RELAY_ENABLE=true + │ and/or DMARC_REPORTS_ENABLE=true) │ - ├─► optional antispam milter (inbound only) - └─► smtp:[upstream]:port (transport_maps; no local delivery) + ├─► optional antispam milter (inbound relay only) + ├─► DMARC aggregate ingest (pipe → dmarc-ingest) + └─► smtp:[upstream]:port (inbound relay only; + transport_maps; no local delivery) ``` -The inbound listener is **absent** when the flag is off (`postconf -MX smtp/inet` -removes Debian's stock smtpd). Outbound delivery still uses the `smtp unix` -client; it is not the same service. +The smtp/inet listener is **absent** when both flags are off (`postconf -MX +smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the +`smtp unix` client; it is not the same service. ### Postfix ([build/postfix-config.sh](../build/postfix-config.sh)) - **465/smtps** — implicit TLS, SASL required; primary listener. - **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with `smtpd_tls_security_level=encrypt`. -- **25/smtp inet** — only when `INBOUND_RELAY_ENABLE=true`. No SASL, no - OpenDKIM, no journal-milter. Accepts only `relay_domains` + - `relay_recipient_maps` (`reject_unauth_destination`, - `reject_unlisted_recipient`). Maps under `/data/postfix/` - (`relay_domains`, `transport`, `relay_recipients`, `tls_policy`), written - atomically by [internal/postfix/inbound.go](../internal/postfix/inbound.go). - Domains with an empty upstream host are omitted from the maps. Optional - `INBOUND_ANTISPAM_MILTER` on this listener only; default - `milter_default_action` is fail-open (`accept`). +- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or + `DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter — + `smtpd_milters` is always overridden on this service (to the antispam milter + or to nothing), because main.cf's submission chain would otherwise let a + signing outage defer relayed mail and would file inbound mail into the + outbound send log under the sender's domain. + **Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only + `relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`, + `reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`, + `transport`, `relay_recipients`, `tls_policy`), written atomically by + [internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with + an empty upstream host are omitted from the maps. Optional + `INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on + DMARC-only port 25); default `milter_default_action` is fail-open + (`accept`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed + report addresses via `check_recipient_access` on `postfix/dmarc_recipients`; + messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are + on, one listener serves both paths; per-IP rate uses + `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` and message size uses the greater of + `INBOUND_MESSAGE_SIZE_LIMIT` and `DMARC_MESSAGE_SIZE_LIMIT`. - **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`; `smtpd_sender_login_maps` + `reject_sender_login_mismatch`. - **Level-1 rate limit** — `smtpd_client_message_rate_limit` / @@ -93,7 +107,7 @@ by the panel. Socket `/run/opendkim/opendkim.sock`. ### Panel binary ([cmd/panel](../cmd/panel)) -One process, three roles: +One process, four roles: 1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse proxy only. On start it runs `postconf -h` once for the deferred-mail retry @@ -107,15 +121,20 @@ One process, three roles: 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 - ceilings apply to every client IP; an application ceiling with trusted IPs - raises the limit for those IPs only and skips the domain check (guide § Rate - limiting). The level-2 count is the stored send-log rows plus the messages + ceilings apply to every client IP; an application ceiling **overrides** the + domain one for that login, higher or lower (guide § Rate limiting). Since + 1.9.0 the client IP allow-list is a separate, restrictive control on the + application (`applications.auth_ip_restrict`), checked here at `MAIL FROM` + and fail-open like the rest of this milter — not an authentication + boundary. The level-2 count is the stored send-log rows plus the messages this process has admitted but not yet written (`internal/milter/inflight.go`), so concurrent sessions cannot each spend the same last slot; a reservation is released at end-of-message, on ABORT, or after a 10-minute TTL. 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. Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). @@ -200,11 +219,13 @@ below is a summary — HTMX fragment endpoints | `/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 + Postfix maps | +| `/reload` | **Global.** `POST` — reload OpenDKIM tables, Postfix sender map, and inbound relay maps when enabled (not DMARC maps — see § Persistence restore) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | +| `/help` | In-panel operator help (any authenticated user) | | `/settings` | Username/password for any user; DMARC report default is **global** only | | `/users`, `/users/*` | **Global.** Panel user CRUD | | `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. | +| `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}` | **Global** list and report detail; domain roll-up scoped like deliveries. Registered only when `DMARC_REPORTS_ENABLE=true`; otherwise 404. | HTMX polling refreshes monitoring fragments (5 s while the operator is active on the page, 30 s when the tab is visible but idle, none when hidden — scheduled in @@ -271,6 +292,7 @@ flowchart TB domainSvc["internal/domain"] appSvc["internal/app"] inboundSvc["internal/inbound"] + dmarcSvc["internal/dmarc"] end subgraph persistence ["Persistence"] store["internal/store — SQLite, embedded migrations"] @@ -293,6 +315,7 @@ flowchart TB web --> domainSvc web --> appSvc web --> inboundSvc + web --> dmarcSvc web --> backupPkg web --> dnscheck web --> health @@ -300,15 +323,18 @@ flowchart TB domainSvc --> store appSvc --> store inboundSvc --> store + dmarcSvc --> store milterPkg --> store logtail --> store domainSvc --> postfix appSvc --> postfix inboundSvc --> postfix + dmarcSvc --> postfix ``` -The three roles inside the `panel` process (HTTP server, journal-milter, -log-tailer goroutine) share one binary and one SQLite handle on purpose — see +The four roles inside the `panel` process (HTTP server, journal-milter, +log-tailer, rate-limit recalc) share one binary and one SQLite handle on +purpose — see [Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the single-connection trade-off that follows from it. @@ -327,6 +353,9 @@ single-connection trade-off that follows from it. | `postfix/transport` | Inbound next-hop `smtp:[host]:port` | | `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all | | `postfix/tls_policy` | TLS policy for inbound next hops | +| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses | +| `postfix/dmarc_transport` | Pipe transport for report ingest | +| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery | | `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate | | `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) | | `manifest.json` | Backup version stamp (consumed on restore) | @@ -344,10 +373,12 @@ under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present; version check on restore. Requires the project directory mounted read-only at `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the first successful boot after restore, the panel runs one **Resync** — OpenDKIM's -tables, Postfix's sender map, and (when `INBOUND_RELAY_ENABLE=true`) inbound -relay maps are re-derived from SQLite and both daemons are reloaded, so drift -between the extracted archive and the database is healed before mail flows -(same step as `POST /reload` on demand). Stopped-container +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 +inbound maps only — not DMARC maps. Stopped-container `tar` of `./data` alone remains possible for state-only copies (see guide). **Optional encryption** of the two secret-bearing downloads @@ -398,6 +429,9 @@ unsupported rather than as a missing doc: `POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if overridden without matching the panel writer in [internal/postfix/inbound.go](../internal/postfix/inbound.go), + `POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`), + `POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`), + `POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`), `POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`), `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for full backups; mount `.:/selfpost-deploy:ro` in compose). diff --git a/docs/assets/icon.png b/docs/assets/icon.png deleted file mode 100644 index f21a361..0000000 Binary files a/docs/assets/icon.png and /dev/null differ diff --git a/docs/assets/panel-redesign/a/domain.html b/docs/assets/panel-redesign/a/domain.html new file mode 100644 index 0000000..8c9bb97 --- /dev/null +++ b/docs/assets/panel-redesign/a/domain.html @@ -0,0 +1,164 @@ + + + + + +example.org · Outbound domains · SelfPost + + + + + + + +
+
+ +
+
+ + +
+
+
+

Outbound domains /

+

example.org

+
+ 1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h +
+ +
+
01

Connection

The same for every domain
+
+
+
Server
+
mail.example.org
+
+
+
Ports
+
465 — SSL/TLS (implicit)
587 — STARTTLS (submission)
+
+
+
+ +
+
+ 02

DNS records

+ Checked 3 min ago +
+
+ +
+

DKIM

publishedNot a secret · selector sp2026
+
+
Host
sp2026._domainkey.example.org
+
Type
TXT
+
Value
v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB
+
+
+ +
+

SPF

mismatchMerge into the existing record — never publish a second one
+
+
Host
example.org
+
Type
TXT
+
Value
v=spf1 ip4:203.0.113.25 ~all
+
In DNS now
v=spf1 include:_spf.google.com ~all
+
+

The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.

+
+ +
+

DMARC

publishedReport address from Settings
+
+
Host
_dmarc.example.org
+
Type
TXT
+
Value
v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org
+
+

p=none does not affect delivery. Tighten to quarantine, then reject, once the reports look clean.

+
+
+ +
+
+ 03

Applications

+ SASL logins that may send as this domain + Add application +
+ + + + + + + + + + + + + + + + + + +
LoginMay send as30 daysLimits
*@example.org1 102 msg · peak 96/h200 / hEditNew passwordDelete
alerts@example.org, noc@example.org182 msg · peak 14/hdomain 2 IPsEditNew passwordDelete
+
+ +
+
04

Domain settings

Rarely changed
+
+ DMARC reportsSame as Settings — dmarc@mail.example.orgdefault +

The form opens here.

+
+
+ Rate limitAuto ×3 — 288 messages / hour, recalculated yesterdayactive +

The form opens here.

+
+
+ ExportDKIM key and applications as one file, optionally encrypted +

The form opens here.

+
+
+ Delete domainRemoves the DKIM key and both applications +

The confirmation opens here.

+
+
+ + +
+ + + diff --git a/docs/assets/panel-redesign/a/login.html b/docs/assets/panel-redesign/a/login.html new file mode 100644 index 0000000..30d06be --- /dev/null +++ b/docs/assets/panel-redesign/a/login.html @@ -0,0 +1,37 @@ + + + + + +Sign in · SelfPost + + + + + + + + + + + + diff --git a/docs/assets/panel-redesign/a/status.html b/docs/assets/panel-redesign/a/status.html new file mode 100644 index 0000000..da61c4f --- /dev/null +++ b/docs/assets/panel-redesign/a/status.html @@ -0,0 +1,136 @@ + + + + + +Overview · SelfPost + + + + + + + +
+
+ +
+
+ +
+
+
+

mail.example.org

+

Overview

+
+ Updated 14:02:31 UTC · refreshes every 10 s +
+ +
+
SELFPOSTWARN1 OF 6 CHECKS
+
+

Mail is flowing. One thing needs attention.

+

The TLS certificate expires in 12 days and has not renewed yet. Everything else is healthy.

+
+
+ +
+
01

Checks

Open a row for its detail
+ +
+ Processes5 of 5 runningok +
+ + + + + + +
ProgramStateDetail
postfixrunningpid 41, uptime 18 days
opendkimrunningpid 39, uptime 18 days
+
+
+ +
+ TLS certificateExpires in 12 days — renewal has not runwarn +
+
+
Expires
2026-10-03 09:14 UTC
+
Names
mail.example.org
+
+

Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for acme lines in the system log.

+
+
+ +
+ MachineCPU 12 % · memory 41 % · ↓ 18 kB/s ↑ 44 kB/sok +
+ + + + + + + +
ResourceUsageDetail
CPU12 %2 cores, load 0.21
Memory41 %812 MB of 1.9 GB
Network↓ 18 kB/s · ↑ 44 kB/seth0: 2.1 GB in, 6.4 GB out
+
+
+ +
+ QueuesOutbound empty · inbound emptyok +

No messages are waiting. Open the outbound queue or the inbound queue.

+
+ +
+ Milter sockets2 of 2 answeringok +
+ + + + + + +
MilterStateDetail
opendkimokinet:127.0.0.1:8891
journal-milterokunix:/run/selfpost/journal.sock
+
+
+ +
+ Hostname and reverse DNSmail.example.org ⇄ 203.0.113.25ok +
+
+
Hostname
mail.example.org
+
Lookup
mail.example.org → 203.0.113.25
203.0.113.25 → mail.example.org
+
+
+
+
+
+ + +
+ + + diff --git a/docs/assets/panel-redesign/a/theme.css b/docs/assets/panel-redesign/a/theme.css new file mode 100644 index 0000000..e5dffaa --- /dev/null +++ b/docs/assets/panel-redesign/a/theme.css @@ -0,0 +1,206 @@ +/* Direction A — "Blank" (the postal form). Pico CSS v2 carries every generic + control (type, buttons, inputs, tables, details, dropdown); this file maps + the brand onto Pico's variables and adds only what is SelfPost's own: + the two-row header, the sheet, numbered form sections, the postmark and + the DNS record block. */ + +/* :root[...] rather than [data-theme=light] alone: Pico declares its light + palette on :root:not([data-theme=dark]), which a bare attribute selector + loses to. */ +:root[data-theme=light] { + --pico-font-family-sans-serif: var(--sp-sans); + --pico-font-family-monospace: var(--sp-mono); + --pico-font-family: var(--sp-sans); + --pico-border-radius: 2px; + --pico-spacing: .9rem; + --pico-typography-spacing-vertical: .9rem; + --pico-form-element-spacing-vertical: .5rem; + --pico-form-element-spacing-horizontal: .75rem; + + --pico-background-color: var(--sp-paper); + --pico-color: var(--sp-ink); + --pico-muted-color: var(--sp-muted); + --pico-muted-border-color: var(--sp-rule); + --pico-h1-color: var(--sp-ink); --pico-h2-color: var(--sp-ink); + --pico-h3-color: var(--sp-ink); --pico-h4-color: var(--sp-ink); + + --pico-primary: var(--sp-brick); + --pico-primary-background: var(--sp-brick); + --pico-primary-border: var(--sp-brick); + --pico-primary-underline: rgb(122 59 46 / .35); + --pico-primary-hover: var(--sp-brick-dark); + --pico-primary-hover-background: var(--sp-brick-dark); + --pico-primary-hover-border: var(--sp-brick-dark); + --pico-primary-hover-underline: var(--sp-brick-dark); + --pico-primary-focus: rgb(122 59 46 / .25); + --pico-primary-inverse: #fff; + + --pico-secondary: var(--sp-ink-2); + --pico-secondary-background: var(--sp-ink-2); + --pico-secondary-border: var(--sp-ink-2); + --pico-secondary-hover: var(--sp-ink); + --pico-secondary-hover-background: var(--sp-ink); + --pico-secondary-hover-border: var(--sp-ink); + --pico-secondary-focus: rgb(18 22 28 / .2); + --pico-secondary-inverse: #fff; + + --pico-form-element-background-color: #fff; + --pico-form-element-border-color: var(--sp-rule-strong); + --pico-form-element-active-border-color: var(--sp-brick); + --pico-form-element-focus-color: rgb(122 59 46 / .25); + --pico-table-border-color: var(--sp-rule); + --pico-code-background-color: var(--sp-well); + --pico-code-color: var(--sp-ink); + --pico-dropdown-background-color: #fff; + --pico-dropdown-border-color: var(--sp-rule); + --pico-dropdown-hover-background-color: var(--sp-brick-tint); +} + +/* Pico grows the root font with the viewport (up to 131%). An ops panel wants + one size everywhere. */ +:root { font-size: 15px; } +body { min-height: 100vh; display: flex; flex-direction: column; } +h1 { font-size: 1.7rem; font-weight: 600; margin: 0; } +small, .sp-muted { color: var(--sp-muted); } +code, .sp-mono { font-family: var(--sp-mono); font-size: .88em; } + +/* ---- header: sections on the first row, the section's pages on the second */ +.sp-wrap { width: 100%; max-width: 72rem; margin-inline: auto; padding-inline: 1.5rem; } +.sp-bar { background: var(--sp-sheet); border-bottom: 1px solid var(--sp-rule); } +.sp-bar nav { align-items: center; } +.sp-bar nav li { padding-block: .45rem; } +.sp-brand img { height: 46px; width: auto; display: block; } +.sp-sections a { + font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; + letter-spacing: .09em; text-transform: uppercase; text-decoration: none; + color: var(--sp-muted); padding: .5rem .8rem; margin: 0; border-radius: 0; +} +.sp-sections a:hover { color: var(--sp-ink); } +.sp-sections a[aria-current] { color: var(--sp-brick); box-shadow: inset 0 -3px 0 var(--sp-brick); } +.sp-user summary { font-size: .9rem; } +.sp-pages { background: var(--sp-paper); border-bottom: 1px solid var(--sp-rule); } +.sp-pages .sp-wrap { display: flex; gap: 1.6rem; } +.sp-pages a { color: var(--sp-muted); text-decoration: none; padding: .6rem 0; font-size: .95rem; } +.sp-pages a:hover { color: var(--sp-ink); } +.sp-pages a[aria-current] { color: var(--sp-ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sp-ink); } + +/* ---- the sheet: one column, one width, every page */ +main.sp-wrap { flex: 1; padding-block: 1.8rem 2.5rem; } +.sp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.4rem; } +.sp-head .sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); margin: 0 0 .15rem; } +.sp-head .sp-kicker a { color: inherit; } +.sp-head-actions { display: flex; gap: .5rem; align-items: center; } +.sp-head-actions > * { margin: 0; } +.sp-sheet { background: var(--sp-sheet); border: 1px solid var(--sp-rule); margin-bottom: 1.4rem; } +.sp-sheet > header { + display: flex; align-items: baseline; gap: .8rem; padding: .7rem 1.2rem; + border-bottom: 1px solid var(--sp-rule); +} +.sp-sheet > header .sp-no { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-brick); font-weight: 600; } +.sp-sheet > header h2 { + font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em; + text-transform: uppercase; margin: 0; flex: 1; +} +.sp-sheet > header small { font-size: .85rem; } +.sp-sheet > .sp-body { padding: 1.1rem 1.2rem; } +.sp-sheet > .sp-body > :last-child { margin-bottom: 0; } +.sp-foot { color: var(--sp-muted); font-size: .8rem; border-top: 1px solid var(--sp-rule); padding-block: 1rem; } +.sp-foot a { color: inherit; } + +/* ---- status tag and the postmark */ +.sp-tag { + display: inline-block; font-family: var(--sp-mono); font-size: .72rem; font-weight: 600; + letter-spacing: .08em; text-transform: uppercase; padding: .1rem .5rem; + border: 1px solid var(--sp-idle-rule); background: var(--sp-idle-bg); color: var(--sp-idle); + border-radius: 2px; white-space: nowrap; text-decoration: none; +} +.sp-tag.ok { color: var(--sp-ok); background: var(--sp-ok-bg); border-color: var(--sp-ok-rule); } +.sp-tag.warn { color: var(--sp-warn); background: var(--sp-warn-bg); border-color: var(--sp-warn-rule); } +.sp-tag.fail { color: var(--sp-fail); background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); } + +.sp-verdict { display: flex; align-items: center; gap: 1.5rem; padding: 1.3rem 1.4rem; } +.sp-verdict h2 { margin: 0 0 .2rem; font-size: 1.35rem; font-weight: 600; } +.sp-verdict p { margin: 0; color: var(--sp-muted); } +.sp-postmark { + flex: none; width: 5.6rem; height: 5.6rem; border-radius: 50%; + border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px; + display: grid; place-content: center; text-align: center; transform: rotate(-9deg); + font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15; + color: var(--sp-ok); +} +.sp-postmark.warn { color: var(--sp-warn); } +.sp-postmark.fail { color: var(--sp-fail); } +.sp-postmark b { font-size: 1.25rem; display: block; } +.sp-postmark span { font-size: .55rem; display: block; } + +/* ---- ledger: one row per check, the row opens into its detail */ +.sp-ledger details { margin: 0; padding: 0; border-bottom: 1px solid var(--sp-rule); } +.sp-ledger details:last-child { border-bottom: 0; } +.sp-ledger summary { + display: grid; grid-template-columns: 13rem 1fr auto 1.2rem; gap: 1rem; align-items: center; + padding: .75rem 1.2rem; margin: 0; color: var(--sp-ink); +} +.sp-ledger summary::after { grid-column: 4; margin: 0; float: none; } +.sp-ledger summary:hover { background: var(--sp-paper); } +.sp-ledger summary strong { font-weight: 600; } +.sp-ledger summary span.sp-muted { font-size: .93rem; } +.sp-ledger .sp-detail { padding: .2rem 1.2rem 1.1rem 15.2rem; } +.sp-ledger .sp-detail table { margin: 0; font-size: .93rem; } +.sp-ledger .sp-detail > :last-child { margin-bottom: 0; } +.sp-ledger meter { width: 9rem; height: .7rem; vertical-align: middle; margin-right: .5rem; } + +/* ---- label / value / copy: the DNS record block and connection settings */ +.sp-record { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--sp-rule); } +.sp-record:last-child { border-bottom: 0; } +.sp-record-head { display: flex; align-items: center; gap: .7rem; margin-bottom: .7rem; } +.sp-record-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; } +.sp-record-head small { margin-left: auto; } +.sp-kv { display: grid; grid-template-columns: 6.5rem 1fr; gap: .45rem 1rem; align-items: start; margin: 0; } +.sp-kv dt { + font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; + color: var(--sp-muted); padding-top: .5rem; margin: 0; +} +.sp-kv dd { margin: 0; display: flex; gap: .4rem; align-items: stretch; min-width: 0; } +.sp-value { + flex: 1; min-width: 0; font-family: var(--sp-mono); font-size: .86rem; line-height: 1.5; + background: var(--sp-well); border: 1px solid var(--sp-rule); padding: .4rem .65rem; + overflow-wrap: anywhere; border-radius: 2px; +} +.sp-value.plain { background: none; border-color: transparent; padding-left: 0; font-family: var(--sp-sans); font-size: .95rem; } +.sp-copy { + flex: none; align-self: flex-start; height: 2.15rem; margin: 0; padding: 0 .7rem; font-size: .8rem; + --pico-background-color: #fff; --pico-border-color: var(--sp-rule-strong); --pico-color: var(--sp-ink-2); +} +.sp-copy:hover { --pico-background-color: var(--sp-paper); --pico-border-color: var(--sp-ink-2); --pico-color: var(--sp-ink); } +.sp-note { margin: .7rem 0 0 7.5rem; font-size: .9rem; color: var(--sp-muted); } +.sp-note.fail { color: var(--sp-fail); } +.sp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0 2.4rem; } + +/* ---- applications */ +.sp-apps { margin: 0; font-size: .95rem; } +.sp-apps td, .sp-apps th { padding-inline: 1.2rem .4rem; vertical-align: middle; } +.sp-apps th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; } +.sp-apps td:last-child { text-align: right; padding-right: 1.2rem; white-space: nowrap; } +.sp-apps .sp-login { font-family: var(--sp-mono); font-weight: 600; } +.sp-link-btn { font-size: .9rem; margin-left: .9rem; } +.sp-link-btn.danger { color: var(--sp-fail); --pico-underline: rgb(180 35 24 / .35); text-decoration-color: rgb(180 35 24 / .35); } +.sp-btn-sm { padding: .35rem .8rem; font-size: .9rem; margin: 0; width: auto; } + +/* ---- sign-in */ +.sp-signin { flex: 1; display: grid; place-content: center; padding: 2rem 1.5rem; } +.sp-signin-card { width: 23rem; max-width: 100%; } +.sp-signin-card img { display: block; width: 15rem; height: auto; margin: 0 auto 1.6rem; } +.sp-signin-card .sp-body { padding: 1.4rem 1.4rem 1.2rem; } +.sp-signin-card button { margin-bottom: 0; } +.sp-tagline { text-align: center; font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .16em; color: var(--sp-muted); margin-top: 1.2rem; } + +@media (max-width: 48rem) { + .sp-ledger summary { grid-template-columns: 1fr auto 1.2rem; } + .sp-ledger summary span.sp-muted { grid-column: 1 / -1; grid-row: 2; } + .sp-ledger summary::after { grid-column: 3; } + .sp-ledger .sp-detail { padding-left: 1.2rem; } + .sp-kv { grid-template-columns: 1fr; gap: .15rem; } + .sp-kv dt { padding-top: .5rem; } + .sp-note { margin-left: 0; } + .sp-pages .sp-wrap { overflow-x: auto; white-space: nowrap; } +} diff --git a/docs/assets/panel-redesign/b/domain.html b/docs/assets/panel-redesign/b/domain.html new file mode 100644 index 0000000..c5053bd --- /dev/null +++ b/docs/assets/panel-redesign/b/domain.html @@ -0,0 +1,212 @@ + + + + + +example.org · Outbound domains · SelfPost + + + + + + +
+ + + +
+ + +
+
+
+ +
+
+

DNS records

Checked 3 min ago
+ +
+
+

DKIM

published + Not a secret · selector sp2026 +
+
+
+ +
+
+
+ + +
+
+ +
+
+
+
+ +
+
+

SPF

mismatch + Merge into the existing record — never publish a second one +
+
+
+ +
+
+
+ + +
+
+ +
+
+
+ + +
The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.
+
+
+
+ +
+
+

DMARC

published + Report address from Settings +
+
+
+ +
+
+
+ + +
+
+ +
+
+
+
+
+
+ +
+
+

Connection

+
+
+
Server
mail.example.org
+
Ports
465 SSL/TLS
587 STARTTLS
+
+

The same for every domain. Authenticate with an application login.

+
+
+ +
+

Sent, 30 days

+
+
+
Messages
1 284
+
Peak
96 /h
+
Average
1.8 /h
+
+
+
+ + +
+ +
+
+

Applications

SASL logins that may send as this domain
+
+ + + + + + + + + + + + + + +
LoginMay send as30 daysLimits
prod-server*@example.org1 102 msg · peak 96/h200 / h
alertsalerts@example.org, noc@example.org182 msg · peak 14/hdomain 2 IPs
+
+
+
+ +
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/b/login.html b/docs/assets/panel-redesign/b/login.html new file mode 100644 index 0000000..b85d14a --- /dev/null +++ b/docs/assets/panel-redesign/b/login.html @@ -0,0 +1,36 @@ + + + + + +Sign in · SelfPost + + + + + +
+
+
+
+
+

Sign in

+
+
+ + +
+
+ + +
+ +
+
+
+
YOUR SMTP. YOUR RULES.
+
© 2026 Mix · License (AGPL-3.0) · Source · No warranty
+
+
+ + diff --git a/docs/assets/panel-redesign/b/status.html b/docs/assets/panel-redesign/b/status.html new file mode 100644 index 0000000..9573fc2 --- /dev/null +++ b/docs/assets/panel-redesign/b/status.html @@ -0,0 +1,168 @@ + + + + + +Overview · SelfPost + + + + + + +
+ + + +
+ + +
+
+ + + +
+
Machine
CPU 12 % · mem 41 %
OK
+
Processes
5 of 5 running
OK
+
Queues
Both empty
OK
+
TLS certificate
Expires in 12 days
Warn
+
Milter sockets
2 of 2 answering
OK
+
Reverse DNS
Forward = reverse
OK
+
+ +
+
+
+

Machine

ok
+
+ + + + + + + +
ResourceUsageDetail
CPU12 %2 cores, load 0.21
Memory41 %812 MB of 1.9 GB
Network↓ 18 kB/s · ↑ 44 kB/seth0: 2.1 GB in, 6.4 GB out
+
+
+
+ +
+
+

Processes

ok
+
+ + + + + + + + + +
ProgramStateDetail
postfixrunningpid 41, uptime 18 days
opendkimrunningpid 39, uptime 18 days
journal-milterrunningpid 40, uptime 18 days
panelrunningpid 44, uptime 18 days
dmarc-ingestrunningpid 47, uptime 18 days
+
+
+
+ +
+
+

TLS certificate

warn
+
+
+
Expires
2026-10-03 09:14 UTC
+
Names
mail.example.org
+
+

Renewal normally runs 30 days before expiry. Check port 80 and the system log.

+
+
+
+ +
+
+

Milter sockets

ok
+ + + + + + +
MilterState
opendkim
inet:127.0.0.1:8891
ok
journal-milter
unix:/run/selfpost/journal.sock
ok
+
+
+ +
+
+

Hostname and reverse DNS

ok
+
+
+
Hostname
mail.example.org
+
Lookup
→ 203.0.113.25
← mail.example.org
+
+
+ +
+
+
+ +
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/b/theme.css b/docs/assets/panel-redesign/b/theme.css new file mode 100644 index 0000000..8a5a59a --- /dev/null +++ b/docs/assets/panel-redesign/b/theme.css @@ -0,0 +1,80 @@ +/* Direction B — "Console". Tabler (Bootstrap 5 admin kit) carries the shell, + cards, tables, badges, input groups and the sign-in page as shipped; this + file only re-points Tabler's variables at the brand and adds the two things + Tabler has no word for: a sidebar group label and the DNS record block. */ + +:root, [data-bs-theme=light] { + --tblr-font-sans-serif: var(--sp-sans); + --tblr-font-monospace: var(--sp-mono); + --tblr-body-bg: var(--sp-paper); + --tblr-body-color: var(--sp-ink); + --tblr-secondary: var(--sp-muted); + --tblr-border-color: var(--sp-rule); + --tblr-border-radius: 3px; + --tblr-border-radius-lg: 4px; + + --tblr-primary: #7a3b2e; + --tblr-primary-rgb: 122, 59, 46; + --tblr-link-color: #7a3b2e; + --tblr-link-color-rgb: 122, 59, 46; + --tblr-link-hover-color: #632f25; + --tblr-link-hover-color-rgb: 99, 47, 37; + + --tblr-green: #2c6b43; --tblr-green-rgb: 44, 107, 67; + --tblr-yellow: #b5700f; --tblr-yellow-rgb: 181, 112, 15; + --tblr-red: #b42318; --tblr-red-rgb: 180, 35, 24; + --tblr-success: #2c6b43; --tblr-success-rgb: 44, 107, 67; + --tblr-warning: #b5700f; --tblr-warning-rgb: 181, 112, 15; + --tblr-danger: #b42318; --tblr-danger-rgb: 180, 35, 24; +} +body { font-feature-settings: normal; } +.btn-primary { --tblr-btn-hover-bg: #632f25; --tblr-btn-active-bg: #632f25; } +code, .font-monospace { font-family: var(--sp-mono); } + +/* ---- sidebar: ink, with the stamp on its own paper */ +.navbar-vertical { + --tblr-navbar-bg: var(--sp-ink); background: var(--sp-ink); + --tblr-navbar-color: #aab2bd; --tblr-navbar-hover-color: #fff; --tblr-navbar-active-color: #fff; + --tblr-navbar-border-color: transparent; +} +.navbar-vertical .navbar-brand { padding: 1.1rem 0 .9rem; justify-content: flex-start; } +.navbar-vertical .navbar-brand img { height: 52px; width: auto; } +.navbar-vertical .nav-link { padding-block: .42rem; } +.navbar-vertical .nav-link-icon { font-size: 1.15rem; } +.navbar-vertical .nav-item.active > .nav-link { + background: rgb(255 255 255 / .07); box-shadow: inset 3px 0 0 #c4705e; color: #fff; +} +.sp-nav-label { + font-family: var(--sp-mono); font-size: .66rem; font-weight: 600; letter-spacing: .14em; + text-transform: uppercase; color: #6f7a88; padding: 1.1rem 1.5rem .3rem; +} +.sp-nav-foot { margin-top: auto; padding-bottom: .8rem; border-top: 1px solid rgb(255 255 255 / .08); padding-top: .6rem; } +.sp-nav-user { color: #6f7a88; font-size: .8rem; padding: .3rem 1.5rem; } + +/* ---- page */ +.page-pretitle { font-family: var(--sp-mono); letter-spacing: .1em; } +.card-title { font-weight: 600; } +.table thead th { font-family: var(--sp-mono); letter-spacing: .08em; } +.sp-tile .card-body { display: flex; align-items: center; gap: .8rem; padding: .85rem 1rem; } +.sp-tile .sp-tile-icon { font-size: 1.5rem; color: var(--sp-muted); } +.sp-tile .fw-semibold { line-height: 1.2; } +.sp-tile .status { margin-left: auto; } +progress.sp-bar { + appearance: none; -webkit-appearance: none; width: 8rem; height: .4rem; border: 0; + border-radius: 1rem; overflow: hidden; vertical-align: middle; margin-right: .6rem; background: var(--sp-well); +} +progress.sp-bar::-webkit-progress-bar { background: var(--sp-well); } +progress.sp-bar::-webkit-progress-value { background: var(--tblr-green); } +progress.sp-bar::-moz-progress-bar { background: var(--tblr-green); } + +/* ---- the DNS record block: Tabler's read-only input group, plus a status */ +.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); } +.sp-record .form-control[readonly], .sp-record textarea[readonly] { + font-family: var(--sp-mono); font-size: .8rem; background: var(--sp-well); +} +.sp-record textarea { resize: none; } +.sp-record .form-label { font-size: .75rem; color: var(--sp-muted); margin-bottom: .25rem; } + +.sp-foot { color: var(--sp-muted); font-size: .8rem; } +.sp-signin-mark { width: 15rem; height: auto; } +.sp-tagline { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .16em; color: var(--sp-muted); } diff --git a/docs/assets/panel-redesign/c/domain.html b/docs/assets/panel-redesign/c/domain.html new file mode 100644 index 0000000..7059e5c --- /dev/null +++ b/docs/assets/panel-redesign/c/domain.html @@ -0,0 +1,200 @@ + + + + + +example.org · Outbound domains · SelfPost + + + + + + + + +
+
+
+
+
+

Outbound domains /

+

example.org

+
+
+
+
+
+ Add application +
+
+
+ +
+
+ +
+ +
+
+

DNS records

Checked 3 min ago
+ +
+

DKIM

publishedNot a secret · selector sp2026
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+

SPF

mismatchMerge into the existing record — never publish a second one
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+

The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.

+
+
+ +
+

DMARC

publishedReport address from Settings
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+

Applications

SASL logins that may send as this domain
+ + + + + + + + + + + + + + +
LoginMay send as30 daysLimits
prod-server*@example.org1 102 msg · peak 96/h200 / h
alertsalerts@example.org, noc@example.org182 msg · peak 14/hdomain 2 IPs
+
+ +
+
+
+

Connection

Same for every domain
+

mail.example.org

+

465 SSL/TLS · 587 STARTTLS

+
+
+
+
+

Sent, 30 days

+
+
1 284messages
+
96peak / h
+
1.8average / h
+
+
+
+
+
+
+ + +
+
+ + + diff --git a/docs/assets/panel-redesign/c/login.html b/docs/assets/panel-redesign/c/login.html new file mode 100644 index 0000000..4574b43 --- /dev/null +++ b/docs/assets/panel-redesign/c/login.html @@ -0,0 +1,38 @@ + + + + + +Sign in · SelfPost + + + + + + + + + + diff --git a/docs/assets/panel-redesign/c/status.html b/docs/assets/panel-redesign/c/status.html new file mode 100644 index 0000000..a3a42ff --- /dev/null +++ b/docs/assets/panel-redesign/c/status.html @@ -0,0 +1,117 @@ + + + + + +Overview · SelfPost + + + + + + + + +
+
+
+
+
+

mail.example.org

+

Overview

+
+
+
Updated 14:02:31 UTC · refreshes every 10 s
+
+ + + +
+
+
+

Outbound domains

+ + + + + + + +
DomainDNSApps30 days
example.orgDKIM SPF DMARC21 284 msg
shop.example.orgDKIM SPF DMARC118 920 msg
notify.acme.ioDKIM SPF DMARC3402 msg
+
+
+
+
+

Inbound domains

+ + + + + + +
DomainMXUpstream
lists.example.orgMXmx.internal:25
acme.ioMX10.0.4.12:2525
+
+
+
+ + +
+
+ + + diff --git a/docs/assets/panel-redesign/c/theme.css b/docs/assets/panel-redesign/c/theme.css new file mode 100644 index 0000000..f41edce --- /dev/null +++ b/docs/assets/panel-redesign/c/theme.css @@ -0,0 +1,98 @@ +/* Direction C — "Counter". Bulma v1 (pure CSS, no JavaScript, no data: URIs) + carries the navbar with hover dropdowns, columns, boxes, tables, tags, + the side menu and input-with-addon fields. This file points Bulma's HSL + variables at the brand and adds the health strip and the record block. */ + +:root { + --bulma-family-primary: var(--sp-sans); + --bulma-family-secondary: var(--sp-sans); + --bulma-family-code: var(--sp-mono); + --bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%; + --bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%; + --bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%; + --bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%; + --bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%; + --bulma-radius: 6px; --bulma-radius-large: 10px; + --bulma-body-size: 15px; + /* Bulma derives link text and the on-primary colour from lightness steps; + at the brick's 33 % both land off-brand, so they are pinned. */ + --bulma-link-text: var(--sp-brick); + --bulma-link-text-hover: var(--sp-brick-dark); +} +.button.is-primary, .button.is-primary:hover { color: #fff; } +a strong { color: inherit; } +html { background: var(--sp-paper); } +body { background: var(--sp-paper); color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; } +main { flex: 1; } +code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .85em; } + +/* ---- navbar: brick, the stamp on it, hover dropdowns */ +.navbar.is-primary { --bulma-navbar-height: 4rem; } +.navbar-brand img { max-height: 3rem; height: 3rem; } +.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; } +.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); } +.navbar.is-primary .navbar-start > .navbar-item.is-active, +.navbar.is-primary .navbar-start > .has-dropdown.sp-current > .navbar-link { box-shadow: inset 0 -3px 0 var(--sp-on-brick); } +.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); } +.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; } +.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); } + +/* ---- page */ +.sp-page { padding-block: 2rem 2.5rem; } +.sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); } +.sp-kicker a { color: inherit; } +.title { color: var(--sp-ink); font-weight: 600; } +.box { box-shadow: none; border: 1px solid var(--sp-rule); } +.box > .sp-box-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; } +.box > .sp-box-head h2 { font-size: 1.1rem; font-weight: 600; margin: 0; } +.box > .sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; } +.table { background: transparent; } +.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); } +.table td { vertical-align: middle; } +.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; } +.sp-muted { color: var(--sp-muted); } +.sp-mono { font-family: var(--sp-mono); } + +/* ---- health strip */ +.sp-health { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; padding: 0; overflow: hidden; } +.sp-health > a { + display: block; padding: 1rem 1.2rem; color: var(--sp-ink); border-right: 1px solid var(--sp-rule); + box-shadow: inset 0 3px 0 var(--sp-ok); +} +.sp-health > a:last-child { border-right: 0; } +.sp-health > a:hover { background: var(--sp-paper); } +.sp-health > a.warn { box-shadow: inset 0 3px 0 #d9962b; background: var(--sp-warn-bg); } +.sp-health > a.fail { box-shadow: inset 0 3px 0 var(--sp-fail); background: var(--sp-fail-bg); } +.sp-health .sp-h-name { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); } +.sp-health .sp-h-value { font-size: 1.15rem; font-weight: 600; line-height: 1.3; } +.sp-health .sp-h-sub { font-size: .8rem; color: var(--sp-muted); } + +/* ---- domain page */ +.sp-side { position: sticky; top: 1.5rem; } +.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; } +.menu-list a { display: flex; align-items: center; gap: .5rem; } +.menu-list a .tag { margin-left: auto; } +.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); margin-top: 1.25rem; padding-top: 1.25rem; } +.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; } +.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; } +.sp-record-head small { margin-left: auto; color: var(--sp-muted); } +.sp-record .label { font-size: .75rem; font-weight: 400; color: var(--sp-muted); margin-bottom: .25rem; } +.sp-record .input[readonly], .sp-record .textarea[readonly] { + font-family: var(--sp-mono); font-size: .82rem; background: var(--sp-well); box-shadow: none; +} +.sp-record .textarea { resize: none; min-height: 0; } +.sp-record .field:not(:last-child) { margin-bottom: .6rem; } +.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; } +.sp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; } +.sp-stats b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.2; } + +/* ---- sign-in */ +.sp-signin { min-height: 100vh; margin: 0; } +.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; } +.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; } +.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; } +.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; } +.sp-signin-form form { width: 21rem; max-width: 100%; margin-inline: auto; } +.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.2rem; } +.sp-foot a { color: inherit; text-decoration: underline; } +.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; } diff --git a/docs/assets/panel-redesign/index.css b/docs/assets/panel-redesign/index.css new file mode 100644 index 0000000..71c0058 --- /dev/null +++ b/docs/assets/panel-redesign/index.css @@ -0,0 +1,22 @@ +/* Cover sheet for the three directions. No library on purpose: it must not + look like any one of them. */ +body { margin: 0; background: var(--sp-paper); color: var(--sp-ink); font: 16px/1.55 var(--sp-sans); } +main { max-width: 68rem; margin: 0 auto; padding: 3rem 1.5rem 4rem; } +h1 { font-size: 2rem; font-weight: 600; margin: .2rem 0 1rem; text-wrap: balance; } +h2 { font-size: 1.15rem; font-weight: 600; margin: 2.6rem 0 .8rem; } +h3 { font-size: 1.4rem; font-weight: 600; margin: .1rem 0 .6rem; } +.kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-brick); margin: 0; } +.lead { max-width: 46rem; color: var(--sp-ink-2); } +ul { padding-left: 1.2rem; max-width: 52rem; } +li { margin-bottom: .5rem; } +a { color: var(--sp-brick); } +code { font-family: var(--sp-mono); font-size: .85em; background: var(--sp-well); padding: .1em .35em; border-radius: 3px; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1.2rem; } +.cards section { background: var(--sp-sheet); border: 1px solid var(--sp-rule); border-top: 3px solid var(--sp-brick); padding: 1.3rem 1.4rem; display: flex; flex-direction: column; } +.cards p { margin: 0 0 1rem; } +.links { margin-top: auto !important; margin-bottom: 0 !important; display: flex; gap: 1.1rem; font-weight: 600; } +.scroll { overflow-x: auto; } +table { border-collapse: collapse; width: 100%; background: var(--sp-sheet); border: 1px solid var(--sp-rule); font-size: .93rem; } +th, td { text-align: left; vertical-align: top; padding: .6rem .9rem; border-bottom: 1px solid var(--sp-rule); } +thead th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; } +tbody th { font-weight: 600; white-space: nowrap; } diff --git a/docs/assets/panel-redesign/index.html b/docs/assets/panel-redesign/index.html new file mode 100644 index 0000000..2e4d2d5 --- /dev/null +++ b/docs/assets/panel-redesign/index.html @@ -0,0 +1,138 @@ + + + + + +Panel redesign · three directions + + + + +
+

SelfPost · panel redesign · stage 2

+

The accepted direction, every screen

+

Direction C (Bulma) was picked as the base: the important things in view, details one + or two clicks away. It takes the postal signature of A — the postmark verdict, numbered mono section + heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of B. Thirty-three screens and a components page live in + panel/. Serve this folder from the repository root: the pages load the panel's own + fonts and mark by relative path.

+ +

Screen map

+
+ + + + + + + + + + +
Signed outSign in · Create administrator
OverviewOverview — postmark verdict, six health cards, both domain lists
OutboundDomains · domain · + domain settings · application form · + password shown once · delete domain
+ Log · message · + Queue · DMARC reports · + by domain · one report
InboundDomains · domain · + delete domain · Log · + Queue (empty state)
+ Quarantine · held message · + Filter lists — roadmap items, drawn as their target look
ServerHealth · Preflight (roadmap) · System log · + Backup · Users · user form · + delete user · Settings
User menuAccount · Help
ContractComponents — every building block in every state · + the plan · panel/check.py lints the mockups and writes panel/outlines.json
+
+ +

What changed against today's panel

+
    +
  • Overview shows verdicts, Health shows tables. Process lists, socket paths and interface counters moved to + Server › Health, one click from each card.
  • +
  • Every page is one of three layouts: a list (form on top, table below), a detail page (sticky side menu + + numbered boxes), or a form page (boxes in equal columns). No page picks its own width.
  • +
  • Own password is Account, instance defaults are Settings. Export sits with the domain, import with Backup, + and each links to the other.
  • +
  • Queues are tables, not a postqueue -p dump — this needs postqueue -j parsing. + The inbound queue is the same queue filtered by recipient domain.
  • +
  • Confirm pages say what will be lost as a list, beside a hint for the usual alternative (export before delete).
  • +
  • Own components on top of Bulma: postmark, perforated edge, numbered box head, health card, DNS record block, facts grid, + timeline, log pane. The mockup's theme.css is about 200 lines.
  • +
  • panel/build.py wraps the fragments in panel/src/ with the shared navbar; the built pages are committed, + so nothing has to be run to view them.
  • +
+ +

Stage 1 · three directions on three key screens

+

Kept for the record: Overview, an outbound domain and sign-in, drawn three ways.

+ +

Shared by all three

+
    +
  • Navigation by mail direction. Overview · Outbound (Domains, Log, Queue, DMARC reports) · + Inbound (Domains, Log, Queue) · Server (System log, Backup, Users, Settings). Account, Help and + Sign out live with the user name. Eleven flat entries become four groups.
  • +
  • One block per DNS record. Today "DNS status" and "records to publish" show every host twice. + Here a record is one block: its status, what to publish, and — only on a mismatch — what DNS holds now.
  • +
  • The domain page stops being nine cards. DNS and applications are the page; limits, report address, + export and delete are "rarely changed" and sit behind one click.
  • +
  • Applications are a table; editing one is its own form page instead of a three-column panel inside a list row.
  • +
  • Brand carriers stay: the stamp, brick, IBM Plex. No inline style or script in any mockup.
  • +
+ +

The directions

+
+
+

A · Pico CSS 2.1

+

Blank

+

The panel as a postal form: one centred sheet, numbered sections, mono field labels, a postmark for the + verdict. Overview is a ledger — one row per check, the row opens into its detail. Sections on the first + header row, the section's pages on the second.

+ +
+
+

B · Tabler 1.5 (Bootstrap 5)

+

Console

+

The classic admin kit: ink sidebar with every page one click away, status tiles, cards and tables in a + 12-column grid, read-only input groups with Copy. Almost nothing is ours; it also looks the most like + every other admin panel.

+ +
+
+

C · Bulma 1.0

+

Counter

+

Brick top bar with CSS-only hover menus. Overview is the whole service on one screen: a health strip over + the outbound and inbound domain lists. The domain page gets a sticky side menu. Softer, rounder, the + boldest use of the brand colour.

+ +
+
+ +

What each one costs

+
+ + + + + + + + + + + + + + + + +
A · PicoB · TablerC · Bulma
LicenceMITMITMIT
Vendored CSS (minified)83 KB694 KB678 KB
JavaScriptnonenone for these screens; Bootstrap bundle only if dropdowns / modals are wantednone
Build stepnonenonenone (a Sass build could trim it; not needed)
CSP default-src 'self'needs img-src 'self' data: — 14 data: SVGs (select chevron, checkbox tick)needs img-src 'self' data: — 28 data: SVGsworks as is — no data: URIs
Our own CSS in the mockup≈ 200 lines≈ 80 lines≈ 100 lines
Markup stylesemantic HTML, few classes — closest to today's templatesclass-heavy Bootstrap markupclass-based, moderate
Own componentssheet, ledger row, postmark, record blocksidebar group label, record blockhealth strip, record block
Recognisablemostleastmiddle
+
+ +

Open points

+
    +
  • Icons in B come from Tabler Icons (MIT) through its webfont for the mockup; the panel would vendor only the used glyphs as an SVG sprite.
  • +
  • Inbound log rides on the agreed inbound-antispam-panel journal; Inbound queue is a new filter over the one Postfix queue and needs its own roadmap row.
  • +
  • Dark scheme and the narrow-screen shell are not drawn at this stage; all three libraries ship both.
  • +
  • The earlier panel-ui experiment and panel-redesign.md are superseded by whichever direction is accepted.
  • +
+
+ + diff --git a/docs/assets/panel-redesign/panel/account.html b/docs/assets/panel-redesign/panel/account.html new file mode 100644 index 0000000..60e6ab7 --- /dev/null +++ b/docs/assets/panel-redesign/panel/account.html @@ -0,0 +1,160 @@ + + + + + +Account · SelfPost + + + + + + + + + +
+
+
+
+

Signed in as admin · global

+

Account

+

Who you are to this panel, what it should tell you about, and where reports about your domains go. Applications keep their own logins and passwords and are not affected.

+
+
+ +
+
+
+
01

Profile

+
+
+
+
+

Where the panel writes to you: the notifications on the right and a password-reset link. It can also receive DMARC reports — your choice, under DMARC reports.

+ +
+
+
+ +
+
02

Password

+
+
+
+
+
+
+
+

Changing it signs out every other session of this account; this one stays signed in.

+ +
+
+
+
+ +
+
+
03

Notifications

Sent to mix@example.org
+
+
+

Your domains

+
+
+
+
+
+

The server · global role

+
+
+
+

One message when a problem starts and one when it clears — never a stream. A domain administrator is told only about the domains assigned to them.

+ +
+
+
+ +
+
04

DMARC reports

authorized
+
+
+
+
+

Used by every outbound domain whose report address is set to admin's default — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.

+ +
+
+
+

Report authorization

publishedNeeded because the address is on another domain than the senders
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/audit.js b/docs/assets/panel-redesign/panel/audit.js new file mode 100644 index 0000000..0127d4c --- /dev/null +++ b/docs/assets/panel-redesign/panel/audit.js @@ -0,0 +1,55 @@ +/* Rendered-page audit for the panel: what check.py cannot see because it needs + a browser. Open any page of the mockups (or of the running panel), paste this + file into the console, then: + + await panelAudit(["overview.html", "out-domain.html", ...]) // same-origin URLs + await panelAudit() // the page you are on + + It loads each page in a 1440px and a 1280px frame and reports + - text under the 13px floor (the postmark's imprint excepted), + - text under 4.5:1 against the ground it actually sits on, + - anything wider than its box, and tables that have to scroll, + - health-card values that got clipped. + An empty `problems` list is the pass. Attach the output to the step's evidence + (docs/plans/panel-redesign.md, Enforcement). */ +async function panelAudit(urls = [location.href], widths = [1440, 1280]) { + const lum = (c) => { + const [r, g, b] = c.map((v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const rgba = (s) => { const m = s.match(/[\d.]+/g).map(Number); return { rgb: m.slice(0, 3), a: m.length > 3 ? m[3] : 1 }; }; + const ground = (el, win) => { + for (let e = el; e; e = e.parentElement) { const b = rgba(win.getComputedStyle(e).backgroundColor); if (b.a > 0.5) return b.rgb; } + return [255, 255, 255]; + }; + const problems = new Set(); + let texts = 0; + for (const url of urls) for (const width of widths) { + const f = document.createElement("iframe"); + f.width = width; f.height = 900; f.src = url; + document.body.appendChild(f); + await new Promise((r) => (f.onload = r)); + await f.contentDocument.fonts.ready; + await new Promise((r) => setTimeout(r, 150)); + const d = f.contentDocument, win = f.contentWindow, at = `${width} ${url.split("/").pop()}`; + const walker = d.createTreeWalker(d.body, NodeFilter.SHOW_TEXT); + for (let n; (n = walker.nextNode()); ) { + const text = n.textContent.trim(), el = n.parentElement; + if (!text || el.closest(".sp-log")) continue; // the log pane has its own dark ground and palette + texts++; + const cs = win.getComputedStyle(el), what = `${el.tagName.toLowerCase()}.${[...el.classList].join(".")} "${text.slice(0, 24)}"`; + if (parseFloat(cs.fontSize) < 12.9 && !el.closest(".sp-postmark")) problems.add(`${at}: ${parseFloat(cs.fontSize)}px ${what}`); + const fg = rgba(cs.color).rgb, bg = ground(el, win), a = lum(fg), b = lum(bg); + const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + const large = parseFloat(cs.fontSize) >= 24 || (parseFloat(cs.fontSize) >= 18.6 && +cs.fontWeight >= 600); + if (ratio < (large ? 3 : 4.5)) problems.add(`${at}: contrast ${ratio.toFixed(2)} ${what}`); + } + if (d.documentElement.scrollWidth > win.innerWidth) problems.add(`${at}: the page scrolls sideways`); + for (const e of d.querySelectorAll(".box, .sp-head, .sp-box-head")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: overflow in .${e.className}`); + for (const e of d.querySelectorAll(".table-container")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: a table scrolls by ${e.scrollWidth - e.clientWidth}px`); + for (const e of d.querySelectorAll(".sp-h-name, .sp-h-value, .sp-h-sub")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: clipped "${e.textContent}"`); + for (const i of d.querySelectorAll("i.ti")) if (win.getComputedStyle(i, "::before").content === "none") problems.add(`${at}: missing icon ${i.className}`); + f.remove(); + } + return { pages: urls.length, widths, textsChecked: texts, problems: [...problems] }; +} diff --git a/docs/assets/panel-redesign/panel/backup.html b/docs/assets/panel-redesign/panel/backup.html new file mode 100644 index 0000000..6ec49c9 --- /dev/null +++ b/docs/assets/panel-redesign/panel/backup.html @@ -0,0 +1,116 @@ + + + + + +Backup · SelfPost + + + + + + + + + +
+
+
+
+

Server

+

Backup

+

Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets.

+
+
+ +
+
+
+
01

Full backup

+
+

data/ (database, DKIM keys, credentials, the queue), docker-compose.yml, .env and certs/ in one archive. Restore into an empty project directory and start the same SelfPost version. The reverse-proxy vhost is not included.

+
+
+
+
+
+
+

Keep this password: SelfPost does not store it, and the file cannot be opened without it.

+ +
+
+
+
+ +
+
+
02

Import a domain

+
+

A domain export from another instance — plain .json or encrypted .spde. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the domain's settings.

+
+
+
+
+

Asked only for an encrypted .spde file.

+ +
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/build.py b/docs/assets/panel-redesign/panel/build.py new file mode 100644 index 0000000..f099787 --- /dev/null +++ b/docs/assets/panel-redesign/panel/build.py @@ -0,0 +1,121 @@ +"""Wrap every fragment in src/ with the panel shell and write it next to this file. + +The mockups are static HTML, but thirty pages share one navbar; keeping that +navbar in one place is the only reason this script exists. Run it from +anywhere: python docs/assets/panel-redesign/panel/build.py + +A fragment starts with one meta comment: + +section/page pick the highlighted entries; "shell: bare" skips the navbar +(sign-in, setup). +""" +import pathlib +import re + +HERE = pathlib.Path(__file__).parent + +NAV = [ + ("overview", "Overview", "overview.html", "ti-activity-heartbeat", []), + ("outbound", "Outbound", "out-domains.html", "ti-send", [ + ("domains", "Domains", "out-domains.html", "ti-world-upload"), + ("log", "Log", "out-log.html", "ti-list-details"), + ("queue", "Queue", "out-queue.html", "ti-stack-2"), + ("dmarc", "DMARC reports", "dmarc.html", "ti-report-analytics"), + ]), + ("inbound", "Inbound", "in-domains.html", "ti-inbox", [ + ("domains", "Domains", "in-domains.html", "ti-world-download"), + ("log", "Log", "in-log.html", "ti-list-details"), + ("queue", "Queue", "in-queue.html", "ti-stack-2"), + ("quarantine", "Quarantine", "in-quarantine.html", "ti-mail-pause"), + ("filter-lists", "Filter lists", "in-filter-lists.html", "ti-filter"), + ]), + ("server", "Server", "health.html", "ti-server-2", [ + ("health", "Health", "health.html", "ti-heartbeat"), + ("preflight", "Preflight", "preflight.html", "ti-checklist"), + ("system-log", "System log", "system-log.html", "ti-file-text"), + ("backup", "Backup", "backup.html", "ti-archive"), + ("users", "Users", "users.html", "ti-users"), + ("settings", "Settings", "settings.html", "ti-settings"), + ]), +] + +HEAD = """ + + + + +{title} · SelfPost + + + + + +""" + +FOOT = ('') + + +def navbar(section, page): + out = ['', + ''] + return "\n".join(out) + + +def subnav(section, page): + for key, _label, _href, _icon, pages in NAV: + if key == section and pages: + cur = ' aria-current="page"' + links = "".join( + f'{plabel}' + for pkey, plabel, phref, picon in pages) + return f'
{links}
' + return "" + + +def build(path): + text = path.read_text(encoding="utf-8") + m = re.match(r"\s*\s*", text, re.S) + meta = dict(part.split(":", 1) for part in m.group(1).split("|")) + meta = {k.strip(): v.strip() for k, v in meta.items()} + body = text[m.end():] + html = HEAD.format(title=meta["title"]) + if meta.get("shell") == "bare": + html += f"\n{body}\n\n\n" + else: + section, page = meta.get("section", ""), meta.get("page", "") + html += (f"\n{navbar(section, page)}\n{subnav(section, page)}\n
\n" + f'
\n{body}\n {FOOT}\n
\n
\n\n\n') + (HERE / path.name).write_text(html, encoding="utf-8", newline="\n") + + +if __name__ == "__main__": + pages = sorted((HERE / "src").glob("*.html")) + for p in pages: + build(p) + print(f"built {len(pages)} pages") diff --git a/docs/assets/panel-redesign/panel/check.py b/docs/assets/panel-redesign/panel/check.py new file mode 100644 index 0000000..96202ad --- /dev/null +++ b/docs/assets/panel-redesign/panel/check.py @@ -0,0 +1,240 @@ +"""Design-contract lint for the panel mockups, and the source of outlines.json. + +The contract (docs/plans/panel-redesign.md) is only worth something if a +machine can say "no". This script is that machine for the mockups; the same +rules are ported to a Go test over internal/web templates when the redesign is +implemented, and that test compares rendered pages with outlines.json. + + python docs/assets/panel-redesign/panel/check.py # lint + python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json + +Exit code 1 on any violation. +""" +import json +import pathlib +import re +import sys +from html.parser import HTMLParser + +HERE = pathlib.Path(__file__).parent +VOID = {"input", "img", "br", "hr", "meta", "link"} + +# The subset of Bulma the panel uses. Reaching for a Bulma class outside this +# list is a design decision, not an implementation detail: add it here and to +# the components page in the same change, or do without it. +BULMA = set(""" +box button buttons checkbox column columns control field file file-cta file-input file-label +file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label +menu-list notification progress select table table-container tag textarea title +is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half +is-hoverable is-left is-light is-primary is-small is-success is-text is-warning +mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5 +""".split()) +# Classes the shell (build.py, later layout.html) owns; screens never write them. +SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"} +# The signed-out screens have no shell, so they carry their own footer. +SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"} +# Selectors allowed to set a width. Everything else gets its width from the +# layout it sits in — that is the whole point of the redesign. +WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip", + ".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf") + + +class Node: + def __init__(self, tag, attrs, parent): + self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], "" + + @property + def classes(self): + return (self.attrs.get("class") or "").split() + + def has(self, cls): + return cls in self.classes + + def walk(self): + yield self + for k in self.kids: + yield from k.walk() + + def find(self, cls): + return [n for n in self.walk() if n.has(cls)] + + +class Tree(HTMLParser): + def __init__(self): + super().__init__() + self.root = Node("root", [], None) + self.cur = self.root + + def handle_starttag(self, tag, attrs): + n = Node(tag, attrs, self.cur) + self.cur.kids.append(n) + if tag not in VOID: + self.cur = n + + def handle_endtag(self, tag): + n = self.cur + while n is not self.root and n.tag != tag: + n = n.parent + if n is not self.root: + self.cur = n.parent + + def handle_data(self, data): + self.cur.text += data + + +def parse(text): + t = Tree() + t.feed(text) + return t.root + + +def text_of(node): + return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip() + + +def css_rules(css): + css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) + css = re.sub(r"@media[^{]*\{", "", css) + for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): + yield sel.strip(), body + + +def box_kind(box): + kinds = [] + for k in box.kids: + if k.has("sp-box-head") or k.has("sp-box-foot"): + continue + for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"), + ("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")): + if k.has(cls): + kinds.append(name) + break + else: + kinds.append("table" if k.tag == "table" or k.has("table-container") else k.tag) + out = [] + for k in kinds: + if not out or out[-1].split("×")[0] != k: + out.append(k) + else: + n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2 + out[-1] = f"{k}×{n}" + return "+".join(out) + + +def outline(node): + """The component skeleton of a page: what a reviewer sees with the text blurred.""" + out = [] + for k in node.kids: + if k.has("sp-head"): + parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)] + if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()): + parts.append("actions") + out.append("+".join(parts)) + elif k.has("box"): + variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "") + head = k.find("sp-box-head") + title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?" + out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}") + elif k.has("columns"): + cols = [] + for c in k.kids: + size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto") + cols.append({size: outline(c)}) + out.append({"columns": cols}) + elif k.tag == "form" or k.has("menu") or k.tag == "aside": + out.append({"form": outline(k)} if k.tag == "form" else "side_menu") + elif k.has("buttons"): + out.append("submit_row") + return out + + +def main(): + errors = [] + css = (HERE / "theme.css").read_text(encoding="utf-8") + defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css)) + + for sel, body in css_rules(css): + if "!important" in body: + errors.append(f"theme.css: !important in `{sel}`") + if re.search(r"(^|[\s,>+~])#[a-z]", sel): + errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page") + # The type floor: 13px. Icons (.ti) and the postmark imprint are not running text. + for value, unit in re.findall(r"font-size\s*:\s*([\d.]+)(rem|em|px)", body): + floor = {"rem": 0.8125, "em": 0.85, "px": 13}[unit] + if float(value) < floor and ".sp-postmark" not in sel and ".ti" not in sel: + errors.append(f"theme.css: `{sel}` sets font-size {value}{unit}, below the 13px floor — use --sp-fs-xs or --sp-fs-s") + # `min-width: 0` sizes nothing: it only lets a flex or grid child shrink so its text can be clipped. + sized = re.sub(r"min-width\s*:\s*0\s*(;|$)", "", body) + if re.search(r"(?")[0] + for bad in ("style=", " — controls keep the normal size; only a progress bar is small") + elif c not in BULMA: + errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component") + if bare: + continue + top = [k for k in root.kids if k.tag != "root"] + if not top or not top[0].has("sp-head"): + errors.append(f"{name}: the page must open with page_head (.sp-head)") + if len([n for n in root.walk() if n.tag == "h1"]) != 1: + errors.append(f"{name}: exactly one

") + marks = root.find("sp-postmark") + if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)): + errors.append(f"{name}: at most one postmark, inside page_head") + for tbl in (n for n in root.walk() if n.tag == "table"): + if not tbl.parent.has("table-container"): + errors.append(f"{name}: a must sit in a table-container so a wide one can scroll") + for box in root.find("box"): + if not box.kids or not box.kids[0].has("sp-box-head"): + errors.append(f"{name}: a box must start with box_head") + for k in top[1:]: + # A flash is conditional, so it is allowed at the top level but never part of an outline. + ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form")) + if not ok: + errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form") + if name != "components": + outlines[name] = outline(root) + + for c in sorted(used - documented - SIGNED_OUT): + errors.append(f"components page does not show `{c}` — document it or stop using it") + shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8"))) + for c in sorted(defined - used - documented - shell_used): + errors.append(f"theme.css defines `{c}` but nothing uses it") + + if "--write" in sys.argv and not errors: + (HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n", + encoding="utf-8", newline="\n") + golden = HERE / "outlines.json" + if golden.exists() and "--write" not in sys.argv: + if json.loads(golden.read_text(encoding="utf-8")) != outlines: + errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit") + + for e in errors: + print("FAIL", e) + print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/assets/panel-redesign/panel/components.html b/docs/assets/panel-redesign/panel/components.html new file mode 100644 index 0000000..cef72d9 --- /dev/null +++ b/docs/assets/panel-redesign/panel/components.html @@ -0,0 +1,294 @@ + + + + + +Components · SelfPost + + + + + + + + + +
+
+
+
SELFPOSTREFDESIGN CONTRACT
+
+

Reference · page_head

+

Components

+

Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.

+
+ +
+ +
flash — the result of the last action, between page_head and the first box. Success, or…
+
…flash, error — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's help is-danger.
+ +
+
00

Page layouts

A page is exactly one of these
+
+
+
list
page_head, then full-width boxes: the add form first, the table second. Example
+
detail
page_head, then columns: is-3 side menu + is-9 boxes. Example
+
form
page_head, then boxes in equal columns, one submit row under them. Example
+
signed out
The split brand / form screen, no navbar. Example
+
+
+
+ +
+
+
+
01

postmark

Worst status of the page's checks
+
+
+
SELFPOSTOK6 OF 6 CHECKS
+
SELFPOSTWARN1 OF 6 CHECKS
+
DNSFAIL1 OF 3 RECORDS
+
+

Only inside page_head, at most one per page, and only where the page has a verdict to give.

+
+
+ +
+
03

status_tag

One mapping, one function
+
+ + + + + + + + +
StatusTagBulma classes
ok, published, running, sentoktag is-success is-light
warn, deferred, quietwarntag is-warning is-light
error, fail, mismatch, bouncedfailtag is-danger is-light
unknown, off, neutral countsofftag is-light
held: quarantine · role: globalquarantinetag is-primary is-light
+ + + +
+
+
02

box · box_head

The end slot: a note, a tag, a link or a filter form
+

The after the end slot is sp-help: an optional link to the section's topic on the Help page. It replaces today's help drawer.

box_body — padded content. A table or a run of sp-record blocks sits directly in the box, without box_body.

+
box_foot — a legend, pagingend slot
+
+
+

box · sp-danger-zone

+

For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.

+
+
+

box · sp-credential

Shown once
+

Only for a secret the panel will never show again.

+
+
+ + +
+
04

health_card

Overview only · each card links to its detail
+ +
+ +
+
+
+
05

dns_record · copy_field

One block per record
+
+

Record

publishedA one-line note
+
+
+
+
+
+
+
+
+
+

Record

mismatch"In DNS now" appears only when it differs
+
+
+

What is wrong and the exact fix.

+
+
+ +
+
07

Table conventions

Always inside table-container · an address breaks after its @
+
+ + + + +
Time, UTCNamesp-clipStatus
09-21 14:01:52the row's linkA long subject is clipped with an ellipsis instead of wrapping the rowsentDetailsDelete
+
Page 1 of 9Older →
+
+ +
+
09

log_pane

sp-d time · sp-w warning · sp-e error
+
13:58:07 an ordinary line
+13:58:09 a deferred delivery — sp-w
+13:57:41 a reject or a bounce — sp-e
+
+
+ +
+
+
06

facts

+
+
+
Label
Value
+
Mono value
4XcB7k2Jm9z1
+
sp-big
5 days
a supporting line
+
+
+
+ +
+
08

timeline

+
+
    +
  1. Done — default
  2. +
  3. Deferred — sp-warn
  4. +
  5. Bounced — sp-fail
  6. +
  7. Expected next — sp-pending
  8. +
+
+
+ +
+
10

empty_state

+
One sentence: what is absent and why that is fine.
+
+ +
+
11

confirm_list

+
+
    +
  • each thing that will be lost, one per line;
  • +
  • inside an sp-danger-zone box, above the danger button.
  • +
+
+
+
+
+ +
+
+ +
+
+
+
12

Form rows · route · help_topic

+
+
+
+
+
+
+
+
+

Help text lives in Bulma's help.

+
+
+

from@example.orgto@example.netdeferred

+
+

help_topic

A heading and a paragraph; topics stack inside one box on the Help page.

+
+
+
+ +
+
13

shell

Owned by the layout — a screen never writes it
+
+
+
Name
The wordmark, not the stamp: at nav size the stamp's perforation is under a pixel and its brick field dissolves into the bar. The stamp lives on sign-in, setup and the favicon.
+
Edge
Built like the mark: the brick field, then the stamp's paper margin, and the perforation along the outer edge of that margin — round holes, flat teeth, the ink hairline. One tile, perf-edge.svg.
+
Sibling strip
The pages of the current group, under the edge. Five or six entries; past that the group is split.
+
Menus
Bulma hover dropdowns, no JavaScript. The user menu holds Account, Help and Sign out.
+
+
+
+ +
+
14

Type scale

Three sizes · nothing to be read is under 13px
+
+ + + + + + +
SizeLooks likeUsed for
body · 16pxThe quick brown fox, 203.0.113.25Text, table cells, inputs, selects, buttons, menus and their dropdowns
--sp-fs-s · 14pxThe quick brown fox, 203.0.113.25Supporting text: labels, help, notes in a box head or foot, mono values (sp-small), logs, the footer
--sp-fs-xs · 13pxUpper-case mono label tagUpper-case mono micro-labels (kicker, table head, dt, box title) and tags — nothing else
+
Bulma's is-small and is-size-7 are not used: a control is never small, and sp-small replaces the helper. The postmark's fine print is an imprint, not text.
+
+ + + + + + diff --git a/docs/assets/panel-redesign/panel/dmarc-domain.html b/docs/assets/panel-redesign/panel/dmarc-domain.html new file mode 100644 index 0000000..5d601ed --- /dev/null +++ b/docs/assets/panel-redesign/panel/dmarc-domain.html @@ -0,0 +1,110 @@ + + + + + +shop.example.org · DMARC reports · SelfPost + + + + + + + + + +
+
+
+
DMARC99.6%PASS · 7 DAYS
+
+

DMARC reports /

+

shop.example.org

+

11 204 pass · 41 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening p=.

+
+ Domain page +
+ +
+
+
+
01

Sources · 7 days

+
+ + + + + +
SourcePassFailDisposition
203.0.113.25 this relay11 2040none
198.51.100.44041none
+
+
+
+
+
02

Reports

+
+ + + + + + +
Received, UTCReporterWindowPassFail
09-21 04:40Outlook.com20 Sep1 9037View
09-21 02:15google.com20 Sep6 4100View
09-20 04:38Outlook.com19 Sep1 87734View
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/dmarc-report.html b/docs/assets/panel-redesign/panel/dmarc-report.html new file mode 100644 index 0000000..da54805 --- /dev/null +++ b/docs/assets/panel-redesign/panel/dmarc-report.html @@ -0,0 +1,119 @@ + + + + + +Outlook.com · 20 Sep · DMARC · SelfPost + + + + + + + + + +
+
+
+
+

DMARC reports / shop.example.org /

+

Outlook.com · 20 Sep 2026

+

shop.example.org1 903 pass7 fail

+
+
+ +
+
+
+
01

Report

+
+
+
Reporter
Outlook.com
+
Report id
5f1c9a0e7b2d4e61a3
+
Window
20 Sep 00:00 – 21 Sep 00:00 UTC
+
Received
2026-09-21 04:40 UTC
+
+
+
+
+
+
+
02

Published policy

+
+
+
p / sp / pct
none / none / 100
+
adkim / aspf
r / r
+
rua
dmarc@mail.example.org
+
+
+
+
+
+ +
+
03

Records

Parsed from the aggregate XML
+
+ + + + + +
SourceCountDispositionSPFDKIMHeader from
203.0.113.25 this relay1 903nonepasspassshop.example.org
198.51.100.447nonefailfailshop.example.org
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/dmarc.html b/docs/assets/panel-redesign/panel/dmarc.html new file mode 100644 index 0000000..decc5fe --- /dev/null +++ b/docs/assets/panel-redesign/panel/dmarc.html @@ -0,0 +1,105 @@ + + + + + +DMARC reports · SelfPost + + + + + + + + + +
+
+
+
+

Outbound

+

DMARC reports

+

How receivers saw mail claiming to be from your domains. Aggregate (rua=) only; open a report for the parsed XML.

+
+
+ +
+
01

Ingest

ok
+
+
+
Last report
2026-09-21 06:12 UTC
+
This week
14 kept · 0 parse failures
+
Hosted address
dmarc@mail.example.org
+
Retention
500 reports or 90 days
+
+
+
+ +
+
02

Recent reports

+
+ + + + + + + +
Received, UTCDomainReporterWindowPassFail
09-21 06:12example.orggoogle.com20 Sep4120View
09-21 04:40shop.example.orgOutlook.com20 Sep1 9037View
09-20 23:58example.orgYahoo19 Sep380View
09-20 06:09notify.acme.iogoogle.com19 Sep610View
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/health.html b/docs/assets/panel-redesign/panel/health.html new file mode 100644 index 0000000..4db8d81 --- /dev/null +++ b/docs/assets/panel-redesign/panel/health.html @@ -0,0 +1,160 @@ + + + + + +Health · SelfPost + + + + + + + + + +
+
+
+
+

Server

+

Health

+

Everything behind the six cards on Overview. Refreshes every 10 s.

+
+
+
+ +
+
+
+
01

Machine

ok
+
+ + + + + + +
ResourceUsageDetail
CPU12 %2 cores, load 0.21
Memory41 %812 MB of 1.9 GB
Network↓ 18 kB/s · ↑ 44 kB/seth0: 2.1 GB in, 6.4 GB out
+
+
+
+
+
02

Processes

ok
+
+ + + + + + + + +
ProgramStateDetail
postfixrunningpid 41, uptime 18 days
opendkimrunningpid 39, uptime 18 days
journal-milterrunningpid 40, uptime 18 days
panelrunningpid 44, uptime 18 days
dmarc-ingestrunningpid 47, uptime 18 days
+
+
+
+ +
+
+
+
03

TLS certificate

warn
+
+
+
Expires
2026-10-03 09:14 UTC
+
Names
mail.example.org
+
+

Expires in 12 days — renewal has not run.

+

Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for acme lines in the system log.

+
+
+
+
+
+
04

Milter sockets

ok
+
+ + + + + + +
MilterState
opendkim
inet:127.0.0.1:8891
ok
journal-milter
unix:/run/selfpost/journal.sock
ok
rspamd · inbound spam filter
inet:antispam:11332
ok
+
+
+
+
+
05

Hostname and reverse DNS

ok
+
+
+
Hostname
mail.example.org
+
Lookup
→ 203.0.113.25
← mail.example.org
+
+
+
+
+
+ +
+
06

Configuration

Safe to run at any time
+
+

Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.

+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/help.html b/docs/assets/panel-redesign/panel/help.html new file mode 100644 index 0000000..2c3d045 --- /dev/null +++ b/docs/assets/panel-redesign/panel/help.html @@ -0,0 +1,119 @@ + + + + + +Help · SelfPost + + + + + + + + + +
+
+
+
+

Reference

+

Help

+

What each screen checks and what to do when it is not green. Every numbered section on a page links to its topic here.

+
+
+ +
+ +
+
+
01

Overview

+

Health checks

Six checks, the worst of them is the postmark. ok needs nothing; warn still delivers mail but will stop doing so if ignored; fail means mail is being lost or refused now.

+

TLS certificate

The certificate Postfix presents on 465 and 587. It warns 14 days before expiry and fails once expired — clients that verify certificates then refuse to connect.

+

Hostname and reverse DNS

The hostname must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.

+
+
+
02

Outbound

+

DNS records

DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one.

+

Applications

An application is one SASL login. It may send as any address of its domain or only as listed addresses. The password is shown once at creation or regeneration and is not stored in readable form.

+

Rate limits

Level 1 is a per-IP ceiling set in .env. Level 2 is optional, per domain and per application, manual or computed from 30-day statistics, and can never exceed level 1.

+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-domain-delete.html b/docs/assets/panel-redesign/panel/in-domain-delete.html new file mode 100644 index 0000000..e1e544a --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-domain-delete.html @@ -0,0 +1,101 @@ + + + + + +Delete lists.example.org · SelfPost + + + + + + + + + +
+
+
+
+

Inbound domains / lists.example.org /

+

Delete lists.example.org

+
+
+ +
+
+
+

This cannot be undone

+
+

Deleting the inbound domain lists.example.org will:

+
    +
  • stop accepting mail for it on port 25 — senders get a relay-denied reply;
  • +
  • remove its upstream and its 4 listed recipients.
  • +
+

Remove or repoint the MX record first, or mail for the domain will bounce. Outbound domains are not touched.

+
+
+ + Keep it +
+
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-domain.html b/docs/assets/panel-redesign/panel/in-domain.html new file mode 100644 index 0000000..5177983 --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-domain.html @@ -0,0 +1,171 @@ + + + + + +lists.example.org · Inbound domains · SelfPost + + + + + + + + + +
+
+
+
DNSOKMX POINTS HERE
+
+

Inbound domains /

+

lists.example.org

+

Accepted on port 25, handed to mx.internal:25 over required TLS · 4 recipients

+
+
+
+
+
+ +
+
+ +
+ +
+
+
01

MX record

Checked 1 min ago
+
+

MX

publishedKeep any existing primary MX if this is a backup-MX
+
+
+
+
+
+
+
+
+
+ +
+
+
+
02

Upstream

Not a mailbox
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
03

Valid recipients

+
+
+
+
+
+
+

Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.

+ +
+
+
+
+
+ +
+
04

Spam filter

on
+
+
+
Engine
rspamd, for every inbound domain of this server
+
Suspicious mail
Held — 1 message in quarantine for this domain
+
Spam
Refused at the door; see reject in the log
+
Allow and deny lists
Kept by the global administrator, one pair for the whole server
+
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-domains.html b/docs/assets/panel-redesign/panel/in-domains.html new file mode 100644 index 0000000..753983b --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-domains.html @@ -0,0 +1,105 @@ + + + + + +Inbound domains · SelfPost + + + + + + + + + +
+
+
+
+

Inbound

+

Inbound domains

+

Backup-MX / forwarder. Port 25 accepts mail only for the domains listed here and hands it to their upstream. No mailboxes.

+
+
+ +
+
01

Add a domain

+
+
+
+
+
+
+

Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.

+
+
+
+ +
+
02

Domains

Spam filter on · 2 domains
+
+ + + + + +
DomainDNSUpstreamRecipientsTLS to upstream
lists.example.orgMXmx.internal:254 addressesrequiredOpenDelete
acme.ioMX10.0.4.12:2525any addressopportunisticOpenDelete
+
The MX tag is green when at least one MX of the domain points at this server.
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-filter-lists.html b/docs/assets/panel-redesign/panel/in-filter-lists.html new file mode 100644 index 0000000..de1a1dd --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-filter-lists.html @@ -0,0 +1,145 @@ + + + + + +Filter lists · SelfPost + + + + + + + + + +
+
+
+
+

Inbound · global role

+

Filter lists

+

Correct the spam filter by hand: senders it must always let through, and senders it must always turn away. One pair of lists for the whole instance.

+
+
+ +
+
01

Add an entry

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
02

Deny

3 entries · wins over Allow
+
+ + + + + + +
ValueNoteAdded
@bulk.example domainnewsletter nobody subscribed to2026-09-02Remove
198.51.100.0/24 ipdictionary attack on lists.example.org2026-08-19Remove
win@lottery.example address—2026-07-30Remove
+
+
+
+
+
03

Allow

2 entries
+
+ + + + + +
ValueNoteAdded
@partner.example domaininvoices were scored as spam2026-09-11Remove
noreply@github.example addresssecurity advisories2026-06-04Remove
+
+
+
+ +
+
04

Where the lists go

applied
+
+
+
Applied by
rspamd, from map files
+
Files
/data/antispam/allow.map
/data/antispam/deny.map
+
Last written
2026-09-11 08:40 UTC
+
When the filter is off
The lists are kept and editable, but nothing reads them until INBOUND_ANTISPAM_MILTER is set.
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-log.html b/docs/assets/panel-redesign/panel/in-log.html new file mode 100644 index 0000000..89d532b --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-log.html @@ -0,0 +1,105 @@ + + + + + +Inbound log · SelfPost + + + + + + + + + +
+
+
+
+

Inbound

+

Inbound log

+

What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from mail.log; the filter's reasons appear when the spam filter is on.

+
+
+ +
+
+ 01

Messages

+
+
+
+ + + +
+
+
+ + + + + + + + + +
Time, UTCFrom · clientToSubjectDecisionWhy
09-21 14:00:18t.berg@uni.example
192.0.2.77
dev@lists.example.orgRe: [dev] patch series v3acceptrspamd · score 0.4Deny sender
09-21 13:59:02invoice@partner-billing.example
203.0.113.140
owner@lists.example.orgOutstanding invoice — action requiredquarantinerspamd · score 9.8 · PHISHING, FORGED_SENDERReview
09-21 13:57:41promo@bulk.example
198.51.100.90
sales@lists.example.org—rejectpostfix · 550 5.1.1 unknown recipientDeny sender
09-21 13:51:30billing@partner.example
198.51.100.12
owner@lists.example.orgInvoice 2026-0917rejectrspamd · score 16.2 · BAYES_SPAMAllow sender
09-21 13:44:02hr@partner.example
192.0.2.31
jobs@acme.ioApplication: backend engineertempfailselfpost · upstream 10.0.4.12 timed out
09-21 13:30:55noreply@github.example
192.0.2.200
security@lists.example.org[advisory] new reportacceptrspamd · allow list
+
Page 1 of 38Older →Allow / Deny sender opens Filter lists with the entry filled in · global administrator only
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-quarantine-message.html b/docs/assets/panel-redesign/panel/in-quarantine-message.html new file mode 100644 index 0000000..3afd458 --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-quarantine-message.html @@ -0,0 +1,149 @@ + + + + + +Held message · Quarantine · SelfPost + + + + + + + + + +
+
+
+
+

Quarantine /

+

Outstanding invoice — action required

+

invoice@partner-billing.exampleowner@lists.example.orgquarantine

+
+
+ +
+
+
+
01

Message

+
+
+
Inbound domain
lists.example.org
+
Client
203.0.113.140
+
Held
2026-09-21 13:59:02 UTC
+
Deleted on
2026-10-05 · in 14 days
+
Size
48 kB · 1 attachment (invoice.html)
+
Would go to
mx.internal:25
+
+
+
+
+
+
+
02

Why it was held

rspamd · score 9.8
+
+ + + + + + +
SymbolScore
PHISHING5.0
FORGED_SENDER3.0
HTML_ATTACHMENT1.8
+
+
+
+ +
+
03

Headers

The body and attachments are never rendered in the panel
+
Received: from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
+From: "Partner Billing" <invoice@partner-billing.example>
+Reply-To: payments.dept@freemail.example
+To: owner@lists.example.org
+Subject: Outstanding invoice — action required
+Date: Mon, 21 Sep 2026 13:58:57 +0000
+Authentication-Results: spf=fail dkim=none dmarc=fail
+
+ +
+
+
+
04

Release

+
+

Hands the message to mx.internal:25 unchanged, as if it had just arrived. It leaves the quarantine.

+
+
+ +
+
+
+
+
+
+

Discard

+
+

Deletes the message now instead of in 14 days. The sender is not told. This cannot be undone.

+
+
+ +
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-quarantine.html b/docs/assets/panel-redesign/panel/in-quarantine.html new file mode 100644 index 0000000..f693374 --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-quarantine.html @@ -0,0 +1,99 @@ + + + + + +Quarantine · SelfPost + + + + + + + + + +
+
+
+
+

Inbound

+

Quarantine

+

Mail the filter found suspicious enough to hold, but not to refuse. Release hands a message to its upstream as if it had just arrived; Discard deletes it. Anything left is deleted after 14 days.

+
+
+ +
+
+ 01

Held messages

+
+
+ +
+
+
+ + + + + + +
Held, UTCFromToSubjectWhySizeDeleted in
09-21 13:59:02invoice@partner-billing.exampleowner@lists.example.orgOutstanding invoice — action requiredscore 9.8 · PHISHING48 kB14 daysReview
09-20 22:14:40no-reply@shipping-update.examplejobs@acme.ioYour parcel could not be deliveredscore 8.1 · URL_SHORTENER12 kB13 daysReview
09-09 07:02:11m.ortega@supplier.exampleowner@lists.example.orgFwd: scanned contract (3 attachments)score 7.4 · MIME_ARCHIVE2.9 MB2 daysReview
+
3 messages · 3.0 MB of 500 MBA domain administrator sees only their assigned inbound domains
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/in-queue.html b/docs/assets/panel-redesign/panel/in-queue.html new file mode 100644 index 0000000..09236bf --- /dev/null +++ b/docs/assets/panel-redesign/panel/in-queue.html @@ -0,0 +1,97 @@ + + + + + +Inbound queue · SelfPost + + + + + + + + + +
+
+
+
+

Inbound

+

Inbound queue

+

Mail accepted on port 25 and still waiting for its upstream. The same Postfix queue as outbound, filtered by recipient domain.

+
+
+ +
+
01

Waiting

Refreshes every 10 s
+
Nothing is waiting. Every accepted message has reached its upstream.
+
+ +
+
02

How retries work

This Postfix's policy, read at panel start
+
+
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
back to the original sender
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/login.html b/docs/assets/panel-redesign/panel/login.html new file mode 100644 index 0000000..997db18 --- /dev/null +++ b/docs/assets/panel-redesign/panel/login.html @@ -0,0 +1,39 @@ + + + + + +Sign in · SelfPost + + + + + + + + + + diff --git a/docs/assets/panel-redesign/panel/out-app-created.html b/docs/assets/panel-redesign/panel/out-app-created.html new file mode 100644 index 0000000..471291c --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-app-created.html @@ -0,0 +1,107 @@ + + + + + +New password · prod-server · SelfPost + + + + + + + + + +
+
+
+
+

Outbound domains / example.org / Applications /

+

New password for prod-server

+
+
+ +
+
+
+

Shown once

Not stored — copy it now
+
+
+
+
+
+

The previous password stopped working when this one was generated. If this one is lost, generate another.

+
+ +
+
+
+
+

Connect with

+
+
+
Server
mail.example.org
+
Port
465 SSL/TLS · 587 STARTTLS
+
May send as
*@example.org
+
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-app.html b/docs/assets/panel-redesign/panel/out-app.html new file mode 100644 index 0000000..7f41848 --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-app.html @@ -0,0 +1,136 @@ + + + + + +prod-server · example.org · SelfPost + + + + + + + + + +
+
+
+
+

Outbound domains / example.org / Applications /

+

prod-server

+

1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.

+
+ +
+ +
+
+
+
+
01

Sender

+
+
+
+

Unique across domains; letters, digits, . - _.

+
+
+
+
+

One per line. Used only with a list.

+
+
+
+ +
+
+
02

Client IP allow-list

off
+
+
+
+
+

A login from anywhere else is refused with a 4xx at MAIL FROM. A sending control, fail-open — not an authentication boundary.

+
+
+
+ +
+
+
03

Rate limit

active
+
+
+
+
+
+
+
+

Overrides the domain limit (288 / h) for this application; may be higher or lower, never above level 1.

+
+
+
+
+ +
+ + Cancel +
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-domain-delete.html b/docs/assets/panel-redesign/panel/out-domain-delete.html new file mode 100644 index 0000000..3ad4f1e --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-domain-delete.html @@ -0,0 +1,110 @@ + + + + + +Delete example.org · SelfPost + + + + + + + + + +
+
+
+
+

Outbound domains / example.org /

+

Delete example.org

+
+
+ +
+
+
+

This cannot be undone

+
+

Deleting example.org will:

+
    +
  • permanently delete its DKIM signing key;
  • +
  • delete both applications (prod-server, alerts) with their credentials and sender bindings;
  • +
  • reload OpenDKIM so the domain is no longer signed.
  • +
+

The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.

+
+
+ + Keep it +
+
+
+
+
+
+
+

Moving it instead?

+
+

Export the domain first. The file carries the key and the application passwords, so the new instance needs no DNS change.

+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-domain-settings.html b/docs/assets/panel-redesign/panel/out-domain-settings.html new file mode 100644 index 0000000..2ac6f50 --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-domain-settings.html @@ -0,0 +1,147 @@ + + + + + +Settings · example.org · SelfPost + + + + + + + + + +
+
+
+
+

Outbound domains / example.org /

+

Domain settings

+

The things set once and rarely touched: where DMARC reports go, the sending ceiling, moving the domain elsewhere.

+
+
+ +
+
+
+
01

DMARC report address

+
+
+
+
+

A default belongs to a user and is set under their Account; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.

+ +
+
+
+ +
+
03

Export domain

+
+

One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. The file is a secret.

+
+
+
+
+
+
+

Keep this password: SelfPost does not store it, and the file cannot be opened without it.

+ +
+
+
+
+ +
+
+
02

Rate limit

active
+
+
+
+
+
+
+

Computed limit: 288 messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00 UTC.

+
+ + + +
+
+

Applies to every sender on this domain and cannot exceed level 1 (600 messages / 3600 s per client IP, set in .env). An application may carry its own limit above or below this one.

+
+
+ +
+
04

Delete domain

+
+

Removes the DKIM key and both applications. Asks once more before doing it.

+ Delete example.org… +
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-domain.html b/docs/assets/panel-redesign/panel/out-domain.html new file mode 100644 index 0000000..17f0d07 --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-domain.html @@ -0,0 +1,179 @@ + + + + + +example.org · Outbound domains · SelfPost + + + + + + + + + +
+
+
+
DNSFAIL1 OF 3 RECORDS
+
+

Outbound domains /

+

example.org

+

1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h

+
+
+
+ Add application +
+
+ +
+
+ +
+ +
+
+
01

DNS records

Checked 3 min ago
+ +
+

DKIM

publishedNot a secret · selector sp2026
+
+
+
+
+
+
+
+
+ +
+

SPF

mismatchMerge into the existing record — never publish a second one
+
+
+
+
+
+
+
+
+
+

The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all. Shallow check: include: is not followed.

+
+ +
+

DMARC

publishedReport address: admin's default
+
+
+
+
+
+
+
+

p=none does not affect delivery. Tighten to quarantine, then reject, once the reports look clean.

+
+
+ +
+
02

Applications

SASL logins that may send as this domain
+
+ + + + + + + +
LoginMay send as30 daysLimits
prod-server*@example.org1 102 msg · peak 96/h200 / hEditNew passwordDelete
alertsalerts@example.org, noc@example.org182 msg · peak 14/hdomain 2 IPsEditNew passwordDelete
+
New password and Delete ask for confirmation; the old password stops working at once.
+
+ +
+
03

Connection

The same for every domain
+
+
+
Server
mail.example.org
+
Port 465
SSL/TLS (implicit)
+
Port 587
STARTTLS (submission)
+
Authentication
Application login, required on every port
+
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-domains.html b/docs/assets/panel-redesign/panel/out-domains.html new file mode 100644 index 0000000..b1f0e76 --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-domains.html @@ -0,0 +1,107 @@ + + + + + +Outbound domains · SelfPost + + + + + + + + + +
+
+
+
+

Outbound

+

Outbound domains

+

Domains this relay signs and sends for. Each gets a DKIM key and its own application logins.

+
+
+ +
+
01

Add a domain

+
+
+
+
+
+
+

A DKIM key is generated at once; the next page shows the three DNS records to publish. + Moving a domain from another SelfPost? Import it instead — the key and passwords come across.

+
+
+
+ +
+
02

Domains

3 domains · DNS cached a few minutes
+
+ + + + + + +
DomainDNSSelectorApps30 days
example.orgDKIM SPF DMARCsp202621 284 msg · peak 96/hOpenDelete
shop.example.orgDKIM SPF DMARCsp2026118 920 msg · peak 410/hOpenDelete
notify.acme.ioDKIM SPF DMARCsp20253402 msg · peak 22/hOpenDelete
+
Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-log.html b/docs/assets/panel-redesign/panel/out-log.html new file mode 100644 index 0000000..07089c5 --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-log.html @@ -0,0 +1,104 @@ + + + + + +Outbound log · SelfPost + + + + + + + + + +
+
+
+
+

Outbound

+

Outbound log

+

Every message an application handed to the relay. Rows older than 30 days are deleted — change that in Settings.

+
+
+ +
+
+ 01

Messages

+
+
+
+
+ +
+
+
+ + + + + + + + + +
Time, UTCFromToSubjectStatus
09-21 14:01:52shop@shop.example.orgm.keller@gmx.deYour order #48213 has shippedsentDetails
09-21 14:01:50shop@shop.example.organna.lind@outlook.comYour order #48212 has shippedsentDetails
09-21 13:58:07alerts@example.orgnoc@example.org[FIRING] disk usage above 90 % on db-2deferredDetails
09-21 13:41:19no-reply@notify.acme.ioj.doe@nonexistent.exampleReset your passwordbouncedDetails
09-21 13:40:02prod@example.orgbilling@partner.exampleInvoice 2026-0917rate-limitedDetails
09-21 13:12:44shop@shop.example.orgp.novak@seznam.czWe received your return requestsentDetails
+
Page 1 of 412Older →New rows appear on their own
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-message.html b/docs/assets/panel-redesign/panel/out-message.html new file mode 100644 index 0000000..95b3b06 --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-message.html @@ -0,0 +1,118 @@ + + + + + +Message · Outbound log · SelfPost + + + + + + + + + +
+
+
+
+

Outbound log /

+

[FIRING] disk usage above 90 % on db-2

+

alerts@example.orgnoc@example.orgdeferred

+
+
+ +
+
+
+
01

Message

+
+
+ +
Application
alerts
+
Accepted
2026-09-21 13:58:07 UTC
+
Status reported
2026-09-21 13:58:09 UTC
+
Queue id
4XcB7k2Jm9z1
+
Journal id
184220
+
+
+
+
+
+
+
02

History

+
+
    +
  1. Accepted from alerts at 198.51.100.7
  2. +
  3. Deferred — the receiving server asked to retry later
  4. +
  5. Still in the queue; kept for 5 days, then bounced
  6. +
+
+
+
+
+ +
+
03

Delivery log

Lines from mail.log with this queue id
+
13:58:07 postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
+13:58:07 postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
+13:58:07 opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
+13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/out-queue.html b/docs/assets/panel-redesign/panel/out-queue.html new file mode 100644 index 0000000..95a1f73 --- /dev/null +++ b/docs/assets/panel-redesign/panel/out-queue.html @@ -0,0 +1,102 @@ + + + + + +Outbound queue · SelfPost + + + + + + + + + +
+
+
+
+

Outbound

+

Outbound queue

+

Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.

+
+
+ +
+
01

Waiting

1 message · 3.1 kB
+
+ + + + +
Queue idArrived, UTCFromToLast reply
4XcB7k2Jm9z109-21 13:58:07alerts@example.orgnoc@example.org451 4.7.1 Greylisted, try again in 5 minutesDetails
+
+ +
+
02

How retries work

This Postfix's policy, read at panel start
+
+
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
no attempt counter, only time
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/outlines.json b/docs/assets/panel-redesign/panel/outlines.json new file mode 100644 index 0000000..c96b228 --- /dev/null +++ b/docs/assets/panel-redesign/panel/outlines.json @@ -0,0 +1,466 @@ +{ + "account": [ + "head", + { + "columns": [ + { + "is-5": [ + "box[Profile]:body", + "box[Password]:body" + ] + }, + { + "is-7": [ + "box[Notifications]:body", + "box[DMARC reports]:record×2" + ] + } + ] + } + ], + "backup": [ + "head", + { + "columns": [ + { + "is-6": [ + "box[Full backup]:body" + ] + }, + { + "is-6": [ + "box[Import a domain]:body" + ] + } + ] + } + ], + "dmarc-domain": [ + "head+postmark+actions", + { + "columns": [ + { + "is-5": [ + "box[Sources · 7 days]:table" + ] + }, + { + "is-7": [ + "box[Reports]:table" + ] + } + ] + } + ], + "dmarc-report": [ + "head", + { + "columns": [ + { + "is-6": [ + "box[Report]:body" + ] + }, + { + "is-6": [ + "box[Published policy]:body" + ] + } + ] + }, + "box[Records]:table" + ], + "dmarc": [ + "head", + "box[Ingest]:body", + "box[Recent reports]:table" + ], + "health": [ + "head+actions", + { + "columns": [ + { + "is-6": [ + "box[Machine]:table" + ] + }, + { + "is-6": [ + "box[Processes]:table" + ] + } + ] + }, + { + "columns": [ + { + "is-4": [ + "box[TLS certificate]:body" + ] + }, + { + "is-4": [ + "box[Milter sockets]:table" + ] + }, + { + "is-4": [ + "box[Hostname and reverse DNS]:body" + ] + } + ] + }, + "box[Configuration]:body" + ], + "help": [ + "head", + { + "columns": [ + { + "is-3": [ + "side_menu" + ] + }, + { + "is-9": [ + "box[Overview]:help×3", + "box[Outbound]:help×3" + ] + } + ] + } + ], + "in-domain-delete": [ + "head", + { + "columns": [ + { + "is-7": [ + "box.sp-danger-zone[This cannot be undone]:body" + ] + } + ] + } + ], + "in-domain": [ + "head+postmark+actions", + { + "columns": [ + { + "is-3": [ + "side_menu" + ] + }, + { + "is-9": [ + "box[MX record]:record", + { + "columns": [ + { + "is-6": [ + "box[Upstream]:body" + ] + }, + { + "is-6": [ + "box[Valid recipients]:body" + ] + } + ] + }, + "box[Spam filter]:body" + ] + } + ] + } + ], + "in-domains": [ + "head", + "box[Add a domain]:body", + "box[Domains]:table" + ], + "in-filter-lists": [ + "head", + "box[Add an entry]:body", + { + "columns": [ + { + "is-6": [ + "box[Deny]:table" + ] + }, + { + "is-6": [ + "box[Allow]:table" + ] + } + ] + }, + "box[Where the lists go]:body" + ], + "in-log": [ + "head", + "box[Messages]:table" + ], + "in-quarantine-message": [ + "head", + { + "columns": [ + { + "is-7": [ + "box[Message]:body" + ] + }, + { + "is-5": [ + "box[Why it was held]:table" + ] + } + ] + }, + "box[Headers]:log", + { + "columns": [ + { + "is-6": [ + "box[Release]:body" + ] + }, + { + "is-6": [ + "box.sp-danger-zone[Discard]:body" + ] + } + ] + } + ], + "in-quarantine": [ + "head", + "box[Held messages]:table" + ], + "in-queue": [ + "head", + "box[Waiting]:empty", + "box[How retries work]:body" + ], + "out-app-created": [ + "head", + { + "columns": [ + { + "is-7": [ + "box.sp-credential[Shown once]:record" + ] + }, + { + "is-5": [ + "box[Connect with]:body" + ] + } + ] + } + ], + "out-app": [ + "head+actions", + { + "form": [ + { + "columns": [ + { + "is-4": [ + "box[Sender]:body" + ] + }, + { + "is-4": [ + "box[Client IP allow-list]:body" + ] + }, + { + "is-4": [ + "box[Rate limit]:body" + ] + } + ] + }, + "submit_row" + ] + } + ], + "out-domain-delete": [ + "head", + { + "columns": [ + { + "is-7": [ + "box.sp-danger-zone[This cannot be undone]:body" + ] + }, + { + "is-5": [ + "box[Moving it instead?]:body" + ] + } + ] + } + ], + "out-domain-settings": [ + "head", + { + "columns": [ + { + "is-6": [ + "box[DMARC report address]:body", + "box[Export domain]:body" + ] + }, + { + "is-6": [ + "box[Rate limit]:body", + "box.sp-danger-zone[Delete domain]:body" + ] + } + ] + } + ], + "out-domain": [ + "head+postmark+actions", + { + "columns": [ + { + "is-3": [ + "side_menu" + ] + }, + { + "is-9": [ + "box[DNS records]:record×3", + "box[Applications]:table", + "box[Connection]:body" + ] + } + ] + } + ], + "out-domains": [ + "head", + "box[Add a domain]:body", + "box[Domains]:table" + ], + "out-log": [ + "head", + "box[Messages]:table" + ], + "out-message": [ + "head", + { + "columns": [ + { + "is-7": [ + "box[Message]:body" + ] + }, + { + "is-5": [ + "box[History]:body" + ] + } + ] + }, + "box[Delivery log]:log" + ], + "out-queue": [ + "head", + "box[Waiting]:table", + "box[How retries work]:body" + ], + "overview": [ + "head+postmark", + "box[Server health]:health", + { + "columns": [ + { + "is-7": [ + "box[Outbound domains]:table" + ] + }, + { + "is-5": [ + "box[Inbound domains]:table" + ] + } + ] + } + ], + "preflight": [ + "head+postmark+actions", + "box[Checks]:table", + "box[Send a test e-mail]:body" + ], + "settings": [ + "head", + { + "form": [ + { + "columns": [ + { + "is-6": [ + "box[Log retention]:body", + "box[Notifications]:body" + ] + }, + { + "is-6": [ + "box[Sending rate limits]:body" + ] + } + ] + }, + "submit_row" + ] + } + ], + "system-log": [ + "head", + "box[Recent lines]:log" + ], + "user-delete": [ + "head", + { + "columns": [ + { + "is-7": [ + "box.sp-danger-zone[This cannot be undone]:body" + ] + } + ] + } + ], + "user": [ + "head+actions", + { + "form": [ + { + "columns": [ + { + "is-6": [ + "box[Account]:body" + ] + }, + { + "is-6": [ + "box[Outbound domains]:body", + "box[Inbound domains]:body" + ] + } + ] + }, + "submit_row" + ] + } + ], + "users": [ + "head+actions", + "box[Panel users]:table" + ] +} diff --git a/docs/assets/panel-redesign/panel/overview.html b/docs/assets/panel-redesign/panel/overview.html new file mode 100644 index 0000000..6b73e25 --- /dev/null +++ b/docs/assets/panel-redesign/panel/overview.html @@ -0,0 +1,122 @@ + + + + + +Overview · SelfPost + + + + + + + + + +
+
+
+
SELFPOSTWARN1 OF 6 CHECKS
+
+

mail.example.org

+

Mail is flowing. One thing needs attention.

+

The TLS certificate expires in 12 days and has not renewed yet.

+
+
Updated 14:02:31 UTC
refreshes every 10 s
+
+ + + +
+
+
+
02

Outbound domains

+
+ + + + + + +
DomainDNSApps30 days
example.orgDKIM SPF DMARC21 284 msg
shop.example.orgDKIM SPF DMARC118 920 msg
notify.acme.ioDKIM SPF DMARC3402 msg
+
+
+
+
+
03

Inbound domains

+
+ + + + + +
DomainMXUpstream
lists.example.orgMXmx.internal:25
acme.ioMX10.0.4.12:2525
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/perf-edge.svg b/docs/assets/panel-redesign/panel/perf-edge.svg new file mode 100644 index 0000000..64107ef --- /dev/null +++ b/docs/assets/panel-redesign/panel/perf-edge.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/docs/assets/panel-redesign/panel/preflight.html b/docs/assets/panel-redesign/panel/preflight.html new file mode 100644 index 0000000..59aeb07 --- /dev/null +++ b/docs/assets/panel-redesign/panel/preflight.html @@ -0,0 +1,124 @@ + + + + + +Preflight · SelfPost + + + + + + + + + +
+
+
+
PREFLIGHTWARN1 OF 7 CHECKS
+
+

Server

+

Preflight

+

Is this installation ready to deliver mail? Run it after the first start, after moving the server, or when receivers start refusing you. Health watches what runs; Preflight checks what the outside world sees.

+
+
+
+ +
+
01

Checks

Ran 14:02 UTC · 3.1 s
+
+ + + + + + + + + + +
CheckResultFoundWhat to do
Reverse DNSok203.0.113.25 ⇄ mail.example.org—
TLS certificatewarnValid for mail.example.org, expires in 12 daysRenewal has not run. Check that port 80 reaches the ACME client and look for acme in the system log.
Port 25ok220 mail.example.org ESMTP—
Ports 465, 587okTLS handshake, 220 banner on both—
HELO nameokmail.example.org—
Proxy headersokPeer 172.18.0.2 is trusted; X-Forwarded-Proto: https—
DKIM signingokinet:127.0.0.1:8891 answers—
+
Ports are dialled from inside the container at the public address; a firewall that treats inside and outside differently can make this pass while the world is still blocked. SPF, DKIM and DMARC are per domain — each outbound domain shows its own.
+
+ +
+
02

Send a test e-mail

Confirms the relay accepted it, not that it arrived
+
+
+
+
+
+
+
+
+
+

Sent from postmaster@mail.example.org through the local Postfix.

+
+
+
+
+
Last test
14:03 UTC to mix@elsewhere.example
+
Result
queued 4XcC1d8Pq2z1
+ +
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/settings.html b/docs/assets/panel-redesign/panel/settings.html new file mode 100644 index 0000000..7ad85e4 --- /dev/null +++ b/docs/assets/panel-redesign/panel/settings.html @@ -0,0 +1,116 @@ + + + + + +Settings · SelfPost + + + + + + + + + +
+
+
+
+

Server

+

Settings

+

What holds for the whole instance, whoever is signed in. Your own name, e-mail, notifications and DMARC report address are under Account.

+
+
+ +
+
+
+
+
01

Log retention

+
+
+
+

The main driver of /data growth. Does not affect mail.log rotation (fourteen daily files).

+
+
+ +
+
02

Notifications

+
+
+

Sent through this relay from postmaster@mail.example.org. Each user picks the events under Account; a user without an e-mail gets none.

+
+
+
+ +
+
+
03

Sending rate limits

Read-only
+
+
+
Level 1 · per client IP
600 / h
Set in .env; restart to change. Nothing below may exceed it.
+
Level 2 · domain, application
Set on each domain and application.
+
+
+
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/setup.html b/docs/assets/panel-redesign/panel/setup.html new file mode 100644 index 0000000..42840ba --- /dev/null +++ b/docs/assets/panel-redesign/panel/setup.html @@ -0,0 +1,44 @@ + + + + + +Create administrator · SelfPost + + + + + + + + + + diff --git a/docs/assets/panel-redesign/panel/src/account.html b/docs/assets/panel-redesign/panel/src/account.html new file mode 100644 index 0000000..8ab8ab7 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/account.html @@ -0,0 +1,88 @@ + +
+
+

Signed in as admin · global

+

Account

+

Who you are to this panel, what it should tell you about, and where reports about your domains go. Applications keep their own logins and passwords and are not affected.

+
+
+ +
+
+
+
01

Profile

+
+
+
+
+

Where the panel writes to you: the notifications on the right and a password-reset link. It can also receive DMARC reports — your choice, under DMARC reports.

+ +
+
+
+ +
+
02

Password

+
+
+
+
+
+
+
+

Changing it signs out every other session of this account; this one stays signed in.

+ +
+
+
+
+ +
+
+
03

Notifications

Sent to mix@example.org
+
+
+

Your domains

+
+
+
+
+
+

The server · global role

+
+
+
+

One message when a problem starts and one when it clears — never a stream. A domain administrator is told only about the domains assigned to them.

+ +
+
+
+ +
+
04

DMARC reports

authorized
+
+
+
+
+

Used by every outbound domain whose report address is set to admin's default — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.

+ +
+
+
+

Report authorization

publishedNeeded because the address is on another domain than the senders
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/backup.html b/docs/assets/panel-redesign/panel/src/backup.html new file mode 100644 index 0000000..d492c03 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/backup.html @@ -0,0 +1,44 @@ + +
+
+

Server

+

Backup

+

Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets.

+
+
+ +
+
+
+
01

Full backup

+
+

data/ (database, DKIM keys, credentials, the queue), docker-compose.yml, .env and certs/ in one archive. Restore into an empty project directory and start the same SelfPost version. The reverse-proxy vhost is not included.

+
+
+
+
+
+
+

Keep this password: SelfPost does not store it, and the file cannot be opened without it.

+ +
+
+
+
+ +
+
+
02

Import a domain

+
+

A domain export from another instance — plain .json or encrypted .spde. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the domain's settings.

+
+
+
+
+

Asked only for an encrypted .spde file.

+ +
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/components.html b/docs/assets/panel-redesign/panel/src/components.html new file mode 100644 index 0000000..7a6b717 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/components.html @@ -0,0 +1,222 @@ + +
+
SELFPOSTREFDESIGN CONTRACT
+
+

Reference · page_head

+

Components

+

Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.

+
+ +
+ +
flash — the result of the last action, between page_head and the first box. Success, or…
+
…flash, error — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's help is-danger.
+ +
+
00

Page layouts

A page is exactly one of these
+
+
+
list
page_head, then full-width boxes: the add form first, the table second. Example
+
detail
page_head, then columns: is-3 side menu + is-9 boxes. Example
+
form
page_head, then boxes in equal columns, one submit row under them. Example
+
signed out
The split brand / form screen, no navbar. Example
+
+
+
+ +
+
+
+
01

postmark

Worst status of the page's checks
+
+
+
SELFPOSTOK6 OF 6 CHECKS
+
SELFPOSTWARN1 OF 6 CHECKS
+
DNSFAIL1 OF 3 RECORDS
+
+

Only inside page_head, at most one per page, and only where the page has a verdict to give.

+
+
+ +
+
03

status_tag

One mapping, one function
+
+ + + + + + + + +
StatusTagBulma classes
ok, published, running, sentoktag is-success is-light
warn, deferred, quietwarntag is-warning is-light
error, fail, mismatch, bouncedfailtag is-danger is-light
unknown, off, neutral countsofftag is-light
held: quarantine · role: globalquarantinetag is-primary is-light
+
+
+ +
+
+
02

box · box_head

The end slot: a note, a tag, a link or a filter form
+

The after the end slot is sp-help: an optional link to the section's topic on the Help page. It replaces today's help drawer.

box_body — padded content. A table or a run of sp-record blocks sits directly in the box, without box_body.

+
box_foot — a legend, pagingend slot
+
+
+

box · sp-danger-zone

+

For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.

+
+
+

box · sp-credential

Shown once
+

Only for a secret the panel will never show again.

+
+
+
+ +
+
04

health_card

Overview only · each card links to its detail
+ +
+ +
+
+
+
05

dns_record · copy_field

One block per record
+
+

Record

publishedA one-line note
+
+
+
+
+
+
+
+
+
+

Record

mismatch"In DNS now" appears only when it differs
+
+
+

What is wrong and the exact fix.

+
+
+ +
+
07

Table conventions

Always inside table-container · an address breaks after its @
+
+ + + + +
Time, UTCNamesp-clipStatus
09-21 14:01:52the row's linkA long subject is clipped with an ellipsis instead of wrapping the rowsentDetailsDelete
+
Page 1 of 9Older →
+
+ +
+
09

log_pane

sp-d time · sp-w warning · sp-e error
+
13:58:07 an ordinary line
+13:58:09 a deferred delivery — sp-w
+13:57:41 a reject or a bounce — sp-e
+
+
+ +
+
+
06

facts

+
+
+
Label
Value
+
Mono value
4XcB7k2Jm9z1
+
sp-big
5 days
a supporting line
+
+
+
+ +
+
08

timeline

+
+
    +
  1. Done — default
  2. +
  3. Deferred — sp-warn
  4. +
  5. Bounced — sp-fail
  6. +
  7. Expected next — sp-pending
  8. +
+
+
+ +
+
10

empty_state

+
One sentence: what is absent and why that is fine.
+
+ +
+
11

confirm_list

+
+
    +
  • each thing that will be lost, one per line;
  • +
  • inside an sp-danger-zone box, above the danger button.
  • +
+
+
+
+
+ +
+
+ +
+
+
+
12

Form rows · route · help_topic

+
+
+
+
+
+
+
+
+

Help text lives in Bulma's help.

+
+
+

from@example.orgto@example.netdeferred

+
+

help_topic

A heading and a paragraph; topics stack inside one box on the Help page.

+
+
+
+ +
+
13

shell

Owned by the layout — a screen never writes it
+
+
+
Name
The wordmark, not the stamp: at nav size the stamp's perforation is under a pixel and its brick field dissolves into the bar. The stamp lives on sign-in, setup and the favicon.
+
Edge
Built like the mark: the brick field, then the stamp's paper margin, and the perforation along the outer edge of that margin — round holes, flat teeth, the ink hairline. One tile, perf-edge.svg.
+
Sibling strip
The pages of the current group, under the edge. Five or six entries; past that the group is split.
+
Menus
Bulma hover dropdowns, no JavaScript. The user menu holds Account, Help and Sign out.
+
+
+
+ +
+
14

Type scale

Three sizes · nothing to be read is under 13px
+
+ + + + + + +
SizeLooks likeUsed for
body · 16pxThe quick brown fox, 203.0.113.25Text, table cells, inputs, selects, buttons, menus and their dropdowns
--sp-fs-s · 14pxThe quick brown fox, 203.0.113.25Supporting text: labels, help, notes in a box head or foot, mono values (sp-small), logs, the footer
--sp-fs-xs · 13pxUpper-case mono label tagUpper-case mono micro-labels (kicker, table head, dt, box title) and tags — nothing else
+
Bulma's is-small and is-size-7 are not used: a control is never small, and sp-small replaces the helper. The postmark's fine print is an imprint, not text.
+
diff --git a/docs/assets/panel-redesign/panel/src/dmarc-domain.html b/docs/assets/panel-redesign/panel/src/dmarc-domain.html new file mode 100644 index 0000000..376c508 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/dmarc-domain.html @@ -0,0 +1,38 @@ + +
+
DMARC99.6%PASS · 7 DAYS
+
+

DMARC reports /

+

shop.example.org

+

11 204 pass · 41 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening p=.

+
+ Domain page +
+ +
+
+
+
01

Sources · 7 days

+
+ + + + + +
SourcePassFailDisposition
203.0.113.25 this relay11 2040none
198.51.100.44041none
+
+
+
+
+
02

Reports

+
+ + + + + + +
Received, UTCReporterWindowPassFail
09-21 04:40Outlook.com20 Sep1 9037View
09-21 02:15google.com20 Sep6 4100View
09-20 04:38Outlook.com19 Sep1 87734View
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/dmarc-report.html b/docs/assets/panel-redesign/panel/src/dmarc-report.html new file mode 100644 index 0000000..c651b99 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/dmarc-report.html @@ -0,0 +1,47 @@ + +
+
+

DMARC reports / shop.example.org /

+

Outlook.com · 20 Sep 2026

+

shop.example.org1 903 pass7 fail

+
+
+ +
+
+
+
01

Report

+
+
+
Reporter
Outlook.com
+
Report id
5f1c9a0e7b2d4e61a3
+
Window
20 Sep 00:00 – 21 Sep 00:00 UTC
+
Received
2026-09-21 04:40 UTC
+
+
+
+
+
+
+
02

Published policy

+
+
+
p / sp / pct
none / none / 100
+
adkim / aspf
r / r
+
rua
dmarc@mail.example.org
+
+
+
+
+
+ +
+
03

Records

Parsed from the aggregate XML
+
+ + + + + +
SourceCountDispositionSPFDKIMHeader from
203.0.113.25 this relay1 903nonepasspassshop.example.org
198.51.100.447nonefailfailshop.example.org
+
diff --git a/docs/assets/panel-redesign/panel/src/dmarc.html b/docs/assets/panel-redesign/panel/src/dmarc.html new file mode 100644 index 0000000..3d92e79 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/dmarc.html @@ -0,0 +1,33 @@ + +
+
+

Outbound

+

DMARC reports

+

How receivers saw mail claiming to be from your domains. Aggregate (rua=) only; open a report for the parsed XML.

+
+
+ +
+
01

Ingest

ok
+
+
+
Last report
2026-09-21 06:12 UTC
+
This week
14 kept · 0 parse failures
+
Hosted address
dmarc@mail.example.org
+
Retention
500 reports or 90 days
+
+
+
+ +
+
02

Recent reports

+
+ + + + + + + +
Received, UTCDomainReporterWindowPassFail
09-21 06:12example.orggoogle.com20 Sep4120View
09-21 04:40shop.example.orgOutlook.com20 Sep1 9037View
09-20 23:58example.orgYahoo19 Sep380View
09-20 06:09notify.acme.iogoogle.com19 Sep610View
+
diff --git a/docs/assets/panel-redesign/panel/src/health.html b/docs/assets/panel-redesign/panel/src/health.html new file mode 100644 index 0000000..13daf39 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/health.html @@ -0,0 +1,88 @@ + +
+
+

Server

+

Health

+

Everything behind the six cards on Overview. Refreshes every 10 s.

+
+
+
+ +
+
+
+
01

Machine

ok
+
+ + + + + + +
ResourceUsageDetail
CPU12 %2 cores, load 0.21
Memory41 %812 MB of 1.9 GB
Network↓ 18 kB/s · ↑ 44 kB/seth0: 2.1 GB in, 6.4 GB out
+
+
+
+
+
02

Processes

ok
+
+ + + + + + + + +
ProgramStateDetail
postfixrunningpid 41, uptime 18 days
opendkimrunningpid 39, uptime 18 days
journal-milterrunningpid 40, uptime 18 days
panelrunningpid 44, uptime 18 days
dmarc-ingestrunningpid 47, uptime 18 days
+
+
+
+ +
+
+
+
03

TLS certificate

warn
+
+
+
Expires
2026-10-03 09:14 UTC
+
Names
mail.example.org
+
+

Expires in 12 days — renewal has not run.

+

Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for acme lines in the system log.

+
+
+
+
+
+
04

Milter sockets

ok
+
+ + + + + + +
MilterState
opendkim
inet:127.0.0.1:8891
ok
journal-milter
unix:/run/selfpost/journal.sock
ok
rspamd · inbound spam filter
inet:antispam:11332
ok
+
+
+
+
+
05

Hostname and reverse DNS

ok
+
+
+
Hostname
mail.example.org
+
Lookup
→ 203.0.113.25
← mail.example.org
+
+
+
+
+
+ +
+
06

Configuration

Safe to run at any time
+
+

Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.

+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/help.html b/docs/assets/panel-redesign/panel/src/help.html new file mode 100644 index 0000000..7a6abb5 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/help.html @@ -0,0 +1,47 @@ + +
+
+

Reference

+

Help

+

What each screen checks and what to do when it is not green. Every numbered section on a page links to its topic here.

+
+
+ +
+ +
+
+
01

Overview

+

Health checks

Six checks, the worst of them is the postmark. ok needs nothing; warn still delivers mail but will stop doing so if ignored; fail means mail is being lost or refused now.

+

TLS certificate

The certificate Postfix presents on 465 and 587. It warns 14 days before expiry and fails once expired — clients that verify certificates then refuse to connect.

+

Hostname and reverse DNS

The hostname must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.

+
+
+
02

Outbound

+

DNS records

DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one.

+

Applications

An application is one SASL login. It may send as any address of its domain or only as listed addresses. The password is shown once at creation or regeneration and is not stored in readable form.

+

Rate limits

Level 1 is a per-IP ceiling set in .env. Level 2 is optional, per domain and per application, manual or computed from 30-day statistics, and can never exceed level 1.

+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/in-domain-delete.html b/docs/assets/panel-redesign/panel/src/in-domain-delete.html new file mode 100644 index 0000000..ff0d89a --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-domain-delete.html @@ -0,0 +1,29 @@ + +
+
+

Inbound domains / lists.example.org /

+

Delete lists.example.org

+
+
+ +
+
+
+

This cannot be undone

+
+

Deleting the inbound domain lists.example.org will:

+
    +
  • stop accepting mail for it on port 25 — senders get a relay-denied reply;
  • +
  • remove its upstream and its 4 listed recipients.
  • +
+

Remove or repoint the MX record first, or mail for the domain will bounce. Outbound domains are not touched.

+
+
+ + Keep it +
+
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/in-domain.html b/docs/assets/panel-redesign/panel/src/in-domain.html new file mode 100644 index 0000000..d04534f --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-domain.html @@ -0,0 +1,99 @@ + +
+
DNSOKMX POINTS HERE
+
+

Inbound domains /

+

lists.example.org

+

Accepted on port 25, handed to mx.internal:25 over required TLS · 4 recipients

+
+
+
+
+
+ +
+
+ +
+ +
+
+
01

MX record

Checked 1 min ago
+
+

MX

publishedKeep any existing primary MX if this is a backup-MX
+
+
+
+
+
+
+
+
+
+ +
+
+
+
02

Upstream

Not a mailbox
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
03

Valid recipients

+
+
+
+
+
+
+

Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.

+ +
+
+
+
+
+ +
+
04

Spam filter

on
+
+
+
Engine
rspamd, for every inbound domain of this server
+
Suspicious mail
Held — 1 message in quarantine for this domain
+
Spam
Refused at the door; see reject in the log
+
Allow and deny lists
Kept by the global administrator, one pair for the whole server
+
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/in-domains.html b/docs/assets/panel-redesign/panel/src/in-domains.html new file mode 100644 index 0000000..6362e9f --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-domains.html @@ -0,0 +1,33 @@ + +
+
+

Inbound

+

Inbound domains

+

Backup-MX / forwarder. Port 25 accepts mail only for the domains listed here and hands it to their upstream. No mailboxes.

+
+
+ +
+
01

Add a domain

+
+
+
+
+
+
+

Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.

+
+
+
+ +
+
02

Domains

Spam filter on · 2 domains
+
+ + + + + +
DomainDNSUpstreamRecipientsTLS to upstream
lists.example.orgMXmx.internal:254 addressesrequiredOpenDelete
acme.ioMX10.0.4.12:2525any addressopportunisticOpenDelete
+
The MX tag is green when at least one MX of the domain points at this server.
+
diff --git a/docs/assets/panel-redesign/panel/src/in-filter-lists.html b/docs/assets/panel-redesign/panel/src/in-filter-lists.html new file mode 100644 index 0000000..3b11e03 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-filter-lists.html @@ -0,0 +1,73 @@ + +
+
+

Inbound · global role

+

Filter lists

+

Correct the spam filter by hand: senders it must always let through, and senders it must always turn away. One pair of lists for the whole instance.

+
+
+ +
+
01

Add an entry

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
02

Deny

3 entries · wins over Allow
+
+ + + + + + +
ValueNoteAdded
@bulk.example domainnewsletter nobody subscribed to2026-09-02Remove
198.51.100.0/24 ipdictionary attack on lists.example.org2026-08-19Remove
win@lottery.example address—2026-07-30Remove
+
+
+
+
+
03

Allow

2 entries
+
+ + + + + +
ValueNoteAdded
@partner.example domaininvoices were scored as spam2026-09-11Remove
noreply@github.example addresssecurity advisories2026-06-04Remove
+
+
+
+ +
+
04

Where the lists go

applied
+
+
+
Applied by
rspamd, from map files
+
Files
/data/antispam/allow.map
/data/antispam/deny.map
+
Last written
2026-09-11 08:40 UTC
+
When the filter is off
The lists are kept and editable, but nothing reads them until INBOUND_ANTISPAM_MILTER is set.
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/in-log.html b/docs/assets/panel-redesign/panel/src/in-log.html new file mode 100644 index 0000000..1300ee2 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-log.html @@ -0,0 +1,33 @@ + +
+
+

Inbound

+

Inbound log

+

What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from mail.log; the filter's reasons appear when the spam filter is on.

+
+
+ +
+
+ 01

Messages

+
+
+
+ + + +
+
+
+ + + + + + + + + +
Time, UTCFrom · clientToSubjectDecisionWhy
09-21 14:00:18t.berg@uni.example
192.0.2.77
dev@lists.example.orgRe: [dev] patch series v3acceptrspamd · score 0.4Deny sender
09-21 13:59:02invoice@partner-billing.example
203.0.113.140
owner@lists.example.orgOutstanding invoice — action requiredquarantinerspamd · score 9.8 · PHISHING, FORGED_SENDERReview
09-21 13:57:41promo@bulk.example
198.51.100.90
sales@lists.example.org—rejectpostfix · 550 5.1.1 unknown recipientDeny sender
09-21 13:51:30billing@partner.example
198.51.100.12
owner@lists.example.orgInvoice 2026-0917rejectrspamd · score 16.2 · BAYES_SPAMAllow sender
09-21 13:44:02hr@partner.example
192.0.2.31
jobs@acme.ioApplication: backend engineertempfailselfpost · upstream 10.0.4.12 timed out
09-21 13:30:55noreply@github.example
192.0.2.200
security@lists.example.org[advisory] new reportacceptrspamd · allow list
+
Page 1 of 38Older →Allow / Deny sender opens Filter lists with the entry filled in · global administrator only
+
diff --git a/docs/assets/panel-redesign/panel/src/in-quarantine-message.html b/docs/assets/panel-redesign/panel/src/in-quarantine-message.html new file mode 100644 index 0000000..ebdf86d --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-quarantine-message.html @@ -0,0 +1,77 @@ + +
+
+

Quarantine /

+

Outstanding invoice — action required

+

invoice@partner-billing.exampleowner@lists.example.orgquarantine

+
+
+ +
+
+
+
01

Message

+
+
+
Inbound domain
lists.example.org
+
Client
203.0.113.140
+
Held
2026-09-21 13:59:02 UTC
+
Deleted on
2026-10-05 · in 14 days
+
Size
48 kB · 1 attachment (invoice.html)
+
Would go to
mx.internal:25
+
+
+
+
+
+
+
02

Why it was held

rspamd · score 9.8
+
+ + + + + + +
SymbolScore
PHISHING5.0
FORGED_SENDER3.0
HTML_ATTACHMENT1.8
+
+
+
+ +
+
03

Headers

The body and attachments are never rendered in the panel
+
Received: from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
+From: "Partner Billing" <invoice@partner-billing.example>
+Reply-To: payments.dept@freemail.example
+To: owner@lists.example.org
+Subject: Outstanding invoice — action required
+Date: Mon, 21 Sep 2026 13:58:57 +0000
+Authentication-Results: spf=fail dkim=none dmarc=fail
+
+ +
+
+
+
04

Release

+
+

Hands the message to mx.internal:25 unchanged, as if it had just arrived. It leaves the quarantine.

+
+
+ +
+
+
+
+
+
+

Discard

+
+

Deletes the message now instead of in 14 days. The sender is not told. This cannot be undone.

+
+
+ +
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/in-quarantine.html b/docs/assets/panel-redesign/panel/src/in-quarantine.html new file mode 100644 index 0000000..1511dcf --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-quarantine.html @@ -0,0 +1,27 @@ + +
+
+

Inbound

+

Quarantine

+

Mail the filter found suspicious enough to hold, but not to refuse. Release hands a message to its upstream as if it had just arrived; Discard deletes it. Anything left is deleted after 14 days.

+
+
+ +
+
+ 01

Held messages

+
+
+ +
+
+
+ + + + + + +
Held, UTCFromToSubjectWhySizeDeleted in
09-21 13:59:02invoice@partner-billing.exampleowner@lists.example.orgOutstanding invoice — action requiredscore 9.8 · PHISHING48 kB14 daysReview
09-20 22:14:40no-reply@shipping-update.examplejobs@acme.ioYour parcel could not be deliveredscore 8.1 · URL_SHORTENER12 kB13 daysReview
09-09 07:02:11m.ortega@supplier.exampleowner@lists.example.orgFwd: scanned contract (3 attachments)score 7.4 · MIME_ARCHIVE2.9 MB2 daysReview
+
3 messages · 3.0 MB of 500 MBA domain administrator sees only their assigned inbound domains
+
diff --git a/docs/assets/panel-redesign/panel/src/in-queue.html b/docs/assets/panel-redesign/panel/src/in-queue.html new file mode 100644 index 0000000..6e721c4 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/in-queue.html @@ -0,0 +1,25 @@ + +
+
+

Inbound

+

Inbound queue

+

Mail accepted on port 25 and still waiting for its upstream. The same Postfix queue as outbound, filtered by recipient domain.

+
+
+ +
+
01

Waiting

Refreshes every 10 s
+
Nothing is waiting. Every accepted message has reached its upstream.
+
+ +
+
02

How retries work

This Postfix's policy, read at panel start
+
+
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
back to the original sender
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/login.html b/docs/assets/panel-redesign/panel/src/login.html new file mode 100644 index 0000000..18e70c7 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/login.html @@ -0,0 +1,25 @@ + + diff --git a/docs/assets/panel-redesign/panel/src/out-app-created.html b/docs/assets/panel-redesign/panel/src/out-app-created.html new file mode 100644 index 0000000..11bccdb --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-app-created.html @@ -0,0 +1,35 @@ + +
+
+

Outbound domains / example.org / Applications /

+

New password for prod-server

+
+
+ +
+
+
+

Shown once

Not stored — copy it now
+
+
+
+
+
+

The previous password stopped working when this one was generated. If this one is lost, generate another.

+
+ +
+
+
+
+

Connect with

+
+
+
Server
mail.example.org
+
Port
465 SSL/TLS · 587 STARTTLS
+
May send as
*@example.org
+
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/out-app.html b/docs/assets/panel-redesign/panel/src/out-app.html new file mode 100644 index 0000000..ffb61c6 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-app.html @@ -0,0 +1,64 @@ + +
+
+

Outbound domains / example.org / Applications /

+

prod-server

+

1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.

+
+ +
+ +
+
+
+
+
01

Sender

+
+
+
+

Unique across domains; letters, digits, . - _.

+
+
+
+
+

One per line. Used only with a list.

+
+
+
+ +
+
+
02

Client IP allow-list

off
+
+
+
+
+

A login from anywhere else is refused with a 4xx at MAIL FROM. A sending control, fail-open — not an authentication boundary.

+
+
+
+ +
+
+
03

Rate limit

active
+
+
+
+
+
+
+
+

Overrides the domain limit (288 / h) for this application; may be higher or lower, never above level 1.

+
+
+
+
+ +
+ + Cancel +
+
diff --git a/docs/assets/panel-redesign/panel/src/out-domain-delete.html b/docs/assets/panel-redesign/panel/src/out-domain-delete.html new file mode 100644 index 0000000..f3156a7 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-domain-delete.html @@ -0,0 +1,38 @@ + +
+
+

Outbound domains / example.org /

+

Delete example.org

+
+
+ +
+
+
+

This cannot be undone

+
+

Deleting example.org will:

+
    +
  • permanently delete its DKIM signing key;
  • +
  • delete both applications (prod-server, alerts) with their credentials and sender bindings;
  • +
  • reload OpenDKIM so the domain is no longer signed.
  • +
+

The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.

+
+
+ + Keep it +
+
+
+
+
+
+
+

Moving it instead?

+
+

Export the domain first. The file carries the key and the application passwords, so the new instance needs no DNS change.

+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/out-domain-settings.html b/docs/assets/panel-redesign/panel/src/out-domain-settings.html new file mode 100644 index 0000000..9125dff --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-domain-settings.html @@ -0,0 +1,75 @@ + +
+
+

Outbound domains / example.org /

+

Domain settings

+

The things set once and rarely touched: where DMARC reports go, the sending ceiling, moving the domain elsewhere.

+
+
+ +
+
+
+
01

DMARC report address

+
+
+
+
+

A default belongs to a user and is set under their Account; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.

+ +
+
+
+ +
+
03

Export domain

+
+

One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. The file is a secret.

+
+
+
+
+
+
+

Keep this password: SelfPost does not store it, and the file cannot be opened without it.

+ +
+
+
+
+ +
+
+
02

Rate limit

active
+
+
+
+
+
+
+

Computed limit: 288 messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00 UTC.

+
+ + + +
+
+

Applies to every sender on this domain and cannot exceed level 1 (600 messages / 3600 s per client IP, set in .env). An application may carry its own limit above or below this one.

+
+
+ +
+
04

Delete domain

+
+

Removes the DKIM key and both applications. Asks once more before doing it.

+ Delete example.org… +
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/out-domain.html b/docs/assets/panel-redesign/panel/src/out-domain.html new file mode 100644 index 0000000..57a6133 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-domain.html @@ -0,0 +1,107 @@ + +
+
DNSFAIL1 OF 3 RECORDS
+
+

Outbound domains /

+

example.org

+

1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h

+
+
+
+ Add application +
+
+ +
+
+ +
+ +
+
+
01

DNS records

Checked 3 min ago
+ +
+

DKIM

publishedNot a secret · selector sp2026
+
+
+
+
+
+
+
+
+ +
+

SPF

mismatchMerge into the existing record — never publish a second one
+
+
+
+
+
+
+
+
+
+

The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all. Shallow check: include: is not followed.

+
+ +
+

DMARC

publishedReport address: admin's default
+
+
+
+
+
+
+
+

p=none does not affect delivery. Tighten to quarantine, then reject, once the reports look clean.

+
+
+ +
+
02

Applications

SASL logins that may send as this domain
+
+ + + + + + + +
LoginMay send as30 daysLimits
prod-server*@example.org1 102 msg · peak 96/h200 / hEditNew passwordDelete
alertsalerts@example.org, noc@example.org182 msg · peak 14/hdomain 2 IPsEditNew passwordDelete
+
New password and Delete ask for confirmation; the old password stops working at once.
+
+ +
+
03

Connection

The same for every domain
+
+
+
Server
mail.example.org
+
Port 465
SSL/TLS (implicit)
+
Port 587
STARTTLS (submission)
+
Authentication
Application login, required on every port
+
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/out-domains.html b/docs/assets/panel-redesign/panel/src/out-domains.html new file mode 100644 index 0000000..4fadd2f --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-domains.html @@ -0,0 +1,35 @@ + +
+
+

Outbound

+

Outbound domains

+

Domains this relay signs and sends for. Each gets a DKIM key and its own application logins.

+
+
+ +
+
01

Add a domain

+
+
+
+
+
+
+

A DKIM key is generated at once; the next page shows the three DNS records to publish. + Moving a domain from another SelfPost? Import it instead — the key and passwords come across.

+
+
+
+ +
+
02

Domains

3 domains · DNS cached a few minutes
+
+ + + + + + +
DomainDNSSelectorApps30 days
example.orgDKIM SPF DMARCsp202621 284 msg · peak 96/hOpenDelete
shop.example.orgDKIM SPF DMARCsp2026118 920 msg · peak 410/hOpenDelete
notify.acme.ioDKIM SPF DMARCsp20253402 msg · peak 22/hOpenDelete
+
Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.
+
diff --git a/docs/assets/panel-redesign/panel/src/out-log.html b/docs/assets/panel-redesign/panel/src/out-log.html new file mode 100644 index 0000000..9ddea15 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-log.html @@ -0,0 +1,32 @@ + +
+
+

Outbound

+

Outbound log

+

Every message an application handed to the relay. Rows older than 30 days are deleted — change that in Settings.

+
+
+ +
+
+ 01

Messages

+
+
+
+
+ +
+
+
+ + + + + + + + + +
Time, UTCFromToSubjectStatus
09-21 14:01:52shop@shop.example.orgm.keller@gmx.deYour order #48213 has shippedsentDetails
09-21 14:01:50shop@shop.example.organna.lind@outlook.comYour order #48212 has shippedsentDetails
09-21 13:58:07alerts@example.orgnoc@example.org[FIRING] disk usage above 90 % on db-2deferredDetails
09-21 13:41:19no-reply@notify.acme.ioj.doe@nonexistent.exampleReset your passwordbouncedDetails
09-21 13:40:02prod@example.orgbilling@partner.exampleInvoice 2026-0917rate-limitedDetails
09-21 13:12:44shop@shop.example.orgp.novak@seznam.czWe received your return requestsentDetails
+
Page 1 of 412Older →New rows appear on their own
+
diff --git a/docs/assets/panel-redesign/panel/src/out-message.html b/docs/assets/panel-redesign/panel/src/out-message.html new file mode 100644 index 0000000..cdb30bc --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-message.html @@ -0,0 +1,46 @@ + +
+
+

Outbound log /

+

[FIRING] disk usage above 90 % on db-2

+

alerts@example.orgnoc@example.orgdeferred

+
+
+ +
+
+
+
01

Message

+
+
+ +
Application
alerts
+
Accepted
2026-09-21 13:58:07 UTC
+
Status reported
2026-09-21 13:58:09 UTC
+
Queue id
4XcB7k2Jm9z1
+
Journal id
184220
+
+
+
+
+
+
+
02

History

+
+
    +
  1. Accepted from alerts at 198.51.100.7
  2. +
  3. Deferred — the receiving server asked to retry later
  4. +
  5. Still in the queue; kept for 5 days, then bounced
  6. +
+
+
+
+
+ +
+
03

Delivery log

Lines from mail.log with this queue id
+
13:58:07 postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
+13:58:07 postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>
+13:58:07 opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
+13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)
+
diff --git a/docs/assets/panel-redesign/panel/src/out-queue.html b/docs/assets/panel-redesign/panel/src/out-queue.html new file mode 100644 index 0000000..523af78 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/out-queue.html @@ -0,0 +1,30 @@ + +
+
+

Outbound

+

Outbound queue

+

Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.

+
+
+ +
+
01

Waiting

1 message · 3.1 kB
+
+ + + + +
Queue idArrived, UTCFromToLast reply
4XcB7k2Jm9z109-21 13:58:07alerts@example.orgnoc@example.org451 4.7.1 Greylisted, try again in 5 minutesDetails
+
+ +
+
02

How retries work

This Postfix's policy, read at panel start
+
+
+
First retry
5 min
+
Later retries
doubling
capped at 66 min
+
Kept in queue
5 days
+
Then
bounced
no attempt counter, only time
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/overview.html b/docs/assets/panel-redesign/panel/src/overview.html new file mode 100644 index 0000000..a7d203a --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/overview.html @@ -0,0 +1,50 @@ + +
+
SELFPOSTWARN1 OF 6 CHECKS
+
+

mail.example.org

+

Mail is flowing. One thing needs attention.

+

The TLS certificate expires in 12 days and has not renewed yet.

+
+
Updated 14:02:31 UTC
refreshes every 10 s
+
+ + + +
+
+
+
02

Outbound domains

+
+ + + + + + +
DomainDNSApps30 days
example.orgDKIM SPF DMARC21 284 msg
shop.example.orgDKIM SPF DMARC118 920 msg
notify.acme.ioDKIM SPF DMARC3402 msg
+
+
+
+
+
03

Inbound domains

+
+ + + + + +
DomainMXUpstream
lists.example.orgMXmx.internal:25
acme.ioMX10.0.4.12:2525
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/preflight.html b/docs/assets/panel-redesign/panel/src/preflight.html new file mode 100644 index 0000000..ba3b009 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/preflight.html @@ -0,0 +1,52 @@ + +
+
PREFLIGHTWARN1 OF 7 CHECKS
+
+

Server

+

Preflight

+

Is this installation ready to deliver mail? Run it after the first start, after moving the server, or when receivers start refusing you. Health watches what runs; Preflight checks what the outside world sees.

+
+
+
+ +
+
01

Checks

Ran 14:02 UTC · 3.1 s
+
+ + + + + + + + + + +
CheckResultFoundWhat to do
Reverse DNSok203.0.113.25 ⇄ mail.example.org—
TLS certificatewarnValid for mail.example.org, expires in 12 daysRenewal has not run. Check that port 80 reaches the ACME client and look for acme in the system log.
Port 25ok220 mail.example.org ESMTP—
Ports 465, 587okTLS handshake, 220 banner on both—
HELO nameokmail.example.org—
Proxy headersokPeer 172.18.0.2 is trusted; X-Forwarded-Proto: https—
DKIM signingokinet:127.0.0.1:8891 answers—
+
Ports are dialled from inside the container at the public address; a firewall that treats inside and outside differently can make this pass while the world is still blocked. SPF, DKIM and DMARC are per domain — each outbound domain shows its own.
+
+ +
+
02

Send a test e-mail

Confirms the relay accepted it, not that it arrived
+
+
+
+
+
+
+
+
+
+

Sent from postmaster@mail.example.org through the local Postfix.

+
+
+
+
+
Last test
14:03 UTC to mix@elsewhere.example
+
Result
queued 4XcC1d8Pq2z1
+ +
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/settings.html b/docs/assets/panel-redesign/panel/src/settings.html new file mode 100644 index 0000000..6ea0ce8 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/settings.html @@ -0,0 +1,44 @@ + +
+
+

Server

+

Settings

+

What holds for the whole instance, whoever is signed in. Your own name, e-mail, notifications and DMARC report address are under Account.

+
+
+ +
+
+
+
+
01

Log retention

+
+
+
+

The main driver of /data growth. Does not affect mail.log rotation (fourteen daily files).

+
+
+ +
+
02

Notifications

+
+
+

Sent through this relay from postmaster@mail.example.org. Each user picks the events under Account; a user without an e-mail gets none.

+
+
+
+ +
+
+
03

Sending rate limits

Read-only
+
+
+
Level 1 · per client IP
600 / h
Set in .env; restart to change. Nothing below may exceed it.
+
Level 2 · domain, application
Set on each domain and application.
+
+
+
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/setup.html b/docs/assets/panel-redesign/panel/src/setup.html new file mode 100644 index 0000000..f7c3d76 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/setup.html @@ -0,0 +1,30 @@ + + diff --git a/docs/assets/panel-redesign/panel/src/system-log.html b/docs/assets/panel-redesign/panel/src/system-log.html new file mode 100644 index 0000000..c0a37ee --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/system-log.html @@ -0,0 +1,22 @@ + +
+
+

Server

+

System log

+

The tail of mail.log — Postfix, OpenDKIM and the milters. For one message, open it from a log page instead.

+
+
+ +
+
01

Recent lines

Last 200 · refreshes every 10 s
+
Sep 21 14:01:52 postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
+Sep 21 14:01:52 postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
+Sep 21 14:00:18 postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
+Sep 21 14:00:18 postfix/smtp[2130]: 4XcB8z5Tn1z1: to=<dev@lists.example.org>, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
+Sep 21 13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)
+Sep 21 13:57:41 postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient
+Sep 21 13:44:02 postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)
+Sep 21 13:41:19 postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)
+Sep 21 13:40:02 journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM
+Sep 21 13:00:00 opendkim[39]: key table reloaded (3 domains)
+
diff --git a/docs/assets/panel-redesign/panel/src/user-delete.html b/docs/assets/panel-redesign/panel/src/user-delete.html new file mode 100644 index 0000000..dcf0888 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/user-delete.html @@ -0,0 +1,29 @@ + +
+
+

Users / acme-ops /

+

Delete acme-ops

+
+
+ +
+
+
+

This cannot be undone

+
+

Deleting the panel user acme-ops will:

+
    +
  • end any signed-in session of this user at once;
  • +
  • remove the assignments to all outbound domains and to acme.io (inbound).
  • +
+

The domains, their applications and passwords, upstreams and recipients are not affected.

+
+
+ + Keep it +
+
+
+
+
+
diff --git a/docs/assets/panel-redesign/panel/src/user.html b/docs/assets/panel-redesign/panel/src/user.html new file mode 100644 index 0000000..e005506 --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/user.html @@ -0,0 +1,54 @@ + +
+
+

Users /

+

acme-ops

+

The same form, empty, creates a new user.

+
+ Delete user +
+ +
+
+
+
+
01

Account

+
+
+
+

For notifications and a password-reset link. The user can change it under Account.

+
+

Leave empty to keep the current one.

+
+
+

Everyone here is an administrator; the role is the reach. Global also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to domain or deleted.

+
+
+
+
+
+
02

Outbound domains

DNS records, applications, log
+
+
+
+
+
+

With All ticked the list below it is ignored — and greyed out where scripts run.

+
+
+
+
03

Inbound domains

Upstream, recipients, log
+
+
+
+
+

Granted separately from outbound: the same name on both lists is two assignments. The domain role needs at least one domain, or All, on either list.

+
+
+
+
+
+ + Cancel +
+
diff --git a/docs/assets/panel-redesign/panel/src/users.html b/docs/assets/panel-redesign/panel/src/users.html new file mode 100644 index 0000000..d4947ff --- /dev/null +++ b/docs/assets/panel-redesign/panel/src/users.html @@ -0,0 +1,23 @@ + +
+
+

Server

+

Users

+

Who may sign in to this panel. Everyone here is an administrator; the role is the reach — global is the whole server, domain is the domains assigned, outbound and inbound separately.

+
+ Create user +
+ +
User acme-ops created.
+ +
+
01

Panel users

3 users
+
+ + + + + + +
UsernameE-mailRoleOutbound domainsInbound domains
admin youmix@example.orgglobalAllAllEdit
shop-teamteam@shop.example.orgdomainshop.example.org—Edit
acme-opsops@acme.iodomainAllacme.ioEdit
+
diff --git a/docs/assets/panel-redesign/panel/system-log.html b/docs/assets/panel-redesign/panel/system-log.html new file mode 100644 index 0000000..c99f1c6 --- /dev/null +++ b/docs/assets/panel-redesign/panel/system-log.html @@ -0,0 +1,94 @@ + + + + + +System log · SelfPost + + + + + + + + + +
+
+
+
+

Server

+

System log

+

The tail of mail.log — Postfix, OpenDKIM and the milters. For one message, open it from a log page instead.

+
+
+ +
+
01

Recent lines

Last 200 · refreshes every 10 s
+
Sep 21 14:01:52 postfix/smtp[2131]: 4XcB9q0Lw2z1: to=<m.keller@gmx.de>, relay=mx00.gmx.net[212.227.15.9]:25, delay=0.8, status=sent (250 Requested mail action okay)
+Sep 21 14:01:52 postfix/qmgr[58]: 4XcB9q0Lw2z1: removed
+Sep 21 14:00:18 postfix/smtpd[2129]: connect from mail.uni.example[192.0.2.77]
+Sep 21 14:00:18 postfix/smtp[2130]: 4XcB8z5Tn1z1: to=<dev@lists.example.org>, relay=mx.internal[10.0.4.2]:25, status=sent (250 2.0.0 Ok: queued)
+Sep 21 13:58:09 postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, status=deferred (451 4.7.1 Greylisted, try again in 5 minutes)
+Sep 21 13:57:41 postfix/smtpd[2119]: NOQUEUE: reject: RCPT from bulk.example[198.51.100.90]: 550 5.1.1 <sales@lists.example.org>: Recipient address rejected: unknown recipient
+Sep 21 13:44:02 postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)
+Sep 21 13:41:19 postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)
+Sep 21 13:40:02 journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM
+Sep 21 13:00:00 opendkim[39]: key table reloaded (3 domains)
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/theme.css b/docs/assets/panel-redesign/panel/theme.css new file mode 100644 index 0000000..2e58ec3 --- /dev/null +++ b/docs/assets/panel-redesign/panel/theme.css @@ -0,0 +1,257 @@ +/* The accepted direction: C ("Counter", Bulma) as the base, the postal + signature of A (postmark, numbered mono section heads, the stamp's + perforation) and the icons of B. Bulma carries navbar, dropdowns, columns, + boxes, tables, tags, menu, forms; what follows is brand mapping plus the + handful of components that are SelfPost's own. */ + +:root { + --bulma-family-primary: var(--sp-sans); + --bulma-family-secondary: var(--sp-sans); + --bulma-family-code: var(--sp-mono); + --bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%; + --bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%; + --bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%; + --bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%; + --bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%; + --bulma-radius: 6px; --bulma-radius-large: 10px; + --bulma-body-size: 16px; + /* The type scale. Three sizes carry all running text; nothing a person has + to read is below 13px. check.py enforces the floor. + body 16px text, inputs, buttons, menus, table cells + s 14px supporting text: labels, help, notes, footers, mono values, logs + xs 13px upper-case mono micro-labels and tags only */ + --sp-fs-s: .875rem; + --sp-fs-xs: .8125rem; + --bulma-link-text: var(--sp-brick); + --bulma-link-text-hover: var(--sp-brick-dark); +} +/* Bulma picks a button's text colour from the lightness of its fill; our brick + and our red are dark enough to land on the wrong side and get near-black + text (3:1). Solid buttons are white on colour; the light variants keep + Bulma's dark-on-tint. */ +.button.is-primary:not(.is-light), .button.is-primary:not(.is-light):hover, +.button.is-danger:not(.is-light), .button.is-danger:not(.is-light):hover { color: #fff; } +a strong { color: inherit; } +html, body { background: var(--sp-paper); } +body { color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; } +main { flex: 1; } +code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .95em; } +.ti { font-size: 1.15em; vertical-align: -.12em; } +.button .ti { margin-right: .35rem; } +.sp-muted { color: var(--sp-muted); } +/* Bulma ships these at .75rem; on the scale they are s or xs. `sp-small` + replaces Bulma's `is-size-7` helper, which is .75rem and !important. */ +.help, .label { font-size: var(--sp-fs-s); } +.tag, .menu-label { font-size: var(--sp-fs-xs); } +.menu-label { color: var(--sp-muted); } /* Bulma's grey is 4.2:1 on the paper */ +.sp-small { font-size: var(--sp-fs-s); } +.sp-mono { font-family: var(--sp-mono); } +.sp-nowrap { white-space: nowrap; } + +/* ---- navbar, the perforated edge under it, sibling pages below */ +.navbar.is-primary { --bulma-navbar-height: 4rem; } +/* The name, not the stamp: at this size the stamp's perforation is under a + pixel and its brick field dissolves into the bar. The stamp stays where it + has room — sign-in, setup, the favicon. */ +.navbar-brand img { max-height: none; height: 1.45rem; } +.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; } +.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); } +.navbar.is-primary .navbar-link .ti { margin-right: .4rem; } +/* Bulma shrinks dropdown items to .875rem; a menu entry is read as often as + the bar it hangs from, so it gets the bar's size. */ +.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); font-size: 1rem; } +.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; padding-right: 2rem; } +.navbar.is-primary .navbar-dropdown .navbar-item .ti { margin-right: .6rem; color: var(--sp-muted); } +.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); } +.navbar.is-primary .navbar-dropdown .navbar-item.is-active .ti { color: var(--sp-brick); } +.navbar.is-primary .sp-current > .navbar-link, .navbar.is-primary a.navbar-item.sp-current { background: rgb(0 0 0 / .18); } +/* The header is built like the mark: a brick field, then the stamp's paper + margin, and the perforation runs along the outer edge of that margin — round + holes, flat teeth, the mark's ink hairline (perf-edge.svg is one tooth and + one hole at the mark's 2.2 / 7.03 ratio). On the mark the margin is one + pitch deep and a tenth of the brick field's height; here that is 8px under + a 60px bar — a little over a tenth, so a 2.5px hole still reads as round. */ +.sp-perf { + height: 8px; + background: + url("perf-edge.svg") repeat-x left bottom / 8px 4px, + linear-gradient(var(--sp-stamp-paper), var(--sp-stamp-paper)) no-repeat left top / 100% 4px; +} +.sp-subnav { border-bottom: 1px solid var(--sp-rule); margin-top: -1px; } +.sp-subnav .container { display: flex; gap: 1.8rem; overflow-x: auto; } +.sp-subnav a { color: var(--sp-muted); padding: .75rem 0 .65rem; white-space: nowrap; border-bottom: 2px solid transparent; } +.sp-subnav a .ti { margin-right: .4rem; } +.sp-subnav a:hover { color: var(--sp-ink); } +.sp-subnav a[aria-current] { color: var(--sp-ink); font-weight: 600; border-bottom-color: var(--sp-brick); } + +/* ---- page head */ +.sp-page { padding-block: 1.8rem 2.5rem; } +.sp-head { display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.sp-head .sp-grow { flex: 1; min-width: 14rem; } +.sp-kicker { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); } +.sp-kicker a { color: inherit; } +.sp-kicker a:hover { color: var(--sp-brick); } +.title { color: var(--sp-ink); font-weight: 600; } +.sp-head .title { margin: 0; overflow-wrap: anywhere; } +.sp-head .sp-lead { color: var(--sp-muted); margin-top: .2rem; max-width: 48rem; } +.sp-head .buttons { margin: 0; } +.sp-head .buttons > * { margin-bottom: 0; } + +/* ---- postmark */ +.sp-postmark { + flex: none; width: 6rem; height: 6rem; border-radius: 50%; transform: rotate(-9deg); + border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px; margin: .3rem .5rem; + display: grid; place-content: center; text-align: center; color: var(--sp-ok); + font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15; +} +.sp-postmark.sp-warn { color: var(--sp-warn); } +.sp-postmark.sp-fail { color: var(--sp-fail); } +.sp-postmark b { font-size: 1.3rem; display: block; } +/* Below the floor on purpose: an imprint, and the heading beside it says the same in words. */ +.sp-postmark span { font-size: .6rem; display: block; } + +/* ---- box with a numbered mono head */ +.box { box-shadow: none; border: 1px solid var(--sp-rule); padding: 0; overflow: hidden; } +.box > .sp-box-head { + display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--sp-rule); +} +.sp-box-head form.sp-end { justify-content: flex-end; } +/* Bulma's .input is 100% wide; in the filter row it takes a flex basis instead, so it sits beside the selects. */ +.sp-box-head form.sp-end > .input { flex: 0 1 10.5rem; } +.sp-box-head .sp-no { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); font-weight: 600; color: var(--sp-brick); } +.sp-box-head h2 { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); font-weight: 600; letter-spacing: .1em; text-transform: uppercase; margin: 0; } +.sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: var(--sp-fs-s); display: flex; gap: .6rem; align-items: center; } +.sp-box-head .sp-help { color: var(--sp-muted); line-height: 1; } +.sp-box-head .sp-help:hover { color: var(--sp-brick); } +.sp-box-head .sp-end + .sp-help { margin-left: 0; } +.sp-box-head h2 + .sp-help { margin-left: auto; } +.notification { border: 1px solid transparent; padding: .8rem 1.25rem; } +.notification.is-success.is-light { border-color: var(--sp-ok-rule); } +.notification.is-danger.is-light { border-color: var(--sp-fail-rule); } +.notification .ti { margin-right: .5rem; } +.box > .sp-box-body { padding: 1.25rem; } +.box > .sp-box-body > :last-child { margin-bottom: 0; } +/* A table never sits bare in a box: the container lets a wide one scroll + sideways instead of being cut off by the box's rounded overflow. */ +.box > .table-container, .box > .table-container > .table { margin: 0; } +.sp-box-foot .sp-end { margin-left: auto; } +/* A filter form in the head keeps full-size controls; the head gives up its + own padding to them instead of shrinking the text. */ +.sp-box-head form.sp-end { margin-block: -.35rem; font-size: 1rem; } +.box > .sp-box-foot {padding: .7rem 1.25rem; border-top: 1px solid var(--sp-rule); color: var(--sp-muted); font-size: var(--sp-fs-s); display: flex; gap: 1rem; align-items: center; } +.box.sp-danger-zone { border-color: var(--sp-fail-rule); } +.box.sp-danger-zone .sp-box-head { background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); } +.box.sp-credential { border-color: #e0c874; } +.box.sp-credential .sp-box-head { background: #fcf6e4; border-color: #e0c874; } + +.table { background: transparent; } +.table thead th { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); } +/* Addresses in tables carry a after the @ (one template function), so a + long one breaks where it reads naturally and its column can still shrink; + `overflow-wrap: anywhere` would do it too, but mid-word. */ +.table td, .table th { vertical-align: middle; padding: .55rem .6rem; } +.box > .table-container td:first-child, .box > .table-container th:first-child { padding-left: 1.25rem; } +.box > .table-container td:last-child, .box > .table-container th:last-child { padding-right: 1.25rem; } +.table td.sp-actions { text-align: right; white-space: nowrap; } +.table td.sp-actions a { margin-left: .9rem; } +.table td.sp-clip { max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; } +.sp-empty { padding: 2.2rem 1.25rem; text-align: center; color: var(--sp-muted); } +.sp-empty .ti { font-size: 2rem; display: block; margin-bottom: .4rem; color: var(--sp-rule-strong); } + +/* ---- health cards */ +/* Six cards: six, three or two columns — never a count that leaves one card alone on a row. */ +.sp-health { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0; } +.sp-health > a { + display: flex; gap: .7rem; align-items: center; padding: .75rem 1rem; color: var(--sp-ink); min-width: 0; + border-right: 1px solid var(--sp-rule); border-bottom: 1px solid var(--sp-rule); margin: 0 -1px -1px 0; +} +.sp-health > a:hover { background: var(--sp-paper); } +.sp-health > a > span:last-child { min-width: 0; } +.sp-h-icon { flex: none; width: 2.1rem; height: 2.1rem; border-radius: 7px; display: grid; place-content: center; background: var(--sp-ok-bg); color: var(--sp-ok); } +.sp-h-icon .ti { font-size: 1.25rem; vertical-align: 0; } +.sp-health > a.sp-warn { background: var(--sp-warn-bg); } +.sp-health > a.sp-warn .sp-h-icon { background: #f6e2b8; color: #8a5510; } +.sp-health > a.sp-fail { background: var(--sp-fail-bg); } +.sp-health > a.sp-fail .sp-h-icon { background: #f5d3cc; color: var(--sp-fail); } +/* Each line is a block with its own tight line height; a verdict is two or + three words and never wraps — a wrapped card would stretch the whole row. */ +.sp-h-name, .sp-h-value, .sp-h-sub { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sp-h-name { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); line-height: 1.3; } +.sp-h-value { font-weight: 600; line-height: 1.3; } +.sp-h-sub { font-size: var(--sp-fs-s); color: var(--sp-muted); line-height: 1.3; } + +/* ---- facts, records, side menu */ +.sp-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1rem 1.5rem; margin: 0; } +.sp-facts dt { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); } +.sp-facts dd { margin: 0; overflow-wrap: anywhere; } +.sp-facts dd.sp-big { font-size: 1.5rem; font-weight: 600; line-height: 1.2; } +.sp-side { position: sticky; top: 1.5rem; } +.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; } +.menu-list a { display: flex; align-items: center; gap: .55rem; } +.menu-list a .ti { color: var(--sp-muted); } +.menu-list a.is-active .ti { color: inherit; } +.menu-list a .tag { margin-left: auto; } +.sp-record { padding: 1.25rem; } +.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); } +.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; } +.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; } +.sp-record-head small { margin-left: auto; color: var(--sp-muted); text-align: right; } +.sp-record .label, .sp-form .label { font-weight: 500; color: var(--sp-ink-2); margin-bottom: .25rem; } +.sp-record .label { font-weight: 400; color: var(--sp-muted); } +.input[readonly], .textarea[readonly] { font-family: var(--sp-mono); font-size: var(--sp-fs-s); background: var(--sp-well); box-shadow: none; } +/* Bulma sizes a control from its own font; the smaller mono text made the + value shorter than the Copy button joined to it. */ +.input[readonly] { height: 2.5rem; } +.textarea[readonly] { min-height: 2.5rem; } +.textarea[readonly] { resize: none; min-height: 0; } +.sp-record .field:not(:last-child) { margin-bottom: .6rem; } +.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; } +.sp-pair { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; } + +/* ---- logs, timeline, help */ +.sp-log { + margin: 0; padding: 1rem 1.25rem; background: #191c21; color: #d9d5cc; border-radius: 0; + font-family: var(--sp-mono); font-size: var(--sp-fs-s); line-height: 1.6; overflow-x: auto; white-space: pre; +} +.sp-log .sp-w { color: #e0b15c; } .sp-log .sp-e { color: #ef8f82; } .sp-log .sp-d { color: #7f8894; } +.sp-timeline { list-style: none; margin: 0; padding: 0; } +.sp-timeline li { position: relative; padding: 0 0 1.1rem 1.6rem; border-left: 2px solid var(--sp-rule); margin-left: .4rem; } +.sp-timeline li:last-child { padding-bottom: 0; border-left-color: transparent; } +.sp-timeline li::before { content: ""; position: absolute; left: -7px; top: .25rem; width: 12px; height: 12px; border-radius: 50%; background: var(--sp-ok); box-shadow: 0 0 0 3px #fff; } +.sp-timeline li.sp-warn::before { background: #d9962b; } +.sp-timeline li.sp-fail::before { background: var(--sp-fail); } +.sp-timeline li.sp-pending::before { background: #fff; border: 2px solid var(--sp-rule-strong); } +.sp-timeline time { font-family: var(--sp-mono); font-size: var(--sp-fs-xs); color: var(--sp-muted); display: block; } +.sp-route { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; font-family: var(--sp-mono); font-size: var(--sp-fs-s); margin-top: .3rem; } +.sp-help-topic + .sp-help-topic { border-top: 1px solid var(--sp-rule); } +.sp-help-topic { padding: 1rem 1.25rem; } +.sp-help-topic h3 { font-weight: 600; margin-bottom: .3rem; } +.sp-list-check { list-style: none; margin: 0 0 1rem; } +.sp-list-check li { padding-left: 1.7rem; position: relative; margin-bottom: .45rem; } +.sp-list-check li .ti { position: absolute; left: 0; top: .15rem; color: var(--sp-fail); } + +/* ---- signed-out pages */ +.sp-signin { min-height: 100vh; margin: 0; } +.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; position: relative; } +.sp-signin-brand::after { + content: ""; position: absolute; top: 0; bottom: 0; right: -7px; width: 7px; z-index: 1; + background: radial-gradient(circle at 0 7px, var(--sp-brick) 5px, transparent 5.6px); background-size: 7px 14px; +} +.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; } +.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: var(--sp-fs-xs); color: var(--sp-on-brick); margin-top: 1.6rem; } +.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; } +.sp-signin-form form { width: 22rem; max-width: 100%; margin-inline: auto; } +.sp-foot { color: var(--sp-muted); font-size: var(--sp-fs-s); padding-block: 1.5rem 0; } +.sp-foot a { color: inherit; text-decoration: underline; } +.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; } + +@media (max-width: 1407px) { .sp-health { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 1023px) { .sp-box-head form.sp-end { flex-wrap: wrap; } } +@media (max-width: 768px) { + .sp-health { grid-template-columns: repeat(2, 1fr); } + .sp-row, .sp-pair { grid-template-columns: 1fr; } + .sp-record-head { flex-wrap: wrap; } + .sp-record-head small { margin-left: 0; text-align: left; flex-basis: 100%; } + .sp-postmark { display: none; } +} diff --git a/docs/assets/panel-redesign/panel/user-delete.html b/docs/assets/panel-redesign/panel/user-delete.html new file mode 100644 index 0000000..374acbc --- /dev/null +++ b/docs/assets/panel-redesign/panel/user-delete.html @@ -0,0 +1,101 @@ + + + + + +Delete acme-ops · SelfPost + + + + + + + + + +
+
+
+
+

Users / acme-ops /

+

Delete acme-ops

+
+
+ +
+
+
+

This cannot be undone

+
+

Deleting the panel user acme-ops will:

+
    +
  • end any signed-in session of this user at once;
  • +
  • remove the assignments to all outbound domains and to acme.io (inbound).
  • +
+

The domains, their applications and passwords, upstreams and recipients are not affected.

+
+
+ + Keep it +
+
+
+
+
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/user.html b/docs/assets/panel-redesign/panel/user.html new file mode 100644 index 0000000..32b7e2a --- /dev/null +++ b/docs/assets/panel-redesign/panel/user.html @@ -0,0 +1,126 @@ + + + + + +acme-ops · Users · SelfPost + + + + + + + + + +
+
+
+
+

Users /

+

acme-ops

+

The same form, empty, creates a new user.

+
+ Delete user +
+ +
+
+
+
+
01

Account

+
+
+
+

For notifications and a password-reset link. The user can change it under Account.

+
+

Leave empty to keep the current one.

+
+
+

Everyone here is an administrator; the role is the reach. Global also sees Server and is the only one who adds or deletes domains. The only global user cannot be changed to domain or deleted.

+
+
+
+
+
+
02

Outbound domains

DNS records, applications, log
+
+
+
+
+
+

With All ticked the list below it is ignored — and greyed out where scripts run.

+
+
+
+
03

Inbound domains

Upstream, recipients, log
+
+
+
+
+

Granted separately from outbound: the same name on both lists is two assignments. The domain role needs at least one domain, or All, on either list.

+
+
+
+
+
+ + Cancel +
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/panel/users.html b/docs/assets/panel-redesign/panel/users.html new file mode 100644 index 0000000..a7959df --- /dev/null +++ b/docs/assets/panel-redesign/panel/users.html @@ -0,0 +1,95 @@ + + + + + +Users · SelfPost + + + + + + + + + +
+
+
+
+

Server

+

Users

+

Who may sign in to this panel. Everyone here is an administrator; the role is the reach — global is the whole server, domain is the domains assigned, outbound and inbound separately.

+
+ Create user +
+ +
User acme-ops created.
+ +
+
01

Panel users

3 users
+
+ + + + + + +
UsernameE-mailRoleOutbound domainsInbound domains
admin youmix@example.orgglobalAllAllEdit
shop-teamteam@shop.example.orgdomainshop.example.org—Edit
acme-opsops@acme.iodomainAllacme.ioEdit
+
+ + +
+
+ + diff --git a/docs/assets/panel-redesign/shared/brand.css b/docs/assets/panel-redesign/shared/brand.css new file mode 100644 index 0000000..3ca41ab --- /dev/null +++ b/docs/assets/panel-redesign/shared/brand.css @@ -0,0 +1,48 @@ +/* Brand layer shared by the three redesign directions: the panel's own fonts + and the palette of the mark (docs/assets/selfpost-proof.html). Each + direction maps these tokens onto its library's variables in its own + theme.css — nothing here knows about Pico, Tabler or Bulma. + + Mockup note: no inline style="" and no inline