diff --git a/.cursor/rules/panel-ui-system.mdc b/.cursor/rules/panel-ui-system.mdc new file mode 100644 index 0000000..8bd512f --- /dev/null +++ b/.cursor/rules/panel-ui-system.mdc @@ -0,0 +1,22 @@ +--- +description: Panel UI mockups use the design system primitives, not a reskin of live templates. +globs: docs/assets/panel-ui/** +alwaysApply: false +--- + +# Panel UI system + +Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`. + +Do not copy `internal/web/view/templates` into mockups. Do not mark `
` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS. + +Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`: +- Two peer jobs → `pair` (one child shrinks to `measure`). +- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it. +- Confirm/login/user form → `measure`. +- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings). +- Save + Delete → `actions-row` inside the card; never submit inside `
` and danger after `
`. +- Danger zone is the second column of the last `pair`, never a full-width `fill`. +- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later. +- Phone: `pair` stacks; `field-row` does not; tables become lists. +- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e2bb58..256c55b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,9 @@ name: release # Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). -# Ordinary commits do not publish anything — only a pushed tag matching -# vX.Y.Z does. That tag is the single source the version comes from: it goes -# into both the image tag and the panel binary's -ldflags version, so the two -# can never drift apart (the invariant restore's version check in spec 7.5.A -# depends on). +# 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 @@ -13,14 +11,18 @@ name: release # 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, at the cost of per-arch tags lingering -# in the registry as a side effect (harmless — the version tag's immutability, -# spec 10.1, is about that tag, not these). +# 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). on: - push: - tags: - - "v[0-9]+.[0-9]+.[0-9]+" + release: + types: [published] workflow_dispatch: + inputs: + version: + description: "Image version as X.Y.Z (no v prefix)." + required: true + type: string permissions: contents: read @@ -32,9 +34,25 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: - - name: Derive version from tag + - name: Derive a SemVer X.Y.Z version id: version - run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "release" ]; then + raw="${RELEASE_TAG:-}" + else + raw="${INPUT_VERSION:-}" + fi + raw="${raw#v}" + if ! [[ "$raw" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::release version is not SemVer X.Y.Z (event=${EVENT_NAME}; tag=${RELEASE_TAG:-} input=${INPUT_VERSION:-}). Refusing to publish ghcr.io/${{ github.repository }}:${raw:-?}" + exit 1 + fi + echo "version=${raw}" >> "$GITHUB_OUTPUT" build: needs: prepare @@ -49,6 +67,8 @@ jobs: runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 + with: + ref: v${{ needs.prepare.outputs.version }} - uses: docker/setup-buildx-action@v3 @@ -113,6 +133,10 @@ jobs: needs: [prepare, build] runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + ref: v${{ needs.prepare.outputs.version }} + - uses: docker/setup-buildx-action@v3 - name: Log in to ghcr.io @@ -152,3 +176,29 @@ jobs: attempt=$((attempt + 1)) backoff=$((backoff * 2)) done + + - name: Remove per-arch tags from GHCR + # Side-effect tags for imagetools assembly only — not part of the public + # version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64). + # imagetools has no "rm" subcommand; delete via the GitHub Packages API. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version="${{ needs.prepare.outputs.version }}" + owner="${{ github.repository_owner }}" + pkg="${{ github.event.repository.name }}" + api="/users/${owner}/packages/container/${pkg}/versions" + for suffix in amd64 arm64; do + tag="${version}-${suffix}" + mapfile -t ids < <(gh api "$api" --paginate \ + --jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id") + if [ "${#ids[@]}" -eq 0 ]; then + echo "no GHCR package version for tag ${tag}" + continue + fi + for id in "${ids[@]}"; do + echo "deleting GHCR package version ${id} (tag ${tag})" + gh api -X DELETE "${api}/${id}" + done + done diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b2b31..80e44de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,304 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [1.3.1] - 2026-08-17 + +Retry policy in the panel, persistent Postfix queue, and self-contained +full backups. Mail queue and delivery history show this Postfix's first +retry, backoff cap, and queue lifetime. Deferred mail survives container +recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; +the archive layout breaks 1.3.0 flat backups (unpack those with +`tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a +tag bump; no schema migration. + +### Added + +- Mail queue and a delivery's history show this Postfix's retry policy + (first delay, backoff cap, queue lifetime), read from `postconf -h` once + at panel start. A manual `postconf -e` override is visible after the next + panel restart. There is no attempt counter — Postfix retries on time. + +- docs: **Plan checklists** in [development.md](docs/development.md) — format, + `Progress` column in [roadmap.md](docs/roadmap.md), per-step commit + + CHANGELOG, version cuts `1.3.1`…`1.8.0` per roadmap stage. +- docs: **Implementation checklists** with model routing in + [docs/plans/](docs/plans/) (queue-retries, inbound-relay, send-log-retention, + domain-stats-auto-ratelimit, dmarc-reports, panel-docs). +- docs: roadmap candidates **send-log-retention** (panel Settings for delivery + journal retention) and **domain-stats-auto-ratelimit** (30-day send stats per + domain/application, auto level-2 rate limit from avg × multiplier) — plans in + [docs/plans/](docs/plans/). + +- Postfix queue under `/data/postfix/queue` — deferred and active mail survive + container recreate and are included in full backups. +- Full backup archives the whole operator project: `data/`, `docker-compose.yml`, + `.env`, and `certs/` (requires `.:/selfpost-deploy:ro` in compose). Restore + by unpacking into an empty project directory. + +### Changed + +- **Breaking:** full-backup archive layout — paths are prefixed with `data/`; + deploy files sit at the archive root. Old flat archives restore with + `tar xzf backup.tar.gz -C ./data` as before. +- docs: operator guide, architecture, security, and backup UI updated for + self-contained backups and persistent queue. + +- docs: HTML mockups for a full panel UI refresh + ([docs/assets/panel-ui/](docs/assets/panel-ui/index.html)) — current screens, + agreed and candidate roadmap surfaces (queue-retries, inbound-relay, + dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms + keep a reading measure), and a 390px emergency layout. The stamp and brick + palette stay; extra nav icons are proposals only. Design artefact; the + running panel is unchanged. + +### Changed + +- docs: panel UI mockups after review — Status restored to live readings + (queue line, PTR, machine/process tables, Configuration); inbound MX DNS + check, recipient mode (list or any), and Danger zone beside recipients; + Backup and Settings as two-column ops pages; Save and Delete user on one + row; help «?» on domain cards; Host/name ‖ Type height matched to the live + panel (`b0ebe06`). + +- docs: panel UI design system and mockups rebuilt as separate pages + (`system.html`, `status.html`, `domain.html`, …), not one hash sheet. + Regions are `stack` / `pair` / `measure` / `fill`; Host ‖ Type is + `field-row`; Save + Delete is `actions-row`. Shared chrome is `shell.js`. + +- docs: panel UI mockups — add-domain sits in the list card (Domains and + Inbound); DMARC candidate screens drill into a domain roll-up and a + parsed aggregate report (aligned vs third-party fail), not a hub-only + summary. + +## [1.3.0] - 2026-08-14 + +Security and quality after 1.2.5: domain-admin send-log authorization, +fail-closed sign-in and application delete, level-2 rate-limit race fix, +restore Resync, expanded tests, operator docs, release CI, and OFL for IBM +Plex. Upgrading from 1.2.x is a tag bump; no migration. + +### Added + +- licence: the SIL Open Font License 1.1 text now travels with the IBM Plex + WOFF2 files (`internal/web/view/static/OFL.txt`). The image copies it next + to LICENSE and NOTICE under `/usr/share/doc/selfpost/`; the panel serves it + at `/static/OFL.txt`. OFL requires the licence to accompany the font. + +- 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). + Explanation only; no attempt counter and no panel knobs. Not yet + implemented. + +### Security + +- The independent security review of the send-log authorization and + fail-closed fixes below (code-review plan § P7; reviewer model ≠ author + model) found no further issues: the domain scope holds on every query path, + a rate-limit refusal cannot consume window budget, and each failure residue + of the reordered application delete fails safe. Nothing was added to + [docs/security.md](docs/security.md) § Accepted risks; the review is + recorded in that file's header. + +### Fixed + +- test (e2e): send-log status scrapers follow the badge markup in + `deliveries_rows`. The release gate still looked for bare `sent` + after the panel started rendering status as `` badges, + so `send_verify_dkim_and_status` timed out even when mail was delivered and + logged. A handler regression test catches this drift in `go test ./...` + without Docker. + +- security (panel): the Deliveries list is scoped to a domain administrator's + assigned domains for every number of assignments. Previously the send log was + narrowed only when exactly one domain was assigned, so an administrator with + none or with two or more read every domain's rows (sender, recipient, subject) + on the list and its polled fragment. The domain scope is now an `IN` + constraint carried by the store query — a filter that states no scope returns + nothing — and the `domain` and `app` query parameters are checked against the + principal's own domains and applications before the query runs, so a + hand-written URL cannot widen the scope. Global administrators are unaffected. + +- mail (level-2 rate limit): the ceiling is no longer overshot by messages that + arrive at the same instant. The milter counted the stored and in-flight + messages and reserved its own slot in two separate steps, so several SMTP + sessions could pass the same check before any of them had reserved. Counting + and reserving now happen as one operation, and the ceiling is handed out + exactly as many times as configured. Postfix's level-1 limit remains the + backstop and the level-2 check stays fail-open on store errors. + +- panel (sign-in): a session that cannot be written to the database no longer + produces a session cookie. The login used to log the failure, set the cookie + and redirect to the dashboard, leaving the browser looking signed in while + every request bounced back to `/login`; it now fails closed with an error on + the sign-in page. + +- panel (applications): deleting an application removes its SASL credentials + before its registry row. If `saslpasswd2` fails, the application stays listed + and the delete can be retried, instead of leaving a hidden account that could + still authenticate to Postfix. This matches the order domain deletion already + used. + +- panel (GUI): a rejected rate-limit change on a domain's page now renders on + the danger surface (`.flash.error`) instead of the success one — it was + green with red text, reading as good news. Deleting a panel user now goes + through a confirmation page, the same pattern as domain deletion, instead of + a plain submit button next to Save with no confirmation at all. + +- panel (restore): after a backup is extracted and the version guard passes, + the panel runs one mail-path Resync on the first boot — OpenDKIM's tables + and Postfix's sender map are re-derived from SQLite and the daemons are + reloaded, so drift between the archive and the database is healed before + mail flows. Later starts skip that step; the Status page Reload button runs + 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 + pass — [architecture.md](docs/architecture.md) route table now marks + **global** routes (404 for domain administrators) and documents the + one-time restore Resync in Persistence; session/password and restore-session + wording corrected in [guide.md](docs/guide.md) and architecture (own-password + change vs admin reset, no "logout everywhere", immediate session restore on + the next request, PTR cache ≈1 min, decrypt has no version check, restore + Resync on first boot, domain add/delete/import and `POST /reload` global-only, + Settings DMARC global-only); [README.md](README.md) port-587 and quick-start + volume wording fixed; [security.md](docs/security.md) CSRF ADR points at + `authz.go` for route gating. No behaviour change. + +- docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance + administration**, and **Domain administration** — DNS setup, operations, + rate limiting, and backup sections follow the instance/domain boundary + instead of mixing them. **Installation** now reads Ports → Local trial → + Initial setup → Full deployment (with the fixed image tag nested under it) → + Environment variables → Reverse proxy; the step-by-step production deploy and + per-proxy TLS commands move here from README's "Reference deploy" (README + keeps a short pointer). Internal (non-operator) environment variables move + to [architecture.md](docs/architecture.md) § Configuration; the guide keeps + a one-line pointer. **Full backup and restore** gains worked commands for + in-place restore, move-to-a-new-host, and encrypted-backup decrypt-first, + plus the version-mismatch error text. README anchors updated for the new + headings. No behaviour change. + +- docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7) + is closed — and `docs/plans/code-review.md` is deleted per its own exit + criteria (history in git and in this file). The plan covered architecture, + quality, GUI, tests, and licence work; P0 was domain-admin send-log + authorization. The [roadmap](docs/roadmap.md)'s recommended order returns to + **queue-retries** and then **inbound-relay**; it still records + **schema-squash** (replace the 1.x SQLite migration chain with a 2.x baseline; + not a reason to cut a major on its own). + +- licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in + `internal/legal/legal.go` (the value the panel footer actually injects), not + `layout.html`. Per-file `SPDX-License-Identifier` headers on the two command + packages were dropped so the tree is consistent; AGPL-3.0 does not require + them ([development.md](docs/development.md) § External libraries). Deleted the + completed `docs/plans/logrotate-mode.md` (history in git and + [1.2.3](#123---2026-08-12)). + +- ci: the release image is published only for a **published** GitHub Release + (`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). + +- 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 + at the end of a window and the sweep that keeps finished buckets out of + memory; sign-in for a successful session, for refusals that do not reveal + which usernames exist, and for a lockout that a correct password cannot + bypass; the one-time setup link for creating the first administrator, closing + afterwards, rejecting a wrong or expired token, and refusing credentials the + panel would not accept later. Every global-only route (`/users`, `/backup`, + domain import, `/status`, `/mail-queue`, `/system-log`, domain add and delete, + reload) is checked to answer a domain administrator — and a request with no + principal — with 404, the check that would have caught the send-log leak. + +- test: restore is covered as the operator performs it, in process. A backup is + downloaded from a running panel through `POST /backup` (plain and encrypted), + unpacked the way `tar -xzf` unpacks it onto the `/data` bind mount, and a + second panel is booted on the result through the startup order the panel + itself uses — version guard, database, one Resync when restoring, then + services and the HTTP application. The restored panel shows the domain and + journal the archive carried, finds the DKIM key, SASL database and Postfix + sender map where its configuration says they are, does not reopen the + one-time setup link, and still honours a session that predates the backup. + Drifted on-disk maps are healed by that Resync step + (`TestResyncAfterRestoreHealsDriftedMaps`). A data directory left by another + version is refused with both versions named and the manifest kept. `serveHTTP` + is split in two so that composition can be started without binding a port; no + behaviour change. + +- test (e2e): the CoreDNS image is pinned to `1.14.6` instead of `latest`, so + the release gate cannot change under a commit between two runs. The level-1 + rate-limit failure message quoted `RATE_LIMIT_MESSAGES_PER_IP=5` while the + stand sets `50`. + +- ci: gofmt on eight files that failed the formatting workflow check (panel + config, DNS check, domain transfer export, rate-limit tests, auth principal, + domain and delivery handlers, web package doc comment). + +- panel (templates): the repeated Host/Type/Value DNS record markup on a + domain's page and the duplicated credentials form on Settings are now + shared partials (`host_type`, `host_type_copy`, `field_value`, + `field_values`, `credentials_fields`) instead of copy-pasted blocks. No + behaviour or visible change. + +- panel (GUI, accessibility): the Deliveries fragment's `hx-get` and pagination + links now `urlquery`-encode the `domain`/`app` filters instead of splicing + them into the query string raw. The four polled regions (deliveries rows, + status, mail queue, system log) carry `aria-live="polite"` so a screen + reader announces the refreshed content. + +- docs: [security.md](docs/security.md) accepted risks now note that + `data-confirm` prompts on destructive forms are JavaScript-only — with + JavaScript disabled the form submits immediately, the same as before the + prompts existed — and why that is acceptable (the prompt is a mis-click + guard, not an authorization boundary). + +- docs: security and operator docs updated for the panel that has shipped + global administrators and domain-admins since 1.2.0. The CSRF ADR in + [security.md](docs/security.md) no longer argues from "single-user"; it now + states that cross-user CSRF between panel roles is not the threat the origin + check defends against, and gives a new revisit trigger. Dropped the + unimplemented "or argon2" alternative for the password hash. + [guide.md](docs/guide.md) documents the Users page and the two roles, + level-2 rate limiting's fail-open behaviour, and that a domain-admin can + export working SASL passwords for domains assigned to them. + [architecture.md](docs/architecture.md) gains `/license` and the + `/account` → `/settings` redirect in the route table (later expanded for + 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 + actual home after migration `0005`. No behaviour change. + +- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is + gone (handlers already call `requireGlobal`); the settings route handler is + named `HandleSettings` in `handlers_settings.go`; domain lists for a + domain-admin now come from `ListDomainsForUser` in SQL instead of loading + every domain and filtering in Go; the login and setup rate limiters sweep + expired buckets on a timer and cap the map at 4096 keys; the five + show/hide field helpers in `panel.js` are one rule table; DMARC copy no + longer promises in-panel report reception in a future release — SelfPost + does not receive inbound mail. + ## [1.2.5] - 2026-08-13 Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration. diff --git a/NOTICE b/NOTICE index ca4c34c..a4d7e16 100644 --- a/NOTICE +++ b/NOTICE @@ -10,8 +10,8 @@ Corresponding Source for the published releases is at: If you modify SelfPost and let users interact with it over a network, AGPL-3.0 §13 requires you to offer them the Corresponding Source of -your modified version. Update the Source link in the panel footer -(internal/web/view/templates/layout.html) so it points at your sources. +your modified version. Update SourceURL in internal/legal/legal.go +so the panel footer points at your sources. Third-party software included in this distribution ---------------------------------------------------- @@ -24,6 +24,9 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js) IBM Plex Sans / IBM Plex Mono Copyright © 2017 IBM Corp., with Reserved Font Name "Plex" Licence: SIL Open Font License 1.1 + Full text: internal/web/view/static/OFL.txt + (also /usr/share/doc/selfpost/OFL.txt in the image, and + /static/OFL.txt from the control panel) https://github.com/IBM/plex Distributed unmodified as three latin-subset WOFF2 files served by the control panel: diff --git a/README.md b/README.md index ab632da..1b91301 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ send log and DNS checks in the panel, encrypted backups. - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Full-server backup and single-domain export/import (optional password encryption) -- Single Docker image; data in a `./data` bind mount +- Single Docker image; production data in a `./data` bind mount (the quick start below uses a named Docker volume instead) ## Documentation @@ -76,14 +76,15 @@ For every domain you add in the panel: - [ ] DKIM TXT record (value shown on the domain page) - [ ] DMARC `_dmarc` TXT record -See [DNS setup](docs/guide.md#dns-setup) in the operator guide. +See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide. ## Quick start > **First boot — create the admin account.** On a fresh container SelfPost prints > a **one-time setup URL** (valid ten minutes). Open it in a browser to choose > the administrator username and password. Until you do, the panel has no login. -> Production deploy: [step 3](#3-start-selfpost). +> Production deploy: [Full deployment](docs/guide.md#full-deployment) in the +> operator guide. One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS files, no compose files. Good for clicking through the UI on your machine; @@ -95,7 +96,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.2.5 + ghcr.io/mixeme/selfpost:1.3.1 ``` **Get the setup URL** (pick one): @@ -129,136 +130,17 @@ TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached only through a reverse proxy on 443 — port 8080 is bound to localhost in the default compose file. -| Artefact | Path | -|---|---| -| Compose file (fixed image tag) | [deploy/docker-compose.yml](deploy/docker-compose.yml) | -| Environment template | [deploy/.env.example](deploy/.env.example) | -| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) | -| nginx | [deploy/nginx/](deploy/nginx/) | -| Caddy | [deploy/caddy/](deploy/caddy/) | -| Traefik | [deploy/traefik/](deploy/traefik/) | +Full walkthrough — fetching the base files, setting up a reverse proxy and +TLS (Apache/nginx/Caddy/Traefik), starting the container, and wiring up +DNS — lives in the operator guide's [Full +deployment](docs/guide.md#full-deployment) section, with proxy-specific +commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). -### 1. Fetch the base files - -```sh -mkdir -p selfpost/data selfpost/certs && cd selfpost -curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml -curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example -cp .env.example .env -``` - -Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare -FQDN, e.g. `mail.example.com`). It must match the PTR record you request from -your provider and the certificate your proxy will obtain. - -### 2. Reverse proxy and TLS - -Pick one proxy. In every case the proxy terminates HTTPS for the panel; the -same certificate must end up under `./certs` as `fullchain.pem` and -`privkey.pem` so Postfix can serve it on 465 (and 587 if enabled). The proxy -must **pass the original `Host` header** — details and rationale: -[Reverse proxy](docs/guide.md#reverse-proxy-mandatory). - -**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and -`proxy_http` enabled. Copy -[deploy/apache/selfpost-vhost.conf](deploy/apache/selfpost-vhost.conf) into your -vhost directory, replace `mail.example.com` with your hostname, enable the site, -then issue a certificate: - -```sh -sudo certbot --apache -d mail.example.com -``` - -Point `./certs` at the PEM files certbot wrote (symlink is fine): - -```sh -ln -s /etc/letsencrypt/live/mail.example.com certs -``` - -**nginx (containerised).** From the `deploy/` directory, merge the nginx -fragment and issue the first certificate before nginx can serve HTTPS: - -```sh -docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \ - run --rm certbot certonly --webroot -w /var/www/certbot \ - -d mail.example.com --email you@example.com --agree-tos --no-eff-email - -docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d -``` - -Edit [deploy/nginx/nginx.conf.example](deploy/nginx/nginx.conf.example) and -replace `mail.example.com` first. The fragment bind-mounts certbot's output into -both nginx and SelfPost. - -**Caddy (containerised, automatic ACME).** Edit -[deploy/caddy/Caddyfile](deploy/caddy/Caddyfile) and the `` placeholders -in [deploy/caddy/docker-compose.caddy.yml](deploy/caddy/docker-compose.caddy.yml), -then: - -```sh -docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d -``` - -Verify Caddy's on-disk cert path for your version before relying on the -default mount — see the comment at the top of the Caddy compose fragment. - -**Traefik (containerised).** Edit the `Host(...)` label and ACME email in -[deploy/traefik/docker-compose.traefik.yml](deploy/traefik/docker-compose.traefik.yml), -start the stack, then extract PEM files for Postfix whenever Traefik issues or -renews a certificate: - -```sh -docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d -./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs -``` - -Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals. - -### 3. Start SelfPost - -If you used Apache on the host (step 2, first option), start only the base -compose file from your `selfpost/` directory: - -```sh -docker compose up -d -``` - -The nginx/Caddy/Traefik fragments from step 2 already include `docker compose up --d` — skip this if you ran one of those. - -**Get the setup URL** — open it in a browser to create the admin account -([first boot](#quick-start)): - -```sh -docker compose logs selfpost 2>&1 | grep -m1 'http' -``` - -```sh -cat ./data/setup-token -``` - -The file is deleted as soon as setup completes. If logs are shipped to a -central aggregator, prefer `cat ./data/setup-token` so the bearer token does -not enter the log pipeline. - -### 4. DNS and sending - -Before sending real mail: - -1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status - page → *Re-check*). -2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the - same time ([DNS setup](docs/guide.md#dns-setup)). -3. Warm up a new IP gradually ([IP warmup](docs/guide.md#ip-warmup)). - -### Ports and upgrades - -The compose file maps **465** (always) and **587** (when -`SUBMISSION_ENABLE=true`). Bump the pinned image tag deliberately when -upgrading — never use `:latest` ([why](docs/guide.md#fixed-image-tag)). - -Optional variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see -[Environment variables](docs/guide.md#environment-variables). +The compose file always publishes **465** and **587**; Postfix listens on 587 +only when `SUBMISSION_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). ## License diff --git a/build/Dockerfile b/build/Dockerfile index 6215816..6173b5f 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -87,12 +87,16 @@ COPY --from=build /out/panel /usr/local/bin/panel COPY --from=build /out/selfpost-backup /usr/local/bin/selfpost-backup # Licence text shipped with the image (AGPL-3.0 conveyance). The panel also -# serves the same text at /license from an embedded copy. +# serves the same text at /license from an embedded copy. OFL.txt is the SIL +# Open Font License 1.1 that must travel with the IBM Plex WOFF2 files +# embedded in the panel binary (NOTICE). COPY --chmod=0644 LICENSE NOTICE /usr/share/doc/selfpost/ +COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.txt # File modes are pinned here so a build context copied from a checkout without # POSIX permissions (e.g. Windows tar sync) cannot land group-writable config -# that logrotate would silently ignore — see docs/plans/logrotate-mode.md. +# that logrotate would silently ignore — see docs/development.md § Building +# binaries and the image. COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh diff --git a/build/entrypoint.sh b/build/entrypoint.sh index c24179f..eb5674e 100644 --- a/build/entrypoint.sh +++ b/build/entrypoint.sh @@ -69,7 +69,7 @@ chmod 755 /data # later phase deliberately hands to another service. /data/log is exempt: it is # deliberately owned by postfix (postlogd writes the delivery log there) and is # normalised on its own below. -find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} + +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 # the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the @@ -99,13 +99,13 @@ chown -R panel:selfpost /data/sasl chmod 2750 /data/sasl [ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 -# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it. -# Ensure the file exists (empty is fine) before Postfix starts so a reload that -# references it never fails on a missing file, and keep it group-readable. -mkdir -p /data/postfix +# 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/. +mkdir -p /data/postfix/queue [ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps -chown -R panel:selfpost /data/postfix +chown panel:selfpost /data/postfix chmod 2750 /data/postfix +chown panel:selfpost /data/postfix/sender_login_maps chmod 0640 /data/postfix/sender_login_maps # Delivery log (architecture.md § Log tailer). postlogd writes it as user @@ -144,4 +144,11 @@ chmod 2750 /run/opendkim /run/selfpost # start the same way the /data normalisation above is. /usr/local/bin/postfix-config.sh +# Initialise the persistent queue tree on first start or after restore. postfix +# set-permissions reads queue_directory from main.cf (set by postfix-config.sh). +if [ ! -d /data/postfix/queue/active ]; then + postfix set-permissions +fi +chown -R postfix:postfix /data/postfix/queue + exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf diff --git a/build/postfix-config.sh b/build/postfix-config.sh index a595c96..d596015 100644 --- a/build/postfix-config.sh +++ b/build/postfix-config.sh @@ -57,6 +57,9 @@ 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. 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 @@ -128,7 +131,8 @@ postconf -e \ # 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}" + "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 diff --git a/cmd/panel/envdoc_test.go b/cmd/panel/envdoc_test.go index 2cb1b6a..edccb8c 100644 --- a/cmd/panel/envdoc_test.go +++ b/cmd/panel/envdoc_test.go @@ -17,7 +17,7 @@ var documentedPublic = []string{ "TRUSTED_PROXY_CIDR", } -// documentedInternal matches guide.md "Internal variables (not part of the operator interface)". +// documentedInternal matches architecture.md § Configuration "Internal env vars". var documentedInternal = []string{ "SELFPOST_DATA_DIR", "SELFPOST_DB_PATH", @@ -33,6 +33,8 @@ var documentedInternal = []string{ "SASL_REALM", "POSTFIX_DIR", "POSTFIX_SENDER_LOGIN_MAPS", + "POSTFIX_QUEUE_DIR", + "SELFPOST_DEPLOY_ROOT", "MILTER_CONNECT_TIMEOUT", "MILTER_COMMAND_TIMEOUT", "MILTER_CONTENT_TIMEOUT", @@ -70,6 +72,7 @@ var loadConfigKeys = []string{ "SASL_DB_PATH", "SASL_REALM", "POSTFIX_DIR", + "SELFPOST_DEPLOY_ROOT", } // buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh @@ -84,6 +87,7 @@ var buildScriptKeys = []string{ "JOURNAL_MILTER_SOCKET", "MAIL_LOG", "POSTFIX_SENDER_LOGIN_MAPS", + "POSTFIX_QUEUE_DIR", "SASL_DB_PATH", "SUBMISSION_ENABLE", "MILTER_CONNECT_TIMEOUT", diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 5c3af6b..7c44b49 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "log" "net/http" "time" @@ -15,23 +16,68 @@ import ( "github.com/mixeme/selfpost/internal/web" ) -// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using -// the database handle shared by all roles: setup, login and the authenticated -// panel surface (security.md). -func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { - // Applications own the SASL accounts and the Postfix sender map; the domain - // service delegates to them when a domain (and its applications) is deleted. - pf := postfix.New(cfg.postfixDir) - apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) - domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef) +// mailStack is the panel's domain and application services plus the on-disk +// mail-path adapters they write through. +type mailStack struct { + Domains *domain.Service + Apps *app.Service + pf *postfix.Postfix + odk *domain.OpenDKIM +} - srvApp, err := web.New(st, domains, apps, web.Config{ +func newMailStack(cfg config, st *store.Store) *mailStack { + pf := postfix.New(cfg.postfixDir) + odk := domain.NewOpenDKIM(cfg.opendkimDir) + apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) + domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) + return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk} +} + +// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and +// reloads both daemons — the same work as the Status page's Reload button. +func (m *mailStack) Resync() error { + if err := m.Domains.Resync(); err != nil { + return fmt.Errorf("opendkim resync: %w", err) + } + if err := m.Apps.Resync(); err != nil { + return fmt.Errorf("postfix resync: %w", err) + } + return nil +} + +func (m *mailStack) skipReloadForTest() { + m.pf.SetReloadHook(func() error { return nil }) + m.odk.SetReloadHook(func() error { return nil }) +} + +// resyncAfterRestore runs one mail-path Resync on the first boot after a +// backup restore. testNoReload skips the supervisord reload step so restore +// tests can verify file regeneration without a running mail stack. +func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error { + ms := newMailStack(cfg, st) + if testNoReload { + ms.skipReloadForTest() + } + return ms.Resync() +} + +// newPanel wires the panel's services over the shared database handle and +// builds the HTTP application from cfg. It is the composition of the panel as +// the environment describes it, with nothing bound to a port yet. +func newPanel(cfg config, st *store.Store) (*web.Server, error) { + ms := newMailStack(cfg, st) + // postfix-config.sh has already run (entrypoint). postconf -h is the + // effective config, including a manual override; the panel keeps this + // snapshot for the process lifetime (architecture.md). + retryPolicy := postfix.LoadRetryPolicy() + return web.New(st, ms.Domains, ms.Apps, web.Config{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, SubmissionEnabled: cfg.submissionEnabled, MailLogPath: cfg.mailLog, DataDir: cfg.dataDir, DBPath: cfg.dbPath, + DeployRoot: cfg.deployRoot, Version: buildinfo.Version, TrustedProxyCIDRs: cfg.trustedProxies, TLSCertFile: cfg.tlsCertFile, @@ -41,7 +87,15 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { DNSResolvers: cfg.dnsResolvers, RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, + RetryPolicy: retryPolicy, }, cfg.setupTokenPath) +} + +// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using +// the database handle shared by all roles: setup, login and the authenticated +// panel surface (security.md). +func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { + srvApp, err := newPanel(cfg, st) if err != nil { return err } diff --git a/cmd/panel/main.go b/cmd/panel/main.go index d553466..065670a 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -4,7 +4,6 @@ // the rate-limit checks. // // Copyright (C) 2026 Mikhail Yenuchenko -// SPDX-License-Identifier: AGPL-3.0-only package main import ( @@ -53,16 +52,16 @@ type config struct { mailLog string retentionDays int - dataDir string - dbPath string - manifestPath string - setupTokenPath string - hostname string - cookieSecure bool - submissionEnabled bool - trustedProxies []*net.IPNet - sessionIdleDays int - dnsResolvers []string + dataDir string + dbPath string + manifestPath string + setupTokenPath string + hostname string + cookieSecure bool + submissionEnabled bool + trustedProxies []*net.IPNet + sessionIdleDays int + dnsResolvers []string rateLimitMessagesPerIP int rateLimitWindowSeconds int @@ -79,6 +78,7 @@ type config struct { saslDBPath string saslRealm string postfixDir string + deployRoot string } func loadConfig() config { @@ -139,6 +139,7 @@ func loadConfig() config { saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")), saslRealm: saslRealm(), postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), + deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), } } @@ -223,7 +224,8 @@ func run() error { // touch the database, so schema/format skew between versions cannot corrupt // the restored state. A match consumes the manifest; its absence is the // normal (non-restore) case. - if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil { + restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) + if err != nil { return err } @@ -236,6 +238,13 @@ func run() error { } defer st.Close() + if restored { + log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite") + if err := resyncAfterRestore(cfg, st, false); err != nil { + return err + } + } + var wg sync.WaitGroup errc := make(chan error, 3) diff --git a/cmd/panel/restore_test.go b/cmd/panel/restore_test.go new file mode 100644 index 0000000..5575d1b --- /dev/null +++ b/cmd/panel/restore_test.go @@ -0,0 +1,490 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mixeme/selfpost/internal/backup" + "github.com/mixeme/selfpost/internal/buildinfo" + "github.com/mixeme/selfpost/internal/secretfile" + "github.com/mixeme/selfpost/internal/store" + "golang.org/x/crypto/bcrypt" +) + +// Restoring a SelfPost backup is not a code path in the panel: the operator +// extracts the archive into a project directory and starts the image, and the +// panel is expected to come up on it (architecture.md § Persistence). Nothing +// below stubs that story out — the archive is downloaded from a running panel +// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel +// is started on the result through the same startup sequence run() uses: +// CheckRestore, store.Open, newPanel, Start. + +const ( + restorePassword = "correct-horse-battery" + restoreDomain = "bs.example.ru" + restoreSubject = "Order confirmation" +) + +// restored is the outcome of a full backup-and-restore round trip. +type restored struct { + panel http.Handler // panel booted on the restored data directory + deployRoot string // the restored project directory + dataDir string // the restored /data + session *http.Cookie // a session opened before the backup was taken +} + +// restoreFromOwnBackup runs the operator's path end to end: seed a panel that +// has been in use, sign in, download a backup from it, extract that archive +// into an empty directory and boot a second panel there. A non-empty password +// takes the encrypted download and decrypts it on the way in, which is what an +// operator does with a .spbk file. +func restoreFromOwnBackup(t *testing.T, password string) restored { + t.Helper() + + live := seedPanelProject(t) + panel := bootPanel(t, live) + session := signIn(t, panel) + archive := downloadBackup(t, panel, session, password) + + target := t.TempDir() + extract(t, archive, target) + + return restored{ + panel: bootPanel(t, target), + deployRoot: target, + dataDir: filepath.Join(target, "data"), + session: session, + } +} + +// The panel has to come up on the restored directory and show the state that +// was in the archive, without the operator touching anything else: the domain +// and its journal are in the database the archive carried, and the credentials +// that worked before the restore still work after it. +func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) { + r := restoreFromOwnBackup(t, "") + + body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel)) + for _, want := range []string{restoreDomain, restoreSubject} { + if !strings.Contains(body, want) { + t.Errorf("the restored panel's send log does not show %q:\n%s", want, body) + } + } + + // The archive carries the daemon files, and the first boot after restore + // re-derives the maps from SQLite so they stay aligned with the database. + for path, want := range map[string]string{ + filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", + filepath.Join("sasl", "sasldb2"): "SASLDB", + filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n", + } { + got, err := os.ReadFile(filepath.Join(r.dataDir, path)) + if err != nil { + t.Errorf("the restored data directory has no %s: %v", path, err) + continue + } + if string(got) != want { + t.Errorf("%s = %q, want %q", path, got, want) + } + } + + for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} { + if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil { + t.Errorf("the restored project directory has no %s: %v", name, err) + } + } +} + +// The one-time setup link is closed by the presence of a panel user, and the +// restored database has one. A restore that reopened it would publish a link +// that creates a second global administrator on a server holding live mail +// credentials (security.md). +func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) { + r := restoreFromOwnBackup(t, "") + + if _, err := os.Stat(filepath.Join(r.dataDir, "setup-token")); !os.IsNotExist(err) { + t.Errorf("the restored panel wrote a setup token (stat err = %v)", err) + } + body := getPage(t, r.panel, "/login", nil) + if strings.Contains(body, "No administrator has been created yet") { + t.Errorf("the restored panel offers first-run setup:\n%s", body) + } +} + +// Sessions live in the database, so they travel in the archive: a cookie that +// was valid when the backup was taken is valid again on the restored panel. +// That is the documented consequence of restoring an older backup (guide § +// Backup and restore) — stated here so it cannot change by accident. +func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) { + r := restoreFromOwnBackup(t, "") + + rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session) + if rec.Code != http.StatusOK { + t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code) + } +} + +// An encrypted download is the same archive inside an envelope, so it restores +// the same way once the password is supplied. The archive is never written to +// disk in the clear by the panel, so this is the only place the two paths can +// be shown to agree. +func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) { + r := restoreFromOwnBackup(t, "a-long-enough-password") + + body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel)) + if !strings.Contains(body, restoreSubject) { + t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body) + } +} + +// A restore boot runs one Resync from SQLite. If the archive's Postfix map +// drifted from the database, that step puts it back before mail flows. +func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) { + deployRoot := seedPanelProject(t) + dataDir := filepath.Join(deployRoot, "data") + cfg := panelConfig(t, deployRoot) + + mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps") + if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil { + t.Fatalf("write stale map: %v", err) + } + + manifest, err := json.Marshal(backup.Manifest{ + Format: backup.FormatFull, + Version: buildinfo.Version, + CreatedAt: "2026-08-14T00:00:00Z", + }) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(cfg.manifestPath, manifest, 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + + restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) + if err != nil { + t.Fatalf("CheckRestore: %v", err) + } + if !restored { + t.Fatal("CheckRestore did not report a restore") + } + + st, err := store.Open(cfg.dbPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer st.Close() + + if err := resyncAfterRestore(cfg, st, true); err != nil { + t.Fatalf("resync after restore: %v", err) + } + + got, err := os.ReadFile(mapPath) + if err != nil { + t.Fatalf("read sender map: %v", err) + } + want := "@" + restoreDomain + " shop\n" + if string(got) != want { + t.Errorf("sender map = %q, want %q", got, want) + } +} + +// The version guard is what stops a restore from being silently corrupted by +// schema skew, and it runs before anything opens the database. The manifest +// stays put on a mismatch: the operator's next move is to start the image the +// backup names, and it has to be there when they do. +func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) { + deployRoot := seedPanelProject(t) + dataDir := filepath.Join(deployRoot, "data") + var archive bytes.Buffer + if err := backup.Create(&archive, backup.Params{ + DataDir: dataDir, + DBPath: filepath.Join(dataDir, "selfpost.db"), + Version: "9.9.9", + DeployRoot: deployRoot, + }); err != nil { + t.Fatalf("create backup: %v", err) + } + target := t.TempDir() + extract(t, archive.Bytes(), target) + + cfg := panelConfig(t, target) + _, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) + if err == nil { + t.Fatal("the panel booted on a data directory left by another version") + } + for _, want := range []string{"9.9.9", buildinfo.Version} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not name %q, so the operator cannot tell which image to run: %v", want, err) + } + } + if _, statErr := os.Stat(cfg.manifestPath); statErr != nil { + t.Errorf("the manifest was consumed by a refused restore: %v", statErr) + } +} + +// seedPanelProject builds an operator project tree: data/ with a panel that has +// been in use, plus docker-compose.yml, .env, and certs/ for full backups. +func seedPanelProject(t *testing.T) string { + t.Helper() + deployRoot := t.TempDir() + dataDir := filepath.Join(deployRoot, "data") + if err := os.MkdirAll(dataDir, 0o750); err != nil { + t.Fatalf("mkdir data: %v", err) + } + + st, err := store.Open(filepath.Join(dataDir, "selfpost.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + hash, err := bcrypt.GenerateFromPassword([]byte(restorePassword), bcrypt.MinCost) + if err != nil { + t.Fatalf("hash password: %v", err) + } + if err := st.CreateGlobalUser("admin", string(hash)); err != nil { + t.Fatalf("create user: %v", err) + } + dom, err := st.AddDomain(restoreDomain, "selfpost") + if err != nil { + t.Fatalf("add domain: %v", err) + } + if _, err := st.AddApplication(dom.ID, "shop", store.AddressModeWildcard, nil); err != nil { + t.Fatalf("add application: %v", err) + } + if err := st.InsertQueued(store.SendLogEntry{ + QueueID: "4A1B2C3D", Domain: restoreDomain, AppLogin: "shop", + From: "noreply@" + restoreDomain, To: "customer@example.net", Subject: restoreSubject, + }); err != nil { + t.Fatalf("insert send-log row: %v", err) + } + if err := st.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + for path, content := range map[string]string{ + filepath.Join("opendkim", "keys", restoreDomain, "selfpost.private"): "PRIVATE KEY", + filepath.Join("sasl", "sasldb2"): "SASLDB", + filepath.Join("postfix", "sender_login_maps"): "@" + restoreDomain + " shop\n", + filepath.Join("log", "mail.log"): "postfix/smtp[1]: 4A1B2C3D: status=sent", + } { + full := filepath.Join(dataDir, path) + if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", full, err) + } + if err := os.WriteFile(full, []byte(content), 0o640); err != nil { + t.Fatalf("write %s: %v", full, err) + } + } + + writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n") + writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n") + writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT") + writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY") + return deployRoot +} + +func writeDeployFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o640); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// panelConfig resolves the panel's own configuration for a project directory, so +// the test finds the files where the running binary would look for them rather +// than where it put them. Cookies are marked insecure for the same reason the +// e2e stand does it: the test client speaks plain HTTP. +func panelConfig(t *testing.T, deployRoot string) config { + t.Helper() + dataDir := filepath.Join(deployRoot, "data") + t.Setenv("SELFPOST_DATA_DIR", dataDir) + t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot) + t.Setenv("PANEL_COOKIE_SECURE", "false") + t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru") + // MAIL_LOG's default is an absolute path, not one derived from the data + // directory; without this the panel would read the host's /data. + t.Setenv("MAIL_LOG", filepath.Join(dataDir, "log", "mail.log")) + return loadConfig() +} + +// bootPanel performs the startup sequence run() performs, in the same order, +// and returns the panel's HTTP handler. +func bootPanel(t *testing.T, deployRoot string) http.Handler { + t.Helper() + cfg := panelConfig(t, deployRoot) + + restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) + if err != nil { + t.Fatalf("the panel refused to start on %s: %v", deployRoot, err) + } + if restored { + if _, err := os.Stat(cfg.manifestPath); err == nil { + t.Errorf("the restore manifest was not consumed, so the next start is gated by it too") + } + } + + st, err := store.Open(cfg.dbPath) + if err != nil { + t.Fatalf("open the restored database: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + if restored { + if err := resyncAfterRestore(cfg, st, true); err != nil { + t.Fatalf("resync after restore: %v", err) + } + } + + panel, err := newPanel(cfg, st) + if err != nil { + t.Fatalf("build the panel: %v", err) + } + if err := panel.Start(); err != nil { + t.Fatalf("start the panel: %v", err) + } + return panel.Handler() +} + +// signIn signs in as the seeded administrator and returns the session cookie. +func signIn(t *testing.T, h http.Handler) *http.Cookie { + t.Helper() + form := url.Values{"username": {"admin"}, "password": {restorePassword}} + rec := request(t, h, http.MethodPost, "/login", strings.NewReader(form.Encode()), nil) + if rec.Code != http.StatusSeeOther { + t.Fatalf("sign in = %d, want 303:\n%s", rec.Code, rec.Body.String()) + } + cookies := rec.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("sign in issued no session cookie") + } + return cookies[0] +} + +// downloadBackup takes a backup through the panel's own /backup route, the way +// the operator does. An empty password downloads the plain archive; otherwise +// the response is a .spbk envelope, which is decrypted here. +func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password string) []byte { + t.Helper() + form := url.Values{} + if password != "" { + form.Set("encrypt", "1") + form.Set("password", password) + form.Set("password_confirm", password) + } + rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session) + if rec.Code != http.StatusOK { + t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q; an archive of every secret on the server must not be cached", got) + } + + body := rec.Body.Bytes() + if password == "" { + if secretfile.HasMagic(body) { + t.Fatal("an unencrypted download came back as an envelope") + } + return body + } + + if !secretfile.HasMagic(body) { + t.Fatal("the download is not an encrypted envelope, so the archive left the panel in the clear") + } + r, err := secretfile.NewReader(bytes.NewReader(body), password) + if err != nil { + t.Fatalf("open the encrypted backup: %v", err) + } + plain, err := io.ReadAll(r) + if err != nil { + t.Fatalf("decrypt the backup: %v", err) + } + return plain +} + +// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project +// directory before the image is started. +func extract(t *testing.T, archive []byte, dir string) { + t.Helper() + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("the download is not a gzip stream: %v", err) + } + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("read the archive: %v", err) + } + name := filepath.Clean(filepath.FromSlash(hdr.Name)) + if strings.HasPrefix(name, "..") || filepath.IsAbs(name) { + t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name) + } + path := filepath.Join(dir, name) + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, hdr.FileInfo().Mode().Perm()); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode().Perm()) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + t.Fatalf("write %s: %v", path, err) + } + if err := f.Close(); err != nil { + t.Fatalf("close %s: %v", path, err) + } + } + } +} + +// getPage performs a GET and returns the body, failing on any non-200. +func getPage(t *testing.T, h http.Handler, target string, session *http.Cookie) string { + t.Helper() + rec := request(t, h, http.MethodGet, target, nil, session) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String()) + } + return rec.Body.String() +} + +// request drives the panel's real handler chain, including the origin check, +// with the headers a browser on the panel's own page would send. +func request(t *testing.T, h http.Handler, method, target string, body io.Reader, session *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, "http://mail.example.ru"+target, body) + req.Host = "mail.example.ru" + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Sec-Fetch-Site", "same-origin") + } + if session != nil { + req.AddCookie(session) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} diff --git a/cmd/selfpost-backup/main.go b/cmd/selfpost-backup/main.go index 7231e40..6e81d3c 100644 --- a/cmd/selfpost-backup/main.go +++ b/cmd/selfpost-backup/main.go @@ -6,9 +6,9 @@ // // docker exec selfpost-backup > selfpost-backup.tar.gz // -// Use -o to write to a file instead. The resulting archive contains DKIM private -// keys, the admin password hash and SASL credentials — treat it as a secret -// (architecture.md § Persistence). +// Use -o to write to a file instead. The resulting archive is self-contained: +// data/, docker-compose.yml, .env, and certs/ when present — treat it as a +// secret (architecture.md § Persistence). // // Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an // argument, which would show up in the process list) the archive is written as @@ -18,7 +18,6 @@ // docker exec -i selfpost-backup -decrypt < backup.spbk > backup.tar.gz // // Copyright (C) 2026 Mikhail Yenuchenko -// SPDX-License-Identifier: AGPL-3.0-only package main import ( @@ -88,9 +87,13 @@ func run(outPath, password string) error { } if err := backup.Create(sink, backup.Params{ - DataDir: dataDir, - DBPath: dbPath, - Version: buildinfo.Version, + DataDir: dataDir, + DBPath: dbPath, + Version: buildinfo.Version, + DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), + OnWarn: func(msg string) { + fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg) + }, }); err != nil { return err } diff --git a/cmd/selfpost-backup/main_test.go b/cmd/selfpost-backup/main_test.go index f6c2cf7..6d9e56c 100644 --- a/cmd/selfpost-backup/main_test.go +++ b/cmd/selfpost-backup/main_test.go @@ -9,13 +9,18 @@ import ( "strings" "testing" + "github.com/mixeme/selfpost/internal/backup" "github.com/mixeme/selfpost/internal/store" ) -// seedDataDir builds the minimum /data tree a backup can be taken from. -func seedDataDir(t *testing.T) string { +// seedProject builds the minimum operator project tree a backup can be taken from. +func seedProject(t *testing.T) (deployRoot, dataDir string) { t.Helper() - dataDir := t.TempDir() + deployRoot = t.TempDir() + dataDir = filepath.Join(deployRoot, "data") + if err := os.MkdirAll(dataDir, 0o750); err != nil { + t.Fatalf("mkdir data: %v", err) + } st, err := store.Open(filepath.Join(dataDir, "selfpost.db")) if err != nil { t.Fatalf("open store: %v", err) @@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string { if err := st.Close(); err != nil { t.Fatalf("close store: %v", err) } + writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n") + writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n") + writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT") + writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY") t.Setenv("SELFPOST_DATA_DIR", dataDir) t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")) - return dataDir + t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot) + return deployRoot, dataDir +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o640); err != nil { + t.Fatalf("write %s: %v", path, err) + } } // An encrypted backup is only worth having if the container it came from can // hand it back as an ordinary archive during a restore, so the two halves of // the CLI are tested as the one round trip an operator actually performs. func TestEncryptedBackupRoundTrip(t *testing.T) { - seedDataDir(t) + seedProject(t) dir := t.TempDir() encrypted := filepath.Join(dir, "backup.spbk") plain := filepath.Join(dir, "backup.tar.gz") @@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) { } names[hdr.Name] = true } - for _, want := range []string{"manifest.json", "selfpost.db"} { + for _, want := range []string{ + backup.DataArchivePrefix + backup.ManifestName, + backup.DataArchivePrefix + "selfpost.db", + backup.ComposeFileName, + backup.EnvFileName, + } { if !names[want] { t.Errorf("decrypted archive has no %s (entries: %v)", want, names) } @@ -91,7 +116,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) { // Without a password the CLI keeps producing the plain archive that existing // backup scripts consume. func TestUnencryptedBackupStaysPlain(t *testing.T) { - seedDataDir(t) + seedProject(t) out := filepath.Join(t.TempDir(), "backup.tar.gz") if err := run(out, ""); err != nil { t.Fatalf("create backup: %v", err) diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml index 50c8088..fd0b5ed 100644 --- a/deploy/caddy/docker-compose.caddy.yml +++ b/deploy/caddy/docker-compose.caddy.yml @@ -31,6 +31,7 @@ services: TLS_KEY_FILE: /etc/postfix/tls/.key volumes: !override - ./data:/data + - .:/selfpost-deploy:ro # Replace with your actual mail/panel hostname, matching # the Caddyfile below and SELFPOST_HOSTNAME. - ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/:/etc/postfix/tls:ro diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1209160..71a855f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -22,7 +22,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:1.2.5 + image: ghcr.io/mixeme/selfpost:1.3.1 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" @@ -58,6 +58,9 @@ services: # 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. diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml index 83e7892..bb57d86 100644 --- a/deploy/nginx/docker-compose.nginx.yml +++ b/deploy/nginx/docker-compose.nginx.yml @@ -27,6 +27,7 @@ services: # network at selfpost:8080 instead (see nginx.conf.example). 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). diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml index 79edc9c..fa4de14 100644 --- a/deploy/traefik/docker-compose.traefik.yml +++ b/deploy/traefik/docker-compose.traefik.yml @@ -17,6 +17,7 @@ services: - "587:587" volumes: !override - ./data:/data + - .:/selfpost-deploy:ro # Populated by extract-cert.sh from traefik's acme.json — see below. - ./traefik/extracted-certs:/etc/postfix/tls:ro labels: diff --git a/docs/architecture.md b/docs/architecture.md index 315fb2a..daeeab6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,14 @@ by the panel. Socket `/run/opendkim/opendkim.sock`. One process, three roles: 1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse - proxy only. + proxy only. On start it runs `postconf -h` once for the deferred-mail retry + parameters (`queue_run_delay`, `minimal_backoff_time`, + `maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`, + `delay_warning_time`) and caches the snapshot on the handlers config. The + Mail queue card and a delivery's `deferred` / `bounced` history print those + numbers; they never call `postconf` per request. If `postconf` is missing, + the panel logs a warning and uses Postfix 3.x compiled-in defaults + (`300s` / `4000s` / `5d` / `0`) with a muted note on the card. 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 @@ -154,32 +161,39 @@ state for an older message and the page reports it as such, not as a failure. ## Panel HTTP surface Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated -unless noted. The table below is a summary — HTMX fragment endpoints +unless noted. Routes marked **global** return **404** for domain administrators +(`requireGlobal()` in +[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table +below is a summary — HTMX fragment endpoints (`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`, `/system-log/body`, …) and every POST variant live in `web.go`. | Route | Purpose | |---|---| | `/healthz` | Liveness (no auth) | +| `/license` | Embedded `LICENSE` text (no auth) | | `/setup/*` | One-time admin bootstrap | | `/login`, `/logout` | Session auth | -| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network | -| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits | -| `/domains/import` | Domain import (`POST`; form on the Backup page) | -| `/deliveries` | Send log with filters | -| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines | -| `/mail-queue` | Postfix queue view | -| `/system-log` | `mail.log` tail | -| `/reload` | Reload OpenDKIM + Postfix maps | -| `/backup` | Full backup download (page also hosts the import form) | -| `/settings` | Admin username/password and DMARC report address | -| `/users`, `/users/*` | Panel user CRUD (global admin only) | +| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) | +| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network | +| `/domains` | Domain list; `POST /domains` (add domain) is **global** | +| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** | +| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) | +| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | +| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) | +| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail | +| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps | +| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | +| `/settings` | Username/password for any user; DMARC report default is **global** only | +| `/users`, `/users/*` | **Global.** Panel user CRUD | HTMX polling refreshes monitoring fragments (5 s while the operator is active on the page, 30 s when the tab is visible but idle, none when hidden — scheduled in `panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend session idle timeout (only non-`HX-Request` GET and mutating requests count as -activity). +activity). The Mail queue retry-policy card is outside that fragment: it is the +start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)), +not a live re-read. ### Sessions @@ -192,13 +206,16 @@ holds the cookie works after process restart, redeploy, or full backup restore. absolute cap (regular use keeps the session alive indefinitely). - **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)). -- **Password change** — all other sessions are deleted; the current session stays - active ([internal/store/sessions.go](../internal/store/sessions.go), - [handlers_account.go](../internal/web/handlers/handlers_account.go)). +- **Password change on `/settings`** — changing your own password deletes + every other session for that user; the current session stays active + ([internal/store/sessions.go](../internal/store/sessions.go), + [handlers_settings.go](../internal/web/handlers/handlers_settings.go)). + A global administrator resetting another user's password on `/users` updates + the hash but does not delete that user's existing sessions. -Restoring an **older** backup also restores session rows: a session invalidated -after that backup was taken can become valid again if the browser still has the -cookie and idle timeout has not expired. +Restoring an **older** backup also restores session rows: a session removed +after that backup was taken can become valid again if the browser still holds +the cookie and the restored row's `expires_at` has not passed. --- @@ -283,19 +300,27 @@ single-connection trade-off that follows from it. | `opendkim/` | DKIM keys + tables | | `sasl/sasldb2` | Application SASL credentials | | `postfix/sender_login_maps` | Login → From binding | +| `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) | -Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue -(transit mail not migrated by design). +Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though +full backups also archive the operator's `./certs` PEM files when present. **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); `mail.log` via logrotate (14 rotated files, check every 6h, rename + `postfix reload` in `postrotate` — see § Log tailer above). -**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of -`/data` tree, minus `log/`, the setup token and any `tls/`; version check on -restore. Stopped-container `tar` of `./data` is safe (see guide). +**Restore:** panel button or `selfpost-backup` CLI — self-contained archive: +`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/` +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 and Postfix's sender map 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 +`tar` of `./data` alone remains possible for state-only copies (see guide). **Optional encryption** of the two secret-bearing downloads ([internal/secretfile](../internal/secretfile/secretfile.go)): password → @@ -317,5 +342,36 @@ origin check, no CSRF tokens) are documented there separately. ## Configuration -Public and internal env vars: [guide § Environment variables](guide.md#environment-variables). +Public env vars: [guide § Environment variables](guide.md#environment-variables). Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go). + +**Internal env vars.** The following are read by the panel or startup scripts +but are not part of the operator interface — not meant to be changed in a +normal deployment; documented here so an accidental override reads as +unsupported rather than as a missing doc: + +- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH` + (`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE` + (`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`), + `JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG` + (`/data/log/mail.log` — read by the panel and written by Postfix, so a change + here has to be matched in `build/postfix-config.sh`), + `PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET` + (`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`), + `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH` + (`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`), + `POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` + (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel + always writes `/sender_login_maps`, so overriding this env alone + desyncs the map Postfix reads from the file the panel maintains), + `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). +- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`), + `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), + `MILTER_WAIT_TIMEOUT` (`30` seconds). +- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily + `postfix reload` to pick up renewed certificates), + `LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six + hours; logrotate keeps 14 rotated files on a daily schedule, and each + rotation triggers `postfix reload`). diff --git a/docs/assets/panel-ui/app.html b/docs/assets/panel-ui/app.html new file mode 100644 index 0000000..23cb394 --- /dev/null +++ b/docs/assets/panel-ui/app.html @@ -0,0 +1,44 @@ + + + + + +SelfPost — panel mockups + + + + +

Макеты собраны отдельными страницами. Откройте Status или оглавление.

+ + diff --git a/docs/assets/panel-ui/backup.html b/docs/assets/panel-ui/backup.html new file mode 100644 index 0000000..f83e70b --- /dev/null +++ b/docs/assets/panel-ui/backup.html @@ -0,0 +1,43 @@ + + + + + +Backup & migration — SelfPost mockups + + + + + +
+

Backup & migration

+
+
+

Full backup

+

Download a self-contained backup — data/ (database, DKIM keys, credentials, Postfix queue), docker-compose.yml, .env, and certs/. Extract into an empty project directory on a new machine, adjust hostname if needed, and start the same SelfPost version before first boot. The reverse-proxy vhost is not included.

+

The backup file is a secret. Encrypting it is the simplest way to store it: the download is then a .spbk that only the password opens.

+
+ +
+ + +

Keep this password: without it the file cannot be opened.

+
+
+
+
+
+

Import a domain

+

Move a single domain here from another SelfPost instance — plain .json or encrypted .spde. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.

+ + + + +

Needed for a .spde file. Leave empty for plain .json.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/deliveries.html b/docs/assets/panel-ui/deliveries.html new file mode 100644 index 0000000..6004baa --- /dev/null +++ b/docs/assets/panel-ui/deliveries.html @@ -0,0 +1,91 @@ + + + + + +Deliveries — SelfPost mockups + + + + + +
+

Deliveries

+
+
+ +
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeFromToSubjectStatus
2026-08-15 20:14:02billing@example.comada@example.netInvoice #4412deferredDetails
2026-08-15 20:11:40news@example.comlist-bounces@example.netAugust digestdeliveredDetails
2026-08-15 19:02:11alerts@alerts.example.comnoreply@blocked.exampleDisk 92% on web-3bouncedDetails
2026-08-15 18:44:09news@example.comsam@example.orgAugust digestdeliveredDetails
+ +

Page 1 of 4 · Older →

+
+
+
+ + + diff --git a/docs/assets/panel-ui/delivery.html b/docs/assets/panel-ui/delivery.html new file mode 100644 index 0000000..16fef89 --- /dev/null +++ b/docs/assets/panel-ui/delivery.html @@ -0,0 +1,77 @@ + + + + + +Invoice #4412 — SelfPost mockups + + + + + +
+
+

Invoice #4412

+

+ billing@example.com + → + ada@example.net + deferred +

+ ← Back to deliveries +
+
+
+

Message

+
+
Domainexample.com
+
Applicationbilling
+
Accepted2026-08-15 20:14:02 UTC
+
Status reported2026-08-15 20:14:08 UTC
+
Queue id4C3A1E2F1A
+
Journal id1842
+
+
+
+

History

+
    +
  1. +

    2026-08-15 20:14:02 UTC

    +

    accepted Received by the relay

    +

    SASL login billing, queued as 4C3A1E2F1A.

    +
  2. +
  3. +

    2026-08-15 20:14:08 UTC

    +

    deferred Receiving MX asked to try later

    +

    Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.

    +
  4. +
  5. +

    not yet

    +

    delivery Waiting on the next retry

    +
  6. +
+
+
+
+
+

Delivery log

+ + + + + + + +
TimeMessage
20:14:02postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing
20:14:02postfix/cleanup[224]: 4C3A1E2F1A: message-id=<4412@example.com>
20:14:08postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)
+
+

20:14:08

+ status=deferred (450 4.2.1 mailbox busy) +

20:14:02

+ client=203.0.113.40, sasl_username=billing +
+
+
+
+ + + diff --git a/docs/assets/panel-ui/dmarc-domain.html b/docs/assets/panel-ui/dmarc-domain.html new file mode 100644 index 0000000..2c41e09 --- /dev/null +++ b/docs/assets/panel-ui/dmarc-domain.html @@ -0,0 +1,88 @@ + + + + + +example.com — DMARC — SelfPost mockups + + + + + +
+
+

example.com candidate

+ ← All DMARC reports + ← Back to example.com +
+
+
+

Last 7 days pass

+

98% aligned. Tightening p= looks reasonable.

+

Domain DNS and rua=

+
+
+

Third-party senders

+

google.com / 66.102.0.0/20 failed SPF and DKIM 6 times. Everyone else is this relay.

+
+
+
+
+

Reports

+ + + + + + + + + + + + + + + + + + + + +
ReceivedReporterWindowPassFail
2026-08-15 06:12google.com14 Aug4126View
2026-08-14 06:08google.com13 Aug3902View
+ +
+
+
+
+

Sources · last 7 days

+ + + + + + +
SourcePassFailDisposition
203.0.113.10 (this relay)8020none
google.com / 66.102.0.0/2008none
+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/dmarc-report-fail.html b/docs/assets/panel-ui/dmarc-report-fail.html new file mode 100644 index 0000000..ccfee61 --- /dev/null +++ b/docs/assets/panel-ui/dmarc-report-fail.html @@ -0,0 +1,88 @@ + + + + + +google.com report — alerts.example.com — SelfPost mockups + + + + + +
+
+

google.com · 14 Aug

+

+ alerts.example.com + 88 pass + 19 fail +

+ ← All DMARC reports +
+
+
+

Report

+
+
Reportergoogle.com
+
Report id3178944098765432109
+
Window2026-08-14 00:00 – 2026-08-15 00:00 UTC
+
Received2026-08-15 04:40 UTC
+
Contactnoreply-dmarc-support@google.com
+
+
+
+

Published policy

+
+
Domainalerts.example.com
+
p / sp / pctnone / none / 100
+
adkim / aspfr / r
+
ruadmarc@mail.example.org
+
+

A third-party source is not in SPF or DKIM. Do not tighten p= until that sender is gone or aligned.

+
+
+
+
+

Records

+ + + + + + + + + + + + + + + + + + + + +
SourceCountDispositionSPFDKIMHeader from
203.0.113.10 (this relay)88nonepasspassalerts.example.com
198.51.100.80 (unknown)19nonefailfailalerts.example.com
+
    +
  • +
    + this relay · 88pass + 203.0.113.10 · SPF pass · DKIM pass +
    +
  • +
  • +
    + unknown · 19fail + 198.51.100.80 · SPF fail · DKIM fail +
    +
  • +
+

Parsed from the aggregate XML. Forensic (ruf=) samples are not stored.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/dmarc-report.html b/docs/assets/panel-ui/dmarc-report.html new file mode 100644 index 0000000..fb376d7 --- /dev/null +++ b/docs/assets/panel-ui/dmarc-report.html @@ -0,0 +1,88 @@ + + + + + +google.com report — example.com — SelfPost mockups + + + + + +
+
+

google.com · 14 Aug

+

+ example.com + 412 pass + 6 fail +

+ ← Back to example.com +
+
+
+

Report

+
+
Reportergoogle.com
+
Report id3178944012345678901
+
Window2026-08-14 00:00 – 2026-08-15 00:00 UTC
+
Received2026-08-15 06:12 UTC
+
Contactnoreply-dmarc-support@google.com
+
+
+
+

Published policy

+
+
Domainexample.com
+
p / sp / pctnone / none / 100
+
adkim / aspfr / r
+
ruadmarc@mail.example.org
+
+

p=none does not affect delivery. Failures here are forwarding through Google, not this relay.

+
+
+
+
+

Records

+ + + + + + + + + + + + + + + + + + + + +
SourceCountDispositionSPFDKIMHeader from
203.0.113.10 (this relay)412nonepasspassexample.com
66.102.1.44 (google.com)6nonefailfailexample.com
+
    +
  • +
    + this relay · 412pass + 203.0.113.10 · SPF pass · DKIM pass +
    +
  • +
  • +
    + google.com · 6fail + 66.102.1.44 · SPF fail · DKIM fail +
    +
  • +
+

Parsed from the aggregate XML. Forensic (ruf=) samples are not stored.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/dmarc.html b/docs/assets/panel-ui/dmarc.html new file mode 100644 index 0000000..a7478b5 --- /dev/null +++ b/docs/assets/panel-ui/dmarc.html @@ -0,0 +1,91 @@ + + + + + +DMARC reports — SelfPost mockups + + + + + +
+
+

DMARC reports candidate

+
+

Aggregate reports SelfPost accepted for rua=. Forensic (ruf=) is out of scope. Open a report for the parsed XML — not a dashboard.

+
+
+

Ingest ok

+

Last report 6 hours ago. Port 25 accepts only the configured report address.

+
+
+

This week

+

14 kept, 0 parse failures. Older summaries are pruned.

+
+
+
+
+

Recent reports

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReceivedDomainReporterWindowPassFail
2026-08-15 06:12example.comgoogle.com14 Aug4126View
2026-08-15 04:40alerts.example.comgoogle.com14 Aug8819View
2026-08-14 06:08example.comgoogle.com13 Aug3902View
+ +

Each row is one aggregate XML SelfPost parsed. Domain names open the roll-up for that sending domain.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/domain-delete.html b/docs/assets/panel-ui/domain-delete.html new file mode 100644 index 0000000..1a08e1f --- /dev/null +++ b/docs/assets/panel-ui/domain-delete.html @@ -0,0 +1,33 @@ + + + + + +Delete example.com — SelfPost mockups + + + + + +
+
+

Delete example.com

+ ← Back to example.com +
+
+
+

Confirm deletion

+

You are about to delete example.com. This will:

+
    +
  • permanently delete its DKIM signing key;
  • +
  • delete all 2 bound applications, including their SASL credentials;
  • +
  • reload OpenDKIM so the domain is no longer signed.
  • +
+

This cannot be undone.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/domain.html b/docs/assets/panel-ui/domain.html new file mode 100644 index 0000000..3c75277 --- /dev/null +++ b/docs/assets/panel-ui/domain.html @@ -0,0 +1,260 @@ + + + + + +example.com — SelfPost mockups + + + + + +
+
+

example.com

+ ← All domains +
+
+
+
+

New application password

+ +
+

Shown once only and not stored. Copy it now.

+ +
newsletter
+ +
xK.9fQ2m-pL7wR
+
+
+
+
+
+

DNS status ok

+ +
+
+
+
+ +
+
+ + mail._domainkey.example.com +
+
+ + TXT +
+
+
+
+ +
+
+ + example.com +
+
+ + TXT +
+
+
+
+
+
+ +
+
+ + _dmarc.example.com +
+
+ + TXT +
+
+

p=none; rua points at SelfPost ingest.

+
+
+ +

Not required (rua= is on a domain SelfPost accepts).

+
+
+
+
+
+
+
+
+
+

DKIM and SPF records

+ +
+

DKIM

+
+
+ +
mail._domainkey.example.com
+
+
+ + TXT +
+
+ +
v=DKIM1; k=rsa; p=MIIBIjANBgkqh…
+

SPF

+
v=spf1 ip4:203.0.113.10 -all
+
+
+
+

DMARC record

+ +
+
+
+ +
_dmarc.example.com
+
+
+ + TXT +
+
+ +
v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org
+

Open DMARC reports for this domain.

+
+
+
+
+
+

Connection settings

+ +
+ +
mail.example.org
+ + 465 — SSL/TLS (implicit) +587 — STARTTLS (submission) +
+
+
+

Add an application

+ +
+
+ + + + +
+
+
+
+
+
+
+

Applications

+ +
+
    +
  • + +

    Any address of the domain — *@example.com

    +
    + + + + +
    +
    +
    +

    Address mode

    + +
    +
    +

    Trusted-IP override active

    + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
  • +
  • + +

    Fixed list — invoices@example.com

    +
    + + + +
    +
  • +
+
+
+
+
+
+

DMARC reports

+ +
+ + +
+
+
+
+

Level-2 rate limit active

+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+

Export domain

+ +
+

Secret file — transfer securely, or encrypt as .spde.

+
+ +
+ + +
+
+
+
+
+

Danger zone

+

Deletes the DKIM key and every application on this domain.

+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/domains.html b/docs/assets/panel-ui/domains.html new file mode 100644 index 0000000..4c41a02 --- /dev/null +++ b/docs/assets/panel-ui/domains.html @@ -0,0 +1,53 @@ + + + + + +Domains — SelfPost mockups + + + + + +
+

Domains

+
+
+

Domains

+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + +
DomainDNSSelectorApps
example.comokmail2Delete
alerts.example.comwarnmail1Delete
+ +

The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/help.html b/docs/assets/panel-ui/help.html new file mode 100644 index 0000000..9d64b90 --- /dev/null +++ b/docs/assets/panel-ui/help.html @@ -0,0 +1,46 @@ + + + + + +Help — SelfPost mockups + + + + + +
+
+

Help candidate

+
+

Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status explanations that do not belong on the cards (what a kernel counter is, why PTR is set at the host, what Reload does not touch). The cards themselves keep their readings, Detail columns, and the Configuration control.

+
+
+

On this panel

+
    +
  • Machine — kernel counters and the rate window
  • +
  • TLS certificate — port 465, reverse-proxy mount
  • +
  • Hostname / reverse DNS — forward-confirmed PTR at the hosting provider
  • +
  • Mail queue retries — time-based, no attempt budget
  • +
  • Inbound — not mailboxes; listed recipients or any address at the domain
  • +
  • Domain page — DNS, records, connection, applications, export (drawer from each card’s «?»)
  • +
+

The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.

+
+
+

Machine

+

CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.

+
+
+

TLS certificate

+

Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.

+
+
+

Hostname / reverse DNS

+

Forward-confirmed reverse DNS: the A/AAAA for SELFPOST_HOSTNAME must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound-backup.html b/docs/assets/panel-ui/inbound-backup.html new file mode 100644 index 0000000..bf0b1f6 --- /dev/null +++ b/docs/assets/panel-ui/inbound-backup.html @@ -0,0 +1,92 @@ + + + + + +backup.example.net — SelfPost mockups + + + + + +
+
+

backup.example.net

+ ← All inbound domains +
+
+
+

DNS status error

+

Cached a few minutes — use Re-check after publishing. Unlike outbound, inbound needs an MX pointing at this server.

+ +
+
+ + backup.example.net +
+
+ + MX +
+
+ + 10 mail.primary.example.net. +

No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.

+
+
+
+
+
+

Upstream

+

Where accepted mail is handed off. Not a mailbox.

+ + + + +
+
+
+

MX record to publish

+

Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.

+
+
+ +
backup.example.net
+
+
+ + MX +
+
+ +
20 mail.example.org.
+
+
+
+
+

Valid recipients

+

Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.

+ + +
+ + +

Unknown recipients are rejected at RCPT so this relay does not generate backscatter.

+
+

Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.

+
+
+
+

Danger zone

+

Stops accepting mail for this domain. Does not touch outbound sending domains.

+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound-delete.html b/docs/assets/panel-ui/inbound-delete.html new file mode 100644 index 0000000..b469692 --- /dev/null +++ b/docs/assets/panel-ui/inbound-delete.html @@ -0,0 +1,33 @@ + + + + + +Delete lists.example.com — SelfPost mockups + + + + + +
+
+

Delete lists.example.com

+ ← Back to lists.example.com +
+
+
+

Confirm deletion

+

You are about to stop accepting inbound mail for lists.example.com. This will:

+
    +
  • remove it from relay_domains and the recipient map;
  • +
  • stop forwarding to 10.0.0.8:25;
  • +
  • leave outbound sending domains untouched.
  • +
+

This cannot be undone from a backup of inbound maps alone unless you restore one. Remove the MX if you do not plan to re-add the domain.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound-domain.html b/docs/assets/panel-ui/inbound-domain.html new file mode 100644 index 0000000..199d8f1 --- /dev/null +++ b/docs/assets/panel-ui/inbound-domain.html @@ -0,0 +1,94 @@ + + + + + +lists.example.com — SelfPost mockups + + + + + +
+
+

lists.example.com

+ ← All inbound domains +
+
+
+

DNS status ok

+

Cached a few minutes — use Re-check after publishing. Unlike outbound, inbound needs an MX pointing at this server.

+ +
+
+ + lists.example.com +
+
+ + MX +
+
+ + 10 mail.example.org. +20 mail.primary.example.net. +

An MX points at mail.example.org (this server). Other MX values are the domain’s own primaries — they are not an error.

+
+
+
+
+
+

Upstream

+

Where accepted mail is handed off. Not a mailbox.

+ + + + +
+
+
+

MX record to publish

+

Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.

+
+
+ +
lists.example.com
+
+
+ + MX +
+
+ +
10 mail.example.org.
+
+
+
+
+

Valid recipients

+

Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.

+ + +
+ + +

Unknown recipients are rejected at RCPT so this relay does not generate backscatter.

+
+

Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.

+
+
+
+

Danger zone

+

Stops accepting mail for this domain. Does not touch outbound sending domains.

+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound.html b/docs/assets/panel-ui/inbound.html new file mode 100644 index 0000000..07e15e7 --- /dev/null +++ b/docs/assets/panel-ui/inbound.html @@ -0,0 +1,58 @@ + + + + + +Inbound — SelfPost mockups + + + + + +
+
+

Inbound 1.x

+
+

Backup-MX / forwarder. Accepts on port 25 only for listed domains. Recipients are either an allow-list or any address at that domain. Off by default in Compose.

+
+
+

Forwarding

+
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
DomainDNSUpstreamRecipientsTLS
lists.example.comok10.0.0.8:2512 listedrequiredDelete
backup.example.neterror192.0.2.20:25anyoffDelete
+ +

The DNS badge is the MX check: at least one MX must point at this server. Results are cached for a few minutes; open a domain for the lookup and a Re-check button.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/index.html b/docs/assets/panel-ui/index.html new file mode 100644 index 0000000..c35e09c --- /dev/null +++ b/docs/assets/panel-ui/index.html @@ -0,0 +1,188 @@ + + + + + +SelfPost — макеты панели + + + + +
+ +
+

макеты · не панель

+

SelfPost — полное обновление интерфейса

+

Проект системы — как собирать экраны, чтобы не чинить пустые колонки, высоту полей и ряды кнопок по одному. Каждый экран — отдельная страница, не простыня с якорями. Знак и палитра из листа утверждения не меняются. HTML в docs/assets/panel-ui/, не вёрстка internal/web.

+ +
+ +
+

Система, не рескин

+

Первый click-through повторил сетку живой панели, поэтому те же сбои чинились по одному. Грамматика задаёт регионы (measure / pair / fill), карточку со слотом справки, field-row (Host и Type одной высоты) и actions-row. Макеты собраны только из этого: один экран — один HTML-файл.

+
+ +
+

Ширина окна

+

Сейчас оболочка центрируется, колонка контента упирается в 64rem, формы ещё уже — 48rem. На широком мониторе поля пустые. Растянуть поля на 100% окна нельзя — они становятся нечитаемыми. Две независимые карточки рядом — можно.

+

Гибрид

+

Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до 90rem (~1440px). Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Таблица Users — fill. Одиночные формы (подтверждения, форма пользователя) остаются 42rem и выровнены влево. Login и setup — по-прежнему узкий центрированный блок.

+
+
+
сейчас, окно ~1600px
+
+ nav + 64rem + пусто +
+
+
+
макет, ops-страница
+
+ nav + контент до 90rem +
+
+
+
макет, форма
+
+ nav + 42rem + поле не растягивает инпуты +
+
+
+
На телефоне это не «узкий десктоп». Таблицы из шести колонок складываются в список. Навигация — выезжающая колонка на CSS-checkbox, без обязательного JavaScript (как панели Edit у приложений уже сейчас).
+
+ +
+

Информационная архитектура

+
+
+

Global administrator

+
    +
  • Status
  • +
  • Domains → domain → delete
  • +
  • Deliveries → message
  • +
  • Mail queue
  • +
  • System log
  • +
  • Inbound 1.x — скрыт, пока INBOUND_RELAY_ENABLE выключен
  • +
  • DMARC reports candidate
  • +
  • Backup, Users, Settings
  • +
  • Help candidate — пункт навбара и drawer с карточек Status и страницы домена
  • +
+
+
+

Domain-admin

+
    +
  • Domains (только назначенные) → domain → DMARC reports этого домена
  • +
  • Deliveries → message, включая пояснение retry
  • +
  • Settings — только учётные данные панели
  • +
  • Help drawer
  • +
  • Нет Status, очереди, system log, Backup, Users, inbound, глобального DMARC hub
  • +
+
+
+

В прототипе роль, inbound-флаг, ширина и тема переключаются в шапке. contributing и schema-squash экранов не имеют.

+
+ +
+

Знак и новые иконки

+

Утверждённый stamp не заменяется. Для узкой шапки телефона предлагается уже существующий SP-значок (фавикон), а не компактный wordmark: на 390px высота важнее, чем прочитать «SelfPost» второй раз.

+
+
+ Полный знак +
эталон · login / setup
+
+
+ Компактный знак +
колонка навбара
+
+
+ SP +
телефон · шапка drawer
+
+
+

Иконки новых пунктов

+

Тот же штрих 1.5, что у Status / Domains. Не пиктограммы-эмодзи и не заливка.

+
+
+ + Inbound +
+
+ + DMARC +
+
+ + Help +
+
+
+ +
+

Экраны

+

Система

+ +

Вход

+ +

Текущая панель

+ +

Roadmap

+ +

Аварийный телефон

+ +
+ +
+

Как смотреть

+

Откройте любой экран из списка — это отдельная страница, не якорь в одном файле. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.

+

После утверждения макетов вёрстка panel.css и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.

+
+ +
+ + diff --git a/docs/assets/panel-ui/login.html b/docs/assets/panel-ui/login.html new file mode 100644 index 0000000..0f5b2a0 --- /dev/null +++ b/docs/assets/panel-ui/login.html @@ -0,0 +1,28 @@ + + + + + +Sign in — SelfPost mockups + + + + + +
+ SelfPost +

Sign in

+
+
+ + + + +
+
+
+

© Mixeme · License (AGPL-3.0)

+
+ + + diff --git a/docs/assets/panel-ui/mail-queue.html b/docs/assets/panel-ui/mail-queue.html new file mode 100644 index 0000000..b2a234e --- /dev/null +++ b/docs/assets/panel-ui/mail-queue.html @@ -0,0 +1,54 @@ + + + + + +Mail queue — SelfPost mockups + + + + + +
+

Mail queue

+
+
+

How delivery retries work

+

This Postfix’s policy, read once at panel start. There is no maximum attempt count — only time.

+
+
First retry5 minutes
+
Later retriesdoubling, cap 1 h 7 min
+
Kept in queue5 days
+
Thenbounced
+
+
+
+
+
+

Pending messages

+ + + + + + + +
Queue idAgeFromToSize
4C3A1E2F1A18 minbilling@example.comada@example.net12 KiB
4C3A1E301011 minnews@example.compat@slow.example48 KiB
4C3A1E31024 minbilling@example.comada@example.net9 KiB
+ + + -Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient------- +4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com + ada@example.net +4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com + pat@slow.example +-- 3 Kbytes in 3 Requests. +
+
+
+ + + diff --git a/docs/assets/panel-ui/mock.css b/docs/assets/panel-ui/mock.css new file mode 100644 index 0000000..e194093 --- /dev/null +++ b/docs/assets/panel-ui/mock.css @@ -0,0 +1,715 @@ +/* SelfPost panel UI mockups. Tokens match internal/web/view/static/panel.css. + This file is a design artifact, not the panel stylesheet. */ + +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap"); + +:root { + color-scheme: light dark; + --font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --bg: #f4f2ed; --fg: #12161c; + --muted: #6b7684; + --card-bg: #fff; + --border: #dedcd7; + --control-border: #cbc8c1; + --input-bg: #fff; + --code-bg: #efede9; + --surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8; + --nav-active-bg: #ede4de; + --accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e; + --on-accent: #fff; + --flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43; + --credential-bg: #fcf6e4; --credential-border: #e0c874; + --danger-fill: #b42318; --danger-fill-hover: #912018; + --danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda; + --st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc; + --st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6; + --st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4; + --st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7; + --nav-w: 14rem; + --ops-max: 90rem; + --form-max: 42rem; + --auth-max: 24rem; + --gallery-h: 3.1rem; +} + +.dark-tokens { + --bg: #16181b; --fg: #e9e6e0; + --muted: #9aa1a9; + --card-bg: #1d2024; + --border: #2c2f34; + --control-border: #3a3e44; + --input-bg: #14161a; + --code-bg: #14161a; + --surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941; + --nav-active-bg: #2a1f1b; + --accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66; + --flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b; + --credential-bg: #26210d; --credential-border: #5e5013; + --danger-fill: #9b2c22; --danger-fill-hover: #b0342a; + --danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17; + --st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f; + --st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12; + --st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721; + --st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34; +} + +@media (prefers-color-scheme: dark) { + :root { color-scheme: dark; } + html:not(.force-light) { } +} + +* { box-sizing: border-box; } +html { scrollbar-gutter: stable; } +body { + font: 400 15px/1.5 var(--font-sans); + margin: 0; background: var(--bg); color: var(--fg); +} + +.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + +/* —— Gallery chrome (index + prototype toolbar) —— */ +.gallery { + position: sticky; top: 0; z-index: 40; + display: flex; flex-wrap: wrap; align-items: center; gap: 0.55rem 1rem; + min-height: var(--gallery-h); + padding: 0.45rem 1rem; + background: var(--surface-bg); + border-bottom: 1px solid var(--border); + font-size: 0.82rem; +} +.gallery a { color: var(--accent-text); } +.gallery .brand-mini { + font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; + letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-text); + text-decoration: none; +} +.gallery .seg { + display: flex; flex-wrap: wrap; align-items: center; gap: 0.2rem 0.35rem; +} +.gallery .seg > span { color: var(--muted); margin-right: 0.15rem; } +.gallery .seg label { + margin: 0; font-weight: 500; cursor: pointer; + padding: 0.2rem 0.55rem; border: 1px solid var(--control-border); + border-radius: 5px; background: var(--card-bg); color: var(--fg); +} +#role-global:checked ~ .gallery label[for="role-global"], +#role-domain:checked ~ .gallery label[for="role-domain"], +#vp-desktop:checked ~ .gallery label[for="vp-desktop"], +#vp-phone:checked ~ .gallery label[for="vp-phone"], +#theme-light:checked ~ .gallery label[for="theme-light"], +#theme-dark:checked ~ .gallery label[for="theme-dark"] { + background: var(--nav-active-bg); color: var(--accent-text); + border-color: var(--accent-fill); font-weight: 600; +} +.gallery .check-lab { + display: flex; align-items: center; gap: 0.35rem; margin: 0; font-weight: 500; cursor: pointer; +} +.gallery .check-lab input { width: auto; margin: 0; } +.tag { + display: inline-block; font-family: var(--font-mono); font-size: 0.68rem; + font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; + padding: 0.12rem 0.4rem; border-radius: 4px; + background: var(--surface-bg); color: var(--muted); border: 1px solid var(--border); +} +.tag.future { color: var(--st-warn-fg); border-color: var(--st-warn-border); background: var(--st-warn-bg); } + +body:has(#theme-dark:checked) { background: #16181b; color: #e9e6e0; } + +#theme-dark:checked ~ .gallery, +#theme-dark:checked ~ .app, +#theme-dark:checked ~ .help-drawer { + --bg: #16181b; --fg: #e9e6e0; + --muted: #9aa1a9; + --card-bg: #1d2024; + --border: #2c2f34; + --control-border: #3a3e44; + --input-bg: #14161a; + --code-bg: #14161a; + --surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941; + --nav-active-bg: #2a1f1b; + --accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66; + --flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b; + --credential-bg: #26210d; --credential-border: #5e5013; + --danger-fill: #9b2c22; --danger-fill-hover: #b0342a; + --danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17; + --st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f; + --st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12; + --st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721; + --st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34; + background: var(--bg); color: var(--fg); +} + +/* —— Prototype shell: nav docked left, content uses remaining width —— */ +.app { + display: flex; align-items: flex-start; min-height: calc(100vh - var(--gallery-h)); + background: var(--bg); +} +.stage { flex: 1 1 auto; min-width: 0; } +.nav { + position: sticky; top: var(--gallery-h); align-self: stretch; + flex: none; width: var(--nav-w); + display: flex; flex-direction: column; gap: 0.75rem; + padding: 1.1rem 0.85rem 1.4rem; + border-right: 1px solid var(--border); + background: var(--bg); + max-height: calc(100vh - var(--gallery-h)); overflow-y: auto; +} +.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; } +.nav .session { margin-top: auto; padding-top: 0.75rem; border-top: 1px solid var(--border); } +.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; } +.nav .session-user { display: flex; align-items: center; gap: 0.5rem; } +.nav .brand { padding: 0; display: block; } +.nav .brand img { display: block; width: 100%; height: auto; } +.nav a, .nav [aria-current], .nav .current { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none; + color: var(--fg); +} +.nav a:hover { background: var(--surface-bg); } +.nav [aria-current], .nav .current { + font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg); + box-shadow: inset 2px 0 0 var(--accent-fill); +} +body.page-status .n-status, +body.page-domains .n-domains, +body.page-domain .n-domains, +body.page-domain-delete .n-domains, +body.page-deliveries .n-deliveries, +body.page-delivery .n-deliveries, +body.page-mail-queue .n-queue, +body.page-system-log .n-log, +body.page-inbound .n-inbound, +body.page-inbound-domain .n-inbound, +body.page-inbound-backup .n-inbound, +body.page-inbound-delete .n-inbound, +body.page-dmarc .n-dmarc, +body.page-dmarc-domain .n-dmarc, +body.page-dmarc-report .n-dmarc, +body.page-dmarc-report-fail .n-dmarc, +body.page-backup .n-backup, +body.page-users .n-users, +body.page-user-form .n-users, +body.page-user-delete .n-users, +body.page-help .n-help, +body.page-settings .n-settings { + font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg); + box-shadow: inset 2px 0 0 var(--accent-fill); +} +.nav .icon { width: 1rem; height: 1rem; flex: none; } +.nav button, .nav .btn-ghost { + display: flex; align-items: center; gap: 0.5rem; + margin: 0; padding: 0.35rem 0.6rem; font: inherit; font-size: 0.95rem; font-weight: 400; + color: var(--danger-fg); background: var(--danger-bg); border: 1px solid var(--danger-border); + border-radius: 5px; cursor: pointer; text-decoration: none; width: 100%; +} +.nav button:hover, .nav .btn-ghost:hover { background: var(--danger-bg-hover); } + +.phone-bar { display: none; } +.phone-bar .icon { width: 1.1rem; height: 1.1rem; } +.mono { font-family: var(--font-mono); } +.sprite { position: absolute; width: 0; height: 0; overflow: hidden; } + +#feat-inbound:checked ~ .gallery label[for="feat-inbound"] { + background: var(--nav-active-bg); color: var(--accent-text); + border-color: var(--accent-fill); +} +.gallery label[for="feat-inbound"] { + margin: 0; font-weight: 500; cursor: pointer; + padding: 0.2rem 0.55rem; border: 1px solid var(--control-border); + border-radius: 5px; background: var(--card-bg); color: var(--fg); +} +#role-domain:checked ~ .gallery .g-only { display: none; } + +main { + flex: 1 1 auto; min-width: 0; width: 100%; + padding: 1.5rem 1.5rem 2.5rem; +} +main.auth { + max-width: none; display: flex; flex-direction: column; align-items: center; + padding-top: 3rem; +} +main.auth > * { width: 100%; max-width: var(--auth-max); margin-left: auto; margin-right: auto; } +main.stack > .page-head, +main.stack > h1 { margin-bottom: 0; } +main.stack > .back { margin: 0; } +main.stack > .route { margin-top: 0; margin-bottom: 0; } +main.stack > p.muted { margin-top: 0; margin-bottom: 0; } +main.stack > .toolbar { margin-bottom: 0; max-width: var(--ops-max); } + +body.page-login .nav, +body.page-setup .nav, +body.page-login .phone-bar, +body.page-setup .phone-bar { display: none !important; } +body.page-login .app, +body.page-setup .app { display: block; } + +#role-domain:checked ~ .app .g-only { display: none !important; } +#role-domain:checked ~ .app .pair:has(> .g-only) { + display: block; + max-width: var(--form-max); +} +#feat-inbound:not(:checked) ~ .app .in-only { display: none !important; } +#feat-inbound:checked ~ .app .in-off { display: none !important; } + +h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin: 0 0 1rem; } +h1.subject { overflow-wrap: anywhere; } +h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; } +.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; } +.card { + background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; + padding: 1.25rem 1.4rem; margin: 0; +} +.card.narrow { max-width: var(--auth-max); } +.card + .card { margin-top: 1rem; } +.card.credential { border-color: var(--credential-border); background: var(--credential-bg); } +.muted { color: var(--muted); } +.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; } +a { color: var(--accent-text); } +.back { display: block; margin: -0.4rem 0 1rem; } +.flash { + background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); + padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1rem; +} +.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); } +.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); } +.version a { color: inherit; } + +label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; } +label.check { + display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600; cursor: pointer; +} +label.check input { width: auto; margin: 0; padding: 0; border: 0; background: none; } +input, select, textarea { + width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem; + border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit; +} +textarea { resize: vertical; } +fieldset { + margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem; + border: 1px solid var(--control-border); border-radius: 5px; +} +fieldset legend { padding: 0 0.25rem; font-weight: 600; } +fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; } + +button, a.btn, a.danger { + display: inline-block; margin-top: 1.1rem; padding: 0.6rem 1.1rem; + font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none; + color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer; +} +button:hover, a.btn:hover { background: var(--accent-fill-hover); } +button.danger, a.danger { background: var(--danger-fill); color: var(--on-accent); } +button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); } +form.inline { display: inline; margin: 0; } +.form-actions { + display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; + margin-top: 1.1rem; +} +.form-actions > button, .form-actions > a.btn, .form-actions > a.danger { margin-top: 0; } + +.st { + display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px; + font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1; + letter-spacing: 0.02em; vertical-align: middle; border: 1px solid transparent; + white-space: nowrap; +} +.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); } +.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); } +.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); } +.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); } +a.st, a.st:hover { color: inherit; text-decoration: none; } + +table { width: 100%; border-collapse: collapse; } +th, td { + text-align: left; padding: 0.5rem 0.45rem; border-bottom: 1px solid var(--border); + white-space: nowrap; +} +th { + font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500; + text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); +} +td.actions { text-align: right; } +.metric { white-space: nowrap; } +.card:has(table) { overflow-x: auto; } +td.subject span { + display: block; max-width: 22rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.code { + display: block; white-space: pre; overflow-x: auto; overflow-wrap: normal; + word-break: normal; font-family: var(--font-mono); + font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); + border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; +} +.code-row { display: flex; align-items: flex-start; gap: 0.5rem; } +.code-row .code { flex: 1; min-width: 0; margin-top: 0; padding-top: 0.45rem; padding-bottom: 0.45rem; } +.input-row { display: flex; align-items: stretch; gap: 0.5rem; } +.input-row input { flex: 1; min-width: 0; } +.input-row button { margin-top: 0; flex: none; white-space: nowrap; } + +.split { + display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); + gap: 1rem; margin-top: 1rem; +} +h1 + .split, h1 + form > .split { margin-top: 0; } +.split > .card { min-width: 0; } +.split > .card + .card { margin-top: 0; } +.split + .card, .card + .split, form:has(.split) + .card { margin-top: 1rem; } +#settings form { width: 100%; max-width: none; } +#settings .split, +#backup > .split { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.check-cols { + display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 1rem 1.2rem; margin-top: 1rem; +} +.check-col { min-width: 0; } +.check-col-title { margin: 0.85rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; } +.field-pair { + display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: 0 1rem; margin-top: 0.45rem; +} +.field-pair > div { min-width: 0; } +.field-pair label { margin-top: 0.45rem; } +/* Host / name beside Type — size the type column to that token rather than + giving it half the row. Vertical padding matches .code-row .code so Type is + the same height as Host when Copy sits beside it. */ +.field-pair.host-type { + grid-template-columns: minmax(0, 1fr) auto; +} +.field-pair.host-type .code { + padding-top: 0.45rem; padding-bottom: 0.45rem; +} +.field-type { width: max-content; } +.field-type .code { + width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box; +} + +.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; } +.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); } +.fact-label { + display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500; + text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); +} +.fact-value { display: block; margin-top: 0.1rem; white-space: nowrap; overflow-x: auto; } +.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; } + +.timeline { + list-style: none; display: flex; flex-direction: column; gap: 1.1rem; + margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border); +} +.event { position: relative; min-width: 0; } +.event::before { + content: ""; position: absolute; left: -1.85rem; top: 0.3rem; + width: 0.65rem; height: 0.65rem; border-radius: 50%; + background: var(--card-bg); border: 2px solid var(--control-border); +} +.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); } +.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); } +.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); } +.event.pending { opacity: 0.7; } +.event.pending::before { border-style: dashed; } +.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); } +.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; } +.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; } + +.route { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: -0.5rem; margin-bottom: 1rem; } +.route .addr { font-family: var(--font-mono); font-size: 0.9rem; white-space: nowrap; overflow-x: auto; max-width: 100%; } +.route .arrow { color: var(--muted); } + +table.log { margin-top: 1rem; } +table.log th:first-child, table.log td.time { width: 1%; } +table.log td.log-text { + font-family: var(--font-mono); + font-size: 0.8rem; white-space: pre; +} + +button.copy, .actions button, .actions > label.toggle, .actions a.danger { + margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600; + border-radius: 5px; white-space: nowrap; + background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border); +} +button.copy:hover, .actions button:hover, .actions > label.toggle:hover { background: var(--surface-bg-hover); } +.actions button.danger, .actions a.danger { color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border); } +.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; } + +.apps { list-style: none; margin: 1rem 0 0; padding: 0; } +.app-item { padding: 0.9rem 0; border-top: 1px solid var(--border); } +.app-item:last-child { padding-bottom: 0; } +.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; } +.app-addr { margin: 0.15rem 0 0; white-space: nowrap; overflow-x: auto; } +.app-item .actions { margin-top: 0.7rem; } +.app-item .actions > .panel-toggle { + position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; +} +.app-item .actions > .panel { display: none; flex: 1 0 100%; } +.app-item .actions > .t-edit:checked ~ .panel-edit { display: block; } +.app-item .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); } +.app-item .actions > .panel button { margin-top: 0.9rem; } + +.encrypt { margin-top: 1.2rem; } +.encrypt-fields { + display: none; margin-top: 0.8rem; margin-left: 1.6rem; padding-left: 0.9rem; + border-left: 2px solid var(--border); +} +.encrypt:has(input[type="checkbox"]:checked) .encrypt-fields { display: block; } +.encrypt-fields label { margin-top: 0.7rem; } + +.rcpt-list, .rcpt-any { display: none; } +.rcpt-mode:has(option[value="list"]:checked) .rcpt-list { display: block; } +.rcpt-mode:has(option[value="any"]:checked) .rcpt-any { display: block; } + +.toolbar { + display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: end; + padding: 0.85rem 1rem; margin-bottom: 1rem; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; +} +.toolbar .field { flex: 1 1 10rem; min-width: 8rem; } +.toolbar label { margin-top: 0; font-size: 0.8rem; } +.toolbar button { margin-top: 0; } + +/* Status: hero + dense grid */ +.status-hero { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.75rem 1.2rem; + padding: 1rem 1.25rem; margin-bottom: 1rem; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; +} +.status-hero.attn, +.card.attn { border-color: var(--st-warn-border); } +.status-hero h1 { margin: 0; } +.status-hero .lead { margin: 0; color: var(--fg); } +.status-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(17.5rem, 1fr)); + gap: 1rem; +} +.status-grid .card { margin: 0; } +.status-grid .card.attn { border-color: var(--st-warn-border); } +.metric-row { + display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline; + padding: 0.35rem 0; border-bottom: 1px solid var(--border); font-size: 0.92rem; +} +.metric-row:last-child { border-bottom: 0; } +.metric-row .k { font-weight: 600; } +.metric-row .v { font-family: var(--font-mono); font-size: 0.85rem; } +meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; } +.card-head { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem; +} +.card-head h2 { margin: 0; } +.help-link { + flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center; justify-content: center; + border-radius: 50%; border: 1px solid var(--control-border); color: var(--muted); + text-decoration: none; font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600; + background: var(--surface-bg); cursor: pointer; margin: 0; +} +.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); } + +.page-head { + display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.5rem 1rem; + margin-bottom: 1rem; +} +.page-head h1 { margin: 0; } + +/* Compact retry policy */ +.retry-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.5rem; margin-top: 0.7rem; } + +.phone-list { display: none; list-style: none; margin: 0; padding: 0; } +.phone-list li { border-bottom: 1px solid var(--border); } +.phone-list a, +.phone-list .item { + display: grid; grid-template-columns: 1fr auto; gap: 0.15rem 0.7rem; + padding: 0.75rem 0.1rem; text-decoration: none; color: var(--fg); +} +.phone-list .when { font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); } +.phone-list .meta { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; } +.phone-list .subj { grid-column: 1 / -1; margin: 0; } + +/* Help drawer — CSS checkbox, no script required */ +.help-drawer { + display: none; position: fixed; top: var(--gallery-h); right: 0; bottom: 0; + width: min(26rem, 100vw); z-index: 30; + background: var(--card-bg); border-left: 1px solid var(--border); + padding: 1.25rem 1.35rem 2rem; overflow-y: auto; +} +html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer, +html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; } +.help-pane { display: none; } +html:has(#help-index:checked) .help-pane-index, +html:has(#help-status:checked) .help-pane-status, +html:has(#help-password:checked) .help-pane-password, +html:has(#help-dns:checked) .help-pane-dns, +html:has(#help-records:checked) .help-pane-records, +html:has(#help-dmarc:checked) .help-pane-dmarc, +html:has(#help-connection:checked) .help-pane-connection, +html:has(#help-apps:checked) .help-pane-apps, +html:has(#help-domain-settings:checked) .help-pane-domain-settings, +html:has(#help-export:checked) .help-pane-export { display: block; } +.help-scrim { + display: none; position: fixed; inset: var(--gallery-h) 0 0 0; z-index: 25; + background: rgba(18, 22, 28, 0.28); +} +.help-drawer h2 { margin-top: 1.2rem; } +.help-drawer h2:first-of-type, +.help-pane h2 { margin-top: 0; } +.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; } +.help-drawer .toc li { margin: 0.35rem 0 0; } +.help-drawer .toc label { + margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer; +} +.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; } +.help-drawer .more label { + display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer; +} +.help-close { + position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem; + font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg); + border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer; +} + +/* Phone preview (gallery toggle) and real narrow windows */ +.nav-burger { display: none; } + +#vp-phone:checked ~ .app { + position: relative; + width: 390px; margin: 0.75rem auto 2rem; min-height: 760px; + border: 1px solid var(--border); border-radius: 6px; overflow: hidden; + max-height: none; +} +#vp-phone:checked ~ .app .nav { + position: absolute; top: 0; left: 0; bottom: 0; + height: auto; max-height: none; + width: min(16rem, 82%); z-index: 20; + transform: translateX(-110%); + border-right: 1px solid var(--border); background: var(--card-bg); +} +#nav-open:checked ~ .app .nav { transform: translateX(0); } +#vp-phone:checked ~ .app .phone-bar { + display: flex; align-items: center; gap: 0.55rem; + margin: 0; padding: 0.65rem 0.85rem; + border-bottom: 1px solid var(--border); background: var(--card-bg); +} +#vp-phone:checked ~ .app .nav-burger { + display: inline-flex; align-items: center; justify-content: center; + width: 2.1rem; height: 2.1rem; margin: 0; padding: 0; + border: 1px solid var(--control-border); border-radius: 5px; + background: var(--surface-bg); cursor: pointer; +} +#vp-phone:checked ~ .app .phone-mark { width: 1.7rem; height: 1.7rem; } +#vp-phone:checked ~ .app .phone-bar .grow { flex: 1; font-weight: 600; } +#vp-phone:checked ~ .app main { padding: 1.5rem 0.9rem 2rem; } +#vp-phone:checked ~ .app .desk-only { display: none !important; } +#vp-phone:checked ~ .app .phone-only { display: block; } +#vp-phone:checked ~ .app .phone-list { display: block; } +#vp-phone:checked ~ .app .status-grid { display: flex; flex-direction: column; } +#vp-phone:checked ~ .app .status-grid .attn { order: -1; } +#vp-phone:checked ~ .app .pair { grid-template-columns: 1fr; } +#vp-phone:checked ~ .app .split, +#vp-phone:checked ~ .app .check-cols, +#vp-phone:checked ~ .app .field-pair:not(.host-type), +#vp-phone:checked ~ .app .facts, +#vp-phone:checked ~ .app .retry-facts { + grid-template-columns: 1fr; +} +#vp-phone:checked ~ .app td.subject span { max-width: none; } +#vp-phone:checked ~ .help-drawer { width: 100vw; } + +.phone-only { display: none; } + +@media (max-width: 52rem) { + .app { min-height: calc(100vh - var(--gallery-h)); } + .nav { + position: fixed; top: var(--gallery-h); left: 0; bottom: 0; + z-index: 20; width: min(16rem, 84vw); max-height: none; + transform: translateX(-110%); + border-right: 1px solid var(--border); background: var(--card-bg); + } + #nav-open:checked ~ .app .nav { transform: translateX(0); } + .phone-bar { + display: flex; align-items: center; gap: 0.55rem; + margin: 0; padding: 0.65rem 0.85rem; + border-bottom: 1px solid var(--border); background: var(--card-bg); + } + .nav-burger { + display: inline-flex; align-items: center; justify-content: center; + width: 2.1rem; height: 2.1rem; margin: 0; padding: 0; + border: 1px solid var(--control-border); border-radius: 5px; + background: var(--surface-bg); cursor: pointer; + } + .phone-mark { width: 1.7rem; height: 1.7rem; } + .phone-bar .grow { flex: 1; font-weight: 600; } + main { padding: 1.5rem 0.9rem 2rem; } + .desk-only { display: none !important; } + .phone-only, .phone-list { display: block; } + .status-grid { display: flex; flex-direction: column; } + .status-grid .attn { order: -1; } + .split, .pair, #settings .split, #backup > .split, + .check-cols, .field-pair:not(.host-type), .facts, .retry-facts { grid-template-columns: 1fr; } + .help-drawer { width: 100vw; } +} + +/* Index page */ +.wrap { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; } +.wrap > header { border-bottom: 2px solid var(--accent-fill); padding-bottom: 1.5rem; margin-bottom: 0.5rem; } +.eyebrow { + font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.14em; + text-transform: uppercase; color: var(--accent-text); margin: 0 0 0.7rem; +} +.wrap h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); font-weight: 200; } +.wrap h1 b { font-weight: 600; } +.wrap > header p { margin: 0; max-width: 62ch; color: var(--muted); } +.wrap section { border-bottom: 1px solid var(--border); padding: 2.2rem 0; } +.wrap h2 { + font-size: 0.8rem; font-weight: 500; margin: 0 0 1.1rem; + letter-spacing: 0.1em; text-transform: uppercase; font-family: var(--font-mono); color: var(--muted); +} +.wrap h3 { font-size: 1.05rem; font-weight: 600; margin: 1.2rem 0 0.4rem; } +.note { + background: #f1eae4; border-left: 2px solid var(--accent-fill); + padding: 0.9rem 1rem; font-size: 0.92rem; line-height: 1.6; max-width: 64ch; margin-top: 1rem; +} +#theme-dark:checked ~ .doc .note { background: #2a1f1b; } +.compare { + display: grid; grid-template-columns: 1fr; gap: 0.75rem; margin-top: 0.8rem; +} +.compare .row { + font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); +} +.bar { + display: flex; height: 2.1rem; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--card-bg); +} +.bar i { display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 0.7rem; } +.bar .navc { width: 14%; background: var(--surface-bg); color: var(--fg); border-right: 1px solid var(--border); } +.bar .col { background: var(--nav-active-bg); color: var(--accent-text); } +.bar .empty { flex: 1; background: var(--code-bg); color: var(--muted); } +.bar .fill { flex: 1; background: var(--st-ok-bg); color: var(--st-ok-fg); } +.ia { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 40rem) { .ia { grid-template-columns: 1fr; } } +.ia ul { margin: 0.3rem 0 0; padding-left: 1.1rem; } +.ia li { margin: 0.2rem 0; } +.screen-index { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.4rem 1.5rem; } +.screen-index a { display: block; padding: 0.25rem 0; } +.icons-row { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: end; margin-top: 0.8rem; } +.icon-card { + background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; + padding: 1rem 1.1rem 0.85rem; text-align: center; min-width: 7.5rem; +} +.icon-card svg { width: 1.5rem; height: 1.5rem; display: block; margin: 0 auto 0.45rem; } +.icon-card span { display: block; font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); } +.mark-row { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; } +.mark-row figure { margin: 0; } +.mark-row figcaption { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; } + +.cta { + display: inline-block; margin-top: 1rem; padding: 0.65rem 1.2rem; + background: var(--accent-fill); color: var(--on-accent); text-decoration: none; + font-weight: 600; border-radius: 5px; +} +.cta:hover { background: var(--accent-fill-hover); color: var(--on-accent); } +.cta-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; } +.cta.quiet { + background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border); +} +.cta.quiet:hover { background: var(--surface-bg-hover); color: var(--accent-text); } diff --git a/docs/assets/panel-ui/settings.html b/docs/assets/panel-ui/settings.html new file mode 100644 index 0000000..981c04f --- /dev/null +++ b/docs/assets/panel-ui/settings.html @@ -0,0 +1,73 @@ + + + + + +Settings — SelfPost mockups + + + + + +
+

Settings

+
+
+
+

Panel credentials

+

These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.

+ + + + +
+

Leave both new-password fields empty to change the username or DMARC address only. Changing the password signs out every other session; this one stays signed in.

+
+
+

DMARC aggregate reports

+

Default rua= for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.

+ + +

When rua= points at another domain, that hub must publish a report-authorisation record. DMARC reports in the panel.

+
+
+ +
mail.example.org._report._dmarc.example.com
+
+
+ + TXT +
+
+ +
v=DMARC1;
+ +

Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.

+
+
+
+
+
+

Sending rate limits

+

Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domain’s page.

+
+
+

Level 1 — per client IP

+ 100 messages / 60 seconds +

RATE_LIMIT_MESSAGES_PER_IP / RATE_LIMIT_WINDOW_SECONDS. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.

+
+
+

Level 2 — domain

+

Optional ceiling for all senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.

+
+
+

Level 2 — application

+

Optional override for trusted IPs: a ceiling strictly above the domain limit (still ≤ level 1). Those IPs skip the domain check; everyone else stays under the domain (or level 1).

+
+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/setup.html b/docs/assets/panel-ui/setup.html new file mode 100644 index 0000000..9931c5d --- /dev/null +++ b/docs/assets/panel-ui/setup.html @@ -0,0 +1,30 @@ + + + + + +Create administrator — SelfPost mockups + + + + + +
+ SelfPost +

Create administrator

+
+

This one-time link creates the single panel administrator. After you submit, the link stops working for good.

+
+ + + + + + +
+
+
+
+ + + diff --git a/docs/assets/panel-ui/shell.js b/docs/assets/panel-ui/shell.js new file mode 100644 index 0000000..b32ef7c --- /dev/null +++ b/docs/assets/panel-ui/shell.js @@ -0,0 +1,276 @@ +/* Shared chrome for panel UI page mockups. Each screen is its own HTML file; + this script injects radios, gallery, nav, sprite, and the help drawer so + file:// viewing does not need a module fetch. */ +(function () { + if (document.body.dataset.shell === "1") return; + document.body.dataset.shell = "1"; + var main = document.querySelector("main"); + if (!main) return; + + var page = document.body.getAttribute("data-page") || ""; + var navKey = document.body.getAttribute("data-nav") || page; + var title = document.body.getAttribute("data-title") || document.title; + var globalOnly = document.body.getAttribute("data-global-only") === "1"; + + document.body.insertAdjacentHTML("afterbegin", + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ); + + var q = new URLSearchParams(location.search); + if (q.get("view") === "phone") document.getElementById("vp-phone").checked = true; + if (q.get("role") === "domain") document.getElementById("role-domain").checked = true; + if (q.get("theme") === "dark") document.getElementById("theme-dark").checked = true; + if (q.get("inbound") === "0") document.getElementById("feat-inbound").checked = false; + + var gallery = + '"; + + var sprite = + '"; + + function icon(id) { + return ''; + } + + var navHtml = + '"; + + var phone = + '
' + + '" + + '' + + '' + + '' + + "
"; + + var help = + '' + + '"; + + var app = document.createElement("div"); + app.className = "app"; + app.innerHTML = navHtml + '
' + phone + "
"; + main.parentNode.insertBefore(app, main); + app.insertAdjacentHTML("beforebegin", gallery + sprite); + app.querySelector(".stage").appendChild(main); + app.insertAdjacentHTML("afterend", help); + + var cur = app.querySelector(".n-" + navKey); + if (cur) cur.setAttribute("aria-current", "page"); + var grow = app.querySelector(".phone-title"); + if (grow) grow.textContent = title; + + var skip = { "index.html": 1, "system.html": 1, "app.html": 1 }; + + function qs() { + var p = new URLSearchParams(); + if (document.getElementById("vp-phone").checked) p.set("view", "phone"); + if (document.getElementById("role-domain").checked) p.set("role", "domain"); + if (document.getElementById("theme-dark").checked) p.set("theme", "dark"); + if (!document.getElementById("feat-inbound").checked) p.set("inbound", "0"); + var s = p.toString(); + return s ? "?" + s : ""; + } + + function withQuery(href) { + if (!href) return href; + if (href.charAt(0) === "#" || /^(https?:|mailto:|javascript:)/i.test(href)) return href; + var hash = ""; + var path = href; + var hashAt = href.indexOf("#"); + if (hashAt >= 0) { + hash = href.slice(hashAt); + path = href.slice(0, hashAt); + } + var qAt = path.indexOf("?"); + var file = qAt >= 0 ? path.slice(0, qAt) : path; + var extra = qAt >= 0 ? path.slice(qAt + 1) : ""; + var base = file.split("/").pop(); + if (!base || !/\.html$/i.test(base) || skip[base]) return href; + var p = new URLSearchParams(extra); + var curQs = new URLSearchParams(qs().replace(/^\?/, "")); + ["view", "role", "theme", "inbound"].forEach(function (k) { + if (curQs.has(k)) p.set(k, curQs.get(k)); + else p.delete(k); + }); + var s = p.toString(); + return base + (s ? "?" + s : "") + hash; + } + + function rewriteLinks() { + document.querySelectorAll("a[href]").forEach(function (a) { + var raw = a.getAttribute("href"); + if (!raw) return; + a.setAttribute("href", withQuery(raw)); + }); + } + + function brandHref() { + var brand = document.querySelector(".nav .brand"); + if (!brand) return; + brand.setAttribute("href", withQuery( + document.getElementById("role-domain").checked ? "domains.html" : "status.html" + )); + } + + function gate() { + brandHref(); + if (!document.getElementById("role-domain").checked) return; + if (globalOnly) location.replace(withQuery("domains.html")); + } + + function syncUrl() { + if (history.replaceState) { + history.replaceState(null, "", location.pathname.split("/").pop() + qs() + location.hash); + } + rewriteLinks(); + brandHref(); + gate(); + } + + ["role-global", "role-domain", "vp-desktop", "vp-phone", "theme-light", "theme-dark", "feat-inbound"].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener("change", syncUrl); + }); + + document.querySelectorAll(".nav a").forEach(function (a) { + a.addEventListener("click", function () { + var open = document.getElementById("nav-open"); + if (open) open.checked = false; + }); + }); + + rewriteLinks(); + gate(); +})(); diff --git a/docs/assets/panel-ui/status.html b/docs/assets/panel-ui/status.html new file mode 100644 index 0000000..39c2f30 --- /dev/null +++ b/docs/assets/panel-ui/status.html @@ -0,0 +1,125 @@ + + + + + +Status — SelfPost mockups + + + + + +
+
+

Status

+ +
+ +
+
+

Overall warn

+

Running, with warnings below.

+
+
+ +
+
+

Mail queue warn

+

3 Kbytes in 3 Requests.

+ +
+
+

TLS certificate ok

+ + 2026-11-02 12:00 UTC +

Valid for another 78 day(s).

+
+
+ +
+
+

Milter sockets ok

+ + + + + + +
MilterStateDetail
OpenDKIMokListening
send-logokListening
+
+
+

Hostname and reverse DNS ok

+ + mail.example.org + + 203.0.113.10 → mail.example.org +

mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.

+
+
+
+ +
+
+

Inbound warn

+

INBOUND_RELAY_ENABLE is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.

+

One domain has no MX pointing at this server. Recipients are a list or any address at the domain. Open Inbound for the list, MX checks, upstream, and recipient maps.

+

Inbound domains

+
+
+ +
+
+

Machine ok

+ + + + + + + + + + + + + + + + + + + +
ResourceUsageDetail
CPU12% 12%4 cores · 4 threads
Memory41% 41%1.6 GiB used of 4.0 GiB.
Network↓ 2.0 KiB/s
↑ 1.0 KiB/s
eth0: 1.0 MiB in, 512.0 KiB out
+
+
+

Processes ok

+ + + + + + + + + + +
ProgramStateDetail
opendkimRUNNINGpid 21, uptime 3 days, 4:12:01
panelRUNNINGpid 18, uptime 3 days, 4:12:03
postfixRUNNINGpid 42, uptime 3 days, 4:11:58
postfix-reloadSTOPPEDNot started
cert-reloadSTOPPEDNot started
logrotateSTOPPEDNot started
+
+
+ +
+
+

Configuration

+

Regenerates the OpenDKIM and Postfix configuration from the + database and reloads both daemons. Use it if you edited the files by hand, + restored a backup, or the running configuration looks out of step with the + domain and application lists. It does not touch the mail queue or the TLS + certificate, and it is safe to run at any time.

+
+
+
+

SelfPost 1.2.3 · © Mixeme · License (AGPL-3.0)

+
+ + + diff --git a/docs/assets/panel-ui/system-log.html b/docs/assets/panel-ui/system-log.html new file mode 100644 index 0000000..fbdda11 --- /dev/null +++ b/docs/assets/panel-ui/system-log.html @@ -0,0 +1,27 @@ + + + + + +System log — SelfPost mockups + + + + + +
+

System log

+
+
+

Recent log entries

+ Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy) +Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=<billing@example.com>, size=12288, nrcpt=1 (queue active) +Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing +Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=<list-bounces@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok) +Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=<noreply@blocked.example>, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected) +
+
+
+ + + diff --git a/docs/assets/panel-ui/system.css b/docs/assets/panel-ui/system.css new file mode 100644 index 0000000..f4cf77a --- /dev/null +++ b/docs/assets/panel-ui/system.css @@ -0,0 +1,128 @@ +/* Panel UI system primitives. + Source of truth: system.html. Screens are separate HTML files composed from + these classes. Do not “fix” empty columns, Host/Type height, or split + actions by one-off rules — compose with these instead. */ + +.stack { + display: flex; flex-direction: column; gap: 1rem; + width: 100%; min-width: 0; +} +.stack > .card, +.stack > .pair, +.stack > .measure, +.stack > .fill { margin-top: 0; } +.stack > .card + .card { margin-top: 0; } + +.measure { width: 100%; max-width: var(--form-max); min-width: 0; } + +.fill { width: 100%; max-width: var(--ops-max); min-width: 0; } +.fill > .card { margin-top: 0; } +.fill .card:has(table) { overflow-x: auto; } +.card > .measure { margin-bottom: 1rem; } + +/* Two peer jobs. One child → reading measure (domain-admin Settings). + Never a full-width lonely card on an ops page. */ +.pair { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + width: 100%; max-width: var(--ops-max); min-width: 0; +} +.pair > * { min-width: 0; } +.card > .stack { min-width: 0; } +.pair > .card { margin-top: 0; } +.pair > .card + .card { margin-top: 0; } +.pair:has(> :only-child) { + display: block; + max-width: var(--form-max); +} + +.actions-row { + display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; + margin-top: 1.1rem; +} +.actions-row > button, +.actions-row > a.btn, +.actions-row > a.danger { margin-top: 0; } + +/* Two labelled controls on one row. Labels share row 1, values share row 2 + so they are the same height by construction — not by matching padding. */ +.field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto minmax(2.5rem, auto); + column-gap: 1rem; + align-items: stretch; + margin-top: 0.45rem; +} +.field-row.equal { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.field-row > .field { display: contents; } +.field-row > .field:first-child > * { grid-column: 1; } +.field-row > .field:last-child > * { grid-column: 2; } +.field-row > .field > label { grid-row: 1; margin-top: 0.45rem; } +.field-row > .field > :not(label) { + grid-row: 2; align-self: stretch; min-width: 0; + margin-top: 0.3rem; box-sizing: border-box; +} +.field-row > .field > .code, +.field-row > .field > .code-row { + height: 100%; +} +.field-row > .field > .code { + display: flex; align-items: center; + padding-top: 0.45rem; padding-bottom: 0.45rem; +} +.field-row > .field:last-child > .code { + width: fit-content; min-width: 2.75rem; + justify-content: center; text-align: center; +} +.field-row .code-row { + display: flex; align-items: stretch; gap: 0.5rem; height: 100%; +} +.field-row .code-row .code { + flex: 1; min-width: 0; margin-top: 0; height: auto; + display: flex; align-items: center; + padding-top: 0.45rem; padding-bottom: 0.45rem; +} +.field-row .code-row .copy { margin-top: 0; align-self: stretch; } + +.card-head { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; + margin-bottom: 0.5rem; +} +.card-head h2 { margin: 0; } + +@media (max-width: 52rem) { + .pair { grid-template-columns: 1fr; } + /* field-row stays two columns: Type is a token, not a second form. */ +} + +/* Specimens on the system page */ +.sys-wrap { max-width: 70rem; } +.sys-wrap > header p, +.sys-wrap section > p, +.sys-wrap li { max-width: 68ch; } +.sys-toc { + display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem; + margin: 1rem 0 0; padding: 0; list-style: none; +} +.sys-toc a { font-size: 0.92rem; } +.specimen { + margin: 1rem 0 0; padding: 0.9rem 1rem 1.1rem; + border: 1px dashed var(--border); border-radius: 6px; background: var(--code-bg); +} +.specimen > figcaption { + font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--muted); margin: 0 0 0.7rem; +} +.specimen.bad { + border-color: var(--danger-border); background: var(--danger-bg); +} +.specimen.bad > figcaption { color: var(--danger-fg); } +.recipes { + width: 100%; margin-top: 0.8rem; font-size: 0.92rem; +} +.recipes th, .recipes td { white-space: normal; vertical-align: top; } +.recipes code { font-size: 0.82rem; } +.forbid { margin: 0.4rem 0 0; padding-left: 1.1rem; } +.forbid li { margin: 0.35rem 0; } diff --git a/docs/assets/panel-ui/system.html b/docs/assets/panel-ui/system.html new file mode 100644 index 0000000..02a4f62 --- /dev/null +++ b/docs/assets/panel-ui/system.html @@ -0,0 +1,298 @@ + + + + + +SelfPost — система панели + + + + + +
+ +
+

система · не рескин

+

SelfPost — грамматика интерфейса

+

Это проект системы. Экраны складываются из регионов и контролов. Запрещённые состояния нельзя «починить паддингом» — их не из чего собрать. Знак, кирпич и IBM Plex не меняются. Живая панель (internal/web) пока не трогается.

+ + +
+ +
+

Зачем

+

Первый макет скопировал грамматику живой панели: страница помечается ops или form, карточки стопкой, пара полей — два независимых .code, Save внутри <form>, Delete снаружи. Дальше каждая мелочь чинилась отдельно — как в panel.css годами. Новый интерфейс должен не давать собрать пустую колонку, разные высоты Host/Type и кнопки на двух строках.

+

Три оси, которые больше не выбираются «на глаз» у каждой страницы:

+
    +
  1. Регион — как блок занимает ширину окна.
  2. +
  3. Карточка — заголовок, справка, тело, действия.
  4. +
  5. Контрол — одно поле или пара полей одной высоты.
  6. +
+
+ +
+

Регионы

+

Страница — это stack регионов, не класс на <main>. Оболочка одна: навбар прижат влево, контент забирает остаток. Навбар не прыгает, когда меняется содержимое.

+ + + + + + + + + + + + + + + + + + + + + + + + +
РегионШиринаКогда
measureдо 42rem, влевоОдна читаемая форма: login, setup, подтверждение удаления, форма пользователя. Не «вся страница Settings».
pairдве колонки до 90remДве равноправные задачи на одном экране. Один ребёнок — сам сжимается в measure (domain-admin без DMARC).
fillдо 90remТаблица, лог, DNS-статус, список приложений. Ячейки не переносят однострочные значения; карточка скроллится по X.
stackколонка с зазоромВертикальный порядок регионов. Зазор даёт gap, не .card + .card.
+ +
+
Нельзя — страница-форма, две задачи стопкой
+
+
+

Full backup

+

Карточка узкая. Справа пустое поле на 1600px.

+ +
+
+

Import a domain

+

Вторая задача под первой — та же пустота.

+ +
+
+
+ +
+
Надо — pair
+
+
+

Full backup

+

Секрет. Шифрование — внутри карточки, поля не растягиваются на 1440px.

+
+
+
+

Import a domain

+

Тот же экран, вторая задача. Не «ещё одна форма ниже».

+
+
+
+
+
+ +
+

Карточка

+

Один хром: card-head (заголовок + слот «?»), тело, при необходимости actions-row. Справка — часть хрома, её не расставляют после того, как карточки уже собраны. Нет «?» — слот пустой, заголовки соседних карточек всё равно на одной линии.

+

Показания (очередь, PTR, CPU) остаются на карточке. В drawer уходит только то, чего на карточке быть не должно: что такое kernel counter, зачем PTR у провайдера, почему пароль показывают один раз.

+
+
Хром карточки
+
+
+
+

DNS status ok

+ ? +
+

Чтение и Re-check здесь. Абзац «зачем MX» — в справке.

+
+
+
+

Danger zone

+
+

Без «?»: действие очевидное. Карточка всё равно пара к форме, не на всю ширину.

+ +
+
+
+
+ +
+

Контролы

+

Одно поле — field (подпись + контроль). Два поля в ряд — field-row: подписи в первой сетке-строке, значения во второй. Высота значений общая, потому что это одна строка грида, а не два блока с подобранным padding. Баг «Type ниже Host» (b0ebe06) из этой разметки не собирается.

+

field-row с узкой второй колонкой — Host / name ‖ Type. field-row equal — два равноправных инпута (лимит и окно).

+ +
+
field-row — Host ‖ Type, с Copy и без
+
+
+

With Copy

+
+
+ +
+ mail._domainkey.example.com + +
+
+
+ + TXT +
+
+
+
+

Lookup, no Copy

+
+
+ + lists.example.com +
+
+ + MX +
+
+
+
+
+ +
+
field-row equal — лимит ‖ окно
+
+
+

Level-2 rate limit

+
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+

Действия

+

Primary, secondary и danger — всегда actions-row. Для вёрстки неважно, POST это или переход на confirm: ряд один. Форма либо оборачивает всю карточку, либо кнопки несут form=. Нельзя оставить Submit внутри блочной формы, а Delete — следующим соседом: блок формы занимает строку целиком.

+
+
Save и Delete в одном ряду
+
+
+

Edit user

+ + +
+ + Delete user +
+
+
+
+
+ +
+

Нельзя

+

Если хочется добавить правило «только на этой странице» — сначала проверить, какого региона не хватило.

+
    +
  • Класс на main (ops / form) как способ выбрать ширину. Ширину выбирает регион.
  • +
  • Одиночная карточка на 90rem с двумя полями ввода. Это measure или pair.
  • +
  • Danger zone отдельным fill. Она вторая колонка последней пары (получатели ‖ удалить, экспорт ‖ удалить).
  • +
  • Два .code рядом с разным padding, чтобы «почти совпало». Только field-row.
  • +
  • Submit внутри <form>, danger-ссылка после </form>.
  • +
  • Перенос однострочного поля, адреса, статуса, hostname. nowrap + горизонтальный скролл карточки.
  • +
  • Status из одних бейджей без Detail / без строки очереди / без PTR.
  • +
  • Телефон как уменьшенный десктоп с шестиколоночной таблицей. Таблица → список; pair → одна колонка; field-row остаётся парой.
  • +
  • Копировать разметку internal/web/view/templates «как есть» в макет. Рецепт экрана — ниже, не шаблон Go.
  • +
+
+ +
+

Рецепты экранов

+

Экраны — отдельные HTML-файлы рядом с этой спецификацией. Собираются только так:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ЭкранСтек регионов
Login / setupцентр оболочки, measure 24rem, полный stamp
Statusstack: overall → pair очередь‖TLS → pair milters‖PTR → inbound если есть → pair machine‖processes → configuration. На карточках остаются числа и Detail.
Domains / Inbound / Deliveries / log / queuefill таблица. Добавление домена — поле в той же карточке списка (measure внутри, не вторая карточка и не инпут на 90rem).
Domainfill DNS status → pair DKIM/SPF ‖ DMARC → pair connection ‖ add app → fill applications → pair domain settings (две половины) → pair export ‖ danger. «?» на рабочих карточках.
Inbound domainfill MX DNS → pair upstream ‖ MX to publish → pair recipients ‖ danger
DMARChub: pair ingest ‖ this week, затем fill список отчётов. Домен: roll-up (pair 7 days ‖ third-party, fill reports + sources). Один XML: просмотр — pair report ‖ policy, fill records. Не дашборд, не ruf=.
Backuppair full backup ‖ import
Settingsglobal: pair credentials ‖ DMARC, затем fill rate limits. Domain-admin: один ребёнок в pair → сам measure
Usersfill таблица. Create/Edit — measure + actions-row
Confirm deletemeasure одна карточка
+
+ +
+

Телефон

+

Аварийный доступ, не продукт. pair складывается в одну колонку. Таблица заменяется списком (как сейчас в прототипе). field-row не складывается: Type — токен. Навбар — выезжающая колонка на checkbox, без обязательного JS. Знак в шапке — SP-иконка, не второй wordmark.

+
+ +
+

Как внедрять

+
    +
  1. Новый экран или правка макета — только классы из system.css (stack, pair, measure, fill, field-row, actions-row).
  2. +
  3. Не добавлять исключения в mock.css «для этой страницы», если это ширина, высота пары полей или ряд кнопок.
  4. +
  5. Click-through — отдельные страницы (status.html, domain.html, …), не простыня с якорями. Оболочка общая: shell.js.
  6. +
  7. Вёрстка internal/web — отдельная задача после утверждения системы, не параллельный рескин шаблонов.
  8. +
+

Классы живут в docs/assets/panel-ui/system.css. Этот файл — спецификация. Экраны — status.html и соседние страницы; оглавление — index.html.

+
+ +
+ + diff --git a/docs/assets/panel-ui/user-delete.html b/docs/assets/panel-ui/user-delete.html new file mode 100644 index 0000000..80daf4f --- /dev/null +++ b/docs/assets/panel-ui/user-delete.html @@ -0,0 +1,27 @@ + + + + + +Delete ops-alerts — SelfPost mockups + + + + + +
+
+

Delete ops-alerts

+ ← Back to ops-alerts +
+
+
+

Confirm deletion

+

You are about to delete the panel user ops-alerts. A signed-in session for this user stops working immediately.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/user-form.html b/docs/assets/panel-ui/user-form.html new file mode 100644 index 0000000..5d43347 --- /dev/null +++ b/docs/assets/panel-ui/user-form.html @@ -0,0 +1,42 @@ + + + + + +Edit user — SelfPost mockups + + + + + +
+
+

Edit user

+ ← Back to users +
+
+
+
+ + + + + + +
+ Assigned domains +

Required for domain administrators.

+ + +
+
+ + Delete user +
+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/users.html b/docs/assets/panel-ui/users.html new file mode 100644 index 0000000..37bbe3b --- /dev/null +++ b/docs/assets/panel-ui/users.html @@ -0,0 +1,29 @@ + + + + + +Users — SelfPost mockups + + + + + +
+

Users

+
+
+

Create user

+ + + + + + +
UsernameRoleDomains
adminGlobalAllEdit
ops-alertsDomain adminalerts.example.comEdit
+
+
+
+ + + diff --git a/docs/development.md b/docs/development.md index 40bc595..8e8b551 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,6 +66,58 @@ mechanics → Haiku. Reviewers must not be the author of the code under review. --- +## Plan checklists + +Open [roadmap.md](roadmap.md) items with a linked plan file carry an +**Implementation checklist** in [plans/](plans/). After a context reset, work +the **active** plan — not the roadmap prose — from the first unchecked line. + +**Checklist format** + +- `- [ ] Step description — **Model**` (model from the routing table above). +- Mark `[x]` only in the commit that actually finishes the step. +- `candidate` items: checklist may exist, but no code until the roadmap status + is agreed. + +**Progress column** in the roadmap index (`0/N`) counts every checklist line in +that plan (including docs and `go vet` / `go test` steps). Update `N` when the +checklist changes; update the numerator when steps are checked off. + +### Roadmap track: commit and version + +For the current 1.x+ roadmap queue ([roadmap.md](roadmap.md) index), stricter +than the default «meaningful step» rule in [§ Commits and release build](#commits-and-release-build): + +**After each checklist step** + +1. Check off the step in the plan file; bump `Progress` in the roadmap index. +2. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]` for that step. +3. Commit on `main` (push / tag / Release only on explicit request). + +**After each roadmap stage** (all checklist steps done; «Done when» satisfied) + +1. Version cut in one commit: rename `[Unreleased]` to `[X.Y.Z] - date`, open a + fresh `[Unreleased]`, bump the image pin in + [deploy/docker-compose.yml](../deploy/docker-compose.yml). +2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see + [§ Release image](#release-image)). + +**Planned version cuts** (from pin `1.3.1`; adjust if semver changes mid-track): + +| Stage | ID | Cut | +|---|---|---| +| 1 | queue-retries | `1.3.1` (PATCH) | +| 2 | inbound-relay | `1.4.0` | +| 3 | send-log-retention | `1.5.0` | +| 4 | domain-stats-auto-ratelimit | `1.6.0` | +| 5 | dmarc-reports | `1.7.0` | +| 6 | panel-docs | `1.8.0` | + +Docs-only prep (checklists in plans, no product code) uses the same per-step +commit rule but **no** version cut until the next product stage ships. + +--- + ## Technology stack and tools | Component | Version / notes | @@ -93,8 +145,9 @@ mechanics → Haiku. Reviewers must not be the author of the code under review. ## External libraries The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and -third-party notices: [NOTICE](../NOTICE). New Go dependencies must be -permissive or GPL-family (see +third-party notices: [NOTICE](../NOTICE). The tree does not use per-file +`SPDX-License-Identifier` headers; AGPL-3.0 does not require them. New Go +dependencies must be permissive or GPL-family (see [.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). ### Main module (`go.mod`) @@ -113,6 +166,7 @@ the tree are AGPL-3.0-compatible. | Asset | Version | Repository | License | |---|---|---|---| | `internal/web/view/static/htmx.min.js` | 2.0.4 | | 0BSD | +| `internal/web/view/static/ibm-plex-*.woff2` | latin subset | | SIL OFL 1.1 (`OFL.txt` beside the files) | ### E2e module (`test/e2e/go.mod`) @@ -128,8 +182,10 @@ the image. Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come from Debian bookworm repositories; licenses are in each package's `copyright` file on . -The image also ships [LICENSE](../LICENSE) and [NOTICE](../NOTICE) under -`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license`. +The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM +Plex [OFL.txt](../internal/web/view/static/OFL.txt) under +`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and +the OFL text at `/static/OFL.txt`. --- @@ -187,9 +243,12 @@ tag / push only on explicit request (see `release.yml`). ### Release image -The release image is published **only on tag** `vX.Y.Z` (not on every push to -`main`). The tag is the single source of version: it drives the image tag and -`-ldflags` in the binaries so they cannot drift apart. +The release image is published **only** for a SemVer version `X.Y.Z`: a +**published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch` +that supplies that version. Pushing a git tag alone does not publish. Ordinary +commits, and a dispatch from `main` without a version input, do not publish. +The version is the single source that drives the image tag and `-ldflags` in +the binaries so they cannot drift apart. **Steps (on explicit request):** @@ -197,8 +256,42 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any local-trial image references) in the **same** release commit. 2. Create and push git tag `vX.Y.Z` on that commit. -3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates, - and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`. +3. Publish the GitHub Release for `vX.Y.Z` (not a draft). +4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates, + and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`). + +**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases) +page lists only **published** releases. A draft is visible to maintainers only — +it looks like “no releases” to everyone else. CI does not create or publish the +GitHub Release; you do that in the UI. Deleting a release’s git tag on GitHub +(or re-pushing tags while cleaning the registry) converts a published release +back into a **draft** — that matches “I published three times and it keeps +disappearing”. After publish, leave the tag on GitHub; clean up only unwanted +GHCR package versions, not the git tag. + +Push workflow and source changes to **github.com/mixeme/selfpost** before +publishing — Actions reads that repo, not Gitea. + +**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to +GitHub, two things follow: + +1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups + prune remote tags that are absent on Gitea (or re-push with `--force` / + `--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published + Release back to draft. Mirror **branches and new tags forward**; do not + delete release tags on the GitHub side. GHCR cleanup is package versions in + the UI — not `git push github --delete` and not tag prune on the mirror. + +2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0` + still points at a commit whose `release.yml` has `on: push: tags` and no + per-arch GHCR cleanup — every mirror (re)push of that tag can republish + `1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run + `release.yml` on **Publish release** (`release: published`), so mirroring + those tags alone does not start the image build. + + Safe mirror: push tags to GitHub without deleting existing ones; keep release + tags on Gitea; publish the GitHub Release on github.com after the mirror has + the tag. Ordinary commits **do not** publish an image. The compose pin and the git tag must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate @@ -265,7 +358,11 @@ operator would actually use. **Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify → send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch, L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives -`docker restart`). Polling with timeouts only — no fixed `sleep`. +`docker restart`); startup checks that supervisord actually brought up +OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate +config-mode and forced-rotation checks (`checkLogrotateConfigMode`, +`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)). +Polling with timeouts only — no fixed `sleep`. Requires **Docker + Compose v2** on the machine running the suite. @@ -280,21 +377,33 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs — `gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e). -### `release.yml` — push of tag `vX.Y.Z` or `workflow_dispatch` +### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer + +Publishing a GitHub Release runs `release.yml` directly (`release: published`, +same pattern as gosentry / imap-scrub). You can also run it manually via +`workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not +run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD. + +`prepare` takes the version from `github.event.release.tag_name` on a release +event, or from the `workflow_dispatch` `version` input. A dispatch whose input +is missing or not `X.Y.Z` fails in `prepare`. ``` -prepare (version from tag) +release: published +prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z) → build [matrix: ubuntu-latest / ubuntu-24.04-arm] - → docker build --load (VERSION from tag) + → docker build --load (VERSION from prepare) → e2e (test/e2e) → push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64 → merge → docker buildx imagetools create → unified manifest X.Y.Z + → GitHub Packages API → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR ``` Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under emulation for e2e is impractical. E2e first, then push — the registry receives -the bytes that passed the gate. +the bytes that passed the gate. Only `ghcr.io/mixeme/selfpost:X.Y.Z` remains +tagged in GHCR; per-arch names exist briefly during the merge job. A failed e2e **blocks** image publication. diff --git a/docs/guide.md b/docs/guide.md index 499efd8..f5fbbfe 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -3,55 +3,53 @@ Detailed install, configuration, and day-to-day operations. For a short overview and quick start, see [README.md](../README.md). +This guide has three parts: **[Installation](#installation)** (getting a +container running with a working reverse proxy and TLS), **[Instance +administration](#instance-administration)** (running and maintaining the +SelfPost server itself — status, backups, users, upgrades), and **[Domain +administration](#domain-administration)** (day-to-day work on the sending +domains hosted on that instance — DNS, deliveries, rate limits, applications). + ## Table of contents -- [Reverse proxy (mandatory)](#reverse-proxy-mandatory) -- [Local trial](#local-trial) -- [Environment variables](#environment-variables) -- [DNS setup](#dns-setup) -- [IP warmup](#ip-warmup) -- [Operations](#operations) -- [Rate limiting](#rate-limiting) -- [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain) - - [Encrypting a backup or export](#encrypting-a-backup-or-export) -- [Published ports](#published-ports) -- [Fixed image tag](#fixed-image-tag) +- [Installation](#installation) + - [Ports](#ports) + - [Local trial](#local-trial) + - [Initial setup](#initial-setup) + - [Full deployment](#full-deployment) + - [Fixed image tag](#fixed-image-tag) + - [Environment variables](#environment-variables) + - [Reverse proxy (mandatory)](#reverse-proxy-mandatory) +- [Instance administration](#instance-administration) + - [Status](#status) + - [Mail queue and System log](#mail-queue-and-system-log) + - [Settings](#settings) + - [Users](#users) + - [Sessions](#sessions) + - [Upgrading](#upgrading) + - [Container health](#container-health) + - [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns) + - [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop) + - [Full backup and restore](#full-backup-and-restore) + - [Encrypting a backup or export](#encrypting-a-backup-or-export) +- [Domain administration](#domain-administration) + - [Domains page](#domains-page) + - [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc) + - [IP warmup](#ip-warmup) + - [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application) + - [Deliveries](#deliveries) + - [Exporting and importing a single domain](#exporting-and-importing-a-single-domain) -## Reverse proxy (mandatory) +## Installation -SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse -proxy in front of it is not optional. The proxy is also the project's only -source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets -bind-mounted **read-only** into the SelfPost container, and Postfix uses those -same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail -service share one hostname — the common case — it's genuinely one certificate -serving both. +### Ports -SelfPost isn't tied to a specific proxy; pick whichever fits your host: +`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465 +(smtps) is always active. Port **587** is published even when +`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless, +but it can look like an open port in external scans. -| Proxy | Where certs live | Fragment | -|---|---|---| -| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) | -| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) | -| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) | -| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) | - -Apache is the recommended default because the certbot Apache plugin already -writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no -extra moving parts between "certificate issued" and "Postfix can read it." - -**The proxy needs no security configuration of its own.** The panel emits its -own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, -`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part -that's easy to get wrong lives in the service rather than in a config file -somebody edits under pressure. There is exactly one thing the proxy must do: -**pass the original `Host` header through**. All four fragments above already -do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy -and Traefik by default). A proxy that rewrites `Host` instead makes the panel -reject every form submission as cross-origin — the log says so explicitly, -printing the `Origin` and `Host` it compared. - -## Local trial +### Local trial The [README quick start](../README.md#quick-start) runs a single container with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No @@ -85,7 +83,99 @@ Add `-v /tmp/selfpost-certs:/etc/postfix/tls:ro` to the `docker run` command (and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN). Clients must skip TLS verification — the cert is not from a public CA. -## Environment variables +### Initial setup + +On first start the one-time setup URL is printed in the container log +(`docker compose logs -f`) and written to `/data/setup-token` inside the +container — `./data/setup-token` on the host, mode `0600` — then deleted when +setup completes. The link is `https:///setup/` (path +token, not a query string), valid for ten minutes. Open it to choose the +administrator username and password — until then the panel has no login. If +this host ships container logs to a central aggregator, prefer reading the +file: + +```sh +docker compose exec selfpost cat /data/setup-token +``` + +### Full deployment + +Production layout: one `docker-compose.yml`, a `.env`, persistent `./data`, and +TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached +only through a [reverse proxy](#reverse-proxy-mandatory) on 443 — port 8080 is +bound to localhost in the default compose file. + +| Artefact | Path | +|---|---| +| Compose file (fixed image tag) | [deploy/docker-compose.yml](../deploy/docker-compose.yml) | +| Environment template | [deploy/.env.example](../deploy/.env.example) | +| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) | +| nginx | [deploy/nginx/](../deploy/nginx/) | +| Caddy | [deploy/caddy/](../deploy/caddy/) | +| Traefik | [deploy/traefik/](../deploy/traefik/) | + +**1. Fetch the base files.** + +```sh +mkdir -p selfpost/data selfpost/certs && cd selfpost +curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml +curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example +cp .env.example .env +``` + +Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare +FQDN, e.g. `mail.example.com`). It must match the PTR record you request from +your provider and the certificate your proxy will obtain. See [Environment +variables](#environment-variables) for the full list. + +**2. Reverse proxy and TLS.** Pick and set up one proxy — see [Reverse proxy +(mandatory)](#reverse-proxy-mandatory) for the per-proxy commands. The same +certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem` +so Postfix can serve it on 465 (and 587 if enabled). + +**3. Start SelfPost.** If you used Apache on the host (the recommended +option), start only the base compose file from your `selfpost/` directory: + +```sh +docker compose up -d +``` + +The nginx/Caddy/Traefik fragments already include `docker compose up -d` — +skip this if you ran one of those. + +**Get the setup URL** — open it in a browser to create the admin account (see +[Initial setup](#initial-setup)): + +```sh +docker compose logs selfpost 2>&1 | grep -m1 'http' +``` + +```sh +cat ./data/setup-token +``` + +**4. DNS and sending.** Before sending real mail: + +1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status + page → *Re-check*) — see [Server-level DNS](#server-level-dns-ptrrdns). +2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the + same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). +3. Warm up a new IP gradually ([IP warmup](#ip-warmup)). + +#### Fixed image tag + +`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), +deliberately never `:latest`. The current pin is `1.3.1`. Intermediate +CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that +image was published. Pinning matters because of the backup version check (see +[Full backup and restore](#full-backup-and-restore)): the panel binary's +embedded version and the image tag that produced it are the same value by +construction (the release CI stamps both from one git tag — see +`.github/workflows/release.yml`), so the pin is what makes "restore into the +same version" a checkable fact rather than a guess. Upgrade by bumping the tag +deliberately, not by riding a moving target — see [Upgrading](#upgrading). + +### Environment variables Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your `docker-compose.yml`. The table below lists every variable an operator is @@ -95,7 +185,7 @@ expected to set; defaults match the code exactly. |---|---|---|---| | `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` | | `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` | -| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting](#rate-limiting). | `100` | `.env` | +| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` | | `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` | | `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` | | `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` | @@ -106,176 +196,223 @@ TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in [deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs` bind mount — configure the mount, not these variables. -**Internal variables (not part of the operator interface).** The following are -read by the panel or startup scripts but are not meant to be changed in a -normal deployment; documenting them here avoids treating accidental overrides as -supported configuration: +The image also reads a number of internal, non-operator env vars (paths, +timeouts, tuning) — not part of this interface; see +[architecture.md § Configuration](architecture.md#configuration) if you need +them. -- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH` - (`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE` - (`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`), - `JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG` - (`/data/log/mail.log` — read by the panel and written by Postfix, so a change - here has to be matched in `build/postfix-config.sh`), - `PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET` - (`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`), - `DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH` - (`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`), - `POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` - (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel - always writes `/sender_login_maps`, so overriding this env alone - desyncs the map Postfix reads from the file the panel maintains). -- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`), - `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), - `MILTER_WAIT_TIMEOUT` (`30` seconds). -- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily - `postfix reload` to pick up renewed certificates), - `LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six - hours; logrotate keeps 14 rotated files on a daily schedule, and each - rotation triggers `postfix reload`). +### Reverse proxy (mandatory) -## DNS setup +SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse +proxy in front of it is not optional. The proxy is also the project's only +source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets +bind-mounted **read-only** into the SelfPost container, and Postfix uses those +same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail +service share one hostname — the common case — it's genuinely one certificate +serving both. -Two different scopes — don't confuse them: +SelfPost isn't tied to a specific proxy; pick whichever fits your host: -**Server level (once, for the machine itself):** -- **PTR/rDNS** for the server's IP, pointing at its mail hostname. Most - receiving mail servers weigh this heavily; get it from whoever assigns the IP - (hosting provider's panel/support), not from your own DNS zone. +| Proxy | Where certs live | Fragment | +|---|---|---| +| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) | +| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) | +| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) | +| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) | -**Domain level (for *every* sending domain you add in the panel):** -- **SPF** — a TXT record on the domain authorizing this server to send on its - behalf (e.g. `v=spf1 a mx ip4: -all`, adjusted to your setup). -- **DKIM** — a TXT record with the exact value the panel shows on that - domain's page (`domain page → DKIM TXT record`), one selector per domain. -- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring - only, safe to publish immediately). On a send-only relay the sending domain - often has no inbox, so `rua=` is optional — configure a default report address - in *Settings* or per domain when you have a mailbox that receives inbound mail - elsewhere. If `rua=` points at another domain, publish `_report._dmarc` on that - hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …) - cannot be used as external report destinations. +Apache is the recommended default because the certbot Apache plugin already +writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no +extra moving parts between "certificate issued" and "Postfix can read it." -Skipping any of the three per-domain records is the single most common reason -mail lands in spam even though SelfPost delivered it correctly — DKIM passing -doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the -panel, add its DNS records at the same time**, not later. +**The proxy needs no security configuration of its own.** The panel emits its +own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`, +`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part +that's easy to get wrong lives in the service rather than in a config file +somebody edits under pressure. There is exactly one thing the proxy must do: +**pass the original `Host` header through**. All four fragments above already +do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy +and Traefik by default). A proxy that rewrites `Host` instead makes the panel +reject every form submission as cross-origin — the log says so explicitly, +printing the `Origin` and `Host` it compared. -The panel checks both scopes for you and tells you what is actually published: -the *Status* page verifies the server's hostname and its reverse record -(forward-confirmed reverse DNS), and each domain's page shows a *DNS status* -card comparing the published DKIM record against the key this server signs with, -plus the domain's SPF, DMARC, and (when configured) DMARC report-authorisation -records. Results are cached for a few minutes; -use *Re-check* right after publishing a record. The SPF check is deliberately -shallow — it looks for a mechanism that literally covers this server's address -and does not follow `include:` or `redirect=`, so a record that authorizes the -server through an include is reported as "cannot tell" rather than as a failure. +In every case the proxy terminates HTTPS for the panel; the resulting +certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`. -## IP warmup +**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and +`proxy_http` enabled. Copy +[deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) into your +vhost directory, replace `mail.example.com` with your hostname, enable the site, +then issue a certificate: -A brand-new IP has no sending history, so receiving servers are cautious with -it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to -a domain, increase gradually over days/weeks rather than sending everything on -day one, and check the IP against major blocklists (Spamhaus and similar) -before and during warmup. This is inherent to how mail reputation works on the -public internet, not something SelfPost's configuration can shortcut. +```sh +sudo certbot --apache -d mail.example.com +``` -## Operations +Point `./certs` at the PEM files certbot wrote (symlink is fine): + +```sh +ln -s /etc/letsencrypt/live/mail.example.com certs +``` + +**nginx (containerised).** From the `deploy/` directory, merge the nginx +fragment and issue the first certificate before nginx can serve HTTPS: + +```sh +docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \ + run --rm certbot certonly --webroot -w /var/www/certbot \ + -d mail.example.com --email you@example.com --agree-tos --no-eff-email + +docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d +``` + +Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and +replace `mail.example.com` first. The fragment bind-mounts certbot's output into +both nginx and SelfPost. + +**Caddy (containerised, automatic ACME).** Edit +[deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `` placeholders +in [deploy/caddy/docker-compose.caddy.yml](../deploy/caddy/docker-compose.caddy.yml), +then: + +```sh +docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d +``` + +Verify Caddy's on-disk cert path for your version before relying on the +default mount — see the comment at the top of the Caddy compose fragment. + +**Traefik (containerised).** Edit the `Host(...)` label and ACME email in +[deploy/traefik/docker-compose.traefik.yml](../deploy/traefik/docker-compose.traefik.yml), +start the stack, then extract PEM files for Postfix whenever Traefik issues or +renews a certificate: + +```sh +docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d +./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs +``` + +Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals. + +## Instance administration After sign-in the panel opens on **Status** — the place to answer "is the service healthy and will mail be accepted?" -- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel), - TLS certificate validity and expiry, milter socket presence, and a short - Postfix queue summary. The **Machine** card adds the resource usage of the - host underneath — processor (core and thread counts), memory and swap, and - per-interface network throughput and totals — read from the kernel's - counters; CPU and throughput are measured between refreshes, so they appear - one refresh after the page opens. A fully busy processor or a machine out of - memory is a warning here, because both delay or kill the mail path; - throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME` - against the PTR record the internet publishes for this server's IP - (forward-confirmed reverse DNS); use *Re-check* after changing DNS. The - **Reload configuration** button re-applies OpenDKIM tables and the Postfix - sender map from the database — use it if daemons drifted from what the panel - shows after manual edits under `/data`. -- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM - TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits - (level 2) and trusted-IP application overrides are configured here. - *Export domain* writes a single-domain archive; - *Import a domain* on the Backup page reads one back in. -- **Deliveries** (`/deliveries`) — searchable send log with server-side filters - by domain and application. A row identifies its message and nothing more — - time, sender, recipient, subject and status `queued` (accepted, not yet - delivered), `sent` (handed off successfully), `deferred` (Postfix is retrying), - `bounced` (final failure), or `rejected` (refused — for example by a level-2 - rate limit); *Details* opens that row's own page - (`/deliveries/{id}`). That page carries the sending domain, the application it - was submitted under, the Postfix queue id and the journal id, beside the - message's history — when it was accepted and what Postfix later reported for - the recipient — and, under both, the `mail.log` lines for its queue id: the - connection to the receiving server, the server's reply, and the status that - reply was filed as. Rows outlive `mail.log`, so an older message's lines may - have rotated away; the page says so. Retention is controlled by - `SEND_LOG_RETENTION_DAYS`. +### Status + +`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS +certificate validity and expiry, milter socket presence, and a short Postfix +queue summary. The **Machine** card adds the resource usage of the host +underneath — processor (core and thread counts), memory and swap, and +per-interface network throughput and totals — read from the kernel's +counters; CPU and throughput are measured between refreshes, so they appear +one refresh after the page opens. A fully busy processor or a machine out of +memory is a warning here, because both delay or kill the mail path; +throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME` +against the PTR record the internet publishes for this server's IP +(forward-confirmed reverse DNS) — see +[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing +DNS. The **Reload configuration** button re-applies OpenDKIM tables and the +Postfix sender map from the database — use it if daemons drifted from what +the panel shows after manual edits under `/data`. + +### Mail queue and System log + - **Mail queue** (`/mail-queue`) — live view of messages Postfix is still - trying to deliver or deferring. + trying to deliver or deferring. A card at the top states this instance's + retry policy — first retry delay, later backoff cap, how long a message + stays in the queue — from `postconf -h`, read once when the panel starts. + A `postconf -e` override inside the container is visible after the next + panel (or container) restart. There is no maximum attempt count: Postfix + retries until the message is delivered or the queue lifetime runs out. - **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and related daemon lines). The log rotates daily (14 files kept) with a `postfix reload` after each rotation; a background loop checks every six hours. It lives in the data volume, so it survives a container recreate along with the rest of the state — `./data/log/` on the host — but it is *not* included in backups: it is diagnostics, not state. -- **Backup** (`/backup`) — download a full-server backup; the same page hosts - the domain-import form (`POST /domains/import`). See - [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain). -- **Settings** (`/settings`) — change the administrator username and/or password. - Application SASL logins are separate and are not changed here. -**Sessions.** A login survives a container restart: sessions live in SQLite, not -in memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default +### Settings + +`/settings` changes the signed-in user's username and/or password. **Global +administrators** also set the panel-wide default DMARC report address (`rua=`) +offered when a domain doesn't set its own — see +[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins +are separate and are not changed here. + +### Users + +`/users` (global administrator only) creates, edits, and deletes panel users. +There are two roles: + +- **Global administrator** — full access to every page and every domain, + including Users, Backup, Status, Mail queue, and System log. +- **Domain-admin** — scoped to one or more domains assigned by a global + administrator. Sees only those domains' pages, applications, and + Deliveries rows; cannot add or delete domains. `/users`, `/backup`, + `/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not + reachable (404). A domain-admin can *export* the + domains assigned to them — see + [Exporting and importing a single domain](#exporting-and-importing-a-single-domain). + +The panel refuses to remove or demote the **last** global administrator, so +it can never end up with none. + +### Sessions + +A login survives a container restart: sessions live in SQLite, not in +memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default seven days) with no absolute lifetime cap — an admin who keeps using the panel stays signed in indefinitely. HTMX polling on the monitoring screens (Deliveries, Mail queue, System log, and the Status health fragment) does **not** count as activity, so an auto-refreshing tab left open will not keep a -session alive forever. Changing the password signs out every other session but -leaves the current browser signed in. +session alive forever. Changing **your own** password on `/settings` signs out +every other session for that user but leaves the current browser signed in. +Signing out (`POST /logout`) ends only the current session — other browsers or +tabs for the same user keep working until their session rows expire. -**Upgrading.** Bump the pinned image tag in `docker-compose.yml` to the target -release, then `docker compose up -d`. The backup version check requires the -running image to match the version that created a full backup — see [Fixed image +### Upgrading + +Bump the pinned image tag in `docker-compose.yml` to the target release, then +`docker compose up -d`. The backup version check requires the running image +to match the version that created a full backup — see [Fixed image tag](#fixed-image-tag). -**Container health.** The image declares a Docker `HEALTHCHECK` that probes -`GET /healthz` on port 8080 (unauthenticated). It returns `200 ok` when -OpenDKIM, the panel, and Postfix are all `RUNNING` under supervisord; -otherwise `503 unhealthy`. This catches a dead mail path that would still leave -the HTTP server up, but it does **not** verify TLS certificates, DNS records, -or end-to-end delivery — use the authenticated **Status** page for that. External -monitoring can use the same endpoint through the reverse proxy if you expose it, -or poll `docker inspect` health state on the host. +### Container health -**First-time setup link.** On first start the one-time setup URL is printed in -the container log (`docker compose logs -f`) and written to `/data/setup-token` -inside the container — `./data/setup-token` on the host, mode `0600` — then -deleted when setup completes. The link is -`https:///setup/` (path token, not a query string), -valid for ten minutes. If this host ships container logs to a central -aggregator, prefer reading the file: +The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port +8080 (unauthenticated). It returns `200 ok` when OpenDKIM, the panel, and +Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This +catches a dead mail path that would still leave the HTTP server up, but it +does **not** verify TLS certificates, DNS records, or end-to-end delivery — +use the authenticated [Status](#status) page for that. External monitoring +can use the same endpoint through the reverse proxy if you expose it, or poll +`docker inspect` health state on the host. -```sh -docker compose exec selfpost cat /data/setup-token -``` +### Server-level DNS (PTR/rDNS) -## Rate limiting +Once, for the machine itself: **PTR/rDNS** for the server's IP, pointing at +its mail hostname. Most receiving mail servers weigh this heavily; get it +from whoever assigns the IP (hosting provider's panel/support), not from +your own DNS zone. -SelfPost applies two independent layers; both can refuse a submission, but only -level 2 writes a `rejected` row in the send log. Level-2 ceilings set in the -panel cannot exceed level 1 (the panel shows the level-1 values and rejects -higher numbers). +The [Status](#status) page verifies the server's hostname against this +record (forward-confirmed reverse DNS). Results are cached for about one +minute; use *Re-check* right after publishing a record. -**Level 1 (IP backstop)** — always on, configured via `.env`: +Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see +[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). + +### Rate limiting — level 1 (IP backstop) + +SelfPost applies two independent layers of rate limiting; both can refuse a +submission, but only level 2 (domain/application, see +[Domain administration](#rate-limiting--level-2-domain-and-application)) +writes a `rejected` row in the send log. Level-2 ceilings set in the panel +cannot exceed level 1 (the panel shows the level-1 values and rejects higher +numbers). + +Level 1 is always on, configured via `.env`: - `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit` - `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit` @@ -283,64 +420,126 @@ higher numbers). This is an anvil limit per connecting client IP. It keeps working even if the journal-milter (level 2) is down. There is no per-IP bypass. -**Level 2 — domain** — optional, on each domain's page. A message ceiling and -window for **every** client IP sending as that domain. When unset, only -level 1 applies for non-privileged senders. +### Full backup and restore -**Level 2 — application (trusted IPs)** — optional override on an application: -list one or more client IPs and a ceiling **strictly above** the domain limit -(still ≤ level 1). Connections from those IPs use the application ceiling and -skip the domain check. Other IPs stay under the domain limit (or level 1 alone). -An application override without trusted IPs is inactive. +**Full backup** is a self-contained project archive: `data/` (SQLite, all +domains' DKIM keys, all applications' SASL credentials, the Postfix queue, +`manifest.json` with the version that created it), plus `docker-compose.yml`, +`.env`, and `certs/` from the operator directory next to `./data`. Delivery +logs under `data/log/` are excluded. The base compose file mounts the project +directory read-only at `/selfpost-deploy` so the panel and CLI can read those +deploy files — without that mount, *Full backup* refuses with an error. -When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is -recorded in Deliveries as `rejected`. +Take a backup from the panel (*Backup* → *Full backup*) or from the host: -## Backup, restore, and moving a single domain +```sh +docker exec selfpost-backup > selfpost-backup.tar.gz +``` -Two related but distinct operations -([architecture.md](architecture.md) § Persistence): +**Restore** means unpacking that archive into an **empty project directory** +(not into `./data` alone) and starting a container of the **exact same image +version** that created it — SelfPost refuses to start otherwise and tells you +which tag to use. On the first successful start after restore, `data/manifest.json` +from the archive is **deleted** — it guards only that one boot, so a later +in-place upgrade is not blocked. On that same first boot the panel also runs +one **Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from +SQLite and both daemons are reloaded, healing any drift between the extracted +files and the database (the Status page's *Reload configuration* button runs +the same step on demand). This is why the compose file pins a fixed tag rather +than `:latest`: without a known version, there'd be no way to tell which image +restoring a given backup actually requires (see [Fixed image +tag](#fixed-image-tag)). -- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys, - all applications' SASL credentials, `manifest.json` with the version that - created it): panel button (*Backup* → *Full backup*), or from the - host: - ```sh - docker exec selfpost-backup > selfpost-backup.tar.gz - ``` - **Restore** means unpacking that archive into a fresh `/data` bind mount and - starting a container of the **exact same image version** that created it — - SelfPost refuses to start otherwise and tells you which tag to use. On the - first successful start after restore, `manifest.json` from the archive is - **deleted** — it guards only that one boot, so a later in-place upgrade is - not blocked. This is why the compose file pins a fixed tag rather than - `:latest`: without a known version, there'd be no way to tell which image - restoring a given backup actually requires. +**Restoring in place** (same host — recovering from data loss, or rolling +back after a bad change): - **Alternative: archive `./data` while stopped.** If the service can be taken - offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on - the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup - this sweeps in `./data/log/` too, which is Postfix's raw log and usually the - bulk of the archive; add `--exclude=./data/log` if you only want the state. - Do **not** tar `./data` while - the container is running: the database uses WAL mode and a naive copy can - capture an inconsistent snapshot. The panel/CLI backup remains preferable when - you cannot afford downtime because it takes a consistent SQLite snapshot via - the Backup API on a live container. +```sh +# 1. Stop the instance being replaced +docker compose down -- **Export/import a single domain** (domain page → *Export domain* to write the - file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working** - SASL passwords — to a different SelfPost instance without regenerating - anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full - restore, this works across different hostnames/instances. +# 2. Move the current project aside rather than deleting it +mv . ../selfpost.before-restore +mkdir selfpost && cd selfpost -Both files are **secrets** — they contain the admin password hash (full -backup) or working application credentials (domain export) in the clear or in -directly reversible form. Treat them like any other credential material: -restrict who can read them, don't email them around — and encrypt them, which -SelfPost can do for you. +# 3. Unpack the backup into the fresh directory +tar xzf ../selfpost-backup.tar.gz -### Encrypting a backup or export +# 4. docker-compose.yml in the archive must pin the exact tag the backup was +# made with — check if unsure: +tar xzf ../selfpost-backup.tar.gz -O data/manifest.json + +# 5. Start it and watch the boot +docker compose up -d +docker compose logs -f selfpost +``` + +A version mismatch at step 5 refuses to start and leaves `/data` untouched — +the panel exits with a message naming the tag to use, e.g.: + +``` +backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.1 — restore into the matching image (selfpost:1.2.3) +``` + +Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up +-d` again — the manifest is still there because the failed boot never got to +delete it. + +**Moving to a different host** is the same flow: create an empty project +directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if +needed) for the new hostname or proxy, then `docker compose up -d`. The archive +carries `certs/` from the old host — re-issue certificates when the hostname or +IP changes. Set up the reverse-proxy vhost separately (not in the backup). + +**Restoring an encrypted (`.spbk`) backup** needs a running container to +decrypt it first — any container with the `selfpost-backup` CLI works; decryption +does not read `/data` and performs no version check. Start one normally +(step 5, but on an empty project you have not unpacked yet), then: + +```sh +docker exec -i selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz +``` + +Stop it, wipe the project directory again, and continue from step 2 above with +the resulting `.tar.gz` — see [Encrypting a backup or +export](#encrypting-a-backup-or-export) for the decrypt command's password +options. + +**Archives from older SelfPost versions** (flat layout: `manifest.json` and +`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore +with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project +that already has `docker-compose.yml` and `.env`. + +Restoring an archive taken **before** a session row was removed can bring +that session back: session rows travel with the backup, and a browser that +still holds the matching cookie is signed in again on the next request if the +restored row's idle expiry has not passed. `POST /logout` removes only the +current session; there is no "logout everywhere". Changing your own password +on `/settings` deletes your other sessions, but a global administrator +resetting another user's password on `/users` does not invalidate that user's +existing sessions. + +**Alternative: archive `./data` while stopped.** If the service can be taken +offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on +the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup +this sweeps in `./data/log/` too, which is Postfix's raw log and usually the +bulk of the archive; add `--exclude=./data/log` if you only want the state. +Do **not** tar `./data` while the container is running: the database uses +WAL mode and a naive copy can capture an inconsistent snapshot. The +panel/CLI backup remains preferable when you cannot afford downtime because +it takes a consistent SQLite snapshot via the Backup API on a live +container. + +See also [Exporting and importing a single +domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped +operation that also lives on the *Backup* page (`/backup`). + +Both a full backup and a domain export are **secrets** — they contain the +admin password hash (full backup), TLS private keys and `.env` (full backup), +or working application credentials (domain export) in the clear or in +directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them +around — and encrypt them, which SelfPost can do for you. + +#### Encrypting a backup or export Both download forms carry an **Encrypt with a password** checkbox. Ticked, the file that comes down is an encrypted envelope instead of the plain archive: @@ -380,21 +579,124 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" selfpost-backup > back With no password set, the CLI keeps writing the plain `.tar.gz` it always has. -## Published ports +## Domain administration -`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465 -(smtps) is always active. Port **587** is published even when -`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless, -but it can look like an open port in external scans. +### Domains page -## Fixed image tag +`/domains` lists sending domains and hosts the add-domain form (**global +administrator only**). Domain administrators see only domains assigned to +them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL +applications. Per-domain rate limits (level 2) and trusted-IP application +overrides are configured here — see [Rate limiting — +level 2](#rate-limiting--level-2-domain-and-application). *Export domain* +writes a single-domain archive; *Import a domain* on the Backup page reads +one back in (**global administrator only**) — see [Exporting and importing a single +domain](#exporting-and-importing-a-single-domain). -`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), -deliberately never `:latest`. The current pin is `1.2.5`. Intermediate -CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that -image was published. Pinning matters because of the backup version check above: -the panel binary's embedded version and the image tag that produced it are the -same value by construction (the release CI stamps both from one git tag — see -`.github/workflows/release.yml`), so the pin is what makes "restore into the -same version" a checkable fact rather than a guess. Upgrade by bumping the tag -deliberately, not by riding a moving target. +### Domain-level DNS (SPF, DKIM, DMARC) + +For *every* sending domain you add in the panel: + +- **SPF** — a TXT record on the domain authorizing this server to send on its + behalf (e.g. `v=spf1 a mx ip4: -all`, adjusted to your setup). +- **DKIM** — a TXT record with the exact value the panel shows on that + domain's page (`domain page → DKIM TXT record`), one selector per domain. +- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring + only, safe to publish immediately). On a send-only relay the sending domain + often has no inbox, so `rua=` is optional — configure a default report address + in *Settings* (see [Settings](#settings)) or per domain when you have a + mailbox that receives inbound mail elsewhere. If `rua=` points at another + domain, publish `_report._dmarc` on that hub domain too; the panel checks + it. Public mail hosts (Gmail, Outlook, …) cannot be used as external + report destinations. + +Skipping any of the three records is the single most common reason mail +lands in spam even though SelfPost delivered it correctly — DKIM passing +doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the +panel, add its DNS records at the same time**, not later. + +Each domain's page shows a *DNS status* card comparing the published DKIM +record against the key this server signs with, plus the domain's SPF, +DMARC, and (when configured) DMARC report-authorisation records. Results are +cached for a few minutes; use *Re-check* right after publishing a record. +The SPF check is deliberately shallow — it looks for a mechanism that +literally covers this server's address and does not follow `include:` or +`redirect=`, so a record that authorizes the server through an include is +reported as "cannot tell" rather than as a failure. + +Server-level DNS (the PTR/rDNS record) is a separate, once-per-machine scope +— see [Server-level DNS](#server-level-dns-ptrrdns). + +### IP warmup + +A brand-new IP has no sending history, so receiving servers are cautious with +it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to +a domain, increase gradually over days/weeks rather than sending everything on +day one, and check the IP against major blocklists (Spamhaus and similar) +before and during warmup. This is inherent to how mail reputation works on the +public internet, not something SelfPost's configuration can shortcut. + +### Rate limiting — level 2 (domain and application) + +Level 2 is optional, configured on each domain's page, and layers on top of +the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop). +Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values +and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix +returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as +`rejected`. + +**Level 2 — domain** — a message ceiling and window for **every** client IP +sending as that domain. When unset, only level 1 applies for non-privileged +senders. + +**Level 2 — application (trusted IPs)** — optional override on an +application: list one or more client IPs and a ceiling **strictly above** +the domain limit (still ≤ level 1). Connections from those IPs use the +application ceiling and skip the domain check. Other IPs stay under the +domain limit (or level 1 alone). An application override without trusted +IPs is inactive. + +**Level 2 is best-effort, not a guarantee.** It runs inside the +journal-milter and is deliberately fail-open: if the rate-limit lookup hits +a store error, or the connecting client's IP is not available to the +milter, level 2 is skipped and the message is accepted rather than held up. +Level 1 is the backstop that keeps working even when level 2 cannot run. + +### Deliveries + +`/deliveries` is a searchable send log with server-side filters by domain +and application. A row identifies its message and nothing more — time, +sender, recipient, subject and status `queued` (accepted, not yet +delivered), `sent` (handed off successfully), `deferred` (Postfix is +retrying), `bounced` (final failure), or `rejected` (refused — for example +by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application)); +*Details* opens that row's own page (`/deliveries/{id}`). That page carries +the sending domain, the application it was submitted under, the Postfix +queue id and the journal id, beside the message's history — when it was +accepted and what Postfix later reported for the recipient. A `deferred` +or `bounced` row includes this Postfix's retry intervals (first delay, +backoff cap, queue lifetime), the same numbers Mail queue shows; domain +administrators see them here because they cannot open Mail queue. Under +both sit the `mail.log` lines for its queue id: the connection to the +receiving server, the server's reply, and the status that reply was filed +as. Rows outlive `mail.log`, so an older message's lines may have rotated +away; the page says so. Retention is controlled by +`SEND_LOG_RETENTION_DAYS`. + +### Exporting and importing a single domain + +Domain page → *Export domain* to write the file, *Backup* → *Import a +domain* to read it back in. This moves one domain — its DKIM key and its +applications' **working** SASL passwords — to a different SelfPost instance +without regenerating anything, so DNS (the DKIM TXT record) doesn't need to +change. Unlike a full restore (see [Full backup and +restore](#full-backup-and-restore)), this works across different +hostnames/instances. *Import* is global-administrator only; *export* is +available to any user who can access the domain, **including a domain-admin** +for a domain assigned to them — so a domain-admin can walk away with that +domain's working SASL passwords in the clear. Weigh that when deciding which +domains to assign to a domain-admin account. + +A domain export is a secret in the same way a full backup is, and can be +encrypted the same way — see [Encrypting a backup or +export](#encrypting-a-backup-or-export). diff --git a/docs/plans/dmarc-reports.md b/docs/plans/dmarc-reports.md index eda56a2..c2d0028 100644 --- a/docs/plans/dmarc-reports.md +++ b/docs/plans/dmarc-reports.md @@ -18,7 +18,8 @@ operator. - gzip + XML aggregate parsing → SQLite summaries per sending domain. - Panel page and/or per-domain section: recent reports, third-party senders, delivery health of report ingestion. -- Reuse `admin.dmarc_report_email` and `domains.dmarc_rua` for DNS templates; +- Reuse the `dmarc_report_email` setting (moved off the old `admin` table into + `settings` by migration `0005`) and `domains.dmarc_rua` for DNS templates; when enabled, suggest a SelfPost-hosted report address. **Out:** @@ -47,3 +48,18 @@ remain a separate, opt-in feature that does not forward mail upstream. - Attack surface of accepting mail (mitigate: strict recipient allow-list). - Report volume and retention (mitigate: caps + pruning). + +## Implementation checklist + +Target version cut: **`1.7.0`** (MINOR). One commit per step; code only after +roadmap status is **agreed**. Expand the sketch sections above before step 1 +if still thin. See [development.md](../development.md) § Plan checklists. + +- [ ] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet** +- [ ] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus** +- [ ] Worker: gzip/XML parse → SQLite — **Opus** +- [ ] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet** +- [ ] Tie-in `dmarc_report_email` / `domains.dmarc_rua` — **Sonnet** +- [ ] Tests and [guide.md](../guide.md) — **Sonnet** +- [ ] Security review ingest path — **Fable** +- [ ] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/plans/domain-stats-auto-ratelimit.md b/docs/plans/domain-stats-auto-ratelimit.md new file mode 100644 index 0000000..73495a9 --- /dev/null +++ b/docs/plans/domain-stats-auto-ratelimit.md @@ -0,0 +1,205 @@ +# Plan: domain-stats-auto-ratelimit + +**Status:** candidate +**Date:** 2026-08-17 +**Version:** `1.x` MINOR; migrations must stay compatible with `1.0.0`. + +--- + +## Goal + +Give the operator **30-day sending statistics** per domain and per application +(total volume, peak and average rate), and an optional **auto** level-2 rate +limit that sets `max_messages` from the average rate (avg × multiplier) over the +level-1 window. + +## Scope + +**In:** + +- Rolling **30-day** stats on each domain page (domain aggregate + per-app rows): + message count, peak msg/h, average msg/h. +- Level-2 rate limit mode **manual** (today) or **auto** for domain and + application scopes. +- Auto formula: `max_messages = ceil(avg_hourly × multiplier)`, window = + level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); capped at level 1. +- Background recalculation (e.g. every 6 h, alongside send-log prune); milter + reads stored `max_messages` / `window_seconds` only (no aggregates on the + hot path). +- Panel UI: stats card, manual/auto toggle, multiplier field, read-only computed + limit in auto mode, optional «Recalculate now». +- RBAC: domain-admin sees stats and may configure auto/manual for assigned + domains only; same authz as existing rate-limit handlers. +- Tests, [guide.md](../guide.md), [CHANGELOG.md](../../CHANGELOG.md); security + review (Fable) for rate-limit path changes. + +**Out:** + +- Changing level 1 (Postfix env) — auto only fills level 2 under the L1 cap. +- Automated IP warmup schedules ([guide.md](../guide.md) § IP warmup stays + operator-driven). +- Prometheus/Grafana, alerting, APIs. +- Counting level-1 refusals or `rejected` rows as sent volume. +- Per-client-IP analytics. + +## Data source + +All metrics come from SQLite `send_log`, same rules as +[`CountMessages`](../../internal/store/ratelimits.go): + +- One message = one distinct `queue_id` (many recipients = one count). +- `status != rejected` (level-2 refusals never queued). +- Level-1 refusals are **not** in `send_log` — stats under-count refusals; + document in UI copy. + +Retention today is env `SEND_LOG_RETENTION_DAYS` (default 90). Stats use the +last **30 days** of rows still present. If retention < 30 days (after +[send-log-retention](send-log-retention.md)), the stats window is +`min(30, retention)` with a warning. + +### Metrics + +| Metric | Definition | +|---|---| +| **total** | `COUNT(DISTINCT queue_id)` in the stats window | +| **peak rate** | maximum messages in any **hourly** bucket in that window (msg/h) | +| **avg rate** | `total / hours_in_window`, where `hours_in_window = min(720, age of domain/app in hours, retention hours)` | + +Keys: `send_log.domain` (domain scope), `send_log.app_login` (application scope). + +## Architecture + +```mermaid +flowchart LR + milter["journal-milter"] --> sendLog["send_log"] + sendLog --> statsQuery["StatsQuery 30d"] + statsQuery --> domainPage["domain page stats card"] + statsQuery --> autoJob["auto recalc job"] + autoJob --> rateLimits["rate_limits"] + rateLimits --> milter +``` + +1. **[`internal/store/stats.go`](../../internal/store/stats.go)** — `SendStats` + with `Total`, `PeakPerHour`, `AvgPerHour`; `DomainSendStats(name, since)` / + `AppSendStats(login, since)`. +2. Hourly buckets: `strftime('%Y-%m-%d %H', created_at)` + `GROUP BY`; subquery + for peak; total via distinct `queue_id`. +3. Indexes `idx_send_log_domain` and `idx_send_log_created_at` exist; add + composite `(domain, created_at)` only if profiling shows need. +4. **Auto recalc** — panel goroutine (same interval as send-log prune): for each + `rate_limits` row with `mode = auto`, recompute `max_messages`, set + `auto_updated_at`. Milter unchanged except reading new columns via existing + `RateLimit` lookup. + +### Auto rate limit + +Extend [`RateLimit`](../../internal/store/ratelimits.go): + +```go +type RateLimit struct { + // existing: Scope, RefID, AllowedIPs, MaxMessages, WindowSeconds + Mode string // "manual" | "auto" + AutoMultiplier float64 // default 2.5 when Mode == "auto" + AutoUpdatedAt time.Time +} +``` + +**Formula:** + +``` +avg_hourly = total_messages_in_window / hours_in_window +max_messages = ceil(avg_hourly * auto_multiplier) +window_seconds = L1 window (not editable in auto mode) +max_messages = min(max_messages, L1 max) +``` + +When `total == 0`: auto limit stays **inactive** (same as empty manual limit); +UI explains that traffic is required before auto can apply. + +**Application overrides** ([`handlers_ratelimit.go`](../../internal/web/handlers/handlers_ratelimit.go)): + +- Trusted IPs required. +- Auto app ceiling **strictly above** domain limit when domain limit is active. +- Ceiling ≤ L1. + +**Fail-open:** store errors during recalc must not weaken enforcement of the +last successfully written limit; recalc failures are logged only. + +### Migration (`0006_rate_limit_auto.sql`) + +```sql +ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual' + CHECK (mode IN ('manual', 'auto')); +ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL; +ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT; +``` + +Existing rows → `manual`. + +### Domain export + +Today rate limits are **not** exported. This plan adds them (including +`mode`, `auto_multiplier`) to domain transfer JSON — document as a boundary +change in [guide.md](../guide.md) § Export. + +## Panel UI + +- Domain page ([`domain_detail.html`](../../internal/web/view/templates/domain_detail.html)): + - **Sending statistics (30 days)** — total, peak msg/h, avg msg/h. + - Per-application stats in the app list. + - Rate limit: Manual / Auto, multiplier (e.g. 1.5–5.0, default 2.5), read-only + computed max/window in auto mode, «Recalculate now». +- Optional later: «30d» column on domain list (global admin only). + +## Tests + +- `internal/store/stats_test.go` — fixtures → total / peak / avg. +- `internal/store/ratelimits_test.go` — auto recalc, L1 cap, app > domain. +- Handler tests — auto form validation, multiplier bounds. +- Milter tests — enforced limit matches last recalculated values. + +`go test` / `go vet` on touched packages. + +## Done when + +- Domain and app 30-day stats visible on the domain page; domain-admin scoping + enforced. +- Manual/auto toggle works for domain and app; auto recalc updates `rate_limits` + and milter enforces stored ceilings. +- Zero-traffic auto stays inactive with clear UI copy. +- [guide.md](../guide.md) and [CHANGELOG.md](../../CHANGELOG.md) updated; + security review passed. + +## Risks + +- Heavy aggregation on large `send_log` tables — mitigate with indexes or + nightly rollups (phase 2). +- Stats without level-1 visibility — mitigate with operator-facing caveat. +- Auto limit too tight after a spike — multiplier is operator-tuned; show peak + alongside avg in auto UI. + +## Dependencies + +- [`send-log-retention`](send-log-retention.md) is a separate roadmap item but + should land before or in parallel so operators can set retention ≥ 30 days + from the panel. + +**Version:** `1.x` MINOR. + +## Implementation checklist + +Target version cut: **`1.6.0`** (MINOR). One commit per step; code only after +roadmap status is **agreed**. See [development.md](../development.md) § Plan +checklists. + +- [ ] Migration `0006_rate_limit_auto.sql` (`mode`, `auto_multiplier`, `auto_updated_at`) — **Opus** +- [ ] `internal/store/stats.go`: total / peak / avg over 30 days — **Opus** +- [ ] Auto recalc job (6h): `ceil(avg × multiplier)`, L1 cap, fail-open on error — **Opus** +- [ ] Extend `RateLimit` + handler forms (manual/auto) — **Opus** +- [ ] Domain page stats card + per-app stats (`domain_detail.html`) — **Sonnet** +- [ ] Domain export JSON includes rate limits — **Opus** +- [ ] Milter tests — enforced limit matches stored ceiling — **Opus** +- [ ] Store and handler tests — **Sonnet** +- [ ] [guide.md](../guide.md) — **Sonnet** +- [ ] Security review rate-limit path — **Fable** +- [ ] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/plans/inbound-relay.md b/docs/plans/inbound-relay.md index 6291ebd..5d643ae 100644 --- a/docs/plans/inbound-relay.md +++ b/docs/plans/inbound-relay.md @@ -3,9 +3,10 @@ **Status:** agreed **Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once the implementation lands (do not fix a major in advance). -**Order:** recommended next among agreed 1.x+ items (after `internal/web` split -and the domain-admin role, both shipped — see [CHANGELOG](../CHANGELOG.md) -`[1.2.0]`). +**Order:** queue-retries shipped in `[1.3.1]`; this is next as the largest +remaining 1.x+ **feature**. The 2026-08-13 full-tree review follow-ups closed +in `[1.3.0]`. The domain-admin role and `internal/web` split have shipped — +see [CHANGELOG](../../CHANGELOG.md) `[1.2.0]`. --- @@ -167,3 +168,25 @@ the SelfPost image, brought up by the operator when the option is enabled. A finished outbound path (already implemented). Agreement obtained — see the status above. + +## Implementation checklist + +Target version cut: **`1.4.0`** (MINOR). One commit per step; see +[development.md](../development.md) § Plan checklists. UI reference: +[panel-ui inbound mockups](../assets/panel-ui/inbound.html). + +- [ ] Migration: inbound domain / recipient / transport tables under `/data` — **Opus** +- [ ] `INBOUND_RELAY_ENABLE` (default false) in entrypoint + `postfix-config.sh` — **Opus** +- [ ] `master.cf`: inbound `smtp inet` on 25; separate from 465/587 — **Opus** +- [ ] Generate `relay_domains`, `transport_maps`, `relay_recipient_maps` (injection-safe) — **Opus** +- [ ] `smtpd_relay_restrictions` / recipient maps — no open relay, no backscatter — **Opus** +- [ ] `internal/store` CRUD + validation (domain, host, port) — **Opus** +- [ ] Panel: list, add, domain detail, recipients, danger zone (mockups) — **Sonnet** +- [ ] Rate limit + `message_size_limit` on inbound smtpd — **Opus** +- [ ] Optional `INBOUND_ANTISPAM_MILTER` + compose fragment — **Opus** +- [ ] DNS MX copy in README/guide; `.env.example` — **Sonnet** +- [ ] Backup/export inbound config (per plan optional flag) — **Opus** +- [ ] Unit + handler tests; image build and container smoke — **Opus** +- [ ] [guide.md](../guide.md) and [security.md](../security.md) — **Sonnet** +- [ ] Security review inbound path — **Fable** +- [ ] `go vet`, `go test`, e2e if applicable — **Haiku** diff --git a/docs/plans/logrotate-mode.md b/docs/plans/logrotate-mode.md deleted file mode 100644 index e16ecdd..0000000 --- a/docs/plans/logrotate-mode.md +++ /dev/null @@ -1,28 +0,0 @@ -# Plan: logrotate-mode (mail.log stops rotating in some images) - -**Status:** done -**Version:** patch; no schema, no configuration surface. - ---- - -## Summary - -`mail.log` stopped rotating when `/etc/logrotate.d/mail` landed in the image -with group/other write permission (e.g. build context from a Windows tar sync). -logrotate ignores such configs but exits 0, so the loop looked healthy while -the log grew without bound. - -## What shipped - -- [`build/Dockerfile`](../../build/Dockerfile): `COPY --chmod` pins config - (`0644`) and script (`0755`) modes so the image no longer depends on checkout - file modes. -- [`build/logrotate-loop.sh`](../../build/logrotate-loop.sh): preflight and - per-iteration checks refuse group/other-writable configs; logrotate stderr - mentioning `Ignoring` is fatal. -- [`test/e2e/logrotate_check.go`](../../test/e2e/logrotate_check.go): asserts - mode `644`, forced `logrotate -f`, and that a group-writable context file - still produces `644` in the image. - -`create 0640 postfix selfpost` in [`build/logrotate-mail.conf`](../../build/logrotate-mail.conf) -is unchanged — required for panel readability after rotation. diff --git a/docs/plans/panel-docs.md b/docs/plans/panel-docs.md new file mode 100644 index 0000000..dac8014 --- /dev/null +++ b/docs/plans/panel-docs.md @@ -0,0 +1,54 @@ +# Plan: panel-docs (in-panel operator documentation) + +**Status:** candidate +**Date:** 2026-08-17 +**Version:** `1.x` MINOR; `candidate` until explicitly agreed. + +--- + +## Goal + +Built-in operator documentation in the panel — short pages or a help drawer that +explain what each Status check and other controls mean, without sending the +operator out to [guide.md](../guide.md). + +## Scope + +**In:** + +- Help drawer or short help pages (CSS-checkbox pattern from + [panel-ui mockups](../assets/panel-ui/system.html)). +- Seed content: Status blurbs removed from cards for a denser layout — Machine + (kernel counters / rate window), TLS certificate (port 465, reverse-proxy + mount), Hostname / reverse DNS (FCrDNS, PTR at the hosting provider), and + similar notes for other surfaces as inline commentary is removed. +- «?» entry points on domain cards (mockups). + +**Out:** + +- A second copy of the full operator guide. +- Translation workflow beyond English (same as the rest of the panel). + +## Done when + +An operator can open help from the panel for those topics; the removed Status +blurbs are preserved there (or equivalent); no requirement to read the git +tree for day-to-day meaning of a card. + +## Risks + +Copy ownership and keeping help in sync when checks change; not bloating every +page with a second column of prose. + +## Implementation checklist + +Target version cut: **`1.8.0`** (MINOR). One commit per step; code only after +roadmap status is **agreed**. See [development.md](../development.md) § Plan +checklists. + +- [ ] Help drawer / pages shell (CSS checkbox pattern from mockups) — **Sonnet** +- [ ] Seed Status blurbs (Machine, TLS, PTR, …) — **Sonnet** +- [ ] «?» entry points on domain cards — **Sonnet** +- [ ] [guide.md](../guide.md) boundary: in-panel help vs full guide — **Sonnet** +- [ ] Template tests — **Sonnet** +- [ ] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/plans/queue-retries.md b/docs/plans/queue-retries.md new file mode 100644 index 0000000..4c0227e --- /dev/null +++ b/docs/plans/queue-retries.md @@ -0,0 +1,156 @@ +# Plan: queue-retries (Postfix retry policy in the panel) + +**Status:** done — shipped in `[1.3.1]` (2026-08-17) +**Date:** 2026-08-13 +**Version:** patch; no schema, no configuration surface. +**Order:** small panel item; does not wait on inbound-relay. + +--- + +## Goal + +Show the operator how Postfix retries deferred mail: first retry delay, later +backoff cap, and how long a message stays in the queue before it bounces. The +numbers come from this container's effective Postfix config, not from +hard-coded copy. + +## Scope + +**In:** + +- A static «How delivery retries work» card on `/mail-queue` (global + administrator). Not inside the HTMX poll fragment — the snapshot is taken at + panel start. +- The same human-readable intervals in `/deliveries/{id}` history for + `deferred` and `bounced` (domain administrators never see Mail queue). +- Operator docs: [guide.md](../guide.md) Mail queue bullet; + [architecture.md](../architecture.md) notes the one-shot `postconf -h` at + panel start. [CHANGELOG.md](../../CHANGELOG.md) `### Added`. + +**Out:** + +- Changing Postfix retry parameters, or exposing them as panel settings + ([product.md](../product.md): Postfix is used as-is). +- An `attempts` column on `send_log`, or «attempt 3 of N» — Postfix has no + attempt budget; it is time-based. +- Counting `status=deferred` lines in `mail.log` or reading `postcat`. Each + attempt is already on the delivery page's log table; the journal stores only + the last status. +- Duplicating the card on Status (summary + link to Mail queue already exist). +- Re-reading `postconf` on every HTTP request. + +## Architecture + +Acceptance is still synchronous SMTP. Delivery stays in Postfix's on-disk +queue. SelfPost does not enqueue, retry, or deliver. + +```mermaid +flowchart LR + smtpAccept["SMTP 250 accepted"] --> postfixQueue["Postfix queue"] + postfixQueue -->|"deferred: backoff until queue lifetime"| retry["Retry MX"] + retry -->|sent| done["sent"] + retry -->|lifetime elapsed or 5xx| bounce["bounced"] + postfixQueue --> mailQueuePage["/mail-queue policy card"] + postfixQueue --> deliveryPage["/deliveries/id history copy"] +``` + +[build/postfix-config.sh](../../build/postfix-config.sh) does not set +`queue_run_delay`, `minimal_backoff_time`, `maximal_backoff_time`, +`maximal_queue_lifetime`, `bounce_queue_lifetime`, or `delay_warning_time`. +Debian/Postfix 3.x compiled-in defaults therefore apply unless the operator +overrides them (`postconf -e` inside the container). + +### Loading the numbers + +Once, when the HTTP role starts ([cmd/panel/httpserver.go](../../cmd/panel/httpserver.go), +after `postfix-config.sh` has run): + +``` +postconf -h queue_run_delay minimal_backoff_time maximal_backoff_time maximal_queue_lifetime bounce_queue_lifetime delay_warning_time +``` + +`postconf -h`, not a parse of `/etc/postfix/main.cf`: stock values are not +written to the file. `postconf` is the effective config, including a manual +override. + +- Fixed argv, no user input — same pattern as + [postfix.Queue](../../internal/postfix/queue.go) / + [security.md](../security.md). +- Cache on `handlers.Config` (via `web.Config`). The HTMX fragment does not + call `postconf`. +- A live `postconf -e` is visible after the next panel (or container) restart. + While the process is up, the panel shows the start-up snapshot. +- Parse Postfix time units (`300s`, `5d`, `1h`, a bare number is seconds) in + `internal/postfix`. Format human strings (`5 minutes`, `5 days`, + `about 1 hour 7 minutes`) in one place so the Mail queue card and + `deliveryEvents` cannot drift. +- If `postconf` is missing (unit tests on Windows, binary outside the + container): log a warning, fall back to Postfix 3.x compiled-in defaults + (`300s` / `4000s` / `5d` / `0`), and put a muted note on the card. Tests + stub the lookup (as `queueIDs` in the log-tailer) or pass a fixture on + `Config`. Do not fail panel start. + +Typical stock values, for orientation only — the UI prints whatever +`postconf` returned: + +| Parameter | Stock | Meaning | +|---|---|---| +| `queue_run_delay` / `minimal_backoff_time` | `300s` | First retry and deferred-queue scan | +| `maximal_backoff_time` | `4000s` | Cap on the doubling gap (~1 h 7 min) | +| `maximal_queue_lifetime` | `5d` | Then bounce | +| `delay_warning_time` | `0` | No delay warning to the sender | + +## Panel copy + +Mail queue card facts: first retry; later retries (doubling, capped); +kept in queue; then bounced. Short prose: there is no fixed attempt count; a +`deferred` message stays in this listing until it is delivered or the queue +lifetime runs out. + +`deliveryEvents(row, policy)`: + +- `deferred`: retries, first after X, then with increasing gaps up to Y, for + up to Z. +- `bounced`: or Postfix gave up after Z in the queue. + +## Tests + +- Duration parser: `5d`, `300s`, `4000s`, `1h`, `0`, bare number. +- `/mail-queue` handler: card shows the fixture policy's human strings, not a + live `postconf`. +- Delivery page / `deliveryEvents`: `deferred` and `bounced` contain those + strings ([handlers_monitor_test.go](../../internal/web/handlers/handlers_monitor_test.go)). +- [templates_test.go](../../internal/web/view/templates_test.go): pass the new + fields if rendering `mail_queue` requires them. + +`go test` / `go vet` on the touched packages. + +## Done when + +- `/mail-queue` states this Postfix's first retry, backoff cap, and queue + lifetime. +- A `deferred` / `bounced` delivery page uses the same intervals. +- A manual `postconf -e maximal_queue_lifetime=2d` followed by a panel restart + changes what the panel prints. +- Guide and architecture describe the snapshot; CHANGELOG has an Added entry. + +## Risks + +- Showing compiled-in fallbacks when `postconf` failed would mislead if the + operator had overridden them — mitigate with the muted note on the card. +- Inventing a max-attempt count would be false; the copy must stay time-based. + +## Implementation checklist + +Target version cut: **`1.3.1`** (PATCH). One commit per step; see +[development.md](../development.md) § Plan checklists. + +- [x] `internal/postfix`: parse Postfix time units (`5d`, `300s`, bare seconds) + tests — **Opus** +- [x] `internal/postfix`: one-shot `postconf -h` (six keys), fallback + warn — **Opus** +- [x] Load policy at HTTP start in `cmd/panel/httpserver.go`; cache on handlers config — **Opus** +- [x] Human-readable duration formatter (shared by Mail queue card and delivery history) — **Sonnet** +- [x] «How delivery retries work» card on `/mail-queue` (outside HTMX fragment) — **Sonnet** +- [x] `deliveryEvents(row, policy)` — intervals in deferred/bounced copy — **Sonnet** +- [x] Handler and template tests (`handlers_monitor_test.go`, `templates_test.go`) — **Sonnet** +- [x] [guide.md](../guide.md) and [architecture.md](../architecture.md) — **Sonnet** +- [x] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/plans/send-log-retention.md b/docs/plans/send-log-retention.md new file mode 100644 index 0000000..b7b4e91 --- /dev/null +++ b/docs/plans/send-log-retention.md @@ -0,0 +1,139 @@ +# Plan: send-log-retention + +**Status:** candidate +**Date:** 2026-08-17 +**Version:** `1.x` MINOR; no schema migration required (uses existing `settings` table). + +--- + +## Goal + +Let the **global administrator** change how long delivery journal rows +(`send_log`, `/deliveries`) are kept, from the panel — without editing `.env`. + +## Context (as-built) + +Retention **already exists**, but only via environment: + +- `SEND_LOG_RETENTION_DAYS` (default **90**) in `.env` / Compose. +- [`cmd/panel/main.go`](../../cmd/panel/main.go) passes it to + [`logtail.Run`](../../internal/logtail/logtail.go). +- [`retentionLoop`](../../internal/logtail/logtail.go) prunes via + [`DeleteSendLogBefore`](../../internal/store/sendlog.go) every **6 hours**. +- No panel control; [`handlers_monitor.go`](../../internal/web/handlers/handlers_monitor.go) + hardcodes «ninety days» in copy. +- Migration `0001_init.sql` describes `settings` as the place for «retention + overrides», but no UI writes that key yet. + +This plan moves the **effective** retention into SQLite `settings`, with env as +bootstrap only. + +## Scope + +**In:** + +- Settings card on `/settings` (global administrator only): **Send log + retention (days)**. +- Key `send_log_retention_days` in [`settings`](../../internal/store/settings.go). +- Validation: integer range **7–365** (exact bounds fixed at implementation). +- On first use: if setting missing, seed from env + (`SEND_LOG_RETENTION_DAYS`, default 90) at panel start or first save. +- Log-tailer reads the setting **each prune cycle** (no container restart). +- Delivery pages and guide copy show the **current** retention, not a hardcoded + 90. +- Tests; [guide.md](../guide.md); [CHANGELOG.md](../../CHANGELOG.md). + +**Out:** + +- Per-domain retention (instance-wide only). +- `mail.log` rotation (logrotate, 14 daily files — unchanged). +- Immediate prune on save when lowering retention (next 6 h cycle is enough; + optional «Prune now» not in v1). +- Domain-admin access to this setting. + +## Architecture + +```mermaid +flowchart LR + settingsPage["/settings form"] --> sqlite["settings.send_log_retention_days"] + env["SEND_LOG_RETENTION_DAYS bootstrap"] --> sqlite + sqlite --> retentionLoop["logtail retentionLoop"] + retentionLoop --> prune["DeleteSendLogBefore"] +``` + +1. **Read path** — `GetSendLogRetentionDays()`: settings value if valid, else env + default. +2. **Write path** — POST `/settings` (global admin): validate, `SetSetting`, + flash confirmation. +3. **Prune path** — change [`logtail.retentionLoop`](../../internal/logtail/logtail.go) + to accept `func() int` or `RetentionReader` that queries settings each cycle + (same 6 h ticker). +4. **Copy** — inject retention days into delivery list/detail templates and + remove hardcoded «ninety days». + +`SEND_LOG_RETENTION_DAYS` remains documented in [guide.md](../guide.md) as the +**initial default** until changed in Settings. + +## Relation to domain-stats-auto-ratelimit + +[domain-stats-auto-ratelimit](domain-stats-auto-ratelimit.md) uses a **30-day** +stats window. Requires effective retention ≥ 30 for full accuracy. When +retention < 30: + +- Stats UI shows a warning and uses `min(30, retention)` as the window, or +- Settings validation warns when saving a value below 30 while stats/auto are + enabled (pick one at implementation; document in guide). + +Recommended roadmap order: **send-log-retention** before or parallel with +domain-stats-auto-ratelimit. + +## Panel UI + +New card on [`settings.html`](../../internal/web/view/templates/settings.html) +(global admin block, near rate limits or under a «Deliveries» heading): + +- Number input: retention days (7–365). +- Muted copy: rows older than this are deleted from `/deliveries`; main driver + of `/data` growth; does not affect `mail.log` rotation. + +Domain administrators keep the narrow credentials-only settings page. + +## Tests + +- Save/load setting; reject out-of-range values. +- `retentionLoop` uses updated value without process restart (mock reader). +- Bootstrap: empty settings → env default used for prune. +- Template/delivery copy reflects configured days. + +`go test` / `go vet` on touched packages. + +## Done when + +- Global admin can set retention on `/settings`; value persists in SQLite. +- Prune uses the panel value on the next cycle; env remains bootstrap default. +- Guide documents panel vs env; CHANGELOG entry added. +- Hardcoded «ninety days» removed from delivery UI. + +## Risks + +- Operator lowers retention while bookmarking old delivery URLs — existing + behaviour; copy already notes pruned rows are gone. +- Settings change without restart — must be tested so log-tailer never keeps a + stale int from panel start only. + +**Version:** `1.x` MINOR. + +## Implementation checklist + +Target version cut: **`1.5.0`** (MINOR). One commit per step; code only after +roadmap status is **agreed**. See [development.md](../development.md) § Plan +checklists. + +- [ ] `GetSendLogRetentionDays` / `SetSetting` key `send_log_retention_days` (7–365) — **Opus** +- [ ] Bootstrap from `SEND_LOG_RETENTION_DAYS` when settings empty — **Opus** +- [ ] `logtail.retentionLoop`: read setting each prune cycle — **Opus** +- [ ] Settings card on `/settings` (`settings.html`) — **Sonnet** +- [ ] Remove hardcoded «ninety days» in handlers and templates — **Sonnet** +- [ ] Tests: save/load, range, loop without restart — **Sonnet** +- [ ] [guide.md](../guide.md) — **Sonnet** +- [ ] `go vet`, `go test` on touched packages — **Haiku** diff --git a/docs/product.md b/docs/product.md index 95ce410..fe8ba1a 100644 --- a/docs/product.md +++ b/docs/product.md @@ -59,7 +59,9 @@ domain administrators with assigned domains). The optional **inbound relay** is the main agreed **1.x+** extension still on the [roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major remains possible pending implementation. Items marked *candidate* in the -roadmap require explicit approval before coding. +roadmap require explicit approval before coding (including **send-log retention +in Settings**, **30-day send statistics**, and **auto level-2 rate limits** — +see linked plans there). --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 3420de8..5f4aadd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,9 @@ planned but unlisted. **Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for -an explicit break. +an explicit break. One such break, when 2.x is cut for any reason, is +[schema-squash](#schema-squash) — replacing the 1.x SQLite migration chain +with a baseline. That item does not by itself justify a major. **Process:** [development.md](development.md). The history of closed phases is in `git log` and [CHANGELOG.md](../CHANGELOG.md). @@ -24,19 +26,27 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). ## Index -| ID | Topic | Status | Plan | -|---|---|---|---| -| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | -| contributing | `CONTRIBUTING.md` | candidate | — | -| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | -| panel-docs | In-panel operator documentation | candidate | — | +| ID | Topic | Status | Progress | Plan | +|---|---|---|---|---| +| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 0/15 | [plans/inbound-relay.md](plans/inbound-relay.md) | +| send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) | +| domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) | +| contributing | `CONTRIBUTING.md` | candidate | — | — | +| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | 0/8 | [plans/dmarc-reports.md](plans/dmarc-reports.md) | +| panel-docs | In-panel operator documentation | candidate | 0/6 | [plans/panel-docs.md](plans/panel-docs.md) | +| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — | -**Recommended order** (not binding): **inbound-relay** first among agreed -items — it is the largest remaining 1.x+ extension. Candidates need explicit -agreement before they join the queue. +**Recommended order** (not binding): the next feature is **inbound-relay**. +**send-log-retention** can land before or beside **domain-stats-auto-ratelimit** +(panel retention ≥ 30 days helps the stats window). queue-retries shipped in +[CHANGELOG.md](../CHANGELOG.md) `[1.3.1]`; the 2026-08-13 full-tree review +follow-ups are in `[1.3.0]`. Candidates need explicit agreement before they +join the queue. After a context reset, pick an item marked `agreed` or `in progress`, then work -the checklist in its linked plan. +the **Implementation checklist** in its linked plan. The `Progress` column above +is `done/total` checklist steps in that plan ([development.md](development.md) +§ Plan checklists). --- @@ -61,6 +71,44 @@ implementation lands. --- +## send-log-retention + +**Goal:** global administrator sets how many days of delivery journal rows +(`send_log`, `/deliveries`) are kept, from `/settings` — stored in SQLite +`settings`, with `SEND_LOG_RETENTION_DAYS` as bootstrap default only. + +**Boundary:** instance-wide retention; not per-domain. Does not change +`mail.log` rotation. Today retention is env-only (default 90 days); the panel +has no control. + +**Done when:** see [plans/send-log-retention.md](plans/send-log-retention.md). + +**Dependencies / risks:** log-tailer must re-read settings each prune cycle; +delivery UI must stop hardcoding «ninety days». +**Version:** `1.x` MINOR; `candidate` until explicitly agreed. + +--- + +## domain-stats-auto-ratelimit + +**Goal:** 30-day sending statistics per domain and application (total, peak and +average msg/h) on the domain page, plus optional **auto** level-2 rate limits +computed as `ceil(avg_hourly × multiplier)` over the level-1 window. + +**Boundary:** extension of v1.0 manual L2 limits; does not change Postfix +level 1. Stats exclude level-1 refusals (not in send log). Auto with zero +traffic stays inactive. + +**Done when:** see +[plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md). + +**Dependencies / risks:** send log data and preferably +[send-log-retention](#send-log-retention) ≥ 30 days; aggregate query cost on +large logs; rate-limit path needs security review. +**Version:** `1.x` MINOR; `candidate` until explicitly agreed. + +--- + ## contributing **Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run @@ -94,9 +142,10 @@ reports (`ruf=`) out of scope for v1. **Dependencies / risks:** inbound SMTP in the image (may share infrastructure with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX); -storage and retention of parsed summaries; the `admin.dmarc_report_email` and -`domains.dmarc_rua` settings added in the DMARC template work must stay the -source of truth for `rua=` in DNS guidance. +storage and retention of parsed summaries; the `dmarc_report_email` setting +(migration `0005` moved it off the old `admin` table into `settings`) and +`domains.dmarc_rua` added in the DMARC template work must stay the source of +truth for `rua=` in DNS guidance. **Order:** after the DMARC `rua=` settings ship; may follow or overlap with inbound-relay depending on how port 25 acceptance is structured. @@ -127,3 +176,40 @@ when checks change; not bloating every page with a second column of prose. **Version:** `1.x` MINOR; `candidate` until explicitly agreed. +--- + +## schema-squash + +**Goal:** when 2.x is cut, stop shipping the 1.x migration files +(`0001_init.sql` … `0005_panel_users.sql`) in the binary and replace them with +one baseline that is the schema as of `user_version = 5`. Fresh 2.x data +directories no longer create-then-drop the historical `admin` table. + +**Boundary:** 1.x keeps the full chain so a 1.0.0 data directory still boots. +Do not delete, rename, or reorder those files while MINOR compatibility with +`1.0.0` holds. `migrate()` maps **file order** to `PRAGMA user_version` (`target += i + 1`); dropping a file in 1.x would skip or mis-apply steps on existing +databases. Git history keeps the old files either way; only the embedded set +in the 2.x image changes. + +**Upgrade gate (required with the squash):** + +| `user_version` | 2.x behaviour | +|---|---| +| `0` (empty file) | Apply the baseline; set `user_version` to the new chain’s head | +| `>= 5` (fully migrated 1.x) | Skip; schema is already the baseline | +| `1`…`4` (mid-chain 1.x) | **Refuse to start** — boot the last 1.x once, then 2.x | + +Restore remains a separate lock: the backup manifest version must match the +running binary ([architecture.md](architecture.md) § Persistence). It does not +replace this gate. + +**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after +it); the gate above is tested; the operator guide says a 2.x image will not +open an unfinished 1.x database. + +**Dependencies / risks:** a decided 2.x cut (another breaking change, or an +explicit major). Squashing five short files is not a reason to cut 2.x on its +own. A missed gate leaves a `user_version = 3` database silently stuck. +**Version:** `2.x` major only; not a 1.x item. + diff --git a/docs/security.md b/docs/security.md index 6849944..32e8bbf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -5,7 +5,12 @@ meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06) covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist in full: no exploitable findings; one defence-in-depth change — `--` before the login in the `saslpasswd2` argv -([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks** — +([internal/app/sasl.go](../internal/app/sasl.go)). The 2026-08-14 review +(code-review plan § P7, Fable; reviewer ≠ author) covered the P0–P1 diff of the +2026-08-13 full-tree review against this document — send-log authorization for +domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed +session creation, and application-delete ordering: no findings, and nothing +needed adding to the accepted risks. (2) **Accepted risks** — deliberate departures beyond the mandatory, recorded so the decision is not lost. @@ -34,8 +39,8 @@ The panel is exposed to the internet — the items below are **not optional**. - Failed attempts do **not** invalidate the token early (protects setup from being DoS-ed). - Once the administrator exists the token is void forever, `/setup/*` → 404. -- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext - and no MD5. +- The administrator password is bcrypt in SQLite only; no plaintext and no + MD5. - `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**. ### Application SASL passwords @@ -81,10 +86,11 @@ The panel is exposed to the internet — the items below are **not optional**. ### Backup and domain export -- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the - administrator's password hash; a domain export carries the DKIM key and - **working** application passwords in the clear (otherwise a transfer without - recreating credentials would be impossible). +- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the + administrator's password hash, `docker-compose.yml`, `.env`, and the TLS + private key from `certs/` when present; a domain export carries the DKIM key + and **working** application passwords in the clear (otherwise a transfer + without recreating credentials would be impossible). - Both downloads can be encrypted with a password (a checkbox on the form): scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each authenticated with the header, the chunk number, and an end-of-stream flag — @@ -112,19 +118,34 @@ deferred item from the roadmap. - **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.** A client that sends neither — a genuinely old browser, or a webview with a frozen engine — stays vulnerable to CSRF from any site. Accepted - deliberately: the panel is single-user, the administrator picks the browser, - and a strict mode would not "protect" such a client, it would simply break the - panel in it. Tightening is one line in `originAllowed` + deliberately: every panel user (global or domain-admin) is an operator who + picks their own browser, not an untrusted party the panel needs to defend + against, and a strict mode would not "protect" such a client, it would + simply break the panel in it. Tightening is one line in `originAllowed` ([internal/web/security.go](../internal/web/security.go)): return `false` instead of `true` in the "neither header present" branch. - **Session-bound CSRF tokens are not implemented.** The origin check closes the neighbouring-subdomain case but depends on browser behaviour; a token does not. The price is a hidden field in roughly two dozen forms. The trigger to - revisit is a requirement for protection that holds regardless of the browser. - A token would not save the panel from XSS inside it either: code executing in - the panel's origin sends the request itself — against that, `html/template` - auto-escaping and CSP do the work, which is why templates must contain no - inline scripts and no inline styles. + revisit is a requirement for protection that holds regardless of the browser, + or a domain-admin population the global administrator does not fully trust + (see the ADR below). A token would not save the panel from XSS inside it + either: code executing in the panel's origin sends the request itself — + against that, `html/template` auto-escaping and CSP do the work, which is why + templates must contain no inline scripts and no inline styles. +- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.** + Delete, regenerate-password, and clear-rate-limit forms carry a + `data-confirm` prompt handled entirely in + [panel.js](../internal/web/view/static/panel.js); with JavaScript disabled + or blocked the form submits immediately, exactly as it did before the + prompts existed. Accepted deliberately: the prompt is a mis-click guard, + not an authorization boundary — the same origin check and session/RBAC + gate every one of these `POST`s whether or not JavaScript ran. Progressive + enhancement means the panel must work with JavaScript off; a + server-rendered confirmation step would need a second page (or a `?confirm=1` + round trip) for every one of these forms, which is what + [`user_delete.html`](../internal/web/view/templates/user_delete.html) and + `domain_delete.html` already do for the two highest-blast-radius deletes. - **Encrypting backups and exports is an option, not the default.** With the checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an operator with nowhere to keep a password would lose the ability to take a @@ -158,24 +179,35 @@ deferred item from the roadmap. **Context.** The panel is forms (`POST`) with a cookie session — the classic CSRF surface. What is needed is a way to tell a request from the panel's own -page apart from one initiated by a third-party site in the logged-in -administrator's browser. +page apart from one initiated by a third-party site in a logged-in user's +browser. The panel is multi-user since 1.2.0 (a global administrator plus +zero or more domain-admin users, each scoped to their assigned domains), but +that is an authorization boundary (who can see or change what), not a change +to the CSRF threat: the attacker in scope here is still an external site +riding a legitimate user's cookie, not one panel user attacking another +through the browser. **Decision.** `originAllowed` in [internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site` (when the browser sends it) or `Origin` (fallback) against the panel's host; a request carrying neither header is **allowed through** rather than rejected. -There are no session-bound tokens embedded in forms. +There are no session-bound tokens embedded in forms. The check applies the same +way regardless of the requesting user's role. -**Why not tokens.** The panel is single-user (one administrator per instance) — -the threat model does not include cross-user CSRF inside the panel itself, only -an external site making the administrator's browser send a request. The origin -check covers that without touching a single template: a token would need a -hidden field in roughly two dozen forms and synchronisation with every new form, -and it would still not protect against XSS inside the panel — code executing in -the panel's origin reads the token and sends the request itself. XSS is handled -by `html/template` auto-escaping and CSP, so that is a separate line of defence, -not a CSRF token. +**Why not tokens.** Cross-user CSRF is not the threat model here: a +domain-admin's browser sending a request still needs that domain-admin's own +cookie, so a token would not add a boundary between roles that the +authorization checks (`Principal.CanAccessDomain`, +[internal/web/auth/principal.go](../internal/web/auth/principal.go); route +gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go)) +don't already enforce. The remaining case is an external site making a +logged-in user's browser send a request, which the origin check covers without +touching a single template. +A token would need a hidden field in roughly two dozen forms and +synchronisation with every new form, and it would still not protect against +XSS inside the panel — code executing in the panel's origin reads the token +and sends the request itself. XSS is handled by `html/template` auto-escaping +and CSP, so that is a separate line of defence, not a CSRF token. **Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a genuinely old browser, or a webview with a frozen engine) stays vulnerable — see @@ -183,7 +215,11 @@ genuinely old browser, or a webview with a frozen engine) stays vulnerable — s such a client, at the price of a narrow residual surface. **Revisit if:** a requirement appears for protection that does not depend on -browser behaviour, or the panel becomes multi-user. +browser behaviour, or domain-admin accounts stop being trusted operators (for +example, if a future release lets a global administrator invite domain-admins +whose browsers/devices are not vetted) — at that point cross-role request +forgery inside the panel would need its own analysis, separate from the +external-site case this ADR covers. ## How this list grows diff --git a/internal/app/service.go b/internal/app/service.go index c4bb392..f691cb3 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -166,14 +166,24 @@ func (s *Service) RegeneratePassword(id int64) (string, error) { // Delete removes an application: its SASL account, its registry row (and address // rows via cascade) and its sender-map bindings, then reloads Postfix (spec // 7.2.8). The domain and other applications are untouched. +// +// The order matches domain deletion: the SASL account goes first, while the +// login is still in the registry. Dropping the row first would, on a +// saslpasswd2 failure, leave an account that can still authenticate to Postfix +// but that the panel no longer knows about — an orphan no operator can see or +// remove. Failing before the row is deleted is recoverable: the application is +// still listed and the delete can be retried. func (s *Service) Delete(id int64) error { - a, err := s.store.DeleteApplication(id) + a, err := s.store.GetApplication(id) if err != nil { return err } if err := s.sasl.Delete(a.Login); err != nil { return err } + if _, err := s.store.DeleteApplication(id); err != nil { + return err + } // Drop the application's level-2 limit, if any (guide § Rate limiting); // rate_limits has no cascade of its own. if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil { diff --git a/internal/app/service_test.go b/internal/app/service_test.go index bc99516..e598c76 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -181,6 +181,37 @@ func TestServiceDelete(t *testing.T) { } } +// If sasldb2 cannot be updated the application must stay in the registry: an +// account that still authenticates but has no panel row is invisible to the +// operator and cannot be deleted again. +func TestServiceDeleteKeepsRowWhenSASLFails(t *testing.T) { + svc, st, rec, _ := newServiceHarness(t) + d := addDomain(t, st, "example.com") + a, _, err := svc.Create(d.ID, "app1", store.AddressModeWildcard, nil) + if err != nil { + t.Fatal(err) + } + + rec.failNext = true // saslpasswd2 -d fails + if err := svc.Delete(a.ID); err == nil { + t.Fatal("Delete reported success although the SASL account was not removed") + } + apps, _ := st.ListApplicationsByDomain(d.ID) + if len(apps) != 1 { + t.Fatalf("registry row dropped while the SASL account can still authenticate: %+v", apps) + } + if _, ok := rec.set["app1"]; !ok { + t.Fatal("SASL account gone despite the failure — the harness no longer proves the ordering") + } + // The delete is retryable now that the row is still there. + if err := svc.Delete(a.ID); err != nil { + t.Fatalf("retried Delete: %v", err) + } + if _, ok := rec.set["app1"]; ok { + t.Error("SASL account not deleted on retry") + } +} + func TestServiceUpdateMode(t *testing.T) { svc, st, _, maps := newServiceHarness(t) d := addDomain(t, st, "example.com") diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 2c2c6aa..10e3862 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -1,18 +1,26 @@ // Package backup implements SelfPost's full-server backup and the restore // version guard (architecture.md § Persistence). A full backup is a // gzip-compressed tar of the consolidated persistent state under /data — the -// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the -// SASL database — plus a manifest recording the SelfPost version that produced -// it. TLS certificates (the reverse proxy's responsibility) and the Postfix -// queue are deliberately excluded (architecture.md § Persistence). +// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the +// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and +// certs/ from the operator's deploy directory, and a manifest recording the +// SelfPost version that produced it. Postfix delivery logs under log/ are +// excluded (diagnostics, not state). // -// Restore is intentionally not a separate code path: a backup is extracted -// into the /data bind mount before first start, and the panel regenerates -// Postfix and OpenDKIM from the restored SQLite state exactly as on any normal -// start. The only restore-specific step is CheckRestore, which refuses to boot -// if the manifest's version does not match the running binary, so +// Restore is not a separate code path in the panel: a backup is extracted into +// the operator's project directory (data/, docker-compose.yml, .env, certs/) +// before first start. The archive carries everything needed to bring the +// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files — +// so the operator only adjusts hostname or proxy settings on a new host. The +// restore-specific steps the panel runs are CheckRestore, which refuses to +// boot if the manifest's version does not match the running binary so // schema/format skew between versions cannot silently corrupt state -// (architecture.md § Persistence). +// (architecture.md § Persistence), and a one-time Resync of OpenDKIM's tables +// and the Postfix sender map from SQLite on that first boot, so any drift +// between the archive and the database is healed before mail flows. If +// on-disk state drifts again later — for example after a manual edit under +// /data — the Status page's "Reload configuration" button runs the same +// Resync on demand. package backup import ( @@ -34,11 +42,21 @@ import ( // FormatFull identifies a full-server backup manifest. const FormatFull = "selfpost-full-backup" -// ManifestName is the manifest's filename, both inside the archive and, after a -// restore extraction, at the root of the data directory where CheckRestore -// looks for it. +// ManifestName is the manifest's filename inside the data directory. After +// restore extraction it lives at data/manifest.json under the project root; +// CheckRestore reads it from the /data bind mount. const ManifestName = "manifest.json" +// DataArchivePrefix is the path prefix for every /data entry in the archive. +const DataArchivePrefix = "data/" + +// ComposeFileName and EnvFileName are required deploy files at the archive root. +const ( + ComposeFileName = "docker-compose.yml" + EnvFileName = ".env" + CertsDirName = "certs" +) + // Manifest is the small JSON document embedded in every backup archive. Its // Version is the single fact that makes restore safe: the panel refuses to // boot a data directory whose manifest version does not match its own binary @@ -52,21 +70,23 @@ type Manifest struct { // Params configures a backup. DataDir is the consolidated state root (/data); // DBPath is the live SQLite file within it, snapshotted consistently rather than // copied byte-for-byte while it may be mid-write; Version is stamped into the -// manifest. +// manifest; DeployRoot is the host project directory mounted read-only (holds +// docker-compose.yml, .env, and optionally certs/). OnWarn is called for +// non-fatal issues such as a missing certs/ directory. type Params struct { - DataDir string - DBPath string - Version string + DataDir string + DBPath string + Version string + DeployRoot string + OnWarn func(string) } // excludedFromArchive lists the data-directory entries a backup never carries. // The live database files are replaced by a consistent VACUUM INTO snapshot // written under the canonical name; the setup token is transient bootstrap // state; a stale manifest from a previous restore must not be re-captured (a -// fresh one is written instead); a "tls" directory holds the reverse proxy's -// certificates, which are explicitly out of scope for a SelfPost backup -// (architecture.md § Persistence) — excluding it keeps that guarantee even when -// an operator points TLS_CERT_FILE inside /data; and "log" is Postfix's raw +// fresh one is written instead); a "tls" directory under /data is skipped when +// an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw // delivery log plus its fourteen rotated files, which is diagnostic output, not // state to restore, and by far the largest thing under /data. var excludedFromArchive = map[string]bool{ @@ -80,16 +100,22 @@ var excludedFromArchive = map[string]bool{ ManifestName: true, } -// Create writes a gzip-compressed tar backup to w. Archive entries are named -// relative to DataDir, so extracting the archive into the /data bind mount -// reconstructs the state in place (architecture.md § Persistence). The SQLite -// database is added as a consistent snapshot under "selfpost.db"; everything -// else under DataDir is copied as-is except the entries in -// excludedFromArchive. +// Create writes a gzip-compressed tar backup to w. Archive layout: +// +// data/manifest.json, data/selfpost.db, data/ +// docker-compose.yml, .env, certs/... +// +// Extract the archive into an empty project directory, then docker compose up. func Create(w io.Writer, p Params) error { if p.DataDir == "" || p.DBPath == "" { return fmt.Errorf("backup: DataDir and DBPath are required") } + if p.DeployRoot == "" { + return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)") + } + if err := validateDeployRoot(p.DeployRoot); err != nil { + return err + } snapshot, cleanup, err := snapshotDB(p.DBPath) if err != nil { @@ -109,17 +135,21 @@ func Create(w io.Writer, p Params) error { if err != nil { return fmt.Errorf("backup: encode manifest: %w", err) } - if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil { + if err := writeTarBytes(tw, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil { return err } // The consistent SQLite snapshot, under the canonical filename the panel // opens on start (the live file and its WAL/SHM are excluded from the walk). - if err := writeTarFile(tw, "selfpost.db", 0o640, snapshot); err != nil { + if err := writeTarFile(tw, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil { return err } - if err := addTree(tw, p.DataDir); err != nil { + if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil { + return err + } + + if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil { return err } @@ -132,33 +162,79 @@ func Create(w io.Writer, p Params) error { return nil } -// addTree walks dataDir and adds every regular file (and directory, to preserve -// empty ones and modes) to tw under its path relative to dataDir, skipping the -// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are -// skipped: /data holds none in normal operation, and copying them into a backup -// would be meaningless or unsafe. -func addTree(tw *tar.Writer, dataDir string) error { - return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error { +// ValidateDeployRoot checks that the operator project directory is mounted and +// contains the files a full backup requires. Call before streaming a response. +func ValidateDeployRoot(deployRoot string) error { + if deployRoot == "" { + return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)") + } + return validateDeployRoot(deployRoot) +} + +func validateDeployRoot(deployRoot string) error { + for _, name := range []string{ComposeFileName, EnvFileName} { + path := filepath.Join(deployRoot, name) + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("backup: deploy root %q is missing %s: %w", deployRoot, name, err) + } + } + return nil +} + +func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error { + for _, name := range []string{ComposeFileName, EnvFileName} { + src := filepath.Join(deployRoot, name) + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("backup: stat deploy file %s: %w", name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("backup: deploy file %s is not a regular file", name) + } + if err := writeTarFile(tw, name, info.Mode().Perm(), src); err != nil { + return err + } + } + + certsDir := filepath.Join(deployRoot, CertsDirName) + if _, err := os.Stat(certsDir); err != nil { + if os.IsNotExist(err) { + if onWarn != nil { + onWarn("certs/ not found in deploy root; backup will not include TLS material") + } + return nil + } + return fmt.Errorf("backup: stat %s: %w", CertsDirName, err) + } + return addTree(tw, certsDir, CertsDirName+"/", nil) +} + +// addTree walks root and adds every regular file (and directory, to preserve +// empty ones and modes) to tw under archivePrefix + path relative to root. +// When exclude is non-nil, top-level names relative to root are skipped. +// Non-regular, non-directory entries (symlinks, sockets) are skipped. +func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } - rel, err := filepath.Rel(dataDir, path) + rel, err := filepath.Rel(root, path) if err != nil { return err } if rel == "." { - return nil // the data root itself is implicit - } - // filepath.Rel yields OS separators; tar names use forward slashes. - name := filepath.ToSlash(rel) - // Exclude by top-level name (the live DB, setup token and stale manifest - // all live at the data root). - if excludedFromArchive[name] { - if d.IsDir() { - return fs.SkipDir - } return nil } + name := archivePrefix + filepath.ToSlash(rel) + if exclude != nil { + top := strings.Split(filepath.ToSlash(rel), "/")[0] + if exclude[top] { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + } info, err := d.Info() if err != nil { @@ -176,7 +252,7 @@ func addTree(tw *tar.Writer, dataDir string) error { case info.Mode().IsRegular(): return writeTarFile(tw, name, info.Mode().Perm(), path) default: - return nil // skip symlinks/sockets/devices + return nil } }) } @@ -264,33 +340,34 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) { // directory), its version must match binaryVersion or the panel refuses to // start, telling the operator which image tag to use. On a match the manifest // is consumed (deleted) so it guards only the first boot after a restore and -// never blocks a later in-place image upgrade. Absence of the manifest is the -// normal case and returns nil. -func CheckRestore(manifestPath, binaryVersion string) error { +// never blocks a later in-place image upgrade, and restored is true so the +// caller can heal drifted daemon maps once. Absence of the manifest is the +// normal case and returns restored == false with a nil error. +func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error) { data, err := os.ReadFile(manifestPath) if os.IsNotExist(err) { - return nil // ordinary start, not a restore + return false, nil // ordinary start, not a restore } if err != nil { - return fmt.Errorf("backup: read restore manifest: %w", err) + return false, fmt.Errorf("backup: read restore manifest: %w", err) } var m Manifest if err := json.Unmarshal(data, &m); err != nil { - return fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err) + return false, fmt.Errorf("backup: restore manifest %s is not valid JSON: %w", manifestPath, err) } if m.Format != FormatFull { - return fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format) + return false, fmt.Errorf("backup: %s is not a SelfPost full backup manifest (format %q)", manifestPath, m.Format) } if m.Version != binaryVersion { - return fmt.Errorf( + return false, fmt.Errorf( "backup: this backup was created by SelfPost %s but this image is %s — restore into the matching image (selfpost:%s)", m.Version, binaryVersion, m.Version) } // Version matches: consume the manifest so subsequent normal starts (and // in-place upgrades) are not gated by it. if err := os.Remove(manifestPath); err != nil { - return fmt.Errorf("backup: consume restore manifest: %w", err) + return false, fmt.Errorf("backup: consume restore manifest: %w", err) } - return nil + return true, nil } diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index f7c99bc..0960f76 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -15,10 +15,15 @@ import ( ) // seedDataDir builds a realistic /data tree: a migrated SQLite database plus the -// DKIM key, SASL and transient files a backup must include or exclude. -func seedDataDir(t *testing.T) (dataDir, dbPath string) { +// DKIM key, SASL and transient files a backup must include or exclude. It also +// seeds a deploy root beside data/ with compose, .env, and certs/. +func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) { t.Helper() - dataDir = t.TempDir() + deployRoot = t.TempDir() + dataDir = filepath.Join(deployRoot, "data") + if err := os.MkdirAll(dataDir, 0o750); err != nil { + t.Fatalf("mkdir data: %v", err) + } dbPath = filepath.Join(dataDir, "selfpost.db") st, err := store.Open(dbPath) @@ -35,6 +40,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) { writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY") writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB") writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login") + writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file") // Transient files that must NOT be archived. writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token") writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal") @@ -43,7 +49,11 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) { // state, and the bulkiest thing under /data. writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=, status=sent (ok)") writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older") - return dataDir, dbPath + writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n") + writeFile(t, filepath.Join(deployRoot, EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n") + writeFile(t, filepath.Join(deployRoot, CertsDirName, "fullchain.pem"), "CERT") + writeFile(t, filepath.Join(deployRoot, CertsDirName, "privkey.pem"), "KEY") + return dataDir, dbPath, deployRoot } func writeFile(t *testing.T, path, content string) { @@ -87,21 +97,26 @@ func readArchive(t *testing.T, data []byte) map[string]string { } func TestCreateIncludesStateExcludesTransient(t *testing.T) { - dataDir, dbPath := seedDataDir(t) + dataDir, dbPath, deployRoot := seedDataDir(t) var buf bytes.Buffer - if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil { + if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err != nil { t.Fatalf("Create: %v", err) } files := readArchive(t, buf.Bytes()) // Present. for _, name := range []string{ - ManifestName, - "selfpost.db", - "opendkim/keys/example.com/selfpost.private", - "sasl/sasldb2", - "postfix/sender_login_maps", + DataArchivePrefix + ManifestName, + DataArchivePrefix + "selfpost.db", + DataArchivePrefix + "opendkim/keys/example.com/selfpost.private", + DataArchivePrefix + "sasl/sasldb2", + DataArchivePrefix + "postfix/sender_login_maps", + DataArchivePrefix + "postfix/queue/deferred/sample", + ComposeFileName, + EnvFileName, + CertsDirName + "/fullchain.pem", + CertsDirName + "/privkey.pem", } { if _, ok := files[name]; !ok { t.Errorf("archive missing %s", name) @@ -109,8 +124,11 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) { } // Excluded. for _, name := range []string{ - "setup-token", "selfpost.db-wal", "selfpost.db-shm", - "log/mail.log", "log/mail.log.1", + DataArchivePrefix + "setup-token", + DataArchivePrefix + "selfpost.db-wal", + DataArchivePrefix + "selfpost.db-shm", + DataArchivePrefix + "log/mail.log", + DataArchivePrefix + "log/mail.log.1", } { if _, ok := files[name]; ok { t.Errorf("archive should not contain %s", name) @@ -119,7 +137,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) { // Manifest is well-formed and carries the version. var m Manifest - if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil { + if err := json.Unmarshal([]byte(files[DataArchivePrefix+ManifestName]), &m); err != nil { t.Fatalf("manifest json: %v", err) } if m.Format != FormatFull || m.Version != "1.2.3" { @@ -128,7 +146,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) { // The archived selfpost.db is a real, openable SQLite snapshot with our data. snapPath := filepath.Join(t.TempDir(), "restored.db") - if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil { + if err := os.WriteFile(snapPath, []byte(files[DataArchivePrefix+"selfpost.db"]), 0o640); err != nil { t.Fatalf("write snapshot: %v", err) } st, err := store.Open(snapPath) @@ -145,6 +163,14 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) { } } +func TestCreateRequiresDeployRoot(t *testing.T) { + dataDir, dbPath, _ := seedDataDir(t) + var buf bytes.Buffer + if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0"}); err == nil { + t.Fatal("Create without DeployRoot succeeded") + } +} + func writeManifest(t *testing.T, dir, format, version string) string { t.Helper() path := filepath.Join(dir, ManifestName) @@ -156,17 +182,25 @@ func writeManifest(t *testing.T, dir, format, version string) string { } func TestCheckRestoreNoManifestIsNormalStart(t *testing.T) { - if err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0"); err != nil { + restored, err := CheckRestore(filepath.Join(t.TempDir(), "manifest.json"), "1.0.0") + if err != nil { t.Errorf("CheckRestore with no manifest = %v, want nil", err) } + if restored { + t.Error("CheckRestore with no manifest reported a restore") + } } func TestCheckRestoreMatchConsumesManifest(t *testing.T) { dir := t.TempDir() path := writeManifest(t, dir, FormatFull, "1.0.0") - if err := CheckRestore(path, "1.0.0"); err != nil { + restored, err := CheckRestore(path, "1.0.0") + if err != nil { t.Fatalf("CheckRestore matching = %v, want nil", err) } + if !restored { + t.Fatal("CheckRestore matching did not report a restore") + } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Errorf("manifest should be consumed after a matching restore, stat err = %v", err) } @@ -175,10 +209,13 @@ func TestCheckRestoreMatchConsumesManifest(t *testing.T) { func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) { dir := t.TempDir() path := writeManifest(t, dir, FormatFull, "1.0.0") - err := CheckRestore(path, "2.0.0") + restored, err := CheckRestore(path, "2.0.0") if err == nil { t.Fatal("CheckRestore mismatch = nil, want error") } + if restored { + t.Error("CheckRestore mismatch reported a restore") + } if !strings.Contains(err.Error(), "1.0.0") || !strings.Contains(err.Error(), "2.0.0") { t.Errorf("error should name both versions: %v", err) } @@ -190,7 +227,11 @@ func TestCheckRestoreVersionMismatchRefusesAndKeeps(t *testing.T) { func TestCheckRestoreWrongFormatRejected(t *testing.T) { dir := t.TempDir() path := writeManifest(t, dir, "something-else", "1.0.0") - if err := CheckRestore(path, "1.0.0"); err == nil { + restored, err := CheckRestore(path, "1.0.0") + if err == nil { t.Error("CheckRestore accepted a non-backup manifest") } + if restored { + t.Error("CheckRestore wrong format reported a restore") + } } diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go index 59a2181..3781664 100644 --- a/internal/dnscheck/dnscheck.go +++ b/internal/dnscheck/dnscheck.go @@ -70,12 +70,12 @@ type Domain struct { // compares DNS against the key this server actually signs with. Hostname and // ServerIPs identify this server and come from a preceding Server check. type Query struct { - Name string - Selector string - ExpectedDKIM string - Hostname string - ServerIPs []string - DMARCReportEmail string // resolved rua= destination; empty = policy-only template + Name string + Selector string + ExpectedDKIM string + Hostname string + ServerIPs []string + DMARCReportEmail string // resolved rua= destination; empty = policy-only template } // resolver is the slice of *net.Resolver this package uses, as an interface so diff --git a/internal/domain/opendkim.go b/internal/domain/opendkim.go index 434e30b..295bdb9 100644 --- a/internal/domain/opendkim.go +++ b/internal/domain/opendkim.go @@ -167,6 +167,14 @@ func (o *OpenDKIM) Reload() error { return o.reload() } +// SetReloadHook replaces how configuration is applied after a rebuild. Tests +// that cannot reach supervisord use this to verify file regeneration alone. +func (o *OpenDKIM) SetReloadHook(fn func() error) { + if fn != nil { + o.reload = fn + } +} + // renderTables builds the KeyTable and SigningTable byte contents for a domain // set, sorted by name so the output is deterministic. Every domain is // re-checked for shell/config-injection safety before being written diff --git a/internal/domain/transfer.go b/internal/domain/transfer.go index 206819e..befb9ee 100644 --- a/internal/domain/transfer.go +++ b/internal/domain/transfer.go @@ -23,7 +23,7 @@ type DomainExport struct { Version string `json:"version"` Domain string `json:"domain"` DKIMSelector string `json:"dkim_selector"` - DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM + DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none) Applications []AppExport `json:"applications"` } diff --git a/internal/legal/legal.go b/internal/legal/legal.go index 3eda2d9..8b69ea6 100644 --- a/internal/legal/legal.go +++ b/internal/legal/legal.go @@ -14,8 +14,9 @@ import ( var License []byte // SourceURL is where Corresponding Source for the published upstream -// releases lives. Operators who ship a modified version must point their -// users at their own sources instead (NOTICE; AGPL-3.0 §13). +// releases lives. It is injected into the panel footer by view.go. +// Operators who ship a modified version must change this constant so the +// footer points at their own sources (NOTICE; AGPL-3.0 §13). const SourceURL = "https://github.com/mixeme/selfpost" // CopyrightLine is the short copyright notice shown in the panel footer. diff --git a/internal/legal/license_test.go b/internal/legal/license_test.go index d0f11e9..35d5077 100644 --- a/internal/legal/license_test.go +++ b/internal/legal/license_test.go @@ -19,3 +19,22 @@ func TestEmbeddedLicenseMatchesRoot(t *testing.T) { t.Fatal("embedded LICENSE is empty") } } + +// NOTICE used to tell modifiers to edit layout.html for the Source URL. The +// footer reads legal.SourceURL; a fork that only changed the template would +// still advertise the upstream repo. +func TestNoticePointsAtSourceURLConstant(t *testing.T) { + notice, err := os.ReadFile(filepath.Join("..", "..", "NOTICE")) + if err != nil { + t.Fatalf("read NOTICE: %v", err) + } + if !bytes.Contains(notice, []byte("internal/legal/legal.go")) { + t.Error("NOTICE must tell modifiers to update SourceURL in internal/legal/legal.go") + } + if bytes.Contains(notice, []byte("layout.html")) { + t.Error("NOTICE still tells modifiers to edit layout.html for the Source URL") + } + if !bytes.Contains(notice, []byte("internal/web/view/static/OFL.txt")) { + t.Error("NOTICE must point at the OFL text that travels with the Plex fonts") + } +} diff --git a/internal/milter/inflight.go b/internal/milter/inflight.go index 78ca52b..e1a4b30 100644 --- a/internal/milter/inflight.go +++ b/internal/milter/inflight.go @@ -36,6 +36,29 @@ type inflight struct { m map[string]map[*reservation]struct{} } +// tryAdmit decides whether one more message may be sent for key and, if so, +// claims its slot — both under a single lock. Counting and reserving in two +// separate critical sections would let two SMTP sessions that reach MAIL FROM +// at the same moment observe the same total and each take the last free slot, +// which is exactly the overshoot the in-flight registry exists to prevent. +// +// stored is the count the send log already holds for the limit's window and max +// is the ceiling; the caller supplies both because only it can query the store. +// The returned total is what was measured, for the refusal log line. +func (f *inflight) tryAdmit(key string, since time.Time, stored, max int64) (*reservation, int64, bool) { + if f == nil { + return nil, stored, stored < max // no in-flight accounting (tests) + } + f.mu.Lock() + defer f.mu.Unlock() + + total := stored + f.countLocked(key, since) + if total >= max { + return nil, total, false + } + return f.reserveLocked(key), total, true +} + // count returns how many reservations for key were taken within the limit's // window (at or after since), pruning any that outlived reservationTTL. func (f *inflight) count(key string, since time.Time) int64 { @@ -45,6 +68,11 @@ func (f *inflight) count(key string, since time.Time) int64 { f.mu.Lock() defer f.mu.Unlock() + return f.countLocked(key, since) +} + +// countLocked is count's body; the caller holds mu. +func (f *inflight) countLocked(key string, since time.Time) int64 { set := f.m[key] cutoff := time.Now().Add(-reservationTTL) var n int64 @@ -63,14 +91,8 @@ func (f *inflight) count(key string, since time.Time) int64 { return n } -// reserve claims a slot for key until the message is recorded or released. -func (f *inflight) reserve(key string) *reservation { - if f == nil { - return nil - } - f.mu.Lock() - defer f.mu.Unlock() - +// reserveLocked is reserve's body; the caller holds mu. +func (f *inflight) reserveLocked(key string) *reservation { if f.m == nil { f.m = make(map[string]map[*reservation]struct{}) } diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index d2d2a34..0d2e167 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -3,6 +3,7 @@ package milter import ( "errors" "net" + "sync" "testing" "time" @@ -15,7 +16,10 @@ import ( // swallows recorder errors and still accepts the message. By default it reports // no configured rate limit, so the level-2 check is inert unless a test sets // limits (see fakeRecorder fields). +// mu guards the recorded slices so several sessions may drive one recorder +// concurrently, as they do in the real server. type fakeRecorder struct { + mu sync.Mutex entries []store.SendLogEntry rejected []store.SendLogEntry fail bool @@ -27,17 +31,25 @@ type fakeRecorder struct { counts map[string]int64 lookupErr error countErr error + + // onCount, if set, runs inside CountMessages. It lets a test hold every + // racing session at the store lookup until they can all proceed together. + onCount func() } func (f *fakeRecorder) InsertQueued(e store.SendLogEntry) error { if f.fail { return errors.New("boom") } + f.mu.Lock() + defer f.mu.Unlock() f.entries = append(f.entries, e) return nil } func (f *fakeRecorder) InsertRejected(e store.SendLogEntry) error { + f.mu.Lock() + defer f.mu.Unlock() f.rejected = append(f.rejected, e) return nil } @@ -57,6 +69,9 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err if f.countErr != nil { return 0, f.countErr } + if f.onCount != nil { + f.onCount() + } return f.counts[scope+"|"+ref], nil } @@ -415,6 +430,58 @@ func TestRateLimitCountsInFlightMessages(t *testing.T) { } } +// The case above is sequential: the second session reads the stored count after +// the first has already reserved. Here every session reads it first — the gate +// holds them all inside the lookup — which is what concurrent SMTP connections +// actually do. However many then race for the single free slot, exactly one may +// pass. (TestTryAdmitHandsOutEachSlotOnce is the test that fails when counting +// and reserving are not one step; this one pins the session-level behaviour.) +func TestRateLimitAdmitsOnlyOneRacingSession(t *testing.T) { + rec := limitedRecorder(4) // one below the ceiling of 5 + fl := &inflight{} + gate := make(chan struct{}) + + // Every session is held inside the stored-count lookup until all of them + // have read it, which is the state the race needs: none of them can see + // another's reservation, because none has been taken yet. + const racers = 32 + var atCount, done sync.WaitGroup + atCount.Add(racers) + go func() { atCount.Wait(); close(gate) }() + rec.onCount = func() { atCount.Done(); <-gate } + + responses := make([]milter.Response, racers) + for i := range racers { + done.Add(1) + go func() { + defer done.Done() + // Connect is skipped so every goroutine starts from the same point; + // the client IP is what Connect would have captured. + s := &session{rec: rec, flight: fl, clientIP: limitIP} + resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"})) + if err != nil { + resp = nil // reported as a missing Continue below + } + responses[i] = resp + }() + } + done.Wait() + + admitted := 0 + for _, resp := range responses { + if resp == milter.RespContinue { + admitted++ + } + } + if admitted != 1 { + t.Fatalf("%d of %d racing sessions admitted, want exactly 1 — the last slot was handed out twice", + admitted, racers) + } + if n := fl.count(store.RateLimitScopeDomain+"|example.com", time.Now().Add(-time.Hour)); n != 1 { + t.Fatalf("in-flight reservations = %d, want 1", n) + } +} + // Once the message is recorded the stored count sees it, so its reservation // must be given back — otherwise it would be counted twice and the ceiling // would drift closed. @@ -477,12 +544,55 @@ func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) { } } +// The ceiling is handed out exactly max times however the sessions interleave. +// Counting and reserving in two critical sections passes the sequential tests +// above and still overshoots here, because between one session's count and its +// reservation any number of others can pass the same check. +func TestTryAdmitHandsOutEachSlotOnce(t *testing.T) { + const ( + max = 500 + workers = 8 + ) + fl := &inflight{} + since := time.Now().Add(-time.Hour) + start := make(chan struct{}) + admitted := make([]int, workers) + var wg sync.WaitGroup + for i := range workers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + for { + _, _, ok := fl.tryAdmit("domain|example.com", since, 0, max) + if !ok { + return + } + admitted[i]++ + } + }() + } + close(start) + wg.Wait() + + total := 0 + for _, n := range admitted { + total += n + } + if total != max { + t.Fatalf("admitted %d messages under a ceiling of %d", total, max) + } +} + // The in-flight count only covers the limit's own window: a reservation older // than it (a session stuck mid-DATA for longer than the window) must not be // counted against a window it no longer belongs to. func TestInflightIgnoresReservationsOutsideWindow(t *testing.T) { fl := &inflight{} - r := fl.reserve("domain|example.com") + r, _, ok := fl.tryAdmit("domain|example.com", time.Now().Add(-time.Hour), 0, 1) + if !ok { + t.Fatal("tryAdmit refused the first message under a ceiling of 1") + } r.at = time.Now().Add(-time.Minute) if n := fl.count("domain|example.com", time.Now().Add(-time.Hour)); n != 1 { diff --git a/internal/milter/ratelimit.go b/internal/milter/ratelimit.go index 913304b..291c79b 100644 --- a/internal/milter/ratelimit.go +++ b/internal/milter/ratelimit.go @@ -54,22 +54,23 @@ func (s *session) overLimit() bool { } // enforceLimit counts recent messages for scope/ref and refuses when at or -// above the ceiling. On admit it reserves an in-flight slot on the session. +// above the ceiling. The stored count and the in-flight slots are weighed and +// the admitted message's own slot is taken in one atomic step (tryAdmit), so +// two sessions racing at MAIL FROM cannot both claim the last free slot. func (s *session) enforceLimit(scope, ref string, rl store.RateLimit) bool { since := time.Now().Add(-time.Duration(rl.WindowSeconds) * time.Second) - n, err := s.rec.CountMessages(scope, ref, since) + stored, err := s.rec.CountMessages(scope, ref, since) if err != nil { log.Printf("journal-milter: rate-limit count %s %q: %v (fail-open)", scope, ref, err) return false } - key := scope + "|" + ref - n += s.flight.count(key, since) - if n >= int64(rl.MaxMessages) { + r, n, ok := s.flight.tryAdmit(scope+"|"+ref, since, stored, int64(rl.MaxMessages)) + if !ok { log.Printf("journal-milter: %s %q over limit: %d/%d in %ds from %s — refusing 4xx", scope, ref, n, rl.MaxMessages, rl.WindowSeconds, s.clientIP) return true } - s.reserved = append(s.reserved, s.flight.reserve(key)) + s.reserved = append(s.reserved, r) return false } diff --git a/internal/postfix/duration.go b/internal/postfix/duration.go new file mode 100644 index 0000000..286b282 --- /dev/null +++ b/internal/postfix/duration.go @@ -0,0 +1,151 @@ +package postfix + +import ( + "fmt" + "strconv" + "strings" + "time" + "unicode" +) + +// ParseDuration converts a Postfix time value to a duration. Units are those +// postconf prints and accepts: s, m, h, d, w. A bare number is seconds. Values +// may concatenate units (`1h7m`), matching Postfix's own conv_time. +func ParseDuration(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("postfix: empty time value") + } + var total time.Duration + i := 0 + for i < len(s) { + for i < len(s) && unicode.IsSpace(rune(s[i])) { + i++ + } + if i >= len(s) { + break + } + if s[i] == '-' { + return 0, fmt.Errorf("postfix: negative time value %q", s) + } + if s[i] == '+' { + i++ + } + start := i + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == start { + return 0, fmt.Errorf("postfix: invalid time value %q", s) + } + n, err := strconv.ParseInt(s[start:i], 10, 64) + if err != nil { + return 0, fmt.Errorf("postfix: invalid time value %q", s) + } + unit := byte('s') + if i < len(s) && isTimeUnit(s[i]) { + unit = s[i] | 0x20 // ASCII fold to lowercase + i++ + } + part, err := durationForUnit(n, unit) + if err != nil { + return 0, err + } + total += part + } + return total, nil +} + +func isTimeUnit(c byte) bool { + switch c | 0x20 { + case 's', 'm', 'h', 'd', 'w': + return true + } + return false +} + +func durationForUnit(n int64, unit byte) (time.Duration, error) { + var unitDur time.Duration + switch unit { + case 's': + unitDur = time.Second + case 'm': + unitDur = time.Minute + case 'h': + unitDur = time.Hour + case 'd': + unitDur = 24 * time.Hour + case 'w': + unitDur = 7 * 24 * time.Hour + default: + return 0, fmt.Errorf("postfix: unknown time unit %q", string(unit)) + } + return time.Duration(n) * unitDur, nil +} + +// FormatDuration renders a Postfix interval the way the Mail queue card and +// delivery history share it, so the two cannot drift. Exact day/hour/minute/ +// second values stay exact (`5 minutes`, `5 days`, `1 hour`); a remainder that +// is rounded to the nearest minute is marked `about` (`about 1 hour 7 minutes` +// for the stock 4000s backoff cap). +func FormatDuration(d time.Duration) string { + if d < 0 { + d = -d + } + sec := int64(d / time.Second) + if sec == 0 { + return "0 seconds" + } + + days := sec / 86400 + rem := sec % 86400 + hours := rem / 3600 + rem %= 3600 + minutes := rem / 60 + seconds := rem % 60 + + about := false + if days > 0 || hours > 0 { + if seconds >= 30 { + minutes++ + about = true + } else if seconds > 0 { + about = true + } + seconds = 0 + if minutes >= 60 { + hours++ + minutes = 0 + } + if hours >= 24 { + days++ + hours = 0 + } + } + + parts := make([]string, 0, 4) + if days > 0 { + parts = append(parts, counted(days, "day")) + } + if hours > 0 { + parts = append(parts, counted(hours, "hour")) + } + if minutes > 0 { + parts = append(parts, counted(minutes, "minute")) + } + if seconds > 0 { + parts = append(parts, counted(seconds, "second")) + } + s := strings.Join(parts, " ") + if about { + return "about " + s + } + return s +} + +func counted(n int64, unit string) string { + if n == 1 { + return "1 " + unit + } + return strconv.FormatInt(n, 10) + " " + unit + "s" +} diff --git a/internal/postfix/duration_test.go b/internal/postfix/duration_test.go new file mode 100644 index 0000000..f65579f --- /dev/null +++ b/internal/postfix/duration_test.go @@ -0,0 +1,64 @@ +package postfix + +import ( + "testing" + "time" +) + +func TestParseDuration(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"5d", 5 * 24 * time.Hour}, + {"300s", 300 * time.Second}, + {"4000s", 4000 * time.Second}, + {"1h", time.Hour}, + {"0", 0}, + {"0s", 0}, + {"300", 300 * time.Second}, + {" 300s ", 300 * time.Second}, + {"1h7m", time.Hour + 7*time.Minute}, + {"1w", 7 * 24 * time.Hour}, + {"2m", 2 * time.Minute}, + } + for _, tc := range cases { + got, err := ParseDuration(tc.in) + if err != nil { + t.Errorf("ParseDuration(%q): %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ParseDuration(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestParseDurationRejectsInvalid(t *testing.T) { + for _, in := range []string{"", "foo", "5x", "-300s", "s", "1h 5x"} { + if _, err := ParseDuration(in); err == nil { + t.Errorf("ParseDuration(%q) = nil, want error", in) + } + } +} + +func TestFormatDuration(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {5 * 24 * time.Hour, "5 days"}, + {300 * time.Second, "5 minutes"}, + {4000 * time.Second, "about 1 hour 7 minutes"}, + {time.Hour, "1 hour"}, + {0, "0 seconds"}, + {time.Second, "1 second"}, + {2 * time.Minute, "2 minutes"}, + {24 * time.Hour, "1 day"}, + } + for _, tc := range cases { + if got := FormatDuration(tc.in); got != tc.want { + t.Errorf("FormatDuration(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/postfix/postfix.go b/internal/postfix/postfix.go index 996952b..1643ea3 100644 --- a/internal/postfix/postfix.go +++ b/internal/postfix/postfix.go @@ -75,6 +75,14 @@ func (p *Postfix) Reload() error { return p.reload() } +// SetReloadHook replaces how configuration is applied after a rebuild. Tests +// that cannot reach supervisord use this to verify file regeneration alone. +func (p *Postfix) SetReloadHook(fn func() error) { + if fn != nil { + p.reload = fn + } +} + // renderSenderLoginMaps builds the sender_login_maps file contents. Keys are // sorted for deterministic output and the logins under each key are sorted and // de-duplicated. Every address and login is re-checked for injection safety diff --git a/internal/postfix/retry.go b/internal/postfix/retry.go new file mode 100644 index 0000000..662ace4 --- /dev/null +++ b/internal/postfix/retry.go @@ -0,0 +1,135 @@ +package postfix + +import ( + "fmt" + "log" + "os/exec" + "strings" + "time" +) + +// RetryPolicy is this Postfix's deferred-mail retry timings, as postconf +// reports them. The panel loads it once at HTTP start (architecture.md) and +// never re-reads it for a request. +type RetryPolicy struct { + QueueRunDelay time.Duration + MinimalBackoff time.Duration + MaximalBackoff time.Duration + MaximalQueueLifetime time.Duration + BounceQueueLifetime time.Duration + DelayWarningTime time.Duration + // FromDefaults is true when postconf could not be read and the compiled-in + // Postfix 3.x values were substituted. The Mail queue card shows a muted + // note in that case so an operator who overrode the parameters is not + // silently shown the stock numbers. + FromDefaults bool +} + +// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters +// build/postfix-config.sh does not set. Used when postconf is missing (unit +// tests, a binary outside the container) so the panel still starts. +func DefaultRetryPolicy() RetryPolicy { + return RetryPolicy{ + QueueRunDelay: 300 * time.Second, + MinimalBackoff: 300 * time.Second, + MaximalBackoff: 4000 * time.Second, + MaximalQueueLifetime: 5 * 24 * time.Hour, + BounceQueueLifetime: 5 * 24 * time.Hour, + DelayWarningTime: 0, + FromDefaults: true, + } +} + +// retryConfKeys is the fixed argv tail for `postconf -h`. Order matches the +// fields of RetryPolicy. No user input is interpolated (security.md). +var retryConfKeys = []string{ + "queue_run_delay", + "minimal_backoff_time", + "maximal_backoff_time", + "maximal_queue_lifetime", + "bounce_queue_lifetime", + "delay_warning_time", +} + +// readRetryConf runs `postconf -h` for the retry-policy keys. Tests replace it +// the same way logtail stubs queueIDs. +var readRetryConf = postconfRetryValues + +func postconfRetryValues() ([]string, error) { + // Fixed argv, no user input — same pattern as Queue (security.md). + cmd := exec.Command("postconf", "-h", + "queue_run_delay", + "minimal_backoff_time", + "maximal_backoff_time", + "maximal_queue_lifetime", + "bounce_queue_lifetime", + "delay_warning_time", + ) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("postconf -h: %w: %s", err, strings.TrimSpace(string(out))) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) != len(retryConfKeys) { + return nil, fmt.Errorf("postconf -h: got %d values, want %d", len(lines), len(retryConfKeys)) + } + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + return lines, nil +} + +// LoadRetryPolicy reads the effective Postfix retry parameters. On any failure +// it logs a warning and returns DefaultRetryPolicy; the HTTP role must not +// refuse to start because postconf is absent. +func LoadRetryPolicy() RetryPolicy { + lines, err := readRetryConf() + if err != nil { + log.Printf("postfix: retry policy: %v; using compiled-in defaults", err) + return DefaultRetryPolicy() + } + p, err := parseRetryPolicy(lines) + if err != nil { + log.Printf("postfix: retry policy: %v; using compiled-in defaults", err) + return DefaultRetryPolicy() + } + return p +} + +func parseRetryPolicy(lines []string) (RetryPolicy, error) { + if len(lines) != len(retryConfKeys) { + return RetryPolicy{}, fmt.Errorf("got %d values, want %d", len(lines), len(retryConfKeys)) + } + var durs [6]time.Duration + for i, line := range lines { + d, err := ParseDuration(line) + if err != nil { + return RetryPolicy{}, fmt.Errorf("%s: %w", retryConfKeys[i], err) + } + durs[i] = d + } + return RetryPolicy{ + QueueRunDelay: durs[0], + MinimalBackoff: durs[1], + MaximalBackoff: durs[2], + MaximalQueueLifetime: durs[3], + BounceQueueLifetime: durs[4], + DelayWarningTime: durs[5], + }, nil +} + +// FirstRetry is the human string for the first deferred retry (minimal +// backoff), shared by the Mail queue card and delivery history. +func (p RetryPolicy) FirstRetry() string { + return FormatDuration(p.MinimalBackoff) +} + +// BackoffCap is the human string for maximal_backoff_time. +func (p RetryPolicy) BackoffCap() string { + return FormatDuration(p.MaximalBackoff) +} + +// QueueLifetime is the human string for maximal_queue_lifetime. +func (p RetryPolicy) QueueLifetime() string { + return FormatDuration(p.MaximalQueueLifetime) +} diff --git a/internal/postfix/retry_test.go b/internal/postfix/retry_test.go new file mode 100644 index 0000000..747cc5d --- /dev/null +++ b/internal/postfix/retry_test.go @@ -0,0 +1,79 @@ +package postfix + +import ( + "errors" + "testing" + "time" +) + +func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) { + old := readRetryConf + readRetryConf = func() ([]string, error) { + return []string{"300s", "300s", "4000s", "2d", "5d", "0"}, nil + } + t.Cleanup(func() { readRetryConf = old }) + + p := LoadRetryPolicy() + if p.FromDefaults { + t.Fatal("FromDefaults = true, want live postconf values") + } + if p.MinimalBackoff != 300*time.Second { + t.Errorf("MinimalBackoff = %v, want 300s", p.MinimalBackoff) + } + if p.MaximalBackoff != 4000*time.Second { + t.Errorf("MaximalBackoff = %v, want 4000s", p.MaximalBackoff) + } + if p.MaximalQueueLifetime != 2*24*time.Hour { + t.Errorf("MaximalQueueLifetime = %v, want 2d", p.MaximalQueueLifetime) + } + if p.BounceQueueLifetime != 5*24*time.Hour { + t.Errorf("BounceQueueLifetime = %v, want 5d", p.BounceQueueLifetime) + } + if p.DelayWarningTime != 0 { + t.Errorf("DelayWarningTime = %v, want 0", p.DelayWarningTime) + } +} + +func TestLoadRetryPolicyFallsBackWhenPostconfFails(t *testing.T) { + old := readRetryConf + readRetryConf = func() ([]string, error) { + return nil, errors.New("exec: not found") + } + t.Cleanup(func() { readRetryConf = old }) + + p := LoadRetryPolicy() + want := DefaultRetryPolicy() + if !p.FromDefaults { + t.Error("FromDefaults = false, want true when postconf fails") + } + if p.QueueRunDelay != want.QueueRunDelay || p.MinimalBackoff != want.MinimalBackoff || + p.MaximalBackoff != want.MaximalBackoff || p.MaximalQueueLifetime != want.MaximalQueueLifetime { + t.Errorf("fallback = %+v, want compiled-in defaults %+v", p, want) + } +} + +func TestLoadRetryPolicyFallsBackOnUnparseableValues(t *testing.T) { + old := readRetryConf + readRetryConf = func() ([]string, error) { + return []string{"300s", "nope", "4000s", "5d", "5d", "0"}, nil + } + t.Cleanup(func() { readRetryConf = old }) + + p := LoadRetryPolicy() + if !p.FromDefaults { + t.Error("FromDefaults = false, want true when a value cannot be parsed") + } +} + +func TestParseRetryPolicyStock(t *testing.T) { + p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "5d", "5d", "0"}) + if err != nil { + t.Fatalf("parseRetryPolicy: %v", err) + } + if p.FromDefaults { + t.Error("parsed policy should not be marked FromDefaults") + } + if p.QueueRunDelay != 300*time.Second || p.MaximalQueueLifetime != 5*24*time.Hour { + t.Errorf("parsed = %+v", p) + } +} diff --git a/internal/store/domains.go b/internal/store/domains.go index 2fd9872..0c28e2a 100644 --- a/internal/store/domains.go +++ b/internal/store/domains.go @@ -75,6 +75,32 @@ func (s *Store) ListDomains() ([]Domain, error) { return out, rows.Err() } +// ListDomainsForUser returns domains assigned to userID with application counts, +// ordered by name. +func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) { + rows, err := s.db.Query(` + SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at, + (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) + FROM domains d + INNER JOIN user_domains ud ON ud.domain_id = d.id + WHERE ud.user_id = ? + ORDER BY d.name`, userID) + if err != nil { + return nil, fmt.Errorf("list domains for user: %w", err) + } + defer rows.Close() + + var out []Domain + for rows.Next() { + d, err := scanDomain(rows) + if err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + // GetDomain returns a single domain (with its application count) by id, or // ErrDomainNotFound. func (s *Store) GetDomain(id int64) (Domain, error) { diff --git a/internal/store/ratelimits_test.go b/internal/store/ratelimits_test.go index e922145..51b0182 100644 --- a/internal/store/ratelimits_test.go +++ b/internal/store/ratelimits_test.go @@ -152,9 +152,9 @@ func TestDeleteRateLimitsForDomain(t *testing.T) { func TestRateLimitActiveAndAllowsIP(t *testing.T) { inactive := []RateLimit{ {}, - {Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling - {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window - {Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs + {Scope: RateLimitScopeDomain, AllowedIPs: []string{"203.0.113.1"}}, // no ceiling + {Scope: RateLimitScopeDomain, MaxMessages: 5}, // no window + {Scope: RateLimitScopeApp, MaxMessages: 5, WindowSeconds: 60}, // app needs IPs {Scope: RateLimitScopeApp, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window } for i, rl := range inactive { diff --git a/internal/store/sendlog.go b/internal/store/sendlog.go index 6bb7e33..6c0a3f9 100644 --- a/internal/store/sendlog.go +++ b/internal/store/sendlog.go @@ -178,15 +178,29 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) { return row, nil } -// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or -// application login. An empty field matches everything. +// SendLogFilter narrows QuerySendLog/CountSendLog. It carries two kinds of +// narrowing, and they behave in opposite ways on purpose. +// +// Domain and AppLogin are the operator's own filters, chosen in the UI: an +// empty field matches everything. +// +// Domains and AllDomains are the authorization scope, which no query parameter +// may widen. Domains is the exhaustive set of domain names the caller is +// entitled to read, applied as an IN constraint; AllDomains lifts that +// restriction and is the only way to read the whole journal. A zero-valued +// filter therefore matches *no* rows: a caller that forgets to state a scope +// gets an empty log rather than every tenant's mail, which is the failure mode +// this struct exists to make impossible. type SendLogFilter struct { - Domain string - AppLogin string + Domain string + AppLogin string + Domains []string + AllDomains bool } // QuerySendLog returns send-log rows matching filter, newest first, for the // monitoring screen's server-side pagination (product.md's send-log view). +// The filter's authorization scope is mandatory: see SendLogFilter. func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) { where, args := sendLogWhere(filter) args = append(args, limit, offset) @@ -233,6 +247,20 @@ func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) { func sendLogWhere(f SendLogFilter) (string, []any) { var clauses []string var args []any + if !f.AllDomains { + // No scope is not "no restriction": a domain administrator whose last + // assignment was deleted owns nothing in the journal and must see + // nothing, and the same clause catches a caller that never set a scope. + if len(f.Domains) == 0 { + return " WHERE 1 = 0", nil + } + marks := make([]string, len(f.Domains)) + for i, name := range f.Domains { + marks[i] = "?" + args = append(args, name) + } + clauses = append(clauses, "domain IN ("+strings.Join(marks, ", ")+")") + } if f.Domain != "" { clauses = append(clauses, "domain = ?") args = append(args, f.Domain) diff --git a/internal/store/sendlog_test.go b/internal/store/sendlog_test.go index 0512ab2..05e4456 100644 --- a/internal/store/sendlog_test.go +++ b/internal/store/sendlog_test.go @@ -1,6 +1,8 @@ package store import ( + "sort" + "strings" "testing" "time" ) @@ -162,6 +164,61 @@ func TestUpdateStatusNoMatch(t *testing.T) { } } +// The journal is read by principals who are only entitled to part of it, so +// the scope is part of the query rather than something the caller remembers to +// apply afterwards. A filter that states no scope is a caller that has not +// decided who is asking, and the safe answer to that is nothing. +func TestSendLogScopeIsMandatory(t *testing.T) { + st := openTestStore(t) + for _, domain := range []string{"first.example.ru", "second.example.ru"} { + if err := st.InsertQueued(SendLogEntry{ + QueueID: "Q-" + domain, Domain: domain, AppLogin: "app-" + domain, + From: "noreply@" + domain, To: "public@example.net", Subject: domain, + }); err != nil { + t.Fatalf("InsertQueued: %v", err) + } + } + + for name, tc := range map[string]struct { + filter SendLogFilter + want []string + }{ + "no scope": {SendLogFilter{}, nil}, + "empty scope": {SendLogFilter{Domains: []string{}}, nil}, + "all domains": {SendLogFilter{AllDomains: true}, []string{"first.example.ru", "second.example.ru"}}, + "one domain": {SendLogFilter{Domains: []string{"first.example.ru"}}, []string{"first.example.ru"}}, + "two domains": {SendLogFilter{Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru", "second.example.ru"}}, + "unknown domain": {SendLogFilter{Domains: []string{"third.example.ru"}}, nil}, + "filter within": {SendLogFilter{Domain: "first.example.ru", Domains: []string{"first.example.ru", "second.example.ru"}}, []string{"first.example.ru"}}, + "filter outside": {SendLogFilter{Domain: "second.example.ru", Domains: []string{"first.example.ru"}}, nil}, + "app filter outside": {SendLogFilter{AppLogin: "app-second.example.ru", Domains: []string{"first.example.ru"}}, nil}, + } { + rows, err := st.QuerySendLog(tc.filter, 50, 0) + if err != nil { + t.Fatalf("%s: QuerySendLog: %v", name, err) + } + var got []string + for _, r := range rows { + got = append(got, r.Domain) + } + // Which rows came back is the question here; the page's own order is + // newest-first and is tested where it matters. + sort.Strings(got) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("%s: rows for %v, want %v", name, got, tc.want) + } + // The count drives pagination, so it has to agree with the page or the + // UI advertises pages of rows the reader is not allowed to see. + n, err := st.CountSendLog(tc.filter) + if err != nil { + t.Fatalf("%s: CountSendLog: %v", name, err) + } + if int(n) != len(tc.want) { + t.Errorf("%s: count %d, want %d", name, n, len(tc.want)) + } + } +} + func TestDeleteSendLogBefore(t *testing.T) { st := openTestStore(t) diff --git a/internal/store/store.go b/internal/store/store.go index 4434eba..c211e97 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,7 +1,8 @@ // Package store owns the SelfPost SQLite database: the single file under /data -// that persists the administrator account, sending domains and applications, -// the send log and rate-limit settings (architecture.md § Persistence). It -// exposes typed queries so the rest of the panel never builds SQL by hand. +// that persists panel users (global administrators and domain-admins), sending +// domains and applications, the send log and rate-limit settings +// (architecture.md § Persistence). It exposes typed queries so the rest of the +// panel never builds SQL by hand. package store import ( diff --git a/internal/web/auth/auth.go b/internal/web/auth/auth.go index cc4bff3..bf4b5a0 100644 --- a/internal/web/auth/auth.go +++ b/internal/web/auth/auth.go @@ -51,6 +51,8 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo loginLimiter: newRateLimiter(10, 15*time.Minute), trustedProxies: cfg.TrustedProxyCIDRs, } + m.setupLimiter.startSweeper() + m.loginLimiter.startSweeper() m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath) return m } diff --git a/internal/web/auth/auth_test.go b/internal/web/auth/auth_test.go index 2c59422..157c87e 100644 --- a/internal/web/auth/auth_test.go +++ b/internal/web/auth/auth_test.go @@ -1,13 +1,17 @@ package auth import ( + "database/sql" "net/http" "net/http/httptest" + "net/url" "path/filepath" "strings" "testing" "time" + "golang.org/x/crypto/bcrypt" + "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/view" ) @@ -22,6 +26,15 @@ func newTestSessionStore(t *testing.T) *sessionStore { return newSessionStore(st, 7*24*time.Hour) } +func mustCreate(t *testing.T, s *sessionStore, username string) string { + t.Helper() + token, err := s.Create(username) + if err != nil { + t.Fatalf("create session: %v", err) + } + return token +} + func mustView(t *testing.T) *view.Engine { t.Helper() v, err := view.New("test") @@ -86,7 +99,7 @@ func TestSessionTokenIgnoresTheOtherName(t *testing.T) { func TestRequireAuthRejectsDuplicateCookies(t *testing.T) { m := testModule(t, false) - token := m.sessions.Create("admin") + token := mustCreate(t, m.sessions, "admin") reached := false h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) @@ -107,7 +120,7 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) { func TestLogoutClearsBothCookieNames(t *testing.T) { m := testModule(t, true) - token := m.sessions.Create("admin") + token := mustCreate(t, m.sessions, "admin") r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil) r.Host = "panel.example.com" @@ -132,9 +145,63 @@ func TestLogoutClearsBothCookieNames(t *testing.T) { } } +// A session that could not be stored must not turn into a cookie: the browser +// would look signed in, and every request it made would be bounced to /login +// with no explanation. Only the sessions table is broken here, so the request +// gets past the user lookup and password check and fails exactly where the +// session is written. +func TestLoginSetsNoCookieWhenTheSessionCannotBeStored(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + st, err := store.Open(path) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { st.Close() }) + hash, err := bcrypt.GenerateFromPassword([]byte("correct-horse-battery"), bcrypt.MinCost) + if err != nil { + t.Fatalf("hash password: %v", err) + } + if err := st.CreateGlobalUser("admin", string(hash)); err != nil { + t.Fatalf("create user: %v", err) + } + dropSessionsTable(t, path) + + m := New(st, Config{}, mustView(t), "") + form := url.Values{"username": {"admin"}, "password": {"correct-horse-battery"}} + r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login", + strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + m.HandleLogin(rec, r) + + if got := rec.Header().Values("Set-Cookie"); len(got) != 0 { + t.Errorf("a session cookie was issued for a session that was never stored: %v", got) + } + if rec.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500 (the login failed)", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "" { + t.Errorf("the browser was sent to %q as if it were signed in", loc) + } +} + +// dropSessionsTable breaks session persistence while leaving the rest of the +// schema usable. The SQLite driver is registered by internal/store. +func dropSessionsTable(t *testing.T, path string) { + t.Helper() + db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("open database directly: %v", err) + } + defer db.Close() + if _, err := db.Exec("DROP TABLE sessions"); err != nil { + t.Fatalf("drop sessions table: %v", err) + } +} + func TestSessionRename(t *testing.T) { s := newTestSessionStore(t) - token := s.Create("admin") + token := mustCreate(t, s, "admin") s.Rename(token, "operator") @@ -149,8 +216,8 @@ func TestSessionRename(t *testing.T) { func TestSessionDestroyOthers(t *testing.T) { s := newTestSessionStore(t) - keep := s.Create("admin") - other := s.Create("admin") + keep := mustCreate(t, s, "admin") + other := mustCreate(t, s, "admin") s.DestroyOthers(keep) @@ -165,7 +232,7 @@ func TestSessionDestroyOthers(t *testing.T) { func TestSessionLookupRejectsExpired(t *testing.T) { s := newTestSessionStore(t) s.idle = -time.Minute - token := s.Create("admin") + token := mustCreate(t, s, "admin") if _, ok := s.Lookup(token); ok { t.Fatal("expired session was accepted") @@ -174,7 +241,7 @@ func TestSessionLookupRejectsExpired(t *testing.T) { func TestSessionTouchThrottled(t *testing.T) { s := newTestSessionStore(t) - token := s.Create("admin") + token := mustCreate(t, s, "admin") if s.Touch(token) { t.Fatal("touch renewed a session created moments ago") diff --git a/internal/web/auth/handlers.go b/internal/web/auth/handlers.go index 0b7e762..331d039 100644 --- a/internal/web/auth/handlers.go +++ b/internal/web/auth/handlers.go @@ -120,7 +120,12 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) { return } - token := m.sessions.Create(user.Username) + token, err := m.sessions.Create(user.Username) + if err != nil { + logf("panel: login: create session failed: %v", err) + m.renderLogin(w, http.StatusInternalServerError, "Internal error. Please try again.") + return + } m.setSessionCookie(w, token) http.Redirect(w, r, "/", http.StatusSeeOther) } diff --git a/internal/web/auth/handlers_test.go b/internal/web/auth/handlers_test.go new file mode 100644 index 0000000..7bfa250 --- /dev/null +++ b/internal/web/auth/handlers_test.go @@ -0,0 +1,284 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +const testPassword = "correct-horse-battery" + +// moduleWithAdmin returns a panel that has already been through setup, with one +// global administrator whose password is testPassword. +func moduleWithAdmin(t *testing.T) *Module { + t.Helper() + m := testModule(t, false) + hash, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.MinCost) + if err != nil { + t.Fatalf("hash password: %v", err) + } + if err := m.store.CreateGlobalUser("admin", string(hash)); err != nil { + t.Fatalf("create user: %v", err) + } + return m +} + +// postLogin submits the sign-in form from remoteAddr (the limiter's key) and +// returns what the handler wrote. +func postLogin(m *Module, remoteAddr, username, password string) *httptest.ResponseRecorder { + form := url.Values{"username": {username}, "password": {password}} + r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/login", + strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.RemoteAddr = remoteAddr + rec := httptest.NewRecorder() + m.HandleLogin(rec, r) + return rec +} + +// sessionCookieValue returns the session token the response issued, or "" if it +// issued none. +func sessionCookieValue(t *testing.T, m *Module, rec *httptest.ResponseRecorder) string { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == m.sessionCookie() { + return c.Value + } + } + return "" +} + +func TestLoginSignsInWithTheRightPassword(t *testing.T) { + m := moduleWithAdmin(t) + + rec := postLogin(m, "203.0.113.7:5000", "admin", testPassword) + + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("status = %d, Location = %q; want a redirect to /", rec.Code, rec.Header().Get("Location")) + } + token := sessionCookieValue(t, m, rec) + if token == "" { + t.Fatal("no session cookie was issued") + } + name, ok := m.sessions.Lookup(token) + if !ok || name != "admin" { + t.Fatalf("the cookie's session resolves to %q, %t; want admin", name, ok) + } +} + +// A refused sign-in must not say which half was wrong: the panel is public, and +// distinguishable answers would turn the form into a list of usernames. +func TestLoginRefusesBadCredentialsWithoutSayingWhy(t *testing.T) { + m := moduleWithAdmin(t) + + bodies := make(map[string]string, 2) + for name, creds := range map[string][2]string{ + "wrong password": {"admin", "not-the-password"}, + "unknown user": {"nobody", testPassword}, + } { + rec := postLogin(m, "203.0.113.7:5000", creds[0], creds[1]) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s: status = %d, want 401", name, rec.Code) + } + if got := sessionCookieValue(t, m, rec); got != "" { + t.Errorf("%s: a session cookie was issued: %q", name, got) + } + bodies[name] = rec.Body.String() + } + if bodies["wrong password"] != bodies["unknown user"] { + t.Error("the two refusals differ, so the form tells an attacker which usernames exist") + } +} + +// The lockout is what makes online guessing pointless, so it has to hold even +// for the request that finally carries the right password — and it has to be +// scoped to the address that spent the attempts. +func TestLoginLocksOutAfterTooManyAttempts(t *testing.T) { + m := moduleWithAdmin(t) + const attacker = "203.0.113.7:5000" + + for i := 0; i < 10; i++ { + if rec := postLogin(m, attacker, "admin", "guess"); rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: status = %d, want 401 (still under the limit)", i+1, rec.Code) + } + } + + rec := postLogin(m, attacker, "admin", testPassword) + if rec.Code != http.StatusTooManyRequests { + t.Errorf("status = %d, want 429; the lockout was bypassed by guessing right", rec.Code) + } + if got := sessionCookieValue(t, m, rec); got != "" { + t.Errorf("a locked-out request was signed in: %q", got) + } + + if rec := postLogin(m, "198.51.100.9:5000", "admin", testPassword); rec.Code != http.StatusSeeOther { + t.Errorf("another address got %d; one guesser locked out the whole internet", rec.Code) + } +} + +// Before the first administrator exists there is nothing to sign in as, so the +// form is replaced by a pointer to the setup link rather than a password box +// that can never succeed. +func TestLoginPointsAtSetupBeforeTheFirstAdministrator(t *testing.T) { + m := testModule(t, false) + + rec := httptest.NewRecorder() + m.HandleLogin(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/login", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "No administrator has been created yet") { + t.Errorf("the login page does not point at the setup link:\n%s", body) + } + if strings.Contains(body, `name="password"`) { + t.Errorf("the login page offers a password field with no account to use it:\n%s", body) + } +} + +// getSetup performs the GET the operator's browser makes when it follows the +// one-time link. +func getSetup(m *Module, token string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + m.HandleSetup(rec, httptest.NewRequest(http.MethodGet, "http://panel.example.com/setup/"+token, nil)) + return rec +} + +func postSetup(m *Module, token string, form url.Values) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/setup/"+token, + strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + m.HandleSetup(rec, r) + return rec +} + +func setupForm(username, password, confirm string) url.Values { + return url.Values{ + "username": {username}, + "password": {password}, + "password_confirm": {confirm}, + } +} + +// The setup link creates the first global administrator and then stops +// existing: the persistent fact is the user row, so the link is dead after a +// restart too, not only for the process that served it. +func TestSetupCreatesTheFirstAdministratorAndThenCloses(t *testing.T) { + m := testModule(t, false) + token, ok := m.setup.activeToken() + if !ok { + t.Fatal("no setup token on a panel with no users") + } + + if rec := getSetup(m, token); rec.Code != http.StatusOK { + t.Fatalf("GET the setup link = %d, want the form", rec.Code) + } + + rec := postSetup(m, token, setupForm("operator", "a-long-enough-password", "a-long-enough-password")) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/login" { + t.Fatalf("status = %d, Location = %q; want a redirect to /login", rec.Code, rec.Header().Get("Location")) + } + + u, err := m.store.GetUserByUsername("operator") + if err != nil { + t.Fatalf("the administrator was not created: %v", err) + } + if u.Role != RoleGlobal { + t.Errorf("the first administrator has role %q, want global", u.Role) + } + if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("a-long-enough-password")); err != nil { + t.Errorf("the stored hash does not match the password that was set: %v", err) + } + + if rec := getSetup(m, token); rec.Code != http.StatusNotFound { + t.Errorf("the setup link still answers %d after setup completed, want 404", rec.Code) + } + if rec := postSetup(m, token, setupForm("second", "a-long-enough-password", "a-long-enough-password")); rec.Code != http.StatusNotFound { + t.Errorf("a second administrator could be created through the setup link (%d)", rec.Code) + } +} + +// A token that is wrong, or one that has aged out and been replaced, is not a +// hint that setup exists: both answer 404, the same as any unknown path. +func TestSetupRejectsAWrongOrExpiredToken(t *testing.T) { + m := testModule(t, false) + token, ok := m.setup.activeToken() + if !ok { + t.Fatal("no setup token on a panel with no users") + } + + if rec := getSetup(m, token+"x"); rec.Code != http.StatusNotFound { + t.Errorf("a wrong token answered %d, want 404", rec.Code) + } + + expireSetupToken(m) + + if rec := getSetup(m, token); rec.Code != http.StatusNotFound { + t.Errorf("the expired token still opens setup (%d)", rec.Code) + } + fresh, _ := m.setup.activeToken() + if fresh == token { + t.Fatal("the expired token was not replaced") + } + if rec := getSetup(m, fresh); rec.Code != http.StatusOK { + t.Errorf("the reissued token does not open setup (%d)", rec.Code) + } +} + +// The first account is the one that can never be locked out of the panel from +// outside, so the rules that apply to every other user apply here too — before +// anything is written. +func TestSetupRejectsCredentialsItWouldNotAcceptLater(t *testing.T) { + for name, form := range map[string]url.Values{ + "username too short": setupForm("op", "a-long-enough-password", "a-long-enough-password"), + "username not ASCII": setupForm("оператор", "a-long-enough-password", "a-long-enough-password"), + "passwords differ": setupForm("operator", "a-long-enough-password", "a-long-enough-passwerd"), + "password too short": setupForm("operator", "short", "short"), + "no password at all": setupForm("operator", "", ""), + "no username at all": setupForm("", "a-long-enough-password", "a-long-enough-password"), + } { + m := testModule(t, false) + token, _ := m.setup.activeToken() + + rec := postSetup(m, token, form) + + if rec.Code != http.StatusBadRequest { + t.Errorf("%s: status = %d, want 400", name, rec.Code) + } + if exists, err := m.store.UserExists(); err != nil || exists { + t.Errorf("%s: an administrator was created anyway (err=%v)", name, err) + } + if rec := getSetup(m, token); rec.Code != http.StatusOK { + t.Errorf("%s: the setup link was burned by a rejected form (%d)", name, rec.Code) + } + } +} + +// Setup is unauthenticated by definition, so the only thing between the token +// and an offline guesser is the limiter in front of it. +func TestSetupIsRateLimited(t *testing.T) { + m := testModule(t, false) + + for i := 0; i < 10; i++ { + if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusNotFound { + t.Fatalf("attempt %d: status = %d, want 404 (still under the limit)", i+1, rec.Code) + } + } + if rec := getSetup(m, "wrong-token"); rec.Code != http.StatusTooManyRequests { + t.Errorf("status = %d, want 429 after the eleventh attempt", rec.Code) + } +} + +// expireSetupToken ages the current token out, the state the panel reaches when +// nobody follows the link within setupTokenTTL. +func expireSetupToken(m *Module) { + m.setup.mu.Lock() + defer m.setup.mu.Unlock() + m.setup.expiresAt = m.setup.expiresAt.Add(-2 * setupTokenTTL) +} diff --git a/internal/web/auth/middleware.go b/internal/web/auth/middleware.go index 127373c..e1d119f 100644 --- a/internal/web/auth/middleware.go +++ b/internal/web/auth/middleware.go @@ -44,15 +44,3 @@ func CurrentUser(r *http.Request) string { } return "" } - -// RequireGlobal wraps a handler that only global administrators may reach. -func RequireGlobal(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - p, ok := CurrentPrincipal(r.Context()) - if !ok || !p.IsGlobal() { - http.NotFound(w, r) - return - } - next.ServeHTTP(w, r) - }) -} diff --git a/internal/web/auth/principal.go b/internal/web/auth/principal.go index 534cff5..e0397ab 100644 --- a/internal/web/auth/principal.go +++ b/internal/web/auth/principal.go @@ -10,8 +10,8 @@ import ( type ctxKey int const ( - usernameKey ctxKey = 0 - principalKey ctxKey = 1 + usernameKey ctxKey = 0 + principalKey ctxKey = 1 ) // Role is a panel user's access level. diff --git a/internal/web/auth/ratelimit.go b/internal/web/auth/ratelimit.go index 5658fc8..f8b09f4 100644 --- a/internal/web/auth/ratelimit.go +++ b/internal/web/auth/ratelimit.go @@ -5,11 +5,14 @@ import ( "time" ) +const defaultMaxBuckets = 4096 + // rateLimiter is a simple fixed-window per-key counter used to throttle the // setup and login routes (security.md). Keys are client IPs. type rateLimiter struct { - max int - window time.Duration + max int + window time.Duration + maxBuckets int mu sync.Mutex buckets map[string]*rlBucket @@ -22,12 +25,25 @@ type rlBucket struct { func newRateLimiter(max int, window time.Duration) *rateLimiter { return &rateLimiter{ - max: max, - window: window, - buckets: make(map[string]*rlBucket), + max: max, + window: window, + maxBuckets: defaultMaxBuckets, + buckets: make(map[string]*rlBucket), } } +func (r *rateLimiter) startSweeper() { + go func() { + ticker := time.NewTicker(r.window) + defer ticker.Stop() + for range ticker.C { + r.mu.Lock() + r.sweep(time.Now()) + r.mu.Unlock() + } + }() +} + func (r *rateLimiter) Allow(key string) bool { now := time.Now() r.mu.Lock() @@ -35,6 +51,7 @@ func (r *rateLimiter) Allow(key string) bool { b := r.buckets[key] if b == nil || now.After(b.windowEnds) { + r.makeRoom(now) r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)} r.sweep(now) return true @@ -46,6 +63,32 @@ func (r *rateLimiter) Allow(key string) bool { return true } +func (r *rateLimiter) makeRoom(now time.Time) { + if r.maxBuckets <= 0 || len(r.buckets) < r.maxBuckets { + return + } + r.sweep(now) + for len(r.buckets) >= r.maxBuckets { + r.evictOldest() + } +} + +func (r *rateLimiter) evictOldest() { + var oldestKey string + var oldestEnds time.Time + first := true + for k, b := range r.buckets { + if first || b.windowEnds.Before(oldestEnds) { + oldestKey = k + oldestEnds = b.windowEnds + first = false + } + } + if oldestKey != "" { + delete(r.buckets, oldestKey) + } +} + func (r *rateLimiter) sweep(now time.Time) { for k, b := range r.buckets { if now.After(b.windowEnds) { diff --git a/internal/web/auth/ratelimit_test.go b/internal/web/auth/ratelimit_test.go new file mode 100644 index 0000000..696bff9 --- /dev/null +++ b/internal/web/auth/ratelimit_test.go @@ -0,0 +1,125 @@ +package auth + +import ( + "testing" + "time" +) + +// The limiter is what stands between the public login form and an unlimited +// guessing rate (security.md), so the ceiling has to be exact: the configured +// number of attempts go through and the next one does not, however often it is +// repeated. +func TestRateLimiterStopsAtTheCeiling(t *testing.T) { + r := newRateLimiter(3, time.Minute) + + for i := 1; i <= 3; i++ { + if !r.Allow("203.0.113.7") { + t.Fatalf("attempt %d of 3 was refused before the ceiling", i) + } + } + for i := 4; i <= 6; i++ { + if r.Allow("203.0.113.7") { + t.Fatalf("attempt %d passed after the ceiling of 3", i) + } + } +} + +// Buckets are per key, so one locked-out address must not lock out the rest of +// the internet — a shared counter would turn a single guesser into a denial of +// service against every operator. +func TestRateLimiterKeepsKeysApart(t *testing.T) { + r := newRateLimiter(1, time.Minute) + + if !r.Allow("203.0.113.7") || r.Allow("203.0.113.7") { + t.Fatal("the first key did not use up its single attempt") + } + if !r.Allow("198.51.100.9") { + t.Fatal("a second address was refused because another one was locked out") + } +} + +// The window is fixed, not sliding: once it has elapsed the count starts again +// from zero rather than being carried over. Time is moved by ageing the bucket +// instead of sleeping, so the test states the boundary rather than approaching +// it. +func TestRateLimiterReopensAfterTheWindow(t *testing.T) { + r := newRateLimiter(2, time.Minute) + r.Allow("203.0.113.7") + r.Allow("203.0.113.7") + if r.Allow("203.0.113.7") { + t.Fatal("the ceiling was not reached") + } + + expire(r, "203.0.113.7") + + if !r.Allow("203.0.113.7") { + t.Fatal("the key is still locked out after its window ended") + } + if !r.Allow("203.0.113.7") { + t.Fatal("the new window did not start from an empty count") + } + if r.Allow("203.0.113.7") { + t.Fatal("the new window allowed more than the ceiling") + } +} + +// Every address that ever tried to sign in gets a bucket, and the only thing +// that removes the finished ones is the sweep on a new window. It runs on the +// key that triggered it as well as on the others, so a long-running panel does +// not accumulate a bucket per source address for ever. +func TestRateLimiterSweepsFinishedBuckets(t *testing.T) { + r := newRateLimiter(2, time.Minute) + for _, key := range []string{"203.0.113.7", "198.51.100.9"} { + r.Allow(key) + expire(r, key) + } + r.Allow("192.0.2.5") // still inside its window + + r.Allow("203.0.113.7") // new window for this key: sweeps the rest + + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.buckets["198.51.100.9"]; ok { + t.Error("a bucket whose window ended is still held") + } + for _, key := range []string{"203.0.113.7", "192.0.2.5"} { + if _, ok := r.buckets[key]; !ok { + t.Errorf("the sweep dropped %s, whose window is still open", key) + } + } +} + +// expire moves a key's window into the past, the same state it would reach by +// waiting for the window to elapse. +func expire(r *rateLimiter, key string) { + r.mu.Lock() + defer r.mu.Unlock() + if b := r.buckets[key]; b != nil { + b.windowEnds = time.Now().Add(-time.Second) + } +} + +// A long-running panel can see many unique client addresses. Finished buckets +// are swept on every new window, and a hard cap evicts the oldest when the map +// would otherwise grow without bound. +func TestRateLimiterCapsBucketCount(t *testing.T) { + r := newRateLimiter(1, time.Minute) + r.maxBuckets = 3 + + for i, key := range []string{"203.0.113.7", "198.51.100.9", "192.0.2.5"} { + if !r.Allow(key) { + t.Fatalf("attempt %d for %s was refused under the cap", i+1, key) + } + expire(r, key) + } + + if !r.Allow("203.0.113.8") { + t.Fatal("a fourth address was refused even though room was made") + } + + r.mu.Lock() + defer r.mu.Unlock() + if len(r.buckets) > 3 { + t.Fatalf("bucket count = %d, want at most 3", len(r.buckets)) + } +} diff --git a/internal/web/auth/session.go b/internal/web/auth/session.go index 29c588d..dace9ef 100644 --- a/internal/web/auth/session.go +++ b/internal/web/auth/session.go @@ -41,17 +41,22 @@ func hashToken(token string) string { return hex.EncodeToString(sum[:]) } -// Create issues a new session for username and returns its token. -func (s *sessionStore) Create(username string) string { +// Create issues a new session for username and returns its token. It fails +// closed: if the row cannot be written the caller gets an error and must not +// hand out a cookie, because a token that is not in the database looks like a +// signed-in browser while every request it makes bounces back to /login. +func (s *sessionStore) Create(username string) (string, error) { token := randomToken(32) now := time.Now() if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil { - logf("panel: session: create failed: %v", err) + return "", err } + // Pruning is housekeeping: the new session is already valid, so a failure + // here is logged and does not fail the login. if _, err := s.store.DeleteExpiredSessions(now); err != nil { logf("panel: session: prune expired failed: %v", err) } - return token + return token, nil } // Lookup returns the session username for a token if it exists and is diff --git a/internal/web/auth/setup.go b/internal/web/auth/setup.go index b560810..569773b 100644 --- a/internal/web/auth/setup.go +++ b/internal/web/auth/setup.go @@ -16,8 +16,9 @@ const setupTokenTTL = 10 * time.Minute // setupManager owns the one-time administrator setup token. The token itself is // ephemeral (regenerated on restart or expiry) and lives only in memory; the -// persistent "setup complete" fact is the presence of the admin row in the -// store, so once that exists the token is gone for good (security.md). +// persistent "setup complete" fact is the presence of any row in the store's +// users table (`store.UserExists`), so once the first global administrator is +// created the token is gone for good (security.md). type setupManager struct { store *store.Store hostname string diff --git a/internal/web/handlers/authz.go b/internal/web/handlers/authz.go index 5d8690b..3c5eabd 100644 --- a/internal/web/handlers/authz.go +++ b/internal/web/handlers/authz.go @@ -32,17 +32,7 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) { if p.IsGlobal() { return h.store.ListDomains() } - all, err := h.store.ListDomains() - if err != nil { - return nil, err - } - var out []store.Domain - for _, d := range all { - if p.CanAccessDomain(d.ID) { - out = append(out, d) - } - } - return out, nil + return h.store.ListDomainsForUser(p.ID) } func domainNameSet(domains []store.Domain) map[string]bool { diff --git a/internal/web/handlers/authz_test.go b/internal/web/handlers/authz_test.go new file mode 100644 index 0000000..017f771 --- /dev/null +++ b/internal/web/handlers/authz_test.go @@ -0,0 +1,125 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/mixeme/selfpost/internal/web/auth" +) + +// route is one entry of the authenticated mux, named the way web.go registers +// it so a route added there without a guard is visible as a missing case here. +type route struct { + method string + target string + handler func(*Handlers) http.HandlerFunc + // pathValues are the {id}-style segments the router would have bound. + pathValues map[string]string +} + +// globalOnlyRoutes is every page and action that only a global administrator +// may reach: the panel's users, the whole-server backup and domain import, the +// machine-wide status and log views, and the domain lifecycle. A domain +// administrator is answered 404 rather than 403 so the panel does not confirm +// that the page exists (security.md). +var globalOnlyRoutes = []route{ + {"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil}, + {"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil}, + {"POST", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil}, + {"GET", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}}, + {"POST", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}}, + {"GET", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}}, + {"POST", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}}, + + {"GET", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil}, + {"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil}, + {"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil}, + + {"GET", "/status", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil}, + {"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil}, + {"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil}, + + {"GET", "/mail-queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil}, + {"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil}, + {"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil}, + {"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil}, + + {"POST", "/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil}, + {"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}}, + {"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}}, + {"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil}, +} + +// A domain administrator has an account on the panel, so authentication is not +// what keeps them off these pages — the per-handler role check is. Each of them +// is reached here with a valid session for a principal that owns a domain, the +// case the send-log leak (P0, code-review.md) showed is easy to get wrong. +func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) { + h, domains := serverWithTwoDomains(t) + p := domainAdmin(t, h.store, "global-only", domains["first.example.ru"].ID) + + for _, rt := range globalOnlyRoutes { + rec := call(h, rt, p) + if rec.Code != http.StatusNotFound { + t.Errorf("%s %s as a domain administrator = %d, want 404:\n%s", + rt.method, rt.target, rec.Code, rec.Body.String()) + } + } +} + +// The same 404 covers a request that carries no principal at all: the auth +// middleware normally redirects those, but a handler must not depend on +// middleware it cannot see for the role it enforces itself. +func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) { + h, _ := serverWithTwoDomains(t) + + for _, rt := range globalOnlyRoutes { + rec := httptest.NewRecorder() + req := httptest.NewRequest(rt.method, rt.target, nil) + for k, v := range rt.pathValues { + req.SetPathValue(k, v) + } + rt.handler(h)(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("%s %s with no principal = %d, want 404", rt.method, rt.target, rec.Code) + } + } +} + +// The 404s above would also pass if a handler were broken into always returning +// one, so at least the two pages that need nothing but the store and the view +// have to be shown opening for a global administrator. +func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) { + h, _ := serverWithTwoDomains(t) + + for _, target := range []string{"/users", "/backup"} { + rt := getRoute(t, target) + if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK { + t.Errorf("GET %s as a global administrator = %d, want 200:\n%s", + target, rec.Code, rec.Body.String()) + } + } +} + +func getRoute(t *testing.T, target string) route { + t.Helper() + for _, rt := range globalOnlyRoutes { + if rt.method == http.MethodGet && rt.target == target { + return rt + } + } + t.Fatalf("no GET %s among the global-only routes", target) + return route{} +} + +func call(h *Handlers, rt route, p auth.Principal) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(rt.method, rt.target, nil) + req = auth.RequestWithPrincipal(req, p) + for k, v := range rt.pathValues { + req.SetPathValue(k, v) + } + rt.handler(h)(rec, req) + return rec +} diff --git a/internal/web/handlers/e2e_markup_probe_test.go b/internal/web/handlers/e2e_markup_probe_test.go new file mode 100644 index 0000000..17c8a9f --- /dev/null +++ b/internal/web/handlers/e2e_markup_probe_test.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "regexp" + "strings" + "testing" +) + +// TestE2ESendLogStatusMarkupDrift renders the real deliveries_rows fragment and +// checks whether the e2e gate's HTML scrapers still match it. +func TestE2ESendLogStatusMarkupDrift(t *testing.T) { + h, _ := serverWithDelivery(t) + rendered := getBody(t, h.HandleDeliveriesRows, "/deliveries/rows") + + // These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate. + statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)
`) + containsCell := func(html, needle string) bool { + return strings.Contains(html, ``+needle+``) + } + + if statusCellPattern.FindStringSubmatch(rendered) == nil { + t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`)) + } + if !containsCell(rendered, "sent") { + t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`)) + } +} + +func snippet(s, needle string) string { + i := strings.Index(s, needle) + if i < 0 { + if len(s) > 200 { + return s[:200] + "..." + } + return s + } + start := i - 20 + if start < 0 { + start = 0 + } + end := i + 120 + if end > len(s) { + end = len(s) + } + return s[start:end] +} diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index bb530d7..33bf795 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -8,6 +8,7 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/view" @@ -20,6 +21,7 @@ type Config struct { MailLogPath string DataDir string DBPath string + DeployRoot string Version string TLSCertFile string OpenDKIMSocket string @@ -28,6 +30,10 @@ type Config struct { // and used to cap domain/app level-2 ceilings (guide § Rate limiting). RateLimitMessagesPerIP int RateLimitWindowSeconds int + // RetryPolicy is this Postfix's deferred-mail timings, snapshotted once + // when the HTTP role starts (architecture.md). The Mail queue card and + // delivery history read it from here; they never call postconf. + RetryPolicy postfix.RetryPolicy } // Handlers holds dependencies for authenticated panel routes. diff --git a/internal/web/handlers/handlers_backup.go b/internal/web/handlers/handlers_backup.go index b07b83e..e9d0759 100644 --- a/internal/web/handlers/handlers_backup.go +++ b/internal/web/handlers/handlers_backup.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/mixeme/selfpost/internal/backup" @@ -70,6 +71,11 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) { h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) return } + if err := backup.ValidateDeployRoot(h.cfg.DeployRoot); err != nil { + logf("panel: full backup: %v", err) + h.renderBackupPageWith(w, r, http.StatusBadRequest, "", deployBackupErr(err)) + return + } stamp := time.Now().UTC().Format("20060102-150405") filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp) @@ -105,9 +111,13 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) { } if err := backup.Create(sink, backup.Params{ - DataDir: h.cfg.DataDir, - DBPath: h.cfg.DBPath, - Version: h.cfg.Version, + DataDir: h.cfg.DataDir, + DBPath: h.cfg.DBPath, + Version: h.cfg.Version, + DeployRoot: h.cfg.DeployRoot, + OnWarn: func(msg string) { + logf("panel: full backup: %s", msg) + }, }); err != nil { logf("panel: full backup failed: %v", err) return @@ -310,6 +320,14 @@ func decryptErrorMessage(err error) string { } } +// deployBackupErr phrases a pre-flight backup failure for the operator. +func deployBackupErr(err error) string { + if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") { + return "Full backup needs the project directory mounted read-only at /selfpost-deploy — add .:/selfpost-deploy:ro to docker-compose.yml and recreate the container." + } + return "Could not create the backup: " + err.Error() +} + // importErrorMessage maps a domain-import failure (already logged by the caller) // to an HTTP status and a user-facing message. Duplicate domain/login are called // out specifically; other failures — validation errors describing what is wrong diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go index d090be4..b3ea23e 100644 --- a/internal/web/handlers/handlers_domains.go +++ b/internal/web/handlers/handlers_domains.go @@ -124,10 +124,10 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { return } h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{ - "Title": "SelfPost — delete " + d.Name, - "User": auth.CurrentUser(r), - "Active": "domains", - "Domain": d, + "Title": "SelfPost — delete " + d.Name, + "User": auth.CurrentUser(r), + "Active": "domains", + "Domain": d, "IsGlobal": true, }) } diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go index 57a0b4c..31e56ee 100644 --- a/internal/web/handlers/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -2,6 +2,7 @@ package handlers import ( "errors" + "fmt" "io/fs" "net/http" "net/url" @@ -107,15 +108,15 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) { row.Subject = mailhdr.DecodeSubject(row.Subject) logRows, logNote := h.deliveryLog(row) h.view.Render(w, http.StatusOK, "delivery", map[string]any{ - "Title": "SelfPost — delivery", - "User": auth.CurrentUser(r), - "Active": "deliveries", + "Title": "SelfPost — delivery", + "User": auth.CurrentUser(r), + "Active": "deliveries", "IsGlobal": p.IsGlobal(), - "Row": row, + "Row": row, // The status in the panel's own badge vocabulary, so the headline reads // the same way as every other health signal in the panel. "Level": deliveryLevel(row.Status), - "Events": deliveryEvents(row), + "Events": deliveryEvents(row, h.cfg.RetryPolicy), // The mail.log lines for this message, and — when there are none — the // reason, which is a normal outcome rather than a failure. "LogRows": logRows, @@ -173,7 +174,9 @@ type deliveryEvent struct { // timestamps *are* the history, and stating them as steps is what makes a row // whose created_at and updated_at differ by six hours legible as "queued for // six hours, then delivered" rather than as two dates in a list of fields. -func deliveryEvents(row store.SendLogRow) []deliveryEvent { +// policy supplies the human intervals for deferred and bounced copy, the same +// strings the Mail queue card prints, so the two cannot drift. +func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []deliveryEvent { // A rejected message has no second step, and its first one is not an // acceptance: the journal-milter refused it, so Postfix never queued it. if row.Status == store.StatusRejected { @@ -217,7 +220,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent { Level: "warn", Status: store.StatusDeferred, Title: "Deferred, will be retried", - Detail: "The receiving server could not take the message yet. Postfix keeps it queued and retries until it is delivered or the queue lifetime runs out.", + Detail: fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.", + policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()), }) case store.StatusBounced: return append(events, deliveryEvent{ @@ -225,7 +229,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent { Level: "error", Status: store.StatusBounced, Title: "Bounced", - Detail: "Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after the queue lifetime. The reason is in the delivery log below.", + Detail: fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.", + policy.QueueLifetime()), }) default: // A status the log-tailer learns to write before this switch does. @@ -299,16 +304,22 @@ func deliveriesBackURL(r *http.Request) string { // sendLogData reads the domain/app filters and page number off the query // string, queries the store, and assembles everything the template needs // (filter dropdown options plus the current selection, rows, and pagination). +// +// The invariant this function owes the journal: a principal who is not global +// only ever reads rows for the domains assigned to them. That scope is stated +// to the store as SendLogFilter.Domains and holds for every number of +// assignments, including none — a domain administrator whose last domain was +// deleted gets an empty log, not the whole one. The query parameters are +// filters *within* that scope and can only narrow it: both are checked against +// the assigned domains and their applications before the query runs, because a +// dropdown that offers only permitted values is a courtesy to the browser, not +// a check on the request. func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { p, ok := h.principal(r) if !ok { return nil, errors.New("no principal") } q := r.URL.Query() - filter := store.SendLogFilter{ - Domain: q.Get("domain"), - AppLogin: q.Get("app"), - } assigned, err := h.assignedDomains(p) if err != nil { @@ -316,31 +327,6 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { } allowedNames := domainNameSet(assigned) - if !p.IsGlobal() { - if filter.Domain != "" && !allowedNames[filter.Domain] { - filter.Domain = "" - } - if filter.Domain == "" && len(assigned) == 1 { - filter.Domain = assigned[0].Name - } - } - - page := parsePage(q.Get("p")) - - total, err := h.store.CountSendLog(filter) - if err != nil { - return nil, err - } - rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize) - if err != nil { - return nil, err - } - view := make([]sendLogRow, len(rows)) - for i := range rows { - rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject) - view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)} - } - domainNames := make([]string, 0, len(assigned)) for _, d := range assigned { domainNames = append(domainNames, d.Name) @@ -362,8 +348,37 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { } sort.Strings(logins) - if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] { - filter.AppLogin = "" + filter := store.SendLogFilter{ + Domain: q.Get("domain"), + AppLogin: q.Get("app"), + // A global administrator reads the whole journal, including rows left + // behind by a domain that has since been deleted. + Domains: domainNames, + AllDomains: p.IsGlobal(), + } + if !p.IsGlobal() { + if filter.Domain != "" && !allowedNames[filter.Domain] { + filter.Domain = "" + } + if filter.AppLogin != "" && !loginSet[filter.AppLogin] { + filter.AppLogin = "" + } + } + + page := parsePage(q.Get("p")) + + total, err := h.store.CountSendLog(filter) + if err != nil { + return nil, err + } + rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize) + if err != nil { + return nil, err + } + view := make([]sendLogRow, len(rows)) + for i := range rows { + rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject) + view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)} } lastPage := 1 @@ -402,13 +417,18 @@ func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) { return } out, errText := readQueue() + policy := h.cfg.RetryPolicy h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{ - "Title": "SelfPost — mail queue", - "User": auth.CurrentUser(r), - "Active": "mail_queue", - "IsGlobal": true, - "Output": out, - "Error": errText, + "Title": "SelfPost — mail queue", + "User": auth.CurrentUser(r), + "Active": "mail_queue", + "IsGlobal": true, + "Output": out, + "Error": errText, + "FirstRetry": policy.FirstRetry(), + "BackoffCap": policy.BackoffCap(), + "QueueLifetime": policy.QueueLifetime(), + "RetryFromDefaults": policy.FromDefaults, }) } diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go index 2efeae5..9e032ab 100644 --- a/internal/web/handlers/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -8,7 +8,9 @@ import ( "strconv" "strings" "testing" + "time" + "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" ) @@ -129,7 +131,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) { }); err != nil { t.Fatalf("insert: %v", err) } - rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0) + rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0) if err != nil || len(rows) != 1 { t.Fatalf("query: %v (%d rows)", err, len(rows)) } @@ -215,6 +217,174 @@ func TestDeliveryPageNotFound(t *testing.T) { } } +// A domain administrator reads the journal of the domains assigned to them and +// nothing else. The list used to be scoped only when exactly one domain was +// assigned, which meant two assignments read as none at all. +func TestSendLogScopedToAssignedDomains(t *testing.T) { + h, domains := serverWithTwoDomains(t) + + for name, tc := range map[string]struct { + username string + domainIDs []int64 + want []string + unwanted []string + }{ + "global sees both": { + "", nil, []string{"First message", "Second message"}, nil, + }, + "one assigned domain": { + "one-domain", []int64{domains["first.example.ru"].ID}, + []string{"First message"}, []string{"Second message", "second-app"}, + }, + "two assigned domains": { + "two-domains", []int64{domains["first.example.ru"].ID, domains["second.example.ru"].ID}, + []string{"First message", "Second message"}, nil, + }, + // Every assigned domain deleted cascades the assignments away. That + // leaves a principal entitled to nothing, which is an empty log — the + // case that used to hand over the whole journal. + "no assigned domains": { + "no-domains", nil, []string{"No messages logged yet."}, + []string{"First message", "Second message"}, + }, + } { + var p auth.Principal + if tc.username == "" { + p = globalPrincipal + } else { + p = domainAdmin(t, h.store, tc.username, tc.domainIDs...) + } + for view, handler := range map[string]http.HandlerFunc{ + "page": h.HandleDeliveries, + "fragment": h.HandleDeliveriesRows, + } { + out := getBodyAs(t, handler, "/deliveries", p) + for _, want := range tc.want { + if !strings.Contains(out, want) { + t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out) + } + } + for _, unwanted := range tc.unwanted { + if strings.Contains(out, unwanted) { + t.Errorf("%s (%s): leaks %q:\n%s", name, view, unwanted, out) + } + } + } + } +} + +// The filter dropdowns offer only permitted values, so a leak through them can +// only come from a hand-written URL — which is exactly why the values are +// checked against the principal's own domains and applications rather than +// trusted for having been rendered by us. +func TestSendLogIgnoresForgedFilters(t *testing.T) { + h, domains := serverWithTwoDomains(t) + p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID) + + for _, target := range []string{ + "/deliveries?domain=second.example.ru", + "/deliveries?app=second-app", + "/deliveries?domain=second.example.ru&app=second-app", + } { + out := getBodyAs(t, h.HandleDeliveries, target, p) + if strings.Contains(out, "Second message") { + t.Errorf("GET %s leaks another domain's journal:\n%s", target, out) + } + if !strings.Contains(out, "First message") { + t.Errorf("GET %s hid the principal's own journal:\n%s", target, out) + } + } +} + +// The detail page has always checked membership; keep it checked, because the +// list and the page are two ways to the same row. +func TestDeliveryPageForeignDomainNotFound(t *testing.T) { + h, domains := serverWithTwoDomains(t) + rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0) + if err != nil || len(rows) != 1 { + t.Fatalf("query: %v (%d rows)", err, len(rows)) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil) + req.SetPathValue("id", itoa(rows[0].ID)) + req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID)) + h.HandleDelivery(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code) + } +} + +// serverWithTwoDomains builds a panel over a store holding two domains, one +// application and one delivered message each, so a scoping test can tell "my +// rows" from "every row" by reading the page. +func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + domains := make(map[string]store.Domain, 2) + for _, d := range []struct{ name, app, subject string }{ + {"first.example.ru", "first-app", "First message"}, + {"second.example.ru", "second-app", "Second message"}, + } { + dom, err := st.AddDomain(d.name, "mail") + if err != nil { + t.Fatalf("add domain %s: %v", d.name, err) + } + if _, err := st.AddApplication(dom.ID, d.app, store.AddressModeWildcard, nil); err != nil { + t.Fatalf("add application %s: %v", d.app, err) + } + if err := st.InsertQueued(store.SendLogEntry{ + QueueID: "Q" + d.app, Domain: d.name, AppLogin: d.app, + From: "noreply@" + d.name, To: "public@example.net", Subject: d.subject, + }); err != nil { + t.Fatalf("insert %s: %v", d.subject, err) + } + domains[d.name] = dom + } + + return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains +} + +var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal} + +func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...int64) auth.Principal { + t.Helper() + const hash = "test-hash" + if len(domainIDs) == 0 { + placeholder, err := st.AddDomain(username+".placeholder.invalid", "mail") + if err != nil { + t.Fatalf("add placeholder domain: %v", err) + } + id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID}) + if err != nil { + t.Fatalf("create domain admin %s: %v", username, err) + } + if err := st.DeleteDomain(placeholder.ID); err != nil { + t.Fatalf("delete placeholder domain: %v", err) + } + domainIDs = nil + u, err := st.GetUser(id) + if err != nil { + t.Fatalf("get domain admin %s: %v", username, err) + } + return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs} + } + id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs) + if err != nil { + t.Fatalf("create domain admin %s: %v", username, err) + } + u, err := st.GetUser(id) + if err != nil { + t.Fatalf("get domain admin %s: %v", username, err) + } + return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs} +} + // serverWithDelivery builds a panel over a store holding one delivery, written // the way the journal-milter wrote them before it decoded subjects itself. func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) { @@ -238,7 +408,7 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) { if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil { t.Fatalf("update status: %v", err) } - rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0) + rows, err := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0) if err != nil || len(rows) != 1 { t.Fatalf("query: %v (%d rows)", err, len(rows)) } @@ -246,18 +416,20 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) { return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0] } -// getBody runs one handler over a GET and returns the page it wrote, failing -// the test on any non-200. The path's {id} is bound by hand because these calls -// bypass the router that would otherwise fill it in. +// getBody runs one handler over a GET as the global administrator. func getBody(t *testing.T, h http.HandlerFunc, target string) string { + t.Helper() + return getBodyAs(t, h, target, globalPrincipal) +} + +// getBodyAs runs one handler over a GET as the given principal and returns the +// page it wrote, failing the test on any non-200. The path's {id} is bound by +// hand because these calls bypass the router that would otherwise fill it in. +func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal) string { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, target, nil) - req = auth.RequestWithPrincipal(req, auth.Principal{ - ID: 1, - Username: "admin", - Role: auth.RoleGlobal, - }) + req = auth.RequestWithPrincipal(req, p) if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" { req.SetPathValue("id", rest) } @@ -280,3 +452,90 @@ func writeMailLog(t *testing.T, lines ...string) string { } return path } + +// fixtureRetryPolicy is a distinctive policy so tests can tell the Config +// snapshot from live postconf and from compiled-in defaults (5 minutes / 5 days). +func fixtureRetryPolicy() postfix.RetryPolicy { + return postfix.RetryPolicy{ + QueueRunDelay: 10 * time.Minute, + MinimalBackoff: 10 * time.Minute, + MaximalBackoff: 4000 * time.Second, + MaximalQueueLifetime: 2 * 24 * time.Hour, + BounceQueueLifetime: 2 * 24 * time.Hour, + } +} + +// The retry card sits on the page itself, outside the HTMX poll, and prints +// whatever policy was cached on Config — never a live postconf. +func TestMailQueueShowsRetryPolicyCard(t *testing.T) { + h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}} + + out := getBody(t, h.HandleMailQueue, "/mail-queue") + for _, want := range []string{ + "How delivery retries work", + "id=\"retry-policy\"", + ">10 minutes<", + "doubling, cap about 1 hour 7 minutes", + ">2 days<", + } { + if !strings.Contains(out, want) { + t.Errorf("mail queue is missing %q:\n%s", want, out) + } + } + if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") { + t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out) + } + if strings.Contains(out, "compiled-in defaults") { + t.Error("a fixture policy must not show the fallback note") + } +} + +func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) { + h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}} + + out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body") + if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") { + t.Errorf("HTMX fragment includes the retry card:\n%s", out) + } +} + +func TestMailQueueNotesCompiledInFallback(t *testing.T) { + h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}} + + out := getBody(t, h.HandleMailQueue, "/mail-queue") + if !strings.Contains(out, "compiled-in defaults") { + t.Errorf("fallback note missing:\n%s", out) + } +} + +func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) { + h, row := serverWithDelivery(t) + h.cfg.RetryPolicy = fixtureRetryPolicy() + if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusDeferred); err != nil { + t.Fatalf("update status: %v", err) + } + + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)) + for _, want := range []string{ + "first after 10 minutes", + "up to about 1 hour 7 minutes", + "for up to 2 days", + } { + if !strings.Contains(out, want) { + t.Errorf("deferred history is missing %q:\n%s", want, out) + } + } +} + +func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) { + h, row := serverWithDelivery(t) + h.cfg.RetryPolicy = fixtureRetryPolicy() + if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil { + t.Fatalf("update status: %v", err) + } + + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)) + if !strings.Contains(out, "gave up after 2 days in the queue") { + t.Errorf("bounced history does not use the fixture lifetime:\n%s", out) + } +} diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_settings.go similarity index 73% rename from internal/web/handlers/handlers_account.go rename to internal/web/handlers/handlers_settings.go index dbacd70..210cb8e 100644 --- a/internal/web/handlers/handlers_account.go +++ b/internal/web/handlers/handlers_settings.go @@ -14,8 +14,8 @@ import ( "golang.org/x/crypto/bcrypt" ) -// HandleAccount serves the signed-in user's account settings. -func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { +// HandleSettings serves the signed-in user's panel settings. +func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: p, ok := h.principal(r) @@ -25,20 +25,20 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { } u, err := h.store.GetUser(p.ID) if err != nil { - logf("panel: account: get user failed: %v", err) + logf("panel: settings: get user failed: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal()) + h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal()) case http.MethodPost: - h.submitAccount(w, r) + h.submitSettings(w, r) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } -func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) { +func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) { var reportAuth dnscheck.Result if showDMARC && formDMARCEmail != "" { if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { @@ -58,13 +58,13 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status data["ReportAuthDNS"] = reportAuth data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail) data["Error"] = formErr - data["Flash"] = accountFlash(r) + data["Flash"] = settingsFlash(r) data["L1Messages"] = h.l1Messages() data["L1Window"] = h.l1Window() h.view.Render(w, status, "settings", data) } -func accountFlash(r *http.Request) string { +func settingsFlash(r *http.Request) string { switch r.URL.Query().Get("updated") { case "username": return "Username changed." @@ -85,16 +85,16 @@ func accountFlash(r *http.Request) string { } } -func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { if !h.auth.AllowLoginAttempt(r) { p, _ := h.principal(r) - h.renderAccount(w, r, http.StatusTooManyRequests, + h.renderSettings(w, r, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal()) return } if err := r.ParseForm(); err != nil { p, _ := h.principal(r) - h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal()) + h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal()) return } @@ -105,7 +105,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { } user, err := h.store.GetUser(p.ID) if err != nil { - logf("panel: account: get user failed: %v", err) + logf("panel: settings: get user failed: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -123,21 +123,21 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil { - h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal()) + h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal()) return } renaming := username != user.Username if renaming { if err := validate.Username(username); err != nil { - h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) + h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) return } } if p.IsGlobal() { if err := validate.Email(dmarcEmail); err != nil { - h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true) + h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true) return } } @@ -147,16 +147,16 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { repassword := password != "" || confirm != "" if repassword { if password != confirm { - h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal()) + h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal()) return } if err := validate.AdminPassword(password); err != nil { - h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) + h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) return } } if !renaming && !repassword && !emailChanging { - h.renderAccount(w, r, http.StatusBadRequest, + h.renderSettings(w, r, http.StatusBadRequest, "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal()) return } @@ -165,8 +165,8 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { if repassword { newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - logf("panel: account: hashing password failed: %v", err) - h.renderAccount(w, r, http.StatusInternalServerError, + logf("panel: settings: hashing password failed: %v", err) + h.renderSettings(w, r, http.StatusInternalServerError, "Internal error. Please try again.", username, dmarcEmail, p.IsGlobal()) return } @@ -174,17 +174,17 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { } if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil { - logf("panel: account: update user failed: %v", err) + logf("panel: settings: update user failed: %v", err) msg := "Could not save the changes. Please check the logs and try again." if errors.Is(err, store.ErrUserNotFound) { msg = "There is no user account to update." } if errors.Is(err, store.ErrUserExists) { msg = "That username is already in use." - h.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal()) + h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal()) return } - h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal()) + h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal()) return } @@ -197,7 +197,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { } } - logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging) + logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging) http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther) } diff --git a/internal/web/handlers/handlers_users.go b/internal/web/handlers/handlers_users.go index ce80617..05330b6 100644 --- a/internal/web/handlers/handlers_users.go +++ b/internal/web/handlers/handlers_users.go @@ -102,10 +102,6 @@ func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) { h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)}) return } - if r.PostFormValue("action") == "delete" { - h.submitUserDelete(w, r, u) - return - } h.submitUserUpdate(w, r, u) default: w.Header().Set("Allow", "GET, POST") @@ -291,6 +287,58 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther) } +// HandleUserDeleteConfirm shows the cascade warning before a panel user is +// removed — the same pattern as HandleDeleteConfirm for domains, so a single +// mis-click on Delete cannot remove a user (P3, code-review.md). +func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } + uid, ok := parseUserID(w, r) + if !ok { + return + } + u, err := h.store.GetUser(uid) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + http.NotFound(w, r) + return + } + logf("panel: get user %d: %v", uid, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + data := h.pageBase(r) + data["Title"] = "SelfPost — delete " + u.Username + data["Active"] = "users" + data["TargetID"] = u.ID + data["TargetUsername"] = u.Username + h.view.Render(w, http.StatusOK, "user_delete", data) +} + +// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm +// and returns to the user list. +func (h *Handlers) HandleUserDelete(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireGlobal(w, r); !ok { + return + } + uid, ok := parseUserID(w, r) + if !ok { + return + } + u, err := h.store.GetUser(uid) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + http.NotFound(w, r) + return + } + logf("panel: get user %d: %v", uid, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.submitUserDelete(w, r, u) +} + func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) { p, ok := h.principal(r) if !ok { diff --git a/internal/web/view/static/OFL.txt b/internal/web/view/static/OFL.txt new file mode 100644 index 0000000..01497cc --- /dev/null +++ b/internal/web/view/static/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/internal/web/view/static/panel.css b/internal/web/view/static/panel.css index 68f107e..65b1747 100644 --- a/internal/web/view/static/panel.css +++ b/internal/web/view/static/panel.css @@ -245,6 +245,11 @@ form.inline { display: inline; margin: 0; } main.page-login, main.page-setup { max-width: 24rem; } .card + .card { margin-top: 1.2rem; } .flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; } +/* RateLimitErr renders as .flash.error: a validation failure, not a success + notice. Without this rule it inherited the success surface (green) and only + .error's text colour, so a rejected rate limit read as good news in red + text. Same box, danger palette. */ +.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); } table { width: 100%; border-collapse: collapse; } /* A table column is at least as wide as the longest unbreakable run inside it, and the panel's tables are full of runs with nothing to break on: email @@ -658,6 +663,10 @@ h1.subject { overflow-wrap: anywhere; } wider than the column it shares. */ .fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; } .fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; } +/* Retry-policy tiles carry a short phrase (`doubling, cap about 1 hour 7 + minutes`) rather than a domain or a queue id, so they need a slightly + wider minimum than the delivery-page facts before wrapping to one column. */ +.facts.retry-facts { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); } /* A message's history: the steps down a line, each with the time it happened, the status it reached and what that means. The line is the list's own left border and the dots sit on top of it, so nothing has to be positioned against diff --git a/internal/web/view/static/panel.js b/internal/web/view/static/panel.js index 2e8993c..98147f9 100644 --- a/internal/web/view/static/panel.js +++ b/internal/web/view/static/panel.js @@ -46,145 +46,81 @@ } }); - // --- Address list shown only in list mode ----------------------------- - // The "Addresses" field applies to list mode only; in wildcard mode the - // server ignores it, so hiding it removes a field that does nothing. The - // toggle runs on load too, because the edit form of an existing application - // may already be set to list mode. - function syncAddressField(select) { - var form = select.closest("form"); - var field = form && form.querySelector("[data-addresses]"); - if (!field) { + // --- Conditional field visibility ------------------------------------ + // Several forms hide a block until a select, checkbox or file input says it + // applies. One rule table drives them all so the five near-identical helpers + // do not drift. + var showWhenRules = [ + { + match: "select[data-list-mode]", + target: "[data-addresses]", + visible: function (el) { return el.value === el.dataset.listMode; } + }, + { + match: "select[data-custom-mode]", + target: "[data-custom-address]", + visible: function (el) { return el.value === el.dataset.customMode; } + }, + { + match: "select[data-global-role]", + target: "[data-domain-pick]", + visible: function (el) { return el.value !== el.dataset.globalRole; } + }, + { + match: "input[data-encrypt-toggle]", + target: "[data-encrypt-fields]", + visible: function (el) { return el.checked; }, + clearWhenHidden: true + }, + { + match: "input[data-import-file]", + target: "[data-import-password-fields]", + visible: function (el) { + var name = (el.files && el.files[0] && el.files[0].name || "").toLowerCase(); + return name !== "" && !/\.json$/.test(name); + }, + clearWhenHidden: true + } + ]; + + function syncShowWhen(control) { + var form = control.closest("form"); + if (!form) { return; } - // The mode values come from the server (store.AddressModeList), so the - // select carries the one that means "list" rather than this script - // hard-coding it. - field.hidden = select.value !== select.dataset.listMode; - } - - function initAddressFields(root) { - root.querySelectorAll("select[data-list-mode]").forEach(function (select) { - syncAddressField(select); - select.addEventListener("change", function () { - syncAddressField(select); - }); - }); - } - - // --- Custom DMARC rua address shown only for "custom" mode ------------ - // Same idea as the address list: the email field only applies when the - // operator picks Custom address. With JavaScript blocked the field stays - // visible and the server still ignores it for inherit/none. - function syncCustomAddressField(select) { - var form = select.closest("form"); - var field = form && form.querySelector("[data-custom-address]"); - if (!field) { + for (var i = 0; i < showWhenRules.length; i++) { + var rule = showWhenRules[i]; + if (!control.matches(rule.match)) { + continue; + } + var target = form.querySelector(rule.target); + if (!target) { + return; + } + var show = rule.visible(control); + target.hidden = !show; + if (!show && rule.clearWhenHidden) { + target.querySelectorAll("input").forEach(function (input) { + input.value = ""; + }); + } return; } - field.hidden = select.value !== select.dataset.customMode; } - function initCustomAddressFields(root) { - root.querySelectorAll("select[data-custom-mode]").forEach(function (select) { - syncCustomAddressField(select); - select.addEventListener("change", function () { - syncCustomAddressField(select); - }); - }); - } - - // --- Domain pick shown only for domain administrators ------------------ - // Global administrators manage every domain, so the assignment checkboxes - // are irrelevant for that role. The toggle runs on load too, because the - // edit form of an existing global user should not flash the fieldset. - function syncDomainPickField(select) { - var form = select.closest("form"); - var field = form && form.querySelector("[data-domain-pick]"); - if (!field) { - return; - } - field.hidden = select.value === select.dataset.globalRole; - } - - function initDomainPickFields(root) { - root.querySelectorAll("select[data-global-role]").forEach(function (select) { - syncDomainPickField(select); - select.addEventListener("change", function () { - syncDomainPickField(select); - }); - }); - } - - // --- Encryption password fields shown only when asked for -------------- - // The backup, export and import forms carry an optional password block. It - // is hidden until the checkbox next to it is ticked, and cleared when it is - // unticked, so a password typed and then abandoned is never submitted. With - // JavaScript blocked the block stays visible and the forms behave exactly as - // the server reads them: the checkbox alone decides whether encryption - // happens. - function syncEncryptFields(box) { - var form = box.closest("form"); - var fields = form && form.querySelector("[data-encrypt-fields]"); - if (!fields) { - return; - } - fields.hidden = !box.checked; - if (!box.checked) { - fields.querySelectorAll("input").forEach(function (input) { - input.value = ""; - }); - } - } - - function initEncryptFields(root) { - root.querySelectorAll("input[data-encrypt-toggle]").forEach(function (box) { - syncEncryptFields(box); - box.addEventListener("change", function () { - syncEncryptFields(box); - }); - }); - } - - // --- Import password field shown based on the chosen file's extension --- - // The domain-import file decides for itself whether it is encrypted (the - // server checks the envelope magic, not a checkbox), so the panel offers - // the password field the same way: reveal it for a .spde file, hide and - // clear it for a plain .json one. With no file chosen yet there is nothing - // to ask a password for, so the field stays hidden until a file names it. - // An unrecognised name leaves the field visible rather than guessing wrong - // and hiding a password the file needs. - function syncImportPasswordField(input) { - var form = input.closest("form"); - var fields = form && form.querySelector("[data-import-password-fields]"); - if (!fields) { - return; - } - var name = (input.files && input.files[0] && input.files[0].name || "").toLowerCase(); - var hide = name === "" || /\.json$/.test(name); - fields.hidden = hide; - if (hide) { - fields.querySelectorAll("input").forEach(function (pw) { - pw.value = ""; - }); - } - } - - function initImportPasswordField(root) { - root.querySelectorAll("input[data-import-file]").forEach(function (input) { - syncImportPasswordField(input); - input.addEventListener("change", function () { - syncImportPasswordField(input); + function initShowWhen(root) { + showWhenRules.forEach(function (rule) { + root.querySelectorAll(rule.match).forEach(function (control) { + syncShowWhen(control); + control.addEventListener("change", function () { + syncShowWhen(control); + }); }); }); } document.addEventListener("DOMContentLoaded", function () { - initAddressFields(document); - initCustomAddressFields(document); - initDomainPickFields(document); - initEncryptFields(document); - initImportPasswordField(document); + initShowWhen(document); }); // --- Adaptive monitoring polling --------------------------------------- diff --git a/internal/web/view/static_test.go b/internal/web/view/static_test.go index 78e6ba9..066cafc 100644 --- a/internal/web/view/static_test.go +++ b/internal/web/view/static_test.go @@ -1,8 +1,10 @@ package view import ( + "io/fs" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -26,6 +28,7 @@ func TestStaticAssetsCarryETag(t *testing.T) { // The fonts are the assets this matters most for: they are the largest // thing the panel serves and the ones a browser is most willing to keep. "ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2", + "OFL.txt", } { rec := serveStatic("/static/"+name, nil) if rec.Code != http.StatusOK { @@ -77,3 +80,27 @@ func TestStaticETagsAreContentDerived(t *testing.T) { t.Errorf("favicon.png and favicon.svg share the ETag %s", png) } } + +// OFL condition 2: the licence text must travel with the Font Software. The +// WOFF2 files are embedded; OFL.txt sits next to them so a copy of the panel +// (source tree, image, or /static/OFL.txt) always has it. +func TestOFLTravelsWithFonts(t *testing.T) { + b, err := fs.ReadFile(assetsFS, "static/OFL.txt") + if err != nil { + t.Fatalf("OFL.txt is not embedded next to the Plex WOFF2 files: %v", err) + } + body := string(b) + if !strings.Contains(body, `Reserved Font Name "Plex"`) { + t.Error("OFL.txt is missing the IBM Plex reserved-font-name notice") + } + if !strings.Contains(body, "SIL OPEN FONT LICENSE Version 1.1") { + t.Error("OFL.txt is missing the SIL OFL 1.1 text") + } + rec := serveStatic("/static/OFL.txt", nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /static/OFL.txt: status %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "SIL OPEN FONT LICENSE Version 1.1") { + t.Error("GET /static/OFL.txt did not serve the OFL text") + } +} diff --git a/internal/web/view/templates/backup.html b/internal/web/view/templates/backup.html index bf9352a..04645c2 100644 --- a/internal/web/view/templates/backup.html +++ b/internal/web/view/templates/backup.html @@ -3,13 +3,15 @@

Full backup

-

Download a full backup of all persistent state — the database, - every domain's DKIM key and the application credentials. Use it to move the - whole server to a new machine: restore it into a container of the - same SelfPost version, with the same data mount, before first - start. TLS certificates and the mail queue are not included.

+

Download a self-contained backup of the whole instance — + data/ (database, DKIM keys, application credentials, and the + Postfix queue), docker-compose.yml, .env, and + certs/. Extract it into an empty project directory on a new + machine, adjust hostname or proxy settings if needed, and start a container of + the same SelfPost version before first boot. The reverse-proxy + vhost is not included — set that up separately on the new host.

The backup file is a secret (it contains - private keys and credentials). Store and transfer it securely and delete it + private keys, TLS material, and credentials). Store and transfer it securely and delete it once the restore succeeds. Encrypting it below is the simplest way to do that: the download is then a .spbk file (SelfPost backup) that only the password opens.

diff --git a/internal/web/view/templates/deliveries_rows.html b/internal/web/view/templates/deliveries_rows.html index b14610f..eb16817 100644 --- a/internal/web/view/templates/deliveries_rows.html +++ b/internal/web/view/templates/deliveries_rows.html @@ -1,5 +1,6 @@ {{define "deliveries_rows"}} -
{{if .Rows}} @@ -14,15 +15,15 @@ - + {{end}}
{{.To}} {{.Subject}} {{.Status}}DetailsDetails

Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}} - {{if .HasPrev}} · ← Newer{{end}} - {{if .HasNext}} · Older →{{end}} + {{if .HasPrev}} · ← Newer{{end}} + {{if .HasNext}} · Older →{{end}}

{{else}}

No messages logged yet.

diff --git a/internal/web/view/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html index bd072e6..a32b250 100644 --- a/internal/web/view/templates/domain_detail.html +++ b/internal/web/view/templates/domain_detail.html @@ -2,6 +2,61 @@ measure (same pattern as Status). */}} {{define "wide"}}wide{{end}} +{{/* The Host/name ‖ Type field-pair repeats for every DNS record this page + shows (DKIM, SPF, DMARC, report authorization) in both the status card + and the publishable-record cards below it — only the host and whether it + carries a Copy button change. Two variants rather than one templated + Copy flag: the DNS status card never offers Copy (its host is derived, + not something to paste), the record cards always do. */}} +{{define "host_type"}} +
+
+ + {{.}} +
+
+ + TXT +
+
+{{end}} + +{{define "host_type_copy"}} +
+
+ +
+ {{.}} + +
+
+
+ + TXT +
+
+{{end}} + +{{/* Value partials pair with the two host_type variants above: field_values + is the DNS status card's raw record dump (no Copy — those values are for + comparison, not for pasting), field_value is the single publishable + value on the record cards below (always has Copy). Callers still guard + the empty case, since "no records yet" and "one blank record" read + differently. */}} +{{define "field_values"}} + +{{range .}}{{.}} +{{end}} +{{end}} + +{{define "field_value"}} + +
+ {{.}} + +
+{{end}} + {{define "content"}}

{{.Domain.Name}}

@@ -37,21 +92,8 @@
-
-
- - {{.Record.Name}} -
-
- - TXT -
-
- {{if .DNS.DKIM.Records}} - - {{range .DNS.DKIM.Records}}{{.}} -{{end}} - {{end}} + {{template "host_type" .Record.Name}} + {{if .DNS.DKIM.Records}}{{template "field_values" .DNS.DKIM.Records}}{{end}} {{if ne .DNS.DKIM.Status "ok"}}

{{.DNS.DKIM.Detail}}

{{end}} @@ -59,21 +101,8 @@
-
-
- - {{.Domain.Name}} -
-
- - TXT -
-
- {{if .DNS.SPF.Records}} - - {{range .DNS.SPF.Records}}{{.}} -{{end}} - {{end}} + {{template "host_type" .Domain.Name}} + {{if .DNS.SPF.Records}}{{template "field_values" .DNS.SPF.Records}}{{end}} {{if ne .DNS.SPF.Status "ok"}}

{{.DNS.SPF.Detail}}

{{end}} @@ -83,21 +112,8 @@
-
-
- - {{.DMARCName}} -
-
- - TXT -
-
- {{if .DNS.DMARC.Records}} - - {{range .DNS.DMARC.Records}}{{.}} -{{end}} - {{end}} + {{template "host_type" .DMARCName}} + {{if .DNS.DMARC.Records}}{{template "field_values" .DNS.DMARC.Records}}{{end}} {{if ne .DNS.DMARC.Status "ok"}}

{{.DNS.DMARC.Detail}}

{{else}} @@ -108,21 +124,8 @@
{{if .DNS.DMARCReportAuth.Status}} -
-
- - {{.ReportAuthName}} -
-
- - TXT -
-
- {{if .DNS.DMARCReportAuth.Records}} - - {{range .DNS.DMARCReportAuth.Records}}{{.}} -{{end}} - {{end}} + {{template "host_type" .ReportAuthName}} + {{if .DNS.DMARCReportAuth.Records}}{{template "field_values" .DNS.DMARCReportAuth.Records}}{{end}}

{{.DNS.DMARCReportAuth.Detail}}

{{else}} @@ -141,49 +144,15 @@

DKIM and SPF records

DKIM

-
-
- -
- {{.Record.Name}} - -
-
-
- - TXT -
-
- - -
- {{.Record.Value}} - -
+ {{template "host_type_copy" .Record.Name}} + {{template "field_value" .Record.Value}}

Not a secret. Signed with selector {{.Domain.DKIMSelector}}.

SPF

-
-
- -
- {{.Domain.Name}} - -
-
-
- - TXT -
-
- - -
- {{.SPFExample}} - -
+ {{template "host_type_copy" .Domain.Name}} + {{template "field_value" .SPFExample}}

Merge into an existing SPF if the domain already has one — do not publish a second record.

@@ -192,19 +161,7 @@

DMARC record

-
-
- -
- {{.DMARCName}} - -
-
-
- - TXT -
-
+ {{template "host_type_copy" .DMARCName}}
@@ -214,31 +171,13 @@ {{if .SameDomainRUA}}

The report address is on this sending domain. SelfPost does - not receive inbound mail — use a mailbox elsewhere or wait for in-panel report - reception in a future release.

+ not receive inbound mail — use a mailbox on another domain.

{{end}} {{if .NeedsReportAuth}}

Report authorization

-
-
- -
- {{.ReportAuthName}} - -
-
-
- - TXT -
-
- - -
- {{.ReportAuthValue}} - -
+ {{template "host_type_copy" .ReportAuthName}} + {{template "field_value" .ReportAuthValue}} {{end}}

p=none does not affect delivery. Tighten to diff --git a/internal/web/view/templates/mail_queue.html b/internal/web/view/templates/mail_queue.html index f9dba13..7174ef7 100644 --- a/internal/web/view/templates/mail_queue.html +++ b/internal/web/view/templates/mail_queue.html @@ -1,10 +1,40 @@ -{{/* A table of queue ids, sizes, senders and recipients: the whole column - rather than the reading measure (see the "wide" block in layout.html). */}} +{{/* Mail queue is two cards in the whole column: the retry-policy snapshot + (static, outside the HTMX poll) and the live postqueue listing. */}} {{define "wide"}}wide{{end}} {{define "content"}}

Mail queue

+
+

How delivery retries work

+

This Postfix's policy, read once at panel start. There is + no maximum attempt count — only time. A deferred message stays in this + listing until it is delivered or the queue lifetime runs out.

+ {{if .RetryFromDefaults}} +

Could not read the effective Postfix configuration; + showing compiled-in defaults. A live change is visible after the next + panel restart.

+ {{end}} +
+
+ First retry + {{.FirstRetry}} +
+
+ Later retries + doubling, cap {{.BackoffCap}} +
+
+ Kept in queue + {{.QueueLifetime}} +
+
+ Then + bounced +
+
+
+

Pending messages

{{template "mail_queue_body" .}} diff --git a/internal/web/view/templates/mail_queue_body.html b/internal/web/view/templates/mail_queue_body.html index 34b7caf..a2162e0 100644 --- a/internal/web/view/templates/mail_queue_body.html +++ b/internal/web/view/templates/mail_queue_body.html @@ -1,5 +1,5 @@ {{define "mail_queue_body"}} -
+
{{if .Error}}

{{.Error}}

{{end}} {{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}
diff --git a/internal/web/view/templates/settings.html b/internal/web/view/templates/settings.html index 27630ec..c83310a 100644 --- a/internal/web/view/templates/settings.html +++ b/internal/web/view/templates/settings.html @@ -2,6 +2,25 @@ in panel.css, as on a delivery's page). */}} {{define "wide"}}wide{{end}} +{{/* The username/password fields are identical for a global administrator + (split card, DMARC alongside) and a domain administrator (narrow card, + no DMARC card) — only the surrounding form and card differ. */}} +{{define "credentials_fields"}} + + + + + + + + + + + +{{end}} + {{define "content"}}

Settings

@@ -16,27 +35,15 @@

These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.

- - - - - - - - - - - + {{template "credentials_fields" .}}

DMARC aggregate reports

Optional default rua= address for every sending domain (can be overridden per domain). Use a mailbox on a domain that - receives inbound mail. SelfPost is send-only today; a future release will - be able to receive reports in the panel itself.

+ receives inbound mail. SelfPost does not receive inbound mail — point + rua= at a mailbox elsewhere.

These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.

- - - - - - - - - - - + {{template "credentials_fields" .}}
diff --git a/internal/web/view/templates/status_body.html b/internal/web/view/templates/status_body.html index 2d901e0..366801b 100644 --- a/internal/web/view/templates/status_body.html +++ b/internal/web/view/templates/status_body.html @@ -1,5 +1,5 @@ {{define "status_body"}} -
+

Overall {{.OverallStatus}}

{{.OverallHeading}}

diff --git a/internal/web/view/templates/system_log_body.html b/internal/web/view/templates/system_log_body.html index 1c99e02..4e48438 100644 --- a/internal/web/view/templates/system_log_body.html +++ b/internal/web/view/templates/system_log_body.html @@ -1,5 +1,5 @@ {{define "system_log_body"}} -
+
{{if .Error}}

{{.Error}}

{{end}} {{if .Lines}}{{range .Lines}}{{.}} {{end}}{{else}}No log lines yet.{{end}} diff --git a/internal/web/view/templates/user_delete.html b/internal/web/view/templates/user_delete.html new file mode 100644 index 0000000..87d007a --- /dev/null +++ b/internal/web/view/templates/user_delete.html @@ -0,0 +1,16 @@ +{{define "content"}} +

Delete {{.TargetUsername}}

+ +{{template "back_link" (back (printf "/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}} + +
+

Confirm deletion

+

You are about to delete the panel user {{.TargetUsername}}. + This cannot be undone; a signed-in session for this user stops working + immediately.

+ +
+ +
+
+{{end}} diff --git a/internal/web/view/templates/user_form.html b/internal/web/view/templates/user_form.html index 2491d5d..b1df42d 100644 --- a/internal/web/view/templates/user_form.html +++ b/internal/web/view/templates/user_form.html @@ -33,10 +33,13 @@ - {{if .IsEdit}} - - {{if .LastGlobalLocked}}

The only global administrator cannot be deleted.

{{end}} - {{end}} + {{if .IsEdit}} + {{if .LastGlobalLocked}} +

The only global administrator cannot be deleted.

+ {{else}} + Delete user + {{end}} + {{end}}
{{end}} diff --git a/internal/web/view/templates_test.go b/internal/web/view/templates_test.go index 781c509..a60abf5 100644 --- a/internal/web/view/templates_test.go +++ b/internal/web/view/templates_test.go @@ -282,6 +282,7 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) { func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) { drillDown := map[string]bool{ "user_form.html": true, + "user_delete.html": true, "domain_detail.html": true, "domain_delete.html": true, "delivery.html": true, @@ -526,3 +527,39 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) { fn(e.Name(), string(body)) } } + +// The retry-policy card is static HTML on mail_queue (outside the HTMX +// fragment), so rendering the page with a fixture must print those strings +// rather than falling back to empty template fields. +func TestMailQueuePageRendersRetryPolicy(t *testing.T) { + engine, err := New("test") + if err != nil { + t.Fatalf("New: %v", err) + } + var buf bytes.Buffer + if err := engine.Page("mail_queue").ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "t", "User": "admin", "Active": "mail_queue", "Version": "test", + "Copyright": "Copyright © 2026 Mikhail Yenuchenko", + "SourceURL": "https://github.com/mixeme/selfpost", + "FirstRetry": "10 minutes", "BackoffCap": "about 1 hour 7 minutes", + "QueueLifetime": "2 days", + }); err != nil { + t.Fatalf("execute mail_queue: %v", err) + } + out := buf.String() + for _, want := range []string{ + "How delivery retries work", + "10 minutes", + "about 1 hour 7 minutes", + "2 days", + `id="retry-policy"`, + `hx-get="/mail-queue/body"`, + } { + if !strings.Contains(out, want) { + t.Errorf("mail_queue is missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "compiled-in defaults") { + t.Error("RetryFromDefaults was unset; the fallback note should stay off") + } +} diff --git a/internal/web/view/view.go b/internal/web/view/view.go index 6cbd788..6b24b1c 100644 --- a/internal/web/view/view.go +++ b/internal/web/view/view.go @@ -37,6 +37,7 @@ var pageFiles = map[string][]string{ "settings": {"templates/settings.html"}, "users": {"templates/users.html"}, "user_form": {"templates/user_form.html"}, + "user_delete": {"templates/user_delete.html"}, "backup": {"templates/backup.html", "templates/encrypt_fields.html"}, "domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"}, "domain_delete": {"templates/domain_delete.html"}, diff --git a/internal/web/web.go b/internal/web/web.go index 466150c..6250981 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,4 +1,4 @@ -// Package web implements the SelfPost control panel's HTTP surface: the +// Package web implements the SelfPost control panel's HTTP surface: the // one-time administrator setup flow (security.md), login/session handling // (security.md) and the authenticated shell the later phases build on. package web @@ -13,6 +13,7 @@ import ( "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/legal" + "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/handlers" @@ -40,11 +41,13 @@ type Config struct { // the log-tailer role follows in cmd/panel. MailLogPath string // DataDir and DBPath locate the persistent state a full backup archives - // (architecture.md § Persistence); Version is stamped into the backup - // manifest. They mirror the panel's own configuration. - DataDir string - DBPath string - Version string + // (architecture.md § Persistence); DeployRoot is the operator project + // directory (docker-compose.yml, .env, certs/); Version is stamped into + // the backup manifest. They mirror the panel's own configuration. + DataDir string + DBPath string + DeployRoot string + Version string // TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply // X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose // direct peer (RemoteAddr) is not in this list never has its XFF header @@ -75,6 +78,10 @@ type Config struct { // display and to cap domain/app level-2 ceilings (guide § Rate limiting). RateLimitMessagesPerIP int RateLimitWindowSeconds int + // RetryPolicy is this Postfix's deferred-mail timings, snapshotted once + // when the HTTP role starts. Handlers read the cache; they never call + // postconf (architecture.md). + RetryPolicy postfix.RetryPolicy } // Server is the panel HTTP application. @@ -106,12 +113,14 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config MailLogPath: cfg.MailLogPath, DataDir: cfg.DataDir, DBPath: cfg.DBPath, + DeployRoot: cfg.DeployRoot, Version: cfg.Version, TLSCertFile: cfg.TLSCertFile, OpenDKIMSocket: cfg.OpenDKIMSocket, JournalSocket: cfg.JournalSocket, RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, + RetryPolicy: cfg.RetryPolicy, }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) return &Server{cfg: cfg, auth: a, handlers: h}, nil } @@ -158,7 +167,7 @@ func (s *Server) Handler() http.Handler { authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) authed.HandleFunc("POST /reload", h.HandleReload) - authed.HandleFunc("/settings", h.HandleAccount) + authed.HandleFunc("/settings", h.HandleSettings) authed.HandleFunc("/account", redirectSettings) authed.HandleFunc("GET /users", h.HandleUsers) @@ -166,6 +175,8 @@ func (s *Server) Handler() http.Handler { authed.HandleFunc("POST /users/new", h.HandleUserNew) authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit) authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit) + authed.HandleFunc("GET /users/{uid}/delete", h.HandleUserDeleteConfirm) + authed.HandleFunc("POST /users/{uid}/delete", h.HandleUserDelete) authed.HandleFunc("GET /backup", h.HandleBackupPage) authed.HandleFunc("POST /backup", h.HandleBackup) diff --git a/test/e2e/compose.override.yml b/test/e2e/compose.override.yml index 6b5c38c..79a38fc 100644 --- a/test/e2e/compose.override.yml +++ b/test/e2e/compose.override.yml @@ -60,8 +60,13 @@ services: # records the test harness publishes, so Postfix's outbound MX lookup for # the sink and the panel's own dnscheck resolve inside the isolated network # instead of hitting the real internet. + # + # Pinned to a release tag rather than `latest`, so a CoreDNS upload cannot + # change what the release gate runs against between two runs of the same + # commit. Not a digest: the tag is a multi-arch manifest and the stand has to + # come up on arm64 developer machines as well as amd64 CI. coredns: - image: coredns/coredns:latest + image: coredns/coredns:1.14.6 command: ["-conf", "/dns/Corefile"] volumes: - ./dns-stage:/dns diff --git a/test/e2e/logrotate_check.go b/test/e2e/logrotate_check.go index 98fde8c..c4ade23 100644 --- a/test/e2e/logrotate_check.go +++ b/test/e2e/logrotate_check.go @@ -10,7 +10,8 @@ import ( ) // checkLogrotateConfigMode verifies the image pins /etc/logrotate.d/mail at 0644 -// so logrotate will not silently ignore it (docs/plans/logrotate-mode.md). +// so logrotate will not silently ignore it (docs/development.md § Building +// binaries and the image). func checkLogrotateConfigMode(s *stack) error { mode, err := s.execIn("selfpost", "stat", "-c", "%a", "/etc/logrotate.d/mail") if err != nil { diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 8a0deb5..7bdac92 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -19,7 +19,7 @@ import ( "time" ) -var statusCellPattern = regexp.MustCompile(`(queued|sent|deferred|bounced|rejected)`) +var statusCellPattern = regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)`) // h is the single shared stand for the whole ordered scenario in TestE2E. // TestHostnameGate does not use it — it spins its own disposable container. @@ -247,7 +247,7 @@ func uniqueToken(label string) string { } func containsCell(html, needle string) bool { - return strings.Contains(html, ""+needle+"") + return strings.Contains(html, ``+needle+``) } func firstStatusCell(html string) string { diff --git a/test/e2e/negative_test.go b/test/e2e/negative_test.go index 4f9be0a..eb5ed6e 100644 --- a/test/e2e/negative_test.go +++ b/test/e2e/negative_test.go @@ -219,5 +219,5 @@ func testLevel1RateLimit(t *testing.T, sc *scenario) { return } } - t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=5) never tripped after %d sends", maxAttempts) + t.Fatalf("level-1 rate limit (RATE_LIMIT_MESSAGES_PER_IP=50) never tripped after %d sends", maxAttempts) }