Compare commits

14 Commits

Author SHA1 Message Date
mix d0bf4a4226 release: 1.4.0
test / test (push) Waiting to run
Close Unreleased for inbound relay; pin compose and docs to 1.4.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 23:20:18 +03:00
mix 0d98d92642 Add optional inbound relay (backup-MX) behind INBOUND_RELAY_ENABLE.
test / test (push) Waiting to run
Port 25 accepts only configured domains and listed recipients, then forwards to an upstream; the outbound path is unchanged when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 23:17:30 +03:00
mix 6218540211 release: 1.3.1
test / test (push) Waiting to run
Close Unreleased for retry-policy panel, persistent queue, and self-contained backups; pin compose and docs to 1.3.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:19:42 +03:00
mix 705239568b Show this Postfix's retry policy on Mail queue and delivery history.
test / test (push) Waiting to run
Numbers come from a one-shot postconf -h at panel start so a manual override is visible after restart, without inventing an attempt count.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:12:03 +03:00
mix 46191172d6 docs: roadmap checklists, Progress column, commit/version protocol
test / test (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 21:47:04 +03:00
mix b99f60c1d6 docs: add roadmap plans for domain stats, auto rate limit, and send-log retention
test / test (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:53:39 +03:00
mix 41c3e6e896 Persist Postfix queue and ship self-contained full backups.
Move the mail queue under /data so recreate no longer drops deferred mail, and archive data/, compose, .env, and certs/ together for restore on a fresh host.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:36:55 +03:00
mix c6a75ce775 docs: add DMARC report mockups and fold add-domain into lists
test / test (push) Has been cancelled
Domains and Inbound keep the add field in the list card. The DMARC candidate hub drills into a domain roll-up and a parsed aggregate report, including a third-party fail. The running panel is unchanged.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-16 21:44:20 +03:00
mix f47cf39802 docs: rebuild panel UI mockups on the design system
test / test (push) Has been cancelled
Split the hash-sheet prototype into one HTML file per screen, composed from stack, pair, measure, and fill. system.html is the grammar; shell.js is shared chrome. The running panel is unchanged.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-16 15:29:22 +03:00
mix 9edb39a8ee docs: iterate panel UI mockups from review
test / test (push) Has been cancelled
Restore Status readings, inbound MX DNS and recipient modes, two-column Backup and Settings, domain-card help, and Host/name Type field height.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-16 00:24:46 +03:00
mix f517e8da41 docs: add panel UI refresh mockups
Click-through gallery for current and roadmap screens, with a hybrid width proposal and a phone layout for emergency ops. The running panel is unchanged.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-15 23:47:55 +03:00
mix 2f888dc7be ci: drop per-arch GHCR tags via GitHub Packages API
test / test (push) Has been cancelled
imagetools has no rm subcommand; the merge cleanup had been failing silently. Document Gitea-to-GitHub tag mirror pitfalls in development.md. CHANGELOG [1.3.0] updated.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-15 22:57:34 +03:00
mix 5598b62e4e release: 1.3.0
test / test (push) Has been cancelled
Close Unreleased for security, tests, docs, and release CI; pin compose and docs to 1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:51:29 +03:00
mix 32f5085c48 docs: record release CI workflow changes in [Unreleased]
The three post-1.2.5 CI commits that tie image publish to a published GitHub
Release and trim per-arch GHCR tags were missing from the changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:47:09 +03:00
101 changed files with 7546 additions and 320 deletions
+22
View File
@@ -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 `<main>` 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 `<form>` and danger after `</form>`.
- 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.
+16 -10
View File
@@ -180,19 +180,25 @@ jobs:
- name: Remove per-arch tags from GHCR - name: Remove per-arch tags from GHCR
# Side-effect tags for imagetools assembly only — not part of the public # 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). # 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: | run: |
set -euo pipefail set -euo pipefail
repo="ghcr.io/${{ github.repository }}"
version="${{ needs.prepare.outputs.version }}" 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 for suffix in amd64 arm64; do
tag="${repo}:${version}-${suffix}" tag="${version}-${suffix}"
set +e mapfile -t ids < <(gh api "$api" --paginate \
out=$(docker buildx imagetools rm "$tag" 2>&1) --jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
rc=$? if [ "${#ids[@]}" -eq 0 ]; then
set -e echo "no GHCR package version for tag ${tag}"
if [ "$rc" -eq 0 ]; then continue
printf '%s\n' "$out"
else
echo "::warning::could not remove ${tag}: ${out}" >&2
fi fi
for id in "${ids[@]}"; do
echo "deleting GHCR package version ${id} (tag ${tag})"
gh api -X DELETE "${api}/${id}"
done
done done
+127 -5
View File
@@ -5,6 +5,111 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [1.4.0] - 2026-08-17
Optional inbound relay (backup-MX / forwarder) on port 25, off by default.
Mail is accepted only for domains configured in the panel and forwarded to
an upstream; there are still no mailboxes. A dedicated security review of
the inbound path is still open on the plan (Fable).
### Added
- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
port 25 without SASL, only for `relay_domains` + known recipients, and
forwards via `transport_maps`. Empty upstream host is omitted from the
maps so mail is never accepted with nowhere to send it.
- Panel **Inbound** pages (global administrator): domains, upstream host/port
and TLS, recipient list or any-at-domain, MX check against
`SELFPOST_HOSTNAME`.
- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
[deploy/antispam/docker-compose.antispam.yml](deploy/antispam/docker-compose.antispam.yml).
Default milter action is fail-open (`accept`).
- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
`INBOUND_MESSAGE_SIZE_LIMIT` (default 25 MiB) on smtpd port 25.
### Changed
- Compose publishes host port 25 even when the flag is off (nothing listens
until it is on), the same pattern as 587 / `SUBMISSION_ENABLE`.
- Full-backup restore Resync also rebuilds inbound maps when the flag is on.
Single-domain export/import is still sending domains only.
## [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 ### Added
- licence: the SIL Open Font License 1.1 text now travels with the IBM Plex - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
@@ -82,6 +187,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
the same Resync on demand. The `internal/backup` package comment now matches the same Resync on demand. The `internal/backup` package comment now matches
this behaviour. 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 ### Changed
- docs: operator and as-built docs aligned with the code after a full - docs: operator and as-built docs aligned with the code after a full
@@ -121,14 +231,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
`internal/legal/legal.go` (the value the panel footer actually injects), not `internal/legal/legal.go` (the value the panel footer actually injects), not
`layout.html`. `release.yml` refuses a `workflow_dispatch` whose version is `layout.html`. Per-file `SPDX-License-Identifier` headers on the two command
not SemVer `X.Y.Z`, so a run from `main` cannot publish `ghcr.io/...:main`. packages were dropped so the tree is consistent; AGPL-3.0 does not require
Per-file `SPDX-License-Identifier` headers on the two command packages were them ([development.md](docs/development.md) § External libraries). Deleted the
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 completed `docs/plans/logrotate-mode.md` (history in git and
[1.2.3](#123---2026-08-12)). [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. - 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 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 at the end of a window and the sweep that keeps finished buckets out of
+17 -4
View File
@@ -10,7 +10,9 @@ Docker image. Postfix, OpenDKIM, and a small Go panel run together under
then point your apps at the SMTP endpoint. then point your apps at the SMTP endpoint.
SelfPost sends mail straight to the internet from **your own IP**, with per-domain SelfPost sends mail straight to the internet from **your own IP**, with per-domain
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail. DKIM signing. It is outbound by default — no mailboxes or webmail. An optional
inbound relay (backup-MX / forwarder on port 25) can be turned on; it forwards
to an upstream, it does not store mail.
**For:** operators who run their own VPS or home server and want a simple relay **For:** operators who run their own VPS or home server and want a simple relay
they control, without a third-party SMTP provider. they control, without a third-party SMTP provider.
@@ -21,6 +23,7 @@ send log and DNS checks in the panel, encrypted backups.
## Features ## Features
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing - Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Web panel — domains, applications, deliveries, mail queue, system log, backup - Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain - Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
@@ -78,6 +81,15 @@ For every domain you add in the panel:
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide. See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
### Per inbound domain (optional)
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
if this host is backup-MX)
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
## Quick start ## Quick start
> **First boot — create the admin account.** On a fresh container SelfPost prints > **First boot — create the admin account.** On a fresh container SelfPost prints
@@ -96,7 +108,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.2.5 ghcr.io/mixeme/selfpost:1.4.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
@@ -136,8 +148,9 @@ DNS — lives in the operator guide's [Full
deployment](docs/guide.md#full-deployment) section, with proxy-specific deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file always publishes **465** and **587**; Postfix listens on 587 The compose file always publishes **465**, **587**, and **25**; Postfix listens
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the on 587 only when `SUBMISSION_ENABLE=true`, and on 25 only when
`INBOUND_RELAY_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 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 (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables). variables](docs/guide.md#environment-variables).
+4 -4
View File
@@ -107,10 +107,10 @@ COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional) # Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client, # inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
# which needs no inbound listener or EXPOSE. # Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 EXPOSE 8080 465 587 25
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix). # Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations. # Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
+20 -6
View File
@@ -69,7 +69,7 @@ chmod 755 /data
# later phase deliberately hands to another service. /data/log is exempt: it is # 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 # deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below. # 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 # 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 # the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
@@ -99,13 +99,20 @@ chown -R panel:selfpost /data/sasl
chmod 2750 /data/sasl chmod 2750 /data/sasl
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 [ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it. # Postfix state under /data (spec 5.1, architecture.md § Persistence). The panel
# Ensure the file exists (empty is fine) before Postfix starts so a reload that # writes sender_login_maps; Postfix owns the on-disk queue tree under queue/.
# references it never fails on a missing file, and keep it group-readable. mkdir -p /data/postfix/queue
mkdir -p /data/postfix
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps [ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
chown -R panel:selfpost /data/postfix # Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
# Empty files keep postfix check happy if the flag is on before any domain exists.
for f in relay_domains transport relay_recipients tls_policy; do
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
chown panel:selfpost "/data/postfix/$f"
chmod 0640 "/data/postfix/$f"
done
chown panel:selfpost /data/postfix
chmod 2750 /data/postfix chmod 2750 /data/postfix
chown panel:selfpost /data/postfix/sender_login_maps
chmod 0640 /data/postfix/sender_login_maps chmod 0640 /data/postfix/sender_login_maps
# Delivery log (architecture.md § Log tailer). postlogd writes it as user # Delivery log (architecture.md § Log tailer). postlogd writes it as user
@@ -144,4 +151,11 @@ chmod 2750 /run/opendkim /run/selfpost
# start the same way the /data normalisation above is. # start the same way the /data normalisation above is.
/usr/local/bin/postfix-config.sh /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 exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+74 -1
View File
@@ -57,12 +57,29 @@ JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so # 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. # the unprivileged panel can rewrite it and just ask for a reload.
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" 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}" SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only # Optional submission service on 587 (spec 5 p.1: off by default, enabled only
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465). # when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
# absent. When true, smtp inet on 25 accepts only relay_domains + known
# recipients (docs/plans/inbound-relay.md).
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives # Delivery log, written by postlogd and read by the panel's log-tailer. It lives
# under the persistent /data (not the ephemeral /var/log) so the delivery lines # under the persistent /data (not the ephemeral /var/log) so the delivery lines
# for messages still marked "queued" survive a container recreate — without # for messages still marked "queued" survive a container recreate — without
@@ -128,7 +145,8 @@ postconf -e \
# resolves the full address first, then the "@domain" wildcard, so both address # resolves the full address first, then the "@domain" wildcard, so both address
# modes work from the same map. # modes work from the same map.
postconf -e \ 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, # Restrictions: authenticated clients only, no relay to foreign destinations,
# and every authenticated sender address must be owned by its login. NO # and every authenticated sender address must be owned by its login. NO
@@ -193,6 +211,61 @@ else
postconf -MX "submission/inet" 2>/dev/null || true postconf -MX "submission/inet" 2>/dev/null || true
fi fi
# --- inbound smtpd on port 25 (optional backup-MX / forwarder) ---------------
# Debian's stock master.cf enables smtp/inet. When the flag is off, remove that
# listener so port 25 is not an inbound smtpd (outbound delivery uses smtp/unix).
# When on: no SASL, no OpenDKIM, accept only relay_domains + listed recipients.
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
[ -e "$f" ] || : > "$f"
done
postconf -e \
"relay_domains=texthash:${RELAY_DOMAINS_MAP}" \
"transport_maps=texthash:${TRANSPORT_MAP}" \
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" \
"smtpd_reject_unlisted_recipient=yes"
INBOUND_MILTERS=""
if [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
case "${INBOUND_ANTISPAM_MILTER}" in
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
*)
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
exit 1
;;
esac
case "${INBOUND_ANTISPAM_MILTER_ACTION}" in
accept|tempfail) ;;
*)
echo "FATAL: INBOUND_ANTISPAM_MILTER_ACTION must be accept or tempfail, got: ${INBOUND_ANTISPAM_MILTER_ACTION}" >&2
exit 1
;;
esac
INBOUND_MILTERS="{ ${INBOUND_ANTISPAM_MILTER}, default_action=${INBOUND_ANTISPAM_MILTER_ACTION} }"
fi
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
postconf -P \
"smtp/inet/smtpd_sasl_auth_enable=no" \
"smtp/inet/smtpd_tls_auth_only=no" \
"smtp/inet/smtpd_sender_login_maps=" \
"smtp/inet/smtpd_sender_restrictions=" \
"smtp/inet/smtpd_client_restrictions=" \
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
"smtp/inet/smtpd_recipient_restrictions=reject_unauth_destination, reject_unlisted_recipient" \
"smtp/inet/smtpd_milters=${INBOUND_MILTERS}" \
"smtp/inet/smtpd_client_message_rate_limit=${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" \
"smtp/inet/message_size_limit=${INBOUND_MESSAGE_SIZE_LIMIT}"
else
postconf -MX "smtp/inet" 2>/dev/null || true
postconf -e \
"relay_domains=" \
"transport_maps=" \
"relay_recipient_maps=" \
"smtp_tls_policy_maps="
fi
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery # Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
# agent and others chrooted to /var/spool/postfix, where they cannot read # agent and others chrooted to /var/spool/postfix, where they cannot read
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail # /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
+23
View File
@@ -15,6 +15,11 @@ var documentedPublic = []string{
"PANEL_SESSION_IDLE_DAYS", "PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS", "SELFPOST_DNS_RESOLVERS",
"TRUSTED_PROXY_CIDR", "TRUSTED_PROXY_CIDR",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT",
} }
// documentedInternal matches architecture.md § Configuration "Internal env vars". // documentedInternal matches architecture.md § Configuration "Internal env vars".
@@ -33,6 +38,12 @@ var documentedInternal = []string{
"SASL_REALM", "SASL_REALM",
"POSTFIX_DIR", "POSTFIX_DIR",
"POSTFIX_SENDER_LOGIN_MAPS", "POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"SELFPOST_DEPLOY_ROOT",
"MILTER_CONNECT_TIMEOUT", "MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT", "MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT", "MILTER_CONTENT_TIMEOUT",
@@ -70,6 +81,8 @@ var loadConfigKeys = []string{
"SASL_DB_PATH", "SASL_DB_PATH",
"SASL_REALM", "SASL_REALM",
"POSTFIX_DIR", "POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE",
} }
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh // buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
@@ -84,8 +97,18 @@ var buildScriptKeys = []string{
"JOURNAL_MILTER_SOCKET", "JOURNAL_MILTER_SOCKET",
"MAIL_LOG", "MAIL_LOG",
"POSTFIX_SENDER_LOGIN_MAPS", "POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"SASL_DB_PATH", "SASL_DB_PATH",
"SUBMISSION_ENABLE", "SUBMISSION_ENABLE",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT",
"POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"MILTER_CONNECT_TIMEOUT", "MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT", "MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT", "MILTER_CONTENT_TIMEOUT",
+22 -6
View File
@@ -11,6 +11,7 @@ import (
"github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/buildinfo" "github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web" "github.com/mixeme/selfpost/internal/web"
@@ -19,10 +20,12 @@ import (
// mailStack is the panel's domain and application services plus the on-disk // mailStack is the panel's domain and application services plus the on-disk
// mail-path adapters they write through. // mail-path adapters they write through.
type mailStack struct { type mailStack struct {
Domains *domain.Service Domains *domain.Service
Apps *app.Service Apps *app.Service
pf *postfix.Postfix Inbound *inbound.Service
odk *domain.OpenDKIM pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool
} }
func newMailStack(cfg config, st *store.Store) *mailStack { func newMailStack(cfg config, st *store.Store) *mailStack {
@@ -30,7 +33,8 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
odk := domain.NewOpenDKIM(cfg.opendkimDir) odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk} inb := inbound.NewService(st, pf)
return &mailStack{Domains: domains, Apps: apps, Inbound: inb, pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled}
} }
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and // Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
@@ -42,6 +46,11 @@ func (m *mailStack) Resync() error {
if err := m.Apps.Resync(); err != nil { if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err) return fmt.Errorf("postfix resync: %w", err)
} }
if m.inboundEnabled {
if err := m.Inbound.Resync(); err != nil {
return fmt.Errorf("inbound maps resync: %w", err)
}
}
return nil return nil
} }
@@ -66,13 +75,18 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
// the environment describes it, with nothing bound to a port yet. // the environment describes it, with nothing bound to a port yet.
func newPanel(cfg config, st *store.Store) (*web.Server, error) { func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
return web.New(st, ms.Domains, ms.Apps, web.Config{ // 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, ms.Inbound, web.Config{
Hostname: cfg.hostname, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled, SubmissionEnabled: cfg.submissionEnabled,
MailLogPath: cfg.mailLog, MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir, DataDir: cfg.dataDir,
DBPath: cfg.dbPath, DBPath: cfg.dbPath,
DeployRoot: cfg.deployRoot,
Version: buildinfo.Version, Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies, TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile, TLSCertFile: cfg.tlsCertFile,
@@ -82,6 +96,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
DNSResolvers: cfg.dnsResolvers, DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
} }
+6
View File
@@ -78,6 +78,9 @@ type config struct {
saslDBPath string saslDBPath string
saslRealm string saslRealm string
postfixDir string postfixDir string
deployRoot string
inboundEnabled bool
} }
func loadConfig() config { func loadConfig() config {
@@ -138,6 +141,9 @@ func loadConfig() config {
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")), saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
saslRealm: saslRealm(), saslRealm: saslRealm(),
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
} }
} }
+60 -26
View File
@@ -22,7 +22,7 @@ import (
) )
// Restoring a SelfPost backup is not a code path in the panel: the operator // Restoring a SelfPost backup is not a code path in the panel: the operator
// extracts the archive into the /data bind mount and starts the image, and the // 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 // 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 // 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 // through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
@@ -37,9 +37,10 @@ const (
// restored is the outcome of a full backup-and-restore round trip. // restored is the outcome of a full backup-and-restore round trip.
type restored struct { type restored struct {
panel http.Handler // panel booted on the restored data directory panel http.Handler // panel booted on the restored data directory
dataDir string // the restored /data deployRoot string // the restored project directory
session *http.Cookie // a session opened before the backup was taken 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 // restoreFromOwnBackup runs the operator's path end to end: seed a panel that
@@ -50,7 +51,7 @@ type restored struct {
func restoreFromOwnBackup(t *testing.T, password string) restored { func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper() t.Helper()
live := seedPanelData(t) live := seedPanelProject(t)
panel := bootPanel(t, live) panel := bootPanel(t, live)
session := signIn(t, panel) session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password) archive := downloadBackup(t, panel, session, password)
@@ -58,7 +59,12 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
target := t.TempDir() target := t.TempDir()
extract(t, archive, target) extract(t, archive, target)
return restored{panel: bootPanel(t, target), dataDir: target, session: session} 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 // The panel has to come up on the restored directory and show the state that
@@ -91,6 +97,12 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
t.Errorf("%s = %q, want %q", path, 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 // The one-time setup link is closed by the presence of a panel user, and the
@@ -138,8 +150,9 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
// A restore boot runs one Resync from SQLite. If the archive's Postfix map // 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. // drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) { func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
dataDir := seedPanelData(t) deployRoot := seedPanelProject(t)
cfg := panelConfig(t, dataDir) dataDir := filepath.Join(deployRoot, "data")
cfg := panelConfig(t, deployRoot)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps") mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil { if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
@@ -191,12 +204,14 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
// stays put on a mismatch: the operator's next move is to start the image the // 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. // backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) { func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
live := seedPanelData(t) deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{ if err := backup.Create(&archive, backup.Params{
DataDir: live, DataDir: dataDir,
DBPath: filepath.Join(live, "selfpost.db"), DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: "9.9.9", Version: "9.9.9",
DeployRoot: deployRoot,
}); err != nil { }); err != nil {
t.Fatalf("create backup: %v", err) t.Fatalf("create backup: %v", err)
} }
@@ -218,13 +233,15 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
} }
} }
// seedPanelData builds the /data tree of a panel that has been in use: an // seedPanelProject builds an operator project tree: data/ with a panel that has
// administrator, a sending domain with an application and one logged message, // been in use, plus docker-compose.yml, .env, and certs/ for full backups.
// and the daemon state the mail path needs (a DKIM key, the SASL database and func seedPanelProject(t *testing.T) string {
// Postfix's sender map).
func seedPanelData(t *testing.T) string {
t.Helper() 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")) st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil { if err != nil {
@@ -268,16 +285,33 @@ func seedPanelData(t *testing.T) string {
t.Fatalf("write %s: %v", full, err) t.Fatalf("write %s: %v", full, err)
} }
} }
return dataDir
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
} }
// panelConfig resolves the panel's own configuration for a data directory, so 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 // 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 // than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP. // e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, dataDir string) config { func panelConfig(t *testing.T, deployRoot string) config {
t.Helper() t.Helper()
dataDir := filepath.Join(deployRoot, "data")
t.Setenv("SELFPOST_DATA_DIR", dataDir) t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
t.Setenv("PANEL_COOKIE_SECURE", "false") t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru") t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data // MAIL_LOG's default is an absolute path, not one derived from the data
@@ -288,13 +322,13 @@ func panelConfig(t *testing.T, dataDir string) config {
// bootPanel performs the startup sequence run() performs, in the same order, // bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler. // and returns the panel's HTTP handler.
func bootPanel(t *testing.T, dataDir string) http.Handler { func bootPanel(t *testing.T, deployRoot string) http.Handler {
t.Helper() t.Helper()
cfg := panelConfig(t, dataDir) cfg := panelConfig(t, deployRoot)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil { if err != nil {
t.Fatalf("the panel refused to start on %s: %v", dataDir, err) t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
} }
if restored { if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil { if _, err := os.Stat(cfg.manifestPath); err == nil {
@@ -380,8 +414,8 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
return plain return plain
} }
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data // extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
// bind mount before the image is started. // directory before the image is started.
func extract(t *testing.T, archive []byte, dir string) { func extract(t *testing.T, archive []byte, dir string) {
t.Helper() t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive)) gz, err := gzip.NewReader(bytes.NewReader(archive))
+10 -6
View File
@@ -6,9 +6,9 @@
// //
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz // docker exec <container> selfpost-backup > selfpost-backup.tar.gz
// //
// Use -o to write to a file instead. The resulting archive contains DKIM private // Use -o to write to a file instead. The resulting archive is self-contained:
// keys, the admin password hash and SASL credentials — treat it as a secret // data/, docker-compose.yml, .env, and certs/ when present — treat it as a
// (architecture.md § Persistence). // secret (architecture.md § Persistence).
// //
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an // 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 // argument, which would show up in the process list) the archive is written as
@@ -87,9 +87,13 @@ func run(outPath, password string) error {
} }
if err := backup.Create(sink, backup.Params{ if err := backup.Create(sink, backup.Params{
DataDir: dataDir, DataDir: dataDir,
DBPath: dbPath, DBPath: dbPath,
Version: buildinfo.Version, 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 { }); err != nil {
return err return err
} }
+32 -7
View File
@@ -9,13 +9,18 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
// seedDataDir builds the minimum /data tree a backup can be taken from. // seedProject builds the minimum operator project tree a backup can be taken from.
func seedDataDir(t *testing.T) string { func seedProject(t *testing.T) (deployRoot, dataDir string) {
t.Helper() 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")) st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil { if err != nil {
t.Fatalf("open store: %v", err) t.Fatalf("open store: %v", err)
@@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string {
if err := st.Close(); err != nil { if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err) 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_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")) 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 // 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 // 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. // the CLI are tested as the one round trip an operator actually performs.
func TestEncryptedBackupRoundTrip(t *testing.T) { func TestEncryptedBackupRoundTrip(t *testing.T) {
seedDataDir(t) seedProject(t)
dir := t.TempDir() dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk") encrypted := filepath.Join(dir, "backup.spbk")
plain := filepath.Join(dir, "backup.tar.gz") plain := filepath.Join(dir, "backup.tar.gz")
@@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
} }
names[hdr.Name] = true 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] { if !names[want] {
t.Errorf("decrypted archive has no %s (entries: %v)", want, names) 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 // Without a password the CLI keeps producing the plain archive that existing
// backup scripts consume. // backup scripts consume.
func TestUnencryptedBackupStaysPlain(t *testing.T) { func TestUnencryptedBackupStaysPlain(t *testing.T) {
seedDataDir(t) seedProject(t)
out := filepath.Join(t.TempDir(), "backup.tar.gz") out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err != nil { if err := run(out, ""); err != nil {
t.Fatalf("create backup: %v", err) t.Fatalf("create backup: %v", err)
+15
View File
@@ -11,6 +11,21 @@ SELFPOST_HOSTNAME=mail.example.com
# primary 465/smtps listener. # primary 465/smtps listener.
SUBMISSION_ENABLE=false SUBMISSION_ENABLE=false
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
# mail on port 25 only for domains configured in the panel and forwards them
# to the upstream you set. Off by default — the outbound path is unchanged.
INBOUND_RELAY_ENABLE=false
# Optional milter on the inbound listener only (not 465/587). Empty = off.
# Example with deploy/antispam/docker-compose.antispam.yml:
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
# INBOUND_ANTISPAM_MILTER_ACTION=accept
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting". # Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
RATE_LIMIT_MESSAGES_PER_IP=100 RATE_LIMIT_MESSAGES_PER_IP=100
RATE_LIMIT_WINDOW_SECONDS=3600 RATE_LIMIT_WINDOW_SECONDS=3600
@@ -0,0 +1,29 @@
# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md).
#
# SelfPost does not ship or start an anti-spam engine. This fragment brings up
# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it
# only when you want filtering on port 25:
#
# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
#
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
#
# Pin the rspamd tag the same way the SelfPost image is pinned; bump it
# deliberately. Default milter action is fail-open (accept) so a down sidecar
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
# defer instead.
services:
selfpost:
environment:
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
depends_on:
- antispam
antispam:
image: rspamd/rspamd:3.11
restart: unless-stopped
# No host ports: only SelfPost talks to the milter on the compose network.
+2
View File
@@ -23,6 +23,7 @@ services:
ports: !override ports: !override
- "465:465" - "465:465"
- "587:587" - "587:587"
- "25:25"
environment: environment:
# Caddy names certificate files after the hostname, not # Caddy names certificate files after the hostname, not
# fullchain.pem/privkey.pem like certbot — override the base file's # fullchain.pem/privkey.pem like certbot — override the base file's
@@ -31,6 +32,7 @@ services:
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro
# Replace <hostname> with your actual mail/panel hostname, matching # Replace <hostname> with your actual mail/panel hostname, matching
# the Caddyfile below and SELFPOST_HOSTNAME. # the Caddyfile below and SELFPOST_HOSTNAME.
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro - ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
+13 -2
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.2.5 image: ghcr.io/mixeme/selfpost:1.4.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
@@ -33,6 +33,11 @@ services:
# Set to true to also publish RFC 6409 submission (587/STARTTLS) # Set to true to also publish RFC 6409 submission (587/STARTTLS)
# alongside the primary 465/smtps listener (spec 5). # alongside the primary 465/smtps listener (spec 5).
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app # Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
# limits (level 2) are configured later from the panel itself. # limits (level 2) are configured later from the panel itself.
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
@@ -58,6 +63,9 @@ services:
# kept) so the delivery lines that resolve a queued send-log row survive # 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. # a container recreate; it is diagnostics and is left out of backups.
- ./data:/data - ./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 # Read-only: SelfPost only ever reads certificates, never manages them
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot # (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
# writes PEM files, e.g. /etc/letsencrypt on the host. # writes PEM files, e.g. /etc/letsencrypt on the host.
@@ -73,6 +81,9 @@ services:
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only # 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
# when the variable is true — see docs/guide.md "Published ports". # when the variable is true — see docs/guide.md "Published ports".
- "587:587" - "587:587"
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
# when the variable is true.
- "25:25"
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as # Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
# root very briefly to fix /data ownership and normalise permissions # root very briefly to fix /data ownership and normalise permissions
@@ -82,7 +93,7 @@ services:
# self-healing. What IS applied: no privilege escalation past what the # self-healing. What IS applied: no privilege escalation past what the
# image already grants, and every Linux capability dropped except the # image already grants, and every Linux capability dropped except the
# small set the root startup phase and Postfix/OpenDKIM genuinely need: # small set the root startup phase and Postfix/OpenDKIM genuinely need:
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024; # - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`; # - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data # - FOWNER — entrypoint then chmods those now panel-owned /data
# dirs/files while still root (owner-check bypass); # dirs/files while still root (owner-check bypass);
+2
View File
@@ -23,10 +23,12 @@ services:
ports: !override ports: !override
- "465:465" - "465:465"
- "587:587" - "587:587"
- "25:25"
# No host publish for 8080 here: nginx reaches it over the compose # No host publish for 8080 here: nginx reaches it over the compose
# network at selfpost:8080 instead (see nginx.conf.example). # network at selfpost:8080 instead (see nginx.conf.example).
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro
# Same host directory certbot below writes into — plain bind mount, # Same host directory certbot below writes into — plain bind mount,
# no named volume, so the PEM files are as directly inspectable as in # no named volume, so the PEM files are as directly inspectable as in
# the Apache scenario (spec 10.3). # the Apache scenario (spec 10.3).
@@ -15,8 +15,10 @@ services:
ports: !override ports: !override
- "465:465" - "465:465"
- "587:587" - "587:587"
- "25:25"
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro
# Populated by extract-cert.sh from traefik's acme.json — see below. # Populated by extract-cert.sh from traefik's acme.json — see below.
- ./traefik/extracted-certs:/etc/postfix/tls:ro - ./traefik/extracted-certs:/etc/postfix/tls:ro
labels: labels:
+64 -14
View File
@@ -53,13 +53,31 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
├─► OpenDKIM milter (sign, tempfail on failure) ├─► OpenDKIM milter (sign, tempfail on failure)
├─► journal-milter (send log + L2 rate limits, fail-open) ├─► journal-milter (send log + L2 rate limits, fail-open)
└─► outbound MX delivery (port 25 client) └─► outbound MX delivery (port 25 client)
Internet ──► Postfix smtp inet :25 (only when INBOUND_RELAY_ENABLE=true)
├─► optional antispam milter (inbound only)
└─► smtp:[upstream]:port (transport_maps; no local delivery)
``` ```
The inbound listener is **absent** when the flag is off (`postconf -MX smtp/inet`
removes Debian's stock smtpd). Outbound delivery still uses the `smtp unix`
client; it is not the same service.
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh)) ### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
- **465/smtps** — implicit TLS, SASL required; primary listener. - **465/smtps** — implicit TLS, SASL required; primary listener.
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with - **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`smtpd_tls_security_level=encrypt`. `smtpd_tls_security_level=encrypt`.
- **25/smtp inet** — only when `INBOUND_RELAY_ENABLE=true`. No SASL, no
OpenDKIM, no journal-milter. Accepts only `relay_domains` +
`relay_recipient_maps` (`reject_unauth_destination`,
`reject_unlisted_recipient`). Maps under `/data/postfix/`
(`relay_domains`, `transport`, `relay_recipients`, `tls_policy`), written
atomically by [internal/postfix/inbound.go](../internal/postfix/inbound.go).
Domains with an empty upstream host are omitted from the maps. Optional
`INBOUND_ANTISPAM_MILTER` on this listener only; default
`milter_default_action` is fail-open (`accept`).
- **No open relay**`permit_sasl_authenticated`, `reject_unauth_destination`; - **No open relay**`permit_sasl_authenticated`, `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. `smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Level-1 rate limit**`smtpd_client_message_rate_limit` / - **Level-1 rate limit**`smtpd_client_message_rate_limit` /
@@ -78,7 +96,14 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
One process, three roles: One process, three roles:
1. **HTTP server**`:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse 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/ 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain (`default_action=accept`) so milter failure does not stop mail. Domain
@@ -173,18 +198,21 @@ below is a summary — HTMX fragment endpoints
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete 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) | | `/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 | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view | | `/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 | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps | | `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/settings` | Username/password for any user; DMARC report default is **global** only | | `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD | | `/users`, `/users/*` | **Global.** Panel user CRUD |
| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on 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 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 `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 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 ### Sessions
@@ -242,6 +270,7 @@ flowchart TB
subgraph services ["Services — multi-store operations + rollback"] subgraph services ["Services — multi-store operations + rollback"]
domainSvc["internal/domain"] domainSvc["internal/domain"]
appSvc["internal/app"] appSvc["internal/app"]
inboundSvc["internal/inbound"]
end end
subgraph persistence ["Persistence"] subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"] store["internal/store — SQLite, embedded migrations"]
@@ -263,16 +292,19 @@ flowchart TB
web --> store web --> store
web --> domainSvc web --> domainSvc
web --> appSvc web --> appSvc
web --> inboundSvc
web --> backupPkg web --> backupPkg
web --> dnscheck web --> dnscheck
web --> health web --> health
web --> secretfile web --> secretfile
domainSvc --> store domainSvc --> store
appSvc --> store appSvc --> store
inboundSvc --> store
milterPkg --> store milterPkg --> store
logtail --> store logtail --> store
domainSvc --> postfix domainSvc --> postfix
appSvc --> postfix appSvc --> postfix
inboundSvc --> postfix
``` ```
The three roles inside the `panel` process (HTTP server, journal-milter, The three roles inside the `panel` process (HTTP server, journal-milter,
@@ -286,28 +318,37 @@ single-connection trade-off that follows from it.
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset | | `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset, inbound relay domains |
| `setup-token` | First-run setup token file | | `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables | | `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials | | `sasl/sasldb2` | Application SASL credentials |
| `postfix/sender_login_maps` | Login → From binding | | `postfix/sender_login_maps` | Login → From binding |
| `postfix/relay_domains` | Inbound domains accepted on port 25 |
| `postfix/transport` | Inbound next-hop `smtp:[host]:port` |
| `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all |
| `postfix/tls_policy` | TLS policy for inbound next hops |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) | | `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `manifest.json` | Backup version stamp (consumed on restore) | | `manifest.json` | Backup version stamp (consumed on restore) |
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
(transit mail not migrated by design). full backups also archive the operator's `./certs` PEM files when present.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 rotated files, check every 6h, rename + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of **Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on `data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
restore. On the first successful boot after restore, the panel runs one under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from version check on restore. Requires the project directory mounted read-only at
SQLite and both daemons are reloaded, so drift between the extracted archive `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
and the database is healed before mail flows (same step as `POST /reload` on first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
demand). Stopped-container `tar` of `./data` is safe (see guide). tables, Postfix's sender map, and (when `INBOUND_RELAY_ENABLE=true`) inbound
relay maps are re-derived from SQLite and both daemons are reloaded, so drift
between the extracted archive and the database is healed before mail flows
(same step as `POST /reload` on demand). Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads **Optional encryption** of the two secret-bearing downloads
([internal/secretfile](../internal/secretfile/secretfile.go)): password → ([internal/secretfile](../internal/secretfile/secretfile.go)): password →
@@ -350,7 +391,16 @@ unsupported rather than as a missing doc:
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` `POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains). desyncs the map Postfix reads from the file the panel maintains),
`POSTFIX_RELAY_DOMAINS` (`/data/postfix/relay_domains`),
`POSTFIX_TRANSPORT_MAPS` (`/data/postfix/transport`),
`POSTFIX_RELAY_RECIPIENTS` (`/data/postfix/relay_recipients`),
`POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if
overridden without matching the panel writer in
[internal/postfix/inbound.go](../internal/postfix/inbound.go),
`POSTFIX_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 and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds). `MILTER_WAIT_TIMEOUT` (`30` seconds).
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — panel mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<script>
(function () {
var map = {
login: "login.html",
setup: "setup.html",
status: "status.html",
domains: "domains.html",
domain: "domain.html",
"domain-delete": "domain-delete.html",
deliveries: "deliveries.html",
delivery: "delivery.html",
"mail-queue": "mail-queue.html",
"system-log": "system-log.html",
backup: "backup.html",
users: "users.html",
"user-form": "user-form.html",
"user-delete": "user-delete.html",
settings: "settings.html",
inbound: "inbound.html",
"inbound-domain": "inbound-domain.html",
"inbound-backup": "inbound-backup.html",
"inbound-delete": "inbound-delete.html",
dmarc: "dmarc.html",
"dmarc-domain": "dmarc-domain.html",
"dmarc-report": "dmarc-report.html",
"dmarc-report-fail": "dmarc-report-fail.html",
help: "help.html"
};
var h = location.hash.replace(/^#/, "");
location.replace((map[h] || "status.html") + location.search);
})();
</script>
</head>
<body>
<p>Макеты собраны отдельными страницами. Откройте <a href="status.html">Status</a> или <a href="index.html">оглавление</a>.</p>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup &amp; migration — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-backup" data-page="backup" data-nav="backup" data-title="Backup" data-global-only="1">
<main class="stack">
<h1>Backup &amp; migration</h1>
<div class="pair">
<div class="card">
<h2>Full backup</h2>
<p class="muted">Download a self-contained backup — <code>data/</code> (database, DKIM keys, credentials, Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and <code>certs/</code>. Extract into an empty project directory on a new machine, adjust hostname if needed, and start the <strong>same SelfPost version</strong> before first boot. The reverse-proxy vhost is not included.</p>
<p class="muted"><strong>The backup file is a secret.</strong> Encrypting it is the simplest way to store it: the download is then a <code>.spbk</code> that only the password opens.</p>
<div class="encrypt">
<label class="check"><input type="checkbox" checked> <span>Encrypt with a password</span></label>
<div class="encrypt-fields">
<label>Password</label><input type="password" placeholder="at least 8 characters">
<label>Repeat password</label><input type="password">
<p class="muted">Keep this password: without it the file cannot be opened.</p>
</div>
</div>
<div class="actions-row"><button type="button">Download full backup</button></div>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance — plain <code>.json</code> or encrypted <code>.spde</code>. 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.</p>
<label>Domain export file</label>
<input type="file">
<label>Password</label>
<input type="password" autocomplete="off">
<p class="muted">Needed for a <code>.spde</code> file. Leave empty for plain <code>.json</code>.</p>
<div class="actions-row"><button type="button">Import domain</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deliveries — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-deliveries" data-page="deliveries" data-nav="deliveries" data-title="Deliveries">
<main class="stack">
<h1>Deliveries</h1>
<form class="toolbar fill" action="#" onsubmit="return false">
<div class="field"><label>Domain</label>
<select><option>All domains</option><option>example.com</option><option>alerts.example.com</option></select>
</div>
<div class="field"><label>Application</label>
<select><option>All applications</option><option>newsletter</option><option>billing</option></select>
</div>
<button type="button">Filter</button>
</form>
<div class="fill">
<div class="card">
<table class="desk-only">
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr>
<td class="time muted">2026-08-15 20:14:02</td>
<td>billing@example.com</td>
<td>ada@example.net</td>
<td class="subject"><span>Invoice #4412</span></td>
<td class="status"><span class="st st-warn">deferred</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 20:11:40</td>
<td>news@example.com</td>
<td>list-bounces@example.net</td>
<td class="subject"><span>August digest</span></td>
<td class="status"><span class="st st-ok">delivered</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 19:02:11</td>
<td>alerts@alerts.example.com</td>
<td>noreply@blocked.example</td>
<td class="subject"><span>Disk 92% on web-3</span></td>
<td class="status"><span class="st st-error">bounced</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 18:44:09</td>
<td>news@example.com</td>
<td>sam@example.org</td>
<td class="subject"><span>August digest</span></td>
<td class="status"><span class="st st-ok">delivered</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<a href="delivery.html">
<span class="st st-warn">deferred</span><span class="when">20:14</span>
<span class="meta">billing@example.com → ada@example.net</span>
<span class="subj">Invoice #4412</span>
</a>
</li>
<li>
<a href="delivery.html">
<span class="st st-ok">delivered</span><span class="when">20:11</span>
<span class="meta">news@example.com → list-bounces@example.net</span>
<span class="subj">August digest</span>
</a>
</li>
<li>
<a href="delivery.html">
<span class="st st-error">bounced</span><span class="when">19:02</span>
<span class="meta">alerts@alerts.example.com → noreply@blocked.example</span>
<span class="subj">Disk 92% on web-3</span>
</a>
</li>
</ul>
<p class="muted">Page 1 of 4 · <a href="deliveries.html">Older →</a></p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invoice #4412 — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-delivery" data-page="delivery" data-nav="deliveries" data-title="Message">
<main class="stack">
<div>
<h1 class="subject">Invoice #4412</h1>
<p class="route">
<span class="addr">billing@example.com</span>
<span class="arrow"></span>
<span class="addr">ada@example.net</span>
<span class="st st-warn">deferred</span>
</p>
<a class="back" href="deliveries.html">&larr; Back to deliveries</a>
</div>
<div class="pair">
<div class="card">
<h2>Message</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
<div class="fact"><span class="fact-label">Application</span><span class="fact-value">billing</span></div>
<div class="fact"><span class="fact-label">Accepted</span><span class="fact-value">2026-08-15 20:14:02 UTC</span></div>
<div class="fact"><span class="fact-label">Status reported</span><span class="fact-value">2026-08-15 20:14:08 UTC</span></div>
<div class="fact"><span class="fact-label">Queue id</span><span class="fact-value mono">4C3A1E2F1A</span></div>
<div class="fact"><span class="fact-label">Journal id</span><span class="fact-value mono">1842</span></div>
</div>
</div>
<div class="card">
<h2>History</h2>
<ol class="timeline">
<li class="event lvl-ok">
<p class="event-time">2026-08-15 20:14:02 UTC</p>
<p class="event-title"><span class="st st-ok">accepted</span> Received by the relay</p>
<p class="event-detail muted">SASL login billing, queued as 4C3A1E2F1A.</p>
</li>
<li class="event lvl-warn">
<p class="event-time">2026-08-15 20:14:08 UTC</p>
<p class="event-title"><span class="st st-warn">deferred</span> Receiving MX asked to try later</p>
<p class="event-detail muted">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.</p>
</li>
<li class="event pending">
<p class="event-time">not yet</p>
<p class="event-title"><span class="st st-unknown">delivery</span> Waiting on the next retry</p>
</li>
</ol>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Delivery log</h2>
<table class="log desk-only">
<thead><tr><th>Time</th><th>Message</th></tr></thead>
<tbody>
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing</td></tr>
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/cleanup[224]: 4C3A1E2F1A: message-id=&lt;4412@example.com&gt;</td></tr>
<tr><td class="time muted">20:14:08</td><td class="log-text">postfix/smtp[230]: 4C3A1E2F1A: to=&lt;ada@example.net&gt;, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)</td></tr>
</tbody>
</table>
<div class="phone-only">
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:08</p>
<span class="code">status=deferred (450 4.2.1 mailbox busy)</span>
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:02</p>
<span class="code">client=203.0.113.40, sasl_username=billing</span>
</div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.com — DMARC — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc-domain" data-page="dmarc-domain" data-nav="dmarc" data-title="example.com DMARC">
<main class="stack">
<div>
<h1>example.com <span class="tag future">candidate</span></h1>
<a class="back g-only" href="dmarc.html">&larr; All DMARC reports</a>
<a class="back" href="domain.html">&larr; Back to example.com</a>
</div>
<div class="pair">
<div class="card">
<h2>Last 7 days <span class="st st-ok">pass</span></h2>
<p>98% aligned. Tightening <code>p=</code> looks reasonable.</p>
<p class="muted"><a href="domain.html">Domain DNS and rua=</a></p>
</div>
<div class="card">
<h2>Third-party senders</h2>
<p>google.com / 66.102.0.0/20 failed SPF and DKIM 6 times. Everyone else is this relay.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Reports</h2>
<table class="desk-only">
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr>
<td class="time muted">2026-08-15 06:12</td>
<td class="muted">google.com</td>
<td>14 Aug</td>
<td>412</td>
<td>6</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
<tr>
<td class="time muted">2026-08-14 06:08</td>
<td class="muted">google.com</td>
<td>13 Aug</td>
<td>390</td>
<td>2</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<a href="dmarc-report.html">
<span class="st st-ok">412 pass</span><span class="when">14 Aug</span>
<span class="meta">google.com · 6 fail</span>
</a>
</li>
<li>
<a href="dmarc-report.html">
<span class="st st-ok">390 pass</span><span class="when">13 Aug</span>
<span class="meta">google.com · 2 fail</span>
</a>
</li>
</ul>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Sources · last 7 days</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>
<tr><td class="muted">203.0.113.10 (this relay)</td><td>802</td><td>0</td><td><span class="st st-ok">none</span></td></tr>
<tr><td class="muted">google.com / 66.102.0.0/20</td><td>0</td><td>8</td><td><span class="st st-warn">none</span></td></tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="dmarc-report.html"><span>this relay</span><span class="st st-ok">802 pass</span></a></li>
<li><a href="dmarc-report.html"><span>google.com / 66.102.0.0/20</span><span class="st st-warn">8 fail</span></a></li>
</ul>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>google.com report — alerts.example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc-report-fail" data-page="dmarc-report-fail" data-nav="dmarc" data-title="DMARC report">
<main class="stack">
<div>
<h1 class="subject">google.com · 14 Aug</h1>
<p class="route">
<span class="addr">alerts.example.com</span>
<span class="st st-ok">88 pass</span>
<span class="st st-error">19 fail</span>
</p>
<a class="back" href="dmarc.html">&larr; All DMARC reports</a>
</div>
<div class="pair">
<div class="card">
<h2>Report</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944098765432109</span></div>
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 2026-08-15 00:00 UTC</span></div>
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 04:40 UTC</span></div>
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
</div>
</div>
<div class="card attn">
<h2>Published policy</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">alerts.example.com</span></div>
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
</div>
<p class="muted">A third-party source is not in SPF or DKIM. Do not tighten <code>p=</code> until that sender is gone or aligned.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Records</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
<tbody>
<tr>
<td class="muted">203.0.113.10 (this relay)</td>
<td>88</td>
<td><span class="st st-ok">none</span></td>
<td><span class="st st-ok">pass</span></td>
<td><span class="st st-ok">pass</span></td>
<td>alerts.example.com</td>
</tr>
<tr>
<td class="muted">198.51.100.80 (unknown)</td>
<td>19</td>
<td><span class="st st-error">none</span></td>
<td><span class="st st-error">fail</span></td>
<td><span class="st st-error">fail</span></td>
<td>alerts.example.com</td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<div class="item">
<span>this relay · 88</span><span class="st st-ok">pass</span>
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
</div>
</li>
<li>
<div class="item">
<span>unknown · 19</span><span class="st st-error">fail</span>
<span class="meta">198.51.100.80 · SPF fail · DKIM fail</span>
</div>
</li>
</ul>
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>google.com report — example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc-report" data-page="dmarc-report" data-nav="dmarc" data-title="DMARC report">
<main class="stack">
<div>
<h1 class="subject">google.com · 14 Aug</h1>
<p class="route">
<span class="addr">example.com</span>
<span class="st st-ok">412 pass</span>
<span class="st st-warn">6 fail</span>
</p>
<a class="back" href="dmarc-domain.html">&larr; Back to example.com</a>
</div>
<div class="pair">
<div class="card">
<h2>Report</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944012345678901</span></div>
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 2026-08-15 00:00 UTC</span></div>
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 06:12 UTC</span></div>
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
</div>
</div>
<div class="card">
<h2>Published policy</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
</div>
<p class="muted"><code>p=none</code> does not affect delivery. Failures here are forwarding through Google, not this relay.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Records</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
<tbody>
<tr>
<td class="muted">203.0.113.10 (this relay)</td>
<td>412</td>
<td><span class="st st-ok">none</span></td>
<td><span class="st st-ok">pass</span></td>
<td><span class="st st-ok">pass</span></td>
<td>example.com</td>
</tr>
<tr>
<td class="muted">66.102.1.44 (google.com)</td>
<td>6</td>
<td><span class="st st-warn">none</span></td>
<td><span class="st st-error">fail</span></td>
<td><span class="st st-error">fail</span></td>
<td>example.com</td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<div class="item">
<span>this relay · 412</span><span class="st st-ok">pass</span>
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
</div>
</li>
<li>
<div class="item">
<span>google.com · 6</span><span class="st st-error">fail</span>
<span class="meta">66.102.1.44 · SPF fail · DKIM fail</span>
</div>
</li>
</ul>
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DMARC reports — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc" data-page="dmarc" data-nav="dmarc" data-title="DMARC" data-global-only="1">
<main class="stack">
<div class="page-head">
<h1>DMARC reports <span class="tag future">candidate</span></h1>
</div>
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>. Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed XML — not a dashboard.</p>
<div class="pair">
<div class="card">
<h2>Ingest <span class="st st-ok">ok</span></h2>
<p>Last report 6 hours ago. Port 25 accepts only the configured report address.</p>
</div>
<div class="card">
<h2>This week</h2>
<p>14 kept, 0 parse failures. Older summaries are pruned.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Recent reports</h2>
<table class="desk-only">
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr>
<td class="time muted">2026-08-15 06:12</td>
<td><a href="dmarc-domain.html">example.com</a></td>
<td class="muted">google.com</td>
<td>14 Aug</td>
<td>412</td>
<td>6</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 04:40</td>
<td>alerts.example.com</td>
<td class="muted">google.com</td>
<td>14 Aug</td>
<td>88</td>
<td>19</td>
<td class="actions"><a href="dmarc-report-fail.html">View</a></td>
</tr>
<tr>
<td class="time muted">2026-08-14 06:08</td>
<td><a href="dmarc-domain.html">example.com</a></td>
<td class="muted">google.com</td>
<td>13 Aug</td>
<td>390</td>
<td>2</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<a href="dmarc-report.html">
<span class="st st-ok">412 pass</span><span class="when">06:12</span>
<span class="meta">example.com · google.com · 14 Aug</span>
<span class="subj">6 fail</span>
</a>
</li>
<li>
<a href="dmarc-report-fail.html">
<span class="st st-error">19 fail</span><span class="when">04:40</span>
<span class="meta">alerts.example.com · google.com · 14 Aug</span>
<span class="subj">88 pass</span>
</a>
</li>
<li>
<a href="dmarc-report.html">
<span class="st st-ok">390 pass</span><span class="when">14 Aug</span>
<span class="meta">example.com · google.com · 13 Aug</span>
<span class="subj">2 fail</span>
</a>
</li>
</ul>
<p class="muted">Each row is one aggregate XML SelfPost parsed. Domain names open the roll-up for that sending domain.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-domain-delete" data-page="domain-delete" data-nav="domains" data-title="Delete domain">
<main class="stack">
<div>
<h1>Delete example.com</h1>
<a class="back" href="domain.html">&larr; Back to example.com</a>
</div>
<div class="measure">
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete <strong>example.com</strong>. This will:</p>
<ul>
<li>permanently delete its DKIM signing key;</li>
<li>delete <strong>all 2 bound applications</strong>, including their SASL credentials;</li>
<li>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<p class="muted">This cannot be undone.</p>
<div class="actions-row"><button type="button" class="danger">Delete example.com</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+260
View File
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-domain" data-page="domain" data-nav="domains" data-title="example.com">
<main class="stack">
<div>
<h1>example.com</h1>
<a class="back" href="domains.html">&larr; All domains</a>
</div>
<div class="measure">
<div class="card credential" id="new-credential">
<div class="card-head">
<h2>New application password</h2>
<label class="help-link" for="help-password" title="Why this is shown once">?</label>
</div>
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now.</p>
<label>Login</label>
<div class="code-row"><span class="code">newsletter</span><button type="button" class="copy">Copy</button></div>
<label>Password</label>
<div class="code-row"><span class="code">xK.9fQ2m-pL7wR</span><button type="button" class="copy">Copy</button></div>
</div>
</div>
<div class="fill">
<div class="card" id="dns-status">
<div class="card-head">
<h2>DNS status <span class="st st-ok">ok</span></h2>
<label class="help-link" for="help-dns" title="What these checks mean">?</label>
</div>
<div class="stack">
<div class="pair">
<div>
<label>DKIM <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">mail._domainkey.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
</div>
<div>
<label>SPF <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
</div>
</div>
<div class="pair">
<div>
<label>DMARC <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">_dmarc.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<p class="muted">p=none; rua points at SelfPost ingest.</p>
</div>
<div>
<label>Report authorization</label>
<p class="muted">Not required (rua= is on a domain SelfPost accepts).</p>
</div>
</div>
</div>
<div class="actions-row"><button type="button">Re-check</button></div>
</div>
</div>
<div class="pair">
<div class="card" id="dkim-spf">
<div class="card-head">
<h2>DKIM and SPF records</h2>
<label class="help-link" for="help-records" title="What to publish">?</label>
</div>
<p class="check-col-title">DKIM</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">mail._domainkey.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">v=DKIM1; k=rsa; p=MIIBIjANBgkqh…</span><button type="button" class="copy">Copy</button></div>
<p class="check-col-title">SPF</p>
<div class="code-row"><span class="code">v=spf1 ip4:203.0.113.10 -all</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="card" id="dmarc-record">
<div class="card-head">
<h2>DMARC record</h2>
<label class="help-link" for="help-dmarc" title="What this record does">?</label>
</div>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">_dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value <span class="muted">(from Settings)</span></label>
<div class="code-row"><span class="code">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="copy">Copy</button></div>
<p class="muted"><a href="dmarc-domain.html">Open DMARC reports</a> for this domain.</p>
</div>
</div>
<div class="pair">
<div class="card" id="connection">
<div class="card-head">
<h2>Connection settings</h2>
<label class="help-link" for="help-connection" title="How to connect">?</label>
</div>
<label>Server</label>
<div class="code-row"><span class="code">mail.example.org</span><button type="button" class="copy">Copy</button></div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit)
587 — STARTTLS (submission)</span>
</div>
<div class="card" id="add-application">
<div class="card-head">
<h2>Add an application</h2>
<label class="help-link" for="help-apps" title="What an application is">?</label>
</div>
<form action="#" onsubmit="return false">
<label>Login</label>
<input placeholder="prod-server" autocomplete="off">
<label>Address mode</label>
<select><option>Any address of the domain</option><option>Specific addresses (list)</option></select>
<div class="actions-row"><button type="button">Create application</button></div>
</form>
</div>
</div>
<div class="fill">
<div class="card" id="applications">
<div class="card-head">
<h2>Applications</h2>
<label class="help-link" for="help-apps" title="Address mode and rate limits">?</label>
</div>
<ul class="apps">
<li class="app-item">
<p class="app-login">newsletter</p>
<p class="app-addr muted">Any address of the domain — *@example.com</p>
<div class="actions">
<input class="panel-toggle t-edit" id="edit-1" type="checkbox" checked>
<label class="toggle for-edit" for="edit-1">Edit <span class="st st-ok">limit</span></label>
<button type="button">New password</button>
<button type="button" class="danger">Delete</button>
<div class="panel panel-edit">
<div class="pair">
<div>
<p class="check-col-title">Address mode</p>
<select><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select>
</div>
<div>
<p class="check-col-title">Trusted-IP override <span class="st st-ok">active</span></p>
<textarea rows="1">203.0.113.40</textarea>
<div class="field-row equal">
<div class="field">
<label>Message limit</label>
<input value="80">
</div>
<div class="field">
<label>Window (seconds)</label>
<input value="60">
</div>
</div>
</div>
</div>
</div>
</div>
</li>
<li class="app-item">
<p class="app-login">billing</p>
<p class="app-addr muted">Fixed list — invoices@example.com</p>
<div class="actions">
<button type="button">Edit</button>
<button type="button">New password</button>
<button type="button" class="danger">Delete</button>
</div>
</li>
</ul>
</div>
</div>
<div class="pair">
<div class="card" id="domain-settings-dmarc">
<div class="card-head">
<h2>DMARC reports</h2>
<label class="help-link" for="help-domain-settings" title="Reports and rate limits">?</label>
</div>
<label>Aggregate reports (rua=)</label>
<select><option selected>Same as Settings (dmarc@mail.example.org)</option><option>No aggregate reports</option><option>Custom address</option></select>
<div class="actions-row"><button type="button">Save DMARC report settings</button></div>
</div>
<div class="card" id="domain-settings-limit">
<div class="card-head">
<h2>Level-2 rate limit <span class="st st-ok">active</span></h2>
</div>
<div class="field-row equal">
<div class="field">
<label>Message limit</label>
<input value="40">
</div>
<div class="field">
<label>Window (seconds)</label>
<input value="60">
</div>
</div>
<div class="actions-row"><button type="button">Save limit</button></div>
</div>
</div>
<div class="pair">
<div class="card" id="export-domain">
<div class="card-head">
<h2>Export domain</h2>
<label class="help-link" for="help-export" title="What the file contains">?</label>
</div>
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt as <code>.spde</code>.</p>
<div class="encrypt">
<label class="check"><input type="checkbox"> <span>Encrypt with a password</span></label>
<div class="encrypt-fields">
<label>Password</label><input type="password">
<label>Repeat password</label><input type="password">
</div>
</div>
<div class="actions-row"><button type="button">Export domain</button></div>
</div>
<div class="card g-only">
<h2>Danger zone</h2>
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
<div class="actions-row"><a class="danger" href="domain-delete.html">Delete domain</a></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Domains — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-domains" data-page="domains" data-nav="domains" data-title="Domains">
<main class="stack">
<div class="page-head"><h1>Domains</h1></div>
<div class="fill">
<div class="card">
<h2>Domains</h2>
<form class="measure g-only" action="#" onsubmit="return false;">
<label for="dname">Add a sending domain</label>
<div class="input-row">
<input id="dname" placeholder="example.com" autocomplete="off">
<button type="button">Add domain</button>
</div>
</form>
<table class="desk-only">
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr></thead>
<tbody>
<tr>
<td><a href="domain.html">example.com</a></td>
<td><a class="st st-ok" href="domain.html">ok</a></td>
<td class="muted">mail</td>
<td>2</td>
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
</tr>
<tr>
<td><a href="domain.html">alerts.example.com</a></td>
<td><a class="st st-warn" href="domain.html">warn</a></td>
<td class="muted">mail</td>
<td>1</td>
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="domain.html"><span>example.com</span><span class="st st-ok">ok</span><span class="meta">2 apps · selector mail</span></a></li>
<li><a href="domain.html"><span>alerts.example.com</span><span class="st st-warn">warn</span><span class="meta">1 app · selector mail</span></a></li>
</ul>
<p class="muted">The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Help — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-help" data-page="help" data-nav="help" data-title="Help">
<main class="stack">
<div class="page-head">
<h1>Help <span class="tag future">candidate</span></h1>
</div>
<p class="muted">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.</p>
<div class="measure stack">
<div class="card">
<h2>On this panel</h2>
<ul>
<li><a href="help.html">Machine</a> — kernel counters and the rate window</li>
<li><a href="help.html">TLS certificate</a> — port 465, reverse-proxy mount</li>
<li><a href="help.html">Hostname / reverse DNS</a> — forward-confirmed PTR at the hosting provider</li>
<li><a href="help.html">Mail queue retries</a> — time-based, no attempt budget</li>
<li><a href="help.html">Inbound</a> — not mailboxes; listed recipients or any address at the domain</li>
<li>Domain page — DNS, records, connection, applications, export (drawer from each cards «?»)</li>
</ul>
<p class="muted">The same texts open in the drawer from Statuss «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.</p>
</div>
<div class="card">
<h2>Machine</h2>
<p>CPU and memory are the containers own readings, not the hosts 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”.</p>
</div>
<div class="card">
<h2>TLS certificate</h2>
<p>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.</p>
</div>
<div class="card">
<h2>Hostname / reverse DNS</h2>
<p>Forward-confirmed reverse DNS: the A/AAAA for <code>SELFPOST_HOSTNAME</code> 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.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>backup.example.net — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound-backup" data-page="inbound-backup" data-nav="inbound" data-title="backup.example.net" data-global-only="1">
<main class="stack">
<div>
<h1>backup.example.net</h1>
<a class="back" href="inbound.html">&larr; All inbound domains</a>
</div>
<div class="fill">
<div class="card">
<h2>DNS status <span class="st st-error">error</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
<label>MX <span class="st st-error">error</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">backup.example.net</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<span class="code">10 mail.primary.example.net.</span>
<p class="error">No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.</p>
<div class="actions-row"><button type="button">Re-check</button></div>
</div>
</div>
<div class="pair">
<div class="card">
<h2>Upstream</h2>
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
<label>Host</label><input value="192.0.2.20">
<label>Port</label><input value="25">
<label>TLS to upstream</label>
<select><option>Opportunistic</option><option>Required</option><option selected>Off</option></select>
<div class="actions-row"><button type="button">Save upstream</button></div>
</div>
<div class="card">
<h2>MX record to publish</h2>
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">backup.example.net</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">20 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
</div>
</div>
<div class="pair">
<div class="card rcpt-mode">
<h2>Valid recipients</h2>
<p class="muted">Who this domain accepts on port 25. Same idea as an applications address mode: a list, or any address at the domain.</p>
<label>Who to accept</label>
<select>
<option value="list">Listed addresses only</option>
<option value="any" selected>Any recipient at this domain</option>
</select>
<div class="rcpt-list">
<label>Addresses (one per line or comma-separated)</label>
<textarea rows="6">postmaster@backup.example.net
abuse@backup.example.net</textarea>
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
</div>
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
<div class="actions-row"><button type="button">Save recipients</button></div>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete lists.example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound-delete" data-page="inbound-delete" data-nav="inbound" data-title="Delete inbound" data-global-only="1">
<main class="stack">
<div>
<h1>Delete lists.example.com</h1>
<a class="back" href="inbound-domain.html">&larr; Back to lists.example.com</a>
</div>
<div class="measure">
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to stop accepting inbound mail for <strong>lists.example.com</strong>. This will:</p>
<ul>
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
<li>stop forwarding to <strong>10.0.0.8:25</strong>;</li>
<li>leave outbound sending domains untouched.</li>
</ul>
<p class="muted">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.</p>
<div class="actions-row"><button type="button" class="danger">Delete lists.example.com</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lists.example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound-domain" data-page="inbound-domain" data-nav="inbound" data-title="lists.example.com" data-global-only="1">
<main class="stack">
<div>
<h1>lists.example.com</h1>
<a class="back" href="inbound.html">&larr; All inbound domains</a>
</div>
<div class="fill">
<div class="card">
<h2>DNS status <span class="st st-ok">ok</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
<label>MX <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">lists.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<span class="code">10 mail.example.org.
20 mail.primary.example.net.</span>
<p class="muted">An MX points at mail.example.org (this server). Other MX values are the domains own primaries — they are not an error.</p>
<div class="actions-row"><button type="button">Re-check</button></div>
</div>
</div>
<div class="pair">
<div class="card">
<h2>Upstream</h2>
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
<label>Host</label><input value="10.0.0.8">
<label>Port</label><input value="25">
<label>TLS to upstream</label>
<select><option>Opportunistic</option><option selected>Required</option><option>Off</option></select>
<div class="actions-row"><button type="button">Save upstream</button></div>
</div>
<div class="card">
<h2>MX record to publish</h2>
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">lists.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">10 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
</div>
</div>
<div class="pair">
<div class="card rcpt-mode">
<h2>Valid recipients</h2>
<p class="muted">Who this domain accepts on port 25. Same idea as an applications address mode: a list, or any address at the domain.</p>
<label>Who to accept</label>
<select>
<option value="list" selected>Listed addresses only</option>
<option value="any">Any recipient at this domain</option>
</select>
<div class="rcpt-list">
<label>Addresses (one per line or comma-separated)</label>
<textarea rows="6">staff@lists.example.com
postmaster@lists.example.com
abuse@lists.example.com</textarea>
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
</div>
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
<div class="actions-row"><button type="button">Save recipients</button></div>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inbound — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound" data-page="inbound" data-nav="inbound" data-title="Inbound" data-global-only="1">
<main class="stack">
<div class="page-head">
<h1>Inbound <span class="tag">1.x</span></h1>
</div>
<p class="muted">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.</p>
<div class="fill">
<div class="card">
<h2>Forwarding</h2>
<form class="measure" action="#" onsubmit="return false">
<label for="iname">Add inbound domain</label>
<div class="input-row">
<input id="iname" placeholder="lists.example.com">
<button type="button">Add domain</button>
</div>
</form>
<table class="desk-only">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr></thead>
<tbody>
<tr>
<td><a href="inbound-domain.html">lists.example.com</a></td>
<td><a class="st st-ok" href="inbound-domain.html">ok</a></td>
<td class="muted">10.0.0.8:25</td>
<td>12 listed</td>
<td><span class="st st-ok">required</span></td>
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
</tr>
<tr>
<td><a href="inbound-backup.html">backup.example.net</a></td>
<td><a class="st st-error" href="inbound-backup.html">error</a></td>
<td class="muted">192.0.2.20:25</td>
<td>any</td>
<td><span class="st st-unknown">off</span></td>
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="inbound-domain.html"><span>lists.example.com</span><span class="st st-ok">ok</span><span class="meta">12 listed · 10.0.0.8:25</span></a></li>
<li><a href="inbound-backup.html"><span>backup.example.net</span><span class="st st-error">error</span><span class="meta">any recipient · 192.0.2.20:25</span></a></li>
</ul>
<p class="muted">The <em>DNS</em> 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 <em>Re-check</em> button.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — макеты панели</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
</head>
<body class="doc">
<div class="wrap">
<header>
<p class="eyebrow">макеты · не панель</p>
<h1>Self<b>Post</b> — полное обновление интерфейса</h1>
<p>Проект системы — как собирать экраны, чтобы не чинить пустые колонки, высоту полей и ряды кнопок по одному. Каждый экран — отдельная страница, не простыня с якорями. Знак и палитра из листа утверждения не меняются. HTML в <code>docs/assets/panel-ui/</code>, не вёрстка <code>internal/web</code>.</p>
<div class="cta-row">
<a class="cta" href="status.html">Открыть Status</a>
<a class="cta quiet" href="system.html">Система интерфейса</a>
<a class="cta quiet" href="status.html?view=phone">Аварийный телефон</a>
<a class="cta quiet" href="status.html?theme=dark">Status, тёмная тема</a>
</div>
</header>
<section>
<h2>Система, не рескин</h2>
<p>Первый click-through повторил сетку живой панели, поэтому те же сбои чинились по одному. <a href="system.html">Грамматика</a> задаёт регионы (<code>measure</code> / <code>pair</code> / <code>fill</code>), карточку со слотом справки, <code>field-row</code> (Host и Type одной высоты) и <code>actions-row</code>. Макеты собраны только из этого: один экран — один HTML-файл.</p>
</section>
<section>
<h2>Ширина окна</h2>
<p>Сейчас оболочка центрируется, колонка контента упирается в <code>64rem</code>, формы ещё уже — <code>48rem</code>. На широком мониторе поля пустые. Растянуть поля на 100% окна нельзя — они становятся нечитаемыми. Две независимые карточки рядом — можно.</p>
<h3>Гибрид</h3>
<p>Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до <strong>90rem (~1440px)</strong>. Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Таблица Users — <code>fill</code>. Одиночные формы (подтверждения, форма пользователя) остаются <strong>42rem и выровнены влево</strong>. Login и setup — по-прежнему узкий центрированный блок.</p>
<div class="compare">
<div>
<div class="row">сейчас, окно ~1600px</div>
<div class="bar">
<i class="navc">nav</i>
<i class="col" style="width:42%">64rem</i>
<i class="empty">пусто</i>
</div>
</div>
<div>
<div class="row">макет, ops-страница</div>
<div class="bar">
<i class="navc">nav</i>
<i class="fill">контент до 90rem</i>
</div>
</div>
<div>
<div class="row">макет, форма</div>
<div class="bar">
<i class="navc">nav</i>
<i class="col" style="width:28%">42rem</i>
<i class="empty">поле не растягивает инпуты</i>
</div>
</div>
</div>
<div class="note">На телефоне это не «узкий десктоп». Таблицы из шести колонок складываются в список. Навигация — выезжающая колонка на CSS-checkbox, без обязательного JavaScript (как панели Edit у приложений уже сейчас).</div>
</section>
<section>
<h2>Информационная архитектура</h2>
<div class="ia">
<div>
<h3>Global administrator</h3>
<ul>
<li>Status</li>
<li>Domains → domain → delete</li>
<li>Deliveries → message</li>
<li>Mail queue</li>
<li>System log</li>
<li>Inbound <span class="tag">1.x</span> — скрыт, пока <code>INBOUND_RELAY_ENABLE</code> выключен</li>
<li>DMARC reports <span class="tag future">candidate</span></li>
<li>Backup, Users, Settings</li>
<li>Help <span class="tag future">candidate</span> — пункт навбара и drawer с карточек Status и страницы домена</li>
</ul>
</div>
<div>
<h3>Domain-admin</h3>
<ul>
<li>Domains (только назначенные) → domain → DMARC reports этого домена</li>
<li>Deliveries → message, включая пояснение retry</li>
<li>Settings — только учётные данные панели</li>
<li>Help drawer</li>
<li>Нет Status, очереди, system log, Backup, Users, inbound, глобального DMARC hub</li>
</ul>
</div>
</div>
<p class="muted" style="margin-top:1rem">В прототипе роль, inbound-флаг, ширина и тема переключаются в шапке. <code>contributing</code> и <code>schema-squash</code> экранов не имеют.</p>
</section>
<section>
<h2>Знак и новые иконки</h2>
<p>Утверждённый stamp не заменяется. Для узкой шапки телефона предлагается уже существующий SP-значок (фавикон), а не компактный wordmark: на 390px высота важнее, чем прочитать «SelfPost» второй раз.</p>
<div class="mark-row">
<figure>
<img src="../selfpost-stamp.svg" width="220" height="100" alt="Полный знак">
<figcaption>эталон · login / setup</figcaption>
</figure>
<figure>
<img src="../selfpost-stamp-compact.svg" width="160" height="73" alt="Компактный знак">
<figcaption>колонка навбара</figcaption>
</figure>
<figure>
<img src="../selfpost-icon.svg" width="64" height="64" alt="SP">
<figcaption>телефон · шапка drawer</figcaption>
</figure>
</div>
<h3>Иконки новых пунктов</h3>
<p class="muted">Тот же штрих 1.5, что у Status / Domains. Не пиктограммы-эмодзи и не заливка.</p>
<div class="icons-row">
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>
<span>Inbound</span>
</div>
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></svg>
<span>DMARC</span>
</div>
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>
<span>Help</span>
</div>
</div>
</section>
<section>
<h2>Экраны</h2>
<h3>Система</h3>
<div class="screen-index">
<a href="system.html">Грамматика и живые образцы</a>
</div>
<h3>Вход</h3>
<div class="screen-index">
<a href="login.html">Sign in</a>
<a href="setup.html">Create administrator</a>
</div>
<h3>Текущая панель</h3>
<div class="screen-index">
<a href="status.html">Status</a>
<a href="domains.html">Domains</a>
<a href="domain.html">Domain detail</a>
<a href="domain-delete.html">Delete domain</a>
<a href="deliveries.html">Deliveries</a>
<a href="delivery.html">Delivery (deferred + retries)</a>
<a href="mail-queue.html">Mail queue + retry policy</a>
<a href="system-log.html">System log</a>
<a href="backup.html">Backup</a>
<a href="users.html">Users</a>
<a href="user-form.html">Edit user</a>
<a href="user-delete.html">Delete user</a>
<a href="settings.html">Settings</a>
</div>
<h3>Roadmap</h3>
<div class="screen-index">
<a href="mail-queue.html">queue-retries — карточка на Mail queue</a>
<a href="delivery.html">queue-retries — history deferred</a>
<a href="inbound.html">Inbound list (колонка DNS = MX)</a>
<a href="inbound-domain.html">Inbound domain — DNS ok</a>
<a href="inbound-backup.html">Inbound domain — DNS error</a>
<a href="inbound-delete.html">Delete inbound domain</a>
<a href="status.html?inbound=0">Inbound выключен (пункта нет)</a>
<a href="dmarc.html">DMARC reports hub <span class="tag future">candidate</span></a>
<a href="dmarc-domain.html">DMARC — domain roll-up</a>
<a href="dmarc-report.html">DMARC report — aligned</a>
<a href="dmarc-report-fail.html">DMARC report — third-party fail</a>
<a href="help.html">Help <span class="tag future">candidate</span></a>
</div>
<h3>Аварийный телефон</h3>
<div class="screen-index">
<a href="status.html?view=phone">Status 390px — Overall, then queue/cert, then the rest</a>
<a href="mail-queue.html?view=phone">Mail queue 390px</a>
<a href="deliveries.html?view=phone">Deliveries 390px — список, не таблица</a>
<a href="delivery.html?view=phone">Delivery 390px</a>
</div>
</section>
<section style="border-bottom:none">
<h2>Как смотреть</h2>
<p>Откройте любой экран из списка — это отдельная страница, не якорь в одном файле. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.</p>
<p class="muted">После утверждения макетов вёрстка <code>panel.css</code> и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.</p>
</section>
</div>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-login" data-page="login" data-nav="login" data-title="Sign in" data-auth="1">
<main class="auth">
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
<h1>Sign in</h1>
<div class="card">
<form action="status.html">
<label for="u">Username</label>
<input id="u" name="username" autocomplete="username">
<label for="p">Password</label>
<input id="p" name="password" type="password" autocomplete="current-password">
<div class="actions-row"><button type="submit">Sign in</button></div>
</form>
</div>
<p class="version" style="text-align:center">© Mixeme · <a href="#">License (AGPL-3.0)</a></p>
</main>
<script src="shell.js"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mail queue — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-mail-queue" data-page="mail-queue" data-nav="queue" data-title="Mail queue" data-global-only="1">
<main class="stack">
<h1>Mail queue</h1>
<div class="fill">
<div class="card">
<h2>How delivery retries work</h2>
<p class="muted">This Postfixs policy, read once at panel start. There is no maximum attempt count — only time.</p>
<div class="facts retry-facts">
<div class="fact"><span class="fact-label">First retry</span><span class="fact-value">5 minutes</span></div>
<div class="fact"><span class="fact-label">Later retries</span><span class="fact-value">doubling, cap 1 h 7 min</span></div>
<div class="fact"><span class="fact-label">Kept in queue</span><span class="fact-value">5 days</span></div>
<div class="fact"><span class="fact-label">Then</span><span class="fact-value">bounced</span></div>
</div>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Pending messages</h2>
<table class="desk-only">
<thead><tr><th>Queue id</th><th>Age</th><th>From</th><th>To</th><th>Size</th></tr></thead>
<tbody>
<tr><td class="mono">4C3A1E2F1A</td><td class="time muted">18 min</td><td>billing@example.com</td><td>ada@example.net</td><td>12 KiB</td></tr>
<tr><td class="mono">4C3A1E3010</td><td class="time muted">11 min</td><td>news@example.com</td><td>pat@slow.example</td><td>48 KiB</td></tr>
<tr><td class="mono">4C3A1E3102</td><td class="time muted">4 min</td><td>billing@example.com</td><td>ada@example.net</td><td>9 KiB</td></tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="delivery.html"><span>4C3A1E2F1A</span><span class="when">18 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
<li><a href="delivery.html"><span>4C3A1E3010</span><span class="when">11 min</span><span class="meta">news@example.com → pat@slow.example</span></a></li>
<li><a href="delivery.html"><span>4C3A1E3102</span><span class="when">4 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
</ul>
<label>postqueue -p</label>
<span class="code">-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.</span>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+715
View File
@@ -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); }
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-settings" data-page="settings" data-nav="settings" data-title="Settings">
<main class="stack">
<h1>Settings</h1>
<form class="stack" action="#" onsubmit="return false">
<div class="pair">
<div class="card">
<h2>Panel credentials</h2>
<p class="muted">These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.</p>
<label>Username</label><input value="admin" autocomplete="username">
<label>Current password</label><input type="password" autocomplete="current-password">
<label>New password</label><input type="password" autocomplete="new-password">
<label>Confirm new password</label><input type="password">
<div class="actions-row"><button type="button">Save changes</button></div>
<p class="muted">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.</p>
</div>
<div class="card g-only">
<h2>DMARC aggregate reports</h2>
<p class="muted">Default <code>rua=</code> for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.</p>
<label>Default report address</label>
<input type="email" value="dmarc@mail.example.org">
<p class="muted">When <code>rua=</code> points at another domain, that hub must publish a report-authorisation record. <a href="dmarc.html">DMARC reports</a> in the panel.</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">mail.example.org._report._dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">v=DMARC1;</span><button type="button" class="copy">Copy</button></div>
<label>Report authorization DNS <span class="st st-ok">ok</span></label>
<p class="muted">Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.</p>
</div>
</div>
</form>
<div class="fill g-only">
<div class="card" id="rate-limits">
<h2>Sending rate limits</h2>
<p class="muted">Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domains page.</p>
<div class="check-cols">
<div class="check-col">
<p class="check-col-title">Level 1 — per client IP</p>
<span class="code">100 messages / 60 seconds</span>
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> / <code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.</p>
</div>
<div class="check-col">
<p class="check-col-title">Level 2 — domain</p>
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.</p>
</div>
<div class="check-col">
<p class="check-col-title">Level 2 — application</p>
<p class="muted">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).</p>
</div>
</div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create administrator — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-setup" data-page="setup" data-nav="setup" data-title="Create administrator" data-auth="1">
<main class="auth">
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
<h1>Create administrator</h1>
<div class="card">
<p class="muted">This one-time link creates the single panel administrator. After you submit, the link stops working for good.</p>
<form action="status.html">
<label>Username</label>
<input autocomplete="username">
<label>Password</label>
<input type="password" autocomplete="new-password">
<label>Confirm password</label>
<input type="password" autocomplete="new-password">
<div class="actions-row"><button type="submit">Create administrator</button></div>
</form>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+276
View File
@@ -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",
'<input class="ctrl" type="radio" name="role" id="role-global" checked>' +
'<input class="ctrl" type="radio" name="role" id="role-domain">' +
'<input class="ctrl" type="radio" name="vp" id="vp-desktop" checked>' +
'<input class="ctrl" type="radio" name="vp" id="vp-phone">' +
'<input class="ctrl" type="radio" name="theme" id="theme-light" checked>' +
'<input class="ctrl" type="radio" name="theme" id="theme-dark">' +
'<input class="ctrl" type="checkbox" id="feat-inbound" checked>' +
'<input class="ctrl" type="checkbox" id="nav-open">' +
'<input class="ctrl" type="radio" name="help" id="help-off" checked>' +
'<input class="ctrl" type="radio" name="help" id="help-index">' +
'<input class="ctrl" type="radio" name="help" id="help-status">' +
'<input class="ctrl" type="radio" name="help" id="help-password">' +
'<input class="ctrl" type="radio" name="help" id="help-dns">' +
'<input class="ctrl" type="radio" name="help" id="help-records">' +
'<input class="ctrl" type="radio" name="help" id="help-dmarc">' +
'<input class="ctrl" type="radio" name="help" id="help-connection">' +
'<input class="ctrl" type="radio" name="help" id="help-apps">' +
'<input class="ctrl" type="radio" name="help" id="help-domain-settings">' +
'<input class="ctrl" type="radio" name="help" id="help-export">'
);
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 =
'<header class="gallery">' +
'<a class="brand-mini" href="index.html">Макеты</a>' +
'<div class="seg"><span>Роль</span>' +
'<label for="role-global">Global</label>' +
'<label for="role-domain">Domain-admin</label></div>' +
'<div class="seg"><span>Ширина</span>' +
'<label for="vp-desktop">Desktop</label>' +
'<label for="vp-phone">Phone</label></div>' +
'<div class="seg"><span>Тема</span>' +
'<label for="theme-light">Light</label>' +
'<label for="theme-dark">Dark</label></div>' +
'<label class="g-only" for="feat-inbound">Inbound</label>' +
'<a href="index.html">Оглавление</a>' +
'<a href="system.html">Система</a>' +
"</header>";
var sprite =
'<svg class="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<symbol id="i-status" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></symbol>' +
'<symbol id="i-domains" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></symbol>' +
'<symbol id="i-deliveries" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></symbol>' +
'<symbol id="i-queue" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></symbol>' +
'<symbol id="i-log" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></symbol>' +
'<symbol id="i-inbound" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></symbol>' +
'<symbol id="i-dmarc" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></symbol>' +
'<symbol id="i-backup" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></symbol>' +
'<symbol id="i-users" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></symbol>' +
'<symbol id="i-help" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></symbol>' +
'<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>' +
'<symbol id="i-account" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></symbol>' +
'<symbol id="i-out" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></symbol>' +
'<symbol id="i-menu" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></symbol>' +
"</svg>";
function icon(id) {
return '<svg class="icon"><use href="#' + id + '"/></svg>';
}
var navHtml =
'<nav class="nav">' +
'<a class="brand" href="status.html"><img src="../selfpost-stamp-compact.svg" width="220" height="100" alt="SelfPost"></a>' +
'<div class="links">' +
'<a class="n-status g-only" href="status.html">' + icon("i-status") + "Status</a>" +
'<a class="n-domains" href="domains.html">' + icon("i-domains") + "Domains</a>" +
'<a class="n-deliveries" href="deliveries.html">' + icon("i-deliveries") + "Deliveries</a>" +
'<a class="n-queue g-only" href="mail-queue.html">' + icon("i-queue") + "Mail queue</a>" +
'<a class="n-log g-only" href="system-log.html">' + icon("i-log") + "System log</a>" +
'<a class="n-inbound g-only in-only" href="inbound.html">' + icon("i-inbound") + "Inbound</a>" +
'<a class="n-dmarc g-only" href="dmarc.html">' + icon("i-dmarc") + 'DMARC <span class="tag future">1.x</span></a>' +
'<a class="n-backup g-only" href="backup.html">' + icon("i-backup") + "Backup</a>" +
'<a class="n-users g-only" href="users.html">' + icon("i-users") + "Users</a>" +
'<a class="n-help" href="help.html">' + icon("i-help") + 'Help <span class="tag future">1.x</span></a>' +
"</div>" +
'<div class="session">' +
'<span class="session-user muted">' + icon("i-account") + "User: admin</span>" +
'<a class="n-settings" href="settings.html">' + icon("i-settings") + "Settings</a>" +
'<a class="btn-ghost" href="login.html">' + icon("i-out") + "Sign out</a>" +
"</div>" +
"</nav>";
var phone =
'<div class="phone-bar">' +
'<label class="nav-burger" for="nav-open" title="Menu">' + icon("i-menu") + "</label>" +
'<img class="phone-mark" src="../selfpost-icon.svg" width="28" height="28" alt="">' +
'<span class="grow phone-title"></span>' +
'<label class="help-link" for="help-index" title="Help">?</label>' +
"</div>";
var help =
'<label class="help-scrim" for="help-off"></label>' +
'<aside class="help-drawer">' +
'<label class="help-close" for="help-off">Close</label>' +
'<article class="help-pane help-pane-index">' +
"<h2>Help</h2>" +
"<p>Short notes for the card you opened — not a second copy of the guide.</p>" +
'<p class="muted">Status</p>' +
'<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>' +
'<p class="muted">Domain</p>' +
'<ul class="toc">' +
'<li><label for="help-password">New application password</label></li>' +
'<li><label for="help-dns">DNS status</label></li>' +
'<li><label for="help-records">DKIM and SPF records</label></li>' +
'<li><label for="help-dmarc">DMARC record</label></li>' +
'<li><label for="help-connection">Connection settings</label></li>' +
'<li><label for="help-apps">Applications</label></li>' +
'<li><label for="help-domain-settings">Domain settings</label></li>' +
'<li><label for="help-export">Export domain</label></li>' +
"</ul></article>" +
'<article class="help-pane help-pane-status">' +
"<h2>Status checks</h2>" +
"<p>The cards keep the readings. This drawer is what used to sit under them as paragraphs.</p>" +
"<h2>Machine</h2>" +
"<p>CPU and memory are the containers readings. Network is a short rate window. These numbers explain load; they do not replace the queue.</p>" +
"<h2>TLS certificate</h2>" +
"<p>Presented on port 465. Issued and renewed outside SelfPost. Warn = expires soon; error = missing, and submission will fail.</p>" +
"<h2>Hostname / reverse DNS</h2>" +
"<p>The hostname must forward to this IP and the PTR must come back to the same name. Set PTR at the provider.</p>" +
"<h2>Mail queue</h2>" +
"<p>Deferred mail is retried on a time schedule (first delay, doubling cap, queue lifetime). There is no “attempt 3 of N”.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-password">' +
"<h2>New application password</h2>" +
"<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is lost, regenerate a new one. The previous password stops working immediately.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-dns">' +
"<h2>DNS status</h2>" +
"<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few minutes — use <em>Re-check</em> after publishing.</p>" +
"<p>SPF is a shallow check: the literal address only, no <code>include:</code> or <code>redirect=</code>. Report authorization is required only when <code>rua=</code> points at a domain this server does not accept.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-records">' +
"<h2>DKIM and SPF records</h2>" +
"<p>DKIM is not a secret. The selector on this page is the one this server signs with. Merge the SPF example into an existing record if the domain already has one — do not publish a second TXT.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-dmarc">' +
"<h2>DMARC record</h2>" +
"<p><code>p=none</code> does not affect delivery. Tighten to <code>p=quarantine</code> then <code>p=reject</code> once reports look clean. The report address is set under Domain settings (or the Settings default).</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-connection">' +
"<h2>Connection settings</h2>" +
"<p>Same host for every domain. Authenticate with an application login from this page. Auth is required on every port. The password is shown once at create or regenerate.</p>" +
"<p>465 is implicit TLS; 587 is STARTTLS submission when that port is enabled.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-apps">' +
"<h2>Applications</h2>" +
"<p>SASL logins for this domain. Login is unique across domains; letters, digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is shown once.</p>" +
"<p>Address mode is which From addresses this application may use: any address of the domain, or a fixed list. A trusted-IP override gives those clients a higher ceiling than the domain (still ≤ level 1) and skips the domain check; everyone else uses the domain limit if set, otherwise level 1.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-domain-settings">' +
"<h2>Domain settings</h2>" +
"<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or you override them per domain. Level 2 is an optional ceiling for all senders on this domain; it must be ≤ level 1. Application overrides live on each application.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-export">' +
"<h2>Export domain</h2>" +
"<p>The file is a secret: it carries the DKIM key and application passwords, so published DNS does not have to change on the other instance. Transfer it securely, or encrypt it as <code>.spde</code>.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
"</aside>";
var app = document.createElement("div");
app.className = "app";
app.innerHTML = navHtml + '<div class="stage">' + phone + "</div>";
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();
})();
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Status — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-status" data-page="status" data-nav="status" data-title="Status" data-global-only="1">
<main class="stack">
<div class="page-head">
<h1>Status</h1>
<label class="help-link" for="help-status" title="What these checks mean">?</label>
</div>
<div class="fill">
<div class="card" id="overall">
<h2>Overall <span class="st st-warn">warn</span></h2>
<p class="muted">Running, with warnings below.</p>
</div>
</div>
<div class="pair">
<div class="card attn" id="queue">
<h2>Mail queue <span class="st st-warn">warn</span></h2>
<p>3 Kbytes in 3 Requests.</p>
<div class="actions-row"><a class="btn" href="mail-queue.html">View queue</a></div>
</div>
<div class="card" id="certificate">
<h2>TLS certificate <span class="st st-ok">ok</span></h2>
<label>Expires</label>
<span class="code">2026-11-02 12:00 UTC</span>
<p class="muted">Valid for another 78 day(s).</p>
</div>
</div>
<div class="pair">
<div class="card" id="sockets">
<h2>Milter sockets <span class="st st-ok">ok</span></h2>
<table>
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>OpenDKIM</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
<tr><td>send-log</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
</tbody>
</table>
</div>
<div class="card" id="hostname">
<h2>Hostname and reverse DNS <span class="st st-ok">ok</span></h2>
<label>Server hostname</label>
<span class="code">mail.example.org</span>
<label>Forward and reverse lookup</label>
<span class="code">203.0.113.10 → mail.example.org</span>
<p class="muted">mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.</p>
<div class="actions-row"><button type="button">Re-check DNS</button></div>
</div>
</div>
<div class="fill in-only" id="inbound-status">
<div class="card">
<h2>Inbound <span class="st st-warn">warn</span></h2>
<p><code>INBOUND_RELAY_ENABLE</code> is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.</p>
<p class="muted">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.</p>
<p><a href="inbound.html">Inbound domains</a></p>
</div>
</div>
<div class="pair">
<div class="card" id="machine">
<h2>Machine <span class="st st-ok">ok</span></h2>
<table>
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr>
<td class="metric">CPU</td>
<td class="metric"><meter value="12" min="0" max="100" low="70" high="90" optimum="10">12%</meter> 12%</td>
<td class="muted">4 cores · 4 threads</td>
</tr>
<tr>
<td class="metric">Memory</td>
<td class="metric"><meter value="41" min="0" max="100" low="70" high="90" optimum="10">41%</meter> 41%</td>
<td class="muted">1.6 GiB used of 4.0 GiB.</td>
</tr>
<tr>
<td class="metric">Network</td>
<td class="metric">↓ 2.0 KiB/s<br>↑ 1.0 KiB/s</td>
<td class="muted"><div>eth0: 1.0 MiB in, 512.0 KiB out</div></td>
</tr>
</tbody>
</table>
</div>
<div class="card" id="processes">
<h2>Processes <span class="st st-ok">ok</span></h2>
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>opendkim</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 21, uptime 3 days, 4:12:01</td></tr>
<tr><td>panel</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 18, uptime 3 days, 4:12:03</td></tr>
<tr><td>postfix</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 42, uptime 3 days, 4:11:58</td></tr>
<tr><td>postfix-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
<tr><td>cert-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
<tr><td>logrotate</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
</tbody>
</table>
</div>
</div>
<div class="fill">
<div class="card" id="configuration">
<h2>Configuration</h2>
<p class="muted">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.</p>
<div class="actions-row"><button type="button">Reload configuration</button></div>
</div>
</div>
<p class="version">SelfPost 1.2.3 · © Mixeme · <a href="#">License (AGPL-3.0)</a></p>
</main>
<script src="shell.js"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>System log — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-system-log" data-page="system-log" data-nav="log" data-title="System log" data-global-only="1">
<main class="stack">
<h1>System log</h1>
<div class="fill">
<div class="card">
<h2>Recent log entries</h2>
<span class="code">Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=&lt;ada@example.net&gt;, 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=&lt;billing@example.com&gt;, 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=&lt;list-bounces@example.net&gt;, 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=&lt;noreply@blocked.example&gt;, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected)</span>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+128
View File
@@ -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; }
+298
View File
@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — система панели</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="doc">
<div class="wrap sys-wrap">
<header>
<p class="eyebrow">система · не рескин</p>
<h1>Self<b>Post</b> — грамматика интерфейса</h1>
<p>Это проект системы. Экраны складываются из регионов и контролов. Запрещённые состояния нельзя «починить паддингом» — их не из чего собрать. Знак, кирпич и IBM Plex не меняются. Живая панель (<code>internal/web</code>) пока не трогается.</p>
<div class="cta-row">
<a class="cta" href="#regions">Регионы</a>
<a class="cta quiet" href="#controls">Контролы</a>
<a class="cta quiet" href="#recipes">Экраны</a>
<a class="cta quiet" href="status.html">Макеты экранов</a>
</div>
<ul class="sys-toc">
<li><a href="#why">Зачем</a></li>
<li><a href="#regions">Регионы</a></li>
<li><a href="#card">Карточка</a></li>
<li><a href="#controls">Контролы</a></li>
<li><a href="#actions">Действия</a></li>
<li><a href="#forbid">Нельзя</a></li>
<li><a href="#recipes">Рецепты экранов</a></li>
<li><a href="#phone">Телефон</a></li>
<li><a href="#apply">Как внедрять</a></li>
</ul>
</header>
<section id="why">
<h2>Зачем</h2>
<p>Первый макет скопировал грамматику живой панели: страница помечается <code>ops</code> или <code>form</code>, карточки стопкой, пара полей — два независимых <code>.code</code>, Save внутри <code>&lt;form&gt;</code>, Delete снаружи. Дальше каждая мелочь чинилась отдельно — как в <code>panel.css</code> годами. Новый интерфейс должен <strong>не давать собрать</strong> пустую колонку, разные высоты Host/Type и кнопки на двух строках.</p>
<p>Три оси, которые больше не выбираются «на глаз» у каждой страницы:</p>
<ol>
<li><strong>Регион</strong> — как блок занимает ширину окна.</li>
<li><strong>Карточка</strong> — заголовок, справка, тело, действия.</li>
<li><strong>Контрол</strong> — одно поле или пара полей одной высоты.</li>
</ol>
</section>
<section id="regions">
<h2>Регионы</h2>
<p>Страница — это <code>stack</code> регионов, не класс на <code>&lt;main&gt;</code>. Оболочка одна: навбар прижат влево, контент забирает остаток. Навбар не прыгает, когда меняется содержимое.</p>
<table class="recipes">
<thead><tr><th>Регион</th><th>Ширина</th><th>Когда</th></tr></thead>
<tbody>
<tr>
<td><code>measure</code></td>
<td>до 42rem, влево</td>
<td>Одна читаемая форма: login, setup, подтверждение удаления, форма пользователя. Не «вся страница Settings».</td>
</tr>
<tr>
<td><code>pair</code></td>
<td>две колонки до 90rem</td>
<td>Две равноправные задачи на одном экране. Один ребёнок — сам сжимается в <code>measure</code> (domain-admin без DMARC).</td>
</tr>
<tr>
<td><code>fill</code></td>
<td>до 90rem</td>
<td>Таблица, лог, DNS-статус, список приложений. Ячейки не переносят однострочные значения; карточка скроллится по X.</td>
</tr>
<tr>
<td><code>stack</code></td>
<td>колонка с зазором</td>
<td>Вертикальный порядок регионов. Зазор даёт <code>gap</code>, не <code>.card + .card</code>.</td>
</tr>
</tbody>
</table>
<figure class="specimen bad">
<figcaption>Нельзя — страница-форма, две задачи стопкой</figcaption>
<div class="measure">
<div class="card">
<h2>Full backup</h2>
<p class="muted">Карточка узкая. Справа пустое поле на 1600px.</p>
<button type="button">Download</button>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Вторая задача под первой — та же пустота.</p>
<button type="button">Import</button>
</div>
</div>
</figure>
<figure class="specimen">
<figcaption>Надо — pair</figcaption>
<div class="pair">
<div class="card">
<h2>Full backup</h2>
<p class="muted">Секрет. Шифрование — внутри карточки, поля не растягиваются на 1440px.</p>
<div class="actions-row"><button type="button">Download full backup</button></div>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Тот же экран, вторая задача. Не «ещё одна форма ниже».</p>
<div class="actions-row"><button type="button">Import domain</button></div>
</div>
</div>
</figure>
</section>
<section id="card">
<h2>Карточка</h2>
<p>Один хром: <code>card-head</code> (заголовок + слот «?»), тело, при необходимости <code>actions-row</code>. Справка — часть хрома, её не расставляют после того, как карточки уже собраны. Нет «?» — слот пустой, заголовки соседних карточек всё равно на одной линии.</p>
<p>Показания (очередь, PTR, CPU) остаются на карточке. В drawer уходит только то, чего на карточке быть не должно: что такое kernel counter, зачем PTR у провайдера, почему пароль показывают один раз.</p>
<figure class="specimen">
<figcaption>Хром карточки</figcaption>
<div class="pair">
<div class="card">
<div class="card-head">
<h2>DNS status <span class="st st-ok">ok</span></h2>
<span class="help-link" title="What these checks mean">?</span>
</div>
<p class="muted">Чтение и Re-check здесь. Абзац «зачем MX» — в справке.</p>
</div>
<div class="card">
<div class="card-head">
<h2>Danger zone</h2>
</div>
<p class="muted">Без «?»: действие очевидное. Карточка всё равно пара к форме, не на всю ширину.</p>
<div class="actions-row"><a class="danger" href="#forbid">Delete inbound domain</a></div>
</div>
</div>
</figure>
</section>
<section id="controls">
<h2>Контролы</h2>
<p>Одно поле — <code>field</code> (подпись + контроль). Два поля в ряд — <code>field-row</code>: подписи в первой сетке-строке, значения во второй. Высота значений общая, потому что это одна строка грида, а не два блока с подобранным padding. Баг «Type ниже Host» (<code>b0ebe06</code>) из этой разметки не собирается.</p>
<p><code>field-row</code> с узкой второй колонкой — Host / name ‖ Type. <code>field-row equal</code> — два равноправных инпута (лимит и окно).</p>
<figure class="specimen">
<figcaption>field-row — Host ‖ Type, с Copy и без</figcaption>
<div class="pair">
<div class="card">
<h2>With Copy</h2>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row">
<span class="code">mail._domainkey.example.com</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
</div>
<div class="card">
<h2>Lookup, no Copy</h2>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">lists.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
</div>
</div>
</figure>
<figure class="specimen">
<figcaption>field-row equal — лимит ‖ окно</figcaption>
<div class="measure">
<div class="card">
<h2>Level-2 rate limit</h2>
<div class="field-row equal">
<div class="field">
<label>Message limit</label>
<input value="40">
</div>
<div class="field">
<label>Window (seconds)</label>
<input value="60">
</div>
</div>
</div>
</div>
</figure>
</section>
<section id="actions">
<h2>Действия</h2>
<p>Primary, secondary и danger — всегда <code>actions-row</code>. Для вёрстки неважно, POST это или переход на confirm: ряд один. Форма либо оборачивает всю карточку, либо кнопки несут <code>form=</code>. Нельзя оставить Submit внутри блочной формы, а Delete — следующим соседом: блок формы занимает строку целиком.</p>
<figure class="specimen">
<figcaption>Save и Delete в одном ряду</figcaption>
<div class="measure">
<div class="card">
<h2>Edit user</h2>
<label>Username</label>
<input value="ops-alerts">
<div class="actions-row">
<button type="button">Save</button>
<a class="danger" href="#forbid">Delete user</a>
</div>
</div>
</div>
</figure>
</section>
<section id="forbid">
<h2>Нельзя</h2>
<p>Если хочется добавить правило «только на этой странице» — сначала проверить, какого региона не хватило.</p>
<ul class="forbid">
<li>Класс на <code>main</code> (<code>ops</code> / <code>form</code>) как способ выбрать ширину. Ширину выбирает регион.</li>
<li>Одиночная карточка на 90rem с двумя полями ввода. Это <code>measure</code> или <code>pair</code>.</li>
<li>Danger zone отдельным <code>fill</code>. Она вторая колонка последней пары (получатели ‖ удалить, экспорт ‖ удалить).</li>
<li>Два <code>.code</code> рядом с разным padding, чтобы «почти совпало». Только <code>field-row</code>.</li>
<li>Submit внутри <code>&lt;form&gt;</code>, danger-ссылка после <code>&lt;/form&gt;</code>.</li>
<li>Перенос однострочного поля, адреса, статуса, hostname. <code>nowrap</code> + горизонтальный скролл карточки.</li>
<li>Status из одних бейджей без Detail / без строки очереди / без PTR.</li>
<li>Телефон как уменьшенный десктоп с шестиколоночной таблицей. Таблица → список; <code>pair</code> → одна колонка; <code>field-row</code> остаётся парой.</li>
<li>Копировать разметку <code>internal/web/view/templates</code> «как есть» в макет. Рецепт экрана — ниже, не шаблон Go.</li>
</ul>
</section>
<section id="recipes">
<h2>Рецепты экранов</h2>
<p>Экраны — отдельные HTML-файлы рядом с этой спецификацией. Собираются только так:</p>
<table class="recipes">
<thead><tr><th>Экран</th><th>Стек регионов</th></tr></thead>
<tbody>
<tr>
<td><a href="login.html">Login</a> / <a href="setup.html">setup</a></td>
<td>центр оболочки, <code>measure</code> 24rem, полный stamp</td>
</tr>
<tr>
<td><a href="status.html">Status</a></td>
<td><code>stack</code>: overall → <code>pair</code> очередь‖TLS → <code>pair</code> milters‖PTR → inbound если есть → <code>pair</code> machine‖processes → configuration. На карточках остаются числа и Detail.</td>
</tr>
<tr>
<td><a href="domains.html">Domains</a> / <a href="inbound.html">Inbound</a> / <a href="deliveries.html">Deliveries</a> / <a href="system-log.html">log</a> / <a href="mail-queue.html">queue</a></td>
<td><code>fill</code> таблица. Добавление домена — поле в той же карточке списка (<code>measure</code> внутри, не вторая карточка и не инпут на 90rem).</td>
</tr>
<tr>
<td><a href="domain.html">Domain</a></td>
<td><code>fill</code> DNS status → <code>pair</code> DKIM/SPF ‖ DMARC → <code>pair</code> connection ‖ add app → <code>fill</code> applications → <code>pair</code> domain settings (две половины) → <code>pair</code> export ‖ danger. «?» на рабочих карточках.</td>
</tr>
<tr>
<td><a href="inbound-domain.html">Inbound domain</a></td>
<td><code>fill</code> MX DNS → <code>pair</code> upstream ‖ MX to publish → <code>pair</code> recipients ‖ danger</td>
</tr>
<tr>
<td><a href="dmarc.html">DMARC</a></td>
<td>hub: <code>pair</code> ingest ‖ this week, затем <code>fill</code> список отчётов. Домен: <a href="dmarc-domain.html">roll-up</a> (<code>pair</code> 7 days ‖ third-party, <code>fill</code> reports + sources). Один XML: <a href="dmarc-report.html">просмотр</a><code>pair</code> report ‖ policy, <code>fill</code> records. Не дашборд, не <code>ruf=</code>.</td>
</tr>
<tr>
<td><a href="backup.html">Backup</a></td>
<td><code>pair</code> full backup ‖ import</td>
</tr>
<tr>
<td><a href="settings.html">Settings</a></td>
<td>global: <code>pair</code> credentials ‖ DMARC, затем <code>fill</code> rate limits. Domain-admin: один ребёнок в <code>pair</code> → сам <code>measure</code></td>
</tr>
<tr>
<td><a href="users.html">Users</a></td>
<td><code>fill</code> таблица. <a href="user-form.html">Create/Edit</a><code>measure</code> + <code>actions-row</code></td>
</tr>
<tr>
<td><a href="domain-delete.html">Confirm delete</a></td>
<td><code>measure</code> одна карточка</td>
</tr>
</tbody>
</table>
</section>
<section id="phone">
<h2>Телефон</h2>
<p>Аварийный доступ, не продукт. <code>pair</code> складывается в одну колонку. Таблица заменяется списком (как сейчас в прототипе). <code>field-row</code> не складывается: Type — токен. Навбар — выезжающая колонка на checkbox, без обязательного JS. Знак в шапке — SP-иконка, не второй wordmark.</p>
</section>
<section id="apply" style="border-bottom:none">
<h2>Как внедрять</h2>
<ol>
<li>Новый экран или правка макета — только классы из <code>system.css</code> (<code>stack</code>, <code>pair</code>, <code>measure</code>, <code>fill</code>, <code>field-row</code>, <code>actions-row</code>).</li>
<li>Не добавлять исключения в <code>mock.css</code> «для этой страницы», если это ширина, высота пары полей или ряд кнопок.</li>
<li>Click-through — отдельные страницы (<code>status.html</code>, <code>domain.html</code>, …), не простыня с якорями. Оболочка общая: <code>shell.js</code>.</li>
<li>Вёрстка <code>internal/web</code> — отдельная задача после утверждения системы, не параллельный рескин шаблонов.</li>
</ol>
<p class="muted">Классы живут в <code>docs/assets/panel-ui/system.css</code>. Этот файл — спецификация. Экраны — <a href="status.html">status.html</a> и соседние страницы; оглавление — <a href="index.html">index.html</a>.</p>
</section>
</div>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete ops-alerts — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-user-delete" data-page="user-delete" data-nav="users" data-title="Delete user" data-global-only="1">
<main class="stack">
<div>
<h1>Delete ops-alerts</h1>
<a class="back" href="user-form.html">&larr; Back to ops-alerts</a>
</div>
<div class="measure">
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete the panel user <strong>ops-alerts</strong>. A signed-in session for this user stops working immediately.</p>
<div class="actions-row"><button type="button" class="danger">Delete ops-alerts</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Edit user — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-user-form" data-page="user-form" data-nav="users" data-title="Edit user" data-global-only="1">
<main class="stack">
<div>
<h1>Edit user</h1>
<a class="back" href="users.html">&larr; Back to users</a>
</div>
<div class="measure">
<div class="card">
<form action="#" onsubmit="return false">
<label>Username</label>
<input value="ops-alerts">
<label>Password (leave empty to keep)</label>
<input type="password">
<label>Role</label>
<select><option selected>Domain administrator</option><option>Global administrator</option></select>
<fieldset>
<legend>Assigned domains</legend>
<p class="muted">Required for domain administrators.</p>
<label class="check"><input type="checkbox"> example.com</label>
<label class="check"><input type="checkbox" checked> alerts.example.com</label>
</fieldset>
<div class="actions-row">
<button type="button">Save</button>
<a class="danger" href="user-delete.html">Delete user</a>
</div>
</form>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Users — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-users" data-page="users" data-nav="users" data-title="Users" data-global-only="1">
<main class="stack">
<h1>Users</h1>
<div class="fill">
<div class="card">
<p><a href="user-form.html">Create user</a></p>
<table>
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
<tbody>
<tr><td>admin</td><td>Global</td><td class="muted">All</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
<tr><td>ops-alerts</td><td>Domain admin</td><td class="muted">alerts.example.com</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
</tbody>
</table>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+74 -1
View File
@@ -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.4.0`; 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 ## Technology stack and tools
| Component | Version / notes | | Component | Version / notes |
@@ -220,6 +272,27 @@ GHCR package versions, not the git tag.
Push workflow and source changes to **github.com/mixeme/selfpost** before Push workflow and source changes to **github.com/mixeme/selfpost** before
publishing — Actions reads that repo, not Gitea. 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 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 must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
CHANGELOG sections (`0.2.0``0.6.0`) record development history before that cut. CHANGELOG sections (`0.2.0``0.6.0`) record development history before that cut.
@@ -324,7 +397,7 @@ prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64 → push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge → merge
→ docker buildx imagetools create → unified manifest X.Y.Z → docker buildx imagetools create → unified manifest X.Y.Z
imagetools rm → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR 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 Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
+126 -48
View File
@@ -32,6 +32,7 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop) - [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
- [Full backup and restore](#full-backup-and-restore) - [Full backup and restore](#full-backup-and-restore)
- [Encrypting a backup or export](#encrypting-a-backup-or-export) - [Encrypting a backup or export](#encrypting-a-backup-or-export)
- [Inbound relay](#inbound-relay)
- [Domain administration](#domain-administration) - [Domain administration](#domain-administration)
- [Domains page](#domains-page) - [Domains page](#domains-page)
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc) - [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
@@ -44,10 +45,13 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
### Ports ### Ports
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465 `deploy/docker-compose.yml` maps **465**, **587**, and **25** to the host. Port
(smtps) is always active. Port **587** is published even when 465 (smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless, `SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port
but it can look like an open port in external scans. **25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
accept inbound mail until you set it to `true` (see [Inbound
relay](#inbound-relay)). Harmless extra publishes can look like open ports in
external scans.
### Local trial ### Local trial
@@ -165,7 +169,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `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 deliberately never `:latest`. The current pin is `1.4.0`. Intermediate
CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that 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 image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
@@ -185,6 +189,11 @@ expected to set; defaults match the code exactly.
|---|---|---|---| |---|---|---|---|
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` | | `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` | | `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound listener only (not 465/587). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` |
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` |
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). | `26214400` (25 MiB) | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` | | `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` | | `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.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` |
@@ -313,13 +322,19 @@ against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS) — see (forward-confirmed reverse DNS) — see
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing [Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
Postfix sender map from the database — use it if daemons drifted from what Postfix sender map from the database (and inbound relay maps when
the panel shows after manual edits under `/data`. `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel
shows after manual edits under `/data`.
### Mail queue and System log ### Mail queue and System log
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still - **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 - **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
related daemon lines). The log rotates daily (14 files kept) with a related daemon lines). The log rotates daily (14 files kept) with a
`postfix reload` after each rotation; a background loop checks every six `postfix reload` after each rotation; a background loop checks every six
@@ -341,12 +356,13 @@ are separate and are not changed here.
There are two roles: There are two roles:
- **Global administrator** — full access to every page and every domain, - **Global administrator** — full access to every page and every domain,
including Users, Backup, Status, Mail queue, and System log. including Users, Backup, Status, Mail queue, System log, and Inbound (when
the inbound relay flag is on).
- **Domain-admin** — scoped to one or more domains assigned by a global - **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and administrator. Sees only those domains' pages, applications, and
Deliveries rows; cannot add or delete domains. `/users`, `/backup`, Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not `/status`, `/mail-queue`, `/system-log`, `/inbound`, and `POST /reload` are
reachable (404). A domain-admin can *export* the not reachable (404). A domain-admin can *export* the
domains assigned to them — see domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain). [Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
@@ -417,25 +433,32 @@ journal-milter (level 2) is down. There is no per-IP bypass.
### Full backup and restore ### Full backup and restore
**Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys, **Full backup** is a self-contained project archive: `data/` (SQLite, all
all applications' SASL credentials, `manifest.json` with the version that domains' DKIM keys, all applications' SASL credentials, the Postfix queue,
created it): panel button (*Backup* → *Full backup*), or from the host: `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.
Take a backup from the panel (*Backup* → *Full backup*) or from the host:
```sh ```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz docker exec <container> selfpost-backup > selfpost-backup.tar.gz
``` ```
**Restore** means unpacking that archive into a fresh `/data` bind mount and **Restore** means unpacking that archive into an **empty project directory**
starting a container of the **exact same image version** that created it — (not into `./data` alone) and starting a container of the **exact same image
SelfPost refuses to start otherwise and tells you which tag to use. On the version** that created it — SelfPost refuses to start otherwise and tells you
first successful start after restore, `manifest.json` from the archive is which tag to use. On the first successful start after restore, `data/manifest.json`
**deleted** — it guards only that one boot, so a later in-place upgrade is from the archive is **deleted** — it guards only that one boot, so a later
not blocked. On that same first boot the panel also runs one **Resync** in-place upgrade is not blocked. On that same first boot the panel also runs
OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and both one **Resync**OpenDKIM's tables and Postfix's sender map are re-derived from
SQLite (and inbound relay maps when `INBOUND_RELAY_ENABLE=true`) and both
daemons are reloaded, healing any drift between the extracted files and the daemons are reloaded, healing any drift between the extracted files and the
database (the Status page's *Reload configuration* button runs the same step database (the Status page's *Reload configuration* button runs the same step on
on demand). This is why the compose file pins a fixed tag rather than demand). This is why the compose file pins a fixed tag rather
`:latest`: without a known version, there'd be no way to tell which image than `:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires (see [Fixed image restoring a given backup actually requires (see [Fixed image
tag](#fixed-image-tag)). tag](#fixed-image-tag)).
@@ -446,16 +469,16 @@ back after a bad change):
# 1. Stop the instance being replaced # 1. Stop the instance being replaced
docker compose down docker compose down
# 2. Move the current /data aside rather than deleting it, start from empty # 2. Move the current project aside rather than deleting it
mv ./data ./data.before-restore mv . ../selfpost.before-restore
mkdir ./data mkdir selfpost && cd selfpost
# 3. Unpack the backup into the fresh directory # 3. Unpack the backup into the fresh directory
tar xzf selfpost-backup.tar.gz -C ./data tar xzf ../selfpost-backup.tar.gz
# 4. docker-compose.yml must already pin the exact tag the backup was made # 4. docker-compose.yml in the archive must pin the exact tag the backup was
# with — check the archive's manifest if unsure: # made with — check if unsure:
tar xzf selfpost-backup.tar.gz -O manifest.json tar xzf ../selfpost-backup.tar.gz -O data/manifest.json
# 5. Start it and watch the boot # 5. Start it and watch the boot
docker compose up -d docker compose up -d
@@ -466,35 +489,38 @@ 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.: 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.2.5 — restore into the matching image (selfpost:1.2.3) backup: this backup was created by SelfPost 1.2.3 but this image is 1.4.0 — restore into the matching image (selfpost:1.2.3)
``` ```
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up 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 -d` again — the manifest is still there because the failed boot never got to
delete it. delete it.
**Moving to a different host** is the same five steps, just starting cold: **Moving to a different host** is the same flow: create an empty project
bring the compose files and the correct pinned image tag to the new host directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if
(step 1 of [Full deployment](#full-deployment)), put the backup archive in needed) for the new hostname or proxy, then `docker compose up -d`. The archive
place of step 3 above, then redo the reverse-proxy/TLS and DNS steps of a carries `certs/` from the old host — re-issue certificates when the hostname or
[Full deployment](#full-deployment) — the PTR record and the certificate both IP changes. Set up the reverse-proxy vhost separately (not in the backup).
belong to the old IP/host and have to be reissued for the new one; nothing in
the backup carries them.
**Restoring an encrypted (`.spbk`) backup** needs a running container to **Restoring an encrypted (`.spbk`) backup** needs a running container to
decrypt it first — any container with the `selfpost-backup` CLI works; decryption decrypt it first — any container with the `selfpost-backup` CLI works; decryption
does not read `/data` and performs no version check. Start one normally does not read `/data` and performs no version check. Start one normally
(step 5, but on an empty `/data` you haven't unpacked yet), then: (step 5, but on an empty project you have not unpacked yet), then:
```sh ```sh
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
``` ```
Stop it, wipe `/data` again, and continue from step 2 above with the Stop it, wipe the project directory again, and continue from step 2 above with
resulting `.tar.gz` — see [Encrypting a backup or the resulting `.tar.gz` — see [Encrypting a backup or
export](#encrypting-a-backup-or-export) for the decrypt command's password export](#encrypting-a-backup-or-export) for the decrypt command's password
options. options.
**Archives from older SelfPost versions** (flat layout: `manifest.json` and
`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 Restoring an archive taken **before** a session row was removed can bring
that session back: session rows travel with the backup, and a browser that that session back: session rows travel with the backup, and a browser that
still holds the matching cookie is signed in again on the next request if the still holds the matching cookie is signed in again on the next request if the
@@ -520,9 +546,9 @@ domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
operation that also lives on the *Backup* page (`/backup`). operation that also lives on the *Backup* page (`/backup`).
Both a full backup and a domain export are **secrets** — they contain the Both a full backup and a domain export are **secrets** — they contain the
admin password hash (full backup) or working application credentials (domain admin password hash (full backup), TLS private keys and `.env` (full backup),
export) in the clear or in directly reversible form. Treat them like any or working application credentials (domain export) in the clear or in
other credential material: restrict who can read them, don't email them 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. around — and encrypt them, which SelfPost can do for you.
#### Encrypting a backup or export #### Encrypting a backup or export
@@ -565,6 +591,53 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > back
With no password set, the CLI keeps writing the plain `.tar.gz` it always has. With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
### Inbound relay
Optional backup-MX / forwarder: Postfix accepts mail on port **25** for
domains you list under *Inbound* and hands each message to the upstream host
you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never
stores the message locally.
**Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the
container. Until then there is no `smtp inet` listener, no Inbound item in
the nav, and `/inbound` is 404. Outbound 465/587 is unchanged.
**Panel** (`/inbound`, global administrator only): add a domain, set the
upstream host/port and TLS to that hop (opportunistic / required / off), and
choose recipients — an allow-list, or any address at that domain. A domain
with an empty upstream is kept in the database but is **not** published into
Postfix maps, so mail is never accepted with nowhere to send it.
**DNS.** Unlike sending domains, an inbound domain needs an **MX** record that
points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish
(`10 <hostname>.`) and a check that succeeds when *any* MX host matches this
server — other MX targets (a primary mail server) are fine; this is how
backup-MX is meant to work. Use *Re-check* after publishing.
**Not an open relay.** The inbound smtpd offers no SASL. It accepts only
domains in `relay_domains` and only listed recipients (`relay_recipient_maps`);
everything else is `reject_unauth_destination` / `reject_unlisted_recipient`.
Prefer an explicit recipient list so unknown addresses are refused at RCPT
and never generate a bounce (backscatter).
**Anti-spam.** SelfPost does not ship a filter. To attach one, set
`INBOUND_ANTISPAM_MILTER` (inbound listener only) and merge
[deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml)
the same way as the nginx/Caddy fragments:
```sh
docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
```
The milter sees the real client IP, HELO and PTR — unlike the upstream, which
only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
defer instead.
Inbound configuration lives in SQLite and `/data/postfix/` map files, so it
is included in a [full backup](#full-backup-and-restore). Single-domain
export/import is sending domains only.
## Domain administration ## Domain administration
### Domains page ### Domains page
@@ -581,7 +654,9 @@ domain](#exporting-and-importing-a-single-domain).
### Domain-level DNS (SPF, DKIM, DMARC) ### Domain-level DNS (SPF, DKIM, DMARC)
For *every* sending domain you add in the panel: For *every* sending domain you add in the panel (outbound). An inbound
forwarding domain is a different object — it needs an MX, not these TXT
records; see [Inbound relay](#inbound-relay).
- **SPF** — a TXT record on the domain authorizing this server to send on its - **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup). behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
@@ -659,8 +734,11 @@ by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
*Details* opens that row's own page (`/deliveries/{id}`). That page carries *Details* opens that row's own page (`/deliveries/{id}`). That page carries
the sending domain, the application it was submitted under, the Postfix the sending domain, the application it was submitted under, the Postfix
queue id and the journal id, beside the message's history — when it was queue id and the journal id, beside the message's history — when it was
accepted and what Postfix later reported for the recipient — and, under accepted and what Postfix later reported for the recipient. A `deferred`
both, the `mail.log` lines for its queue id: the connection to the 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 receiving server, the server's reply, and the status that reply was filed
as. Rows outlive `mail.log`, so an older message's lines may have rotated as. Rows outlive `mail.log`, so an older message's lines may have rotated
away; the page says so. Retention is controlled by away; the page says so. Retention is controlled by
+15
View File
@@ -48,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). - Attack surface of accepting mail (mitigate: strict recipient allow-list).
- Report volume and retention (mitigate: caps + pruning). - 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**
+205
View File
@@ -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 &lt; 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.55.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 &gt; 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**
+26 -8
View File
@@ -1,13 +1,9 @@
# Plan: inbound-relay (inbound relay) # Plan: inbound-relay (inbound relay)
**Status:** agreed **Status:** done — shipped in `[1.4.0]` (2026-08-17)
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once **Version:** 1.4.0 MINOR (flag off is compatible; not a 2.x break).
the implementation lands (do not fix a major in advance). **Order:** after queue-retries `[1.3.1]`. One checklist step remains:
**Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG security review of the inbound path (Fable).
`[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
The domain-admin role
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
`[1.2.0]`.
--- ---
@@ -169,3 +165,25 @@ the SelfPost image, brought up by the operator when the option is enabled.
A finished outbound path (already implemented). Agreement obtained — see the A finished outbound path (already implemented). Agreement obtained — see the
status above. 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).
- [x] Migration: inbound domain / recipient / transport tables under `/data` — **Opus**
- [x] `INBOUND_RELAY_ENABLE` (default false) in entrypoint + `postfix-config.sh` — **Opus**
- [x] `master.cf`: inbound `smtp inet` on 25; separate from 465/587 — **Opus**
- [x] Generate `relay_domains`, `transport_maps`, `relay_recipient_maps` (injection-safe) — **Opus**
- [x] `smtpd_relay_restrictions` / recipient maps — no open relay, no backscatter — **Opus**
- [x] `internal/store` CRUD + validation (domain, host, port) — **Opus**
- [x] Panel: list, add, domain detail, recipients, danger zone (mockups) — **Sonnet**
- [x] Rate limit + `message_size_limit` on inbound smtpd — **Opus**
- [x] Optional `INBOUND_ANTISPAM_MILTER` + compose fragment — **Opus**
- [x] DNS MX copy in README/guide; `.env.example` — **Sonnet**
- [x] Backup/export inbound config (per plan optional flag) — **Opus**
- [x] Unit + handler tests; image build and container smoke — **Opus**
- [x] [guide.md](../guide.md) and [security.md](../security.md) — **Sonnet**
- [ ] Security review inbound path — **Fable**
- [x] `go vet`, `go test`, e2e if applicable — **Haiku**
+54
View File
@@ -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**
+16 -1
View File
@@ -1,6 +1,6 @@
# Plan: queue-retries (Postfix retry policy in the panel) # Plan: queue-retries (Postfix retry policy in the panel)
**Status:** agreed **Status:** done — shipped in `[1.3.1]` (2026-08-17)
**Date:** 2026-08-13 **Date:** 2026-08-13
**Version:** patch; no schema, no configuration surface. **Version:** patch; no schema, no configuration surface.
**Order:** small panel item; does not wait on inbound-relay. **Order:** small panel item; does not wait on inbound-relay.
@@ -139,3 +139,18 @@ lifetime runs out.
- Showing compiled-in fallbacks when `postconf` failed would mislead if the - Showing compiled-in fallbacks when `postconf` failed would mislead if the
operator had overridden them — mitigate with the muted note on the card. 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. - 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**
+139
View File
@@ -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 **7365** (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 &lt; 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 (7365).
- 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` (7365) — **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**
+9 -6
View File
@@ -50,16 +50,19 @@ Explicitly excluded to prevent scope creep:
- Organisations / tenancy beyond global + domain-admin roles; managing - Organisations / tenancy beyond global + domain-admin roles; managing
**multiple sending domains** under one global administrator is in scope (see **multiple sending domains** under one global administrator is in scope (see
below) below)
- Inbound antispam/antivirus (rspamd, ClamAV, etc.) - Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
expose a milter hook; it does not ship or start a filter
- A custom MTA — Postfix is used as-is - A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only - Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
The **domain-admin** role ships in the current line (global administrator plus The **domain-admin** role ships in the current line (global administrator plus
domain administrators with assigned domains). The optional **inbound relay** is domain administrators with assigned domains). The optional **inbound relay**
the main agreed **1.x+** extension still on the (backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major `INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. Items
remains possible pending implementation. Items marked *candidate* in the marked *candidate* in the
roadmap require explicit approval before coding. [roadmap](roadmap.md) 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).
--- ---
+41 -42
View File
@@ -26,66 +26,65 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
## Index ## Index
| ID | Topic | Status | Plan | | ID | Topic | Status | Progress | Plan |
|---|---|---|---| |---|---|---|---|---|
| queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | [plans/queue-retries.md](plans/queue-retries.md) | | send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) |
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.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 | — | | contributing | `CONTRIBUTING.md` | candidate | — | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | | 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 | | | 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** | — | | schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): **queue-retries** is a small panel item **Recommended order** (not binding): the next feature is
that can land first or in parallel; the next feature is **inbound-relay**. **send-log-retention** once agreed. It can land before or beside
The 2026-08-13 full-tree review follow-ups (send-log authorization, **domain-stats-auto-ratelimit** (panel retention ≥ 30 days helps the stats
fail-closed paths, docs, GUI, tests, licence) are closed — history in window). inbound-relay shipped in
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]` and git. Candidates need [CHANGELOG.md](../CHANGELOG.md) `[1.4.0]`; queue-retries in `[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. explicit agreement before they join the queue.
After a context reset, pick an item marked `agreed` or `in progress`, then work 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).
--- ---
## queue-retries ## send-log-retention
**Goal:** show on Mail queue and on a delivery's history how this Postfix **Goal:** global administrator sets how many days of delivery journal rows
retries deferred mail — first delay, backoff cap, queue lifetime — reading (`send_log`, `/deliveries`) are kept, from `/settings` — stored in SQLite
the effective config (`postconf -h`) once at panel start so a manual `settings`, with `SEND_LOG_RETENTION_DAYS` as bootstrap default only.
override is visible.
**Boundary:** explanation only. Postfix stays as-is; no attempt counter, no **Boundary:** instance-wide retention; not per-domain. Does not change
panel knobs for queue lifetime, no schema change. Domain administrators see `mail.log` rotation. Today retention is env-only (default 90 days); the panel
the intervals on `/deliveries/{id}` (they cannot open Mail queue). has no control.
**Done when:** see the criteria in **Done when:** see [plans/send-log-retention.md](plans/send-log-retention.md).
[plans/queue-retries.md](plans/queue-retries.md).
**Dependencies / risks:** `postconf` unavailable outside the container **Dependencies / risks:** log-tailer must re-read settings each prune cycle;
(fallback + muted note). Copy must stay time-based — Postfix has no max delivery UI must stop hardcoding «ninety days».
attempt count. **Version:** `1.x` MINOR; `candidate` until explicitly agreed.
**Version:** patch.
--- ---
## inbound-relay ## domain-stats-auto-ratelimit
**Goal:** optional acceptance of mail on port 25 for explicitly configured **Goal:** 30-day sending statistics per domain and application (total, peak and
domains, forwarded to an upstream (backup-MX / relay-forwarder). Off by default average msg/h) on the domain page, plus optional **auto** level-2 rate limits
(`INBOUND_RELAY_ENABLE=false`); without the flag the outbound path is computed as `ceil(avg_hourly × multiplier)` over the level-1 window.
unchanged.
**Boundary:** an extension of v1.0 — [product.md](product.md) excludes inbound **Boundary:** extension of v1.0 manual L2 limits; does not change Postfix
mail and mailboxes. This is relay/forward, not IMAP/POP3/webmail; an anti-spam level 1. Stats exclude level-1 refusals (not in send log). Auto with zero
engine stays outside the image, only the attachment point is provided. traffic stays inactive.
**Done when:** see the criteria in **Done when:** see
[plans/inbound-relay.md](plans/inbound-relay.md). [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md).
**Dependencies / risks:** a finished outbound path; open relay and backscatter; **Dependencies / risks:** send log data and preferably
a wider attack surface (port 25 accepting mail). [send-log-retention](#send-log-retention) ≥ 30 days; aggregate query cost on
**Version:** target bump `1.x`; `2.x` possible — to be settled once the large logs; rate-limit path needs security review.
implementation lands. **Version:** `1.x` MINOR; `candidate` until explicitly agreed.
--- ---
+18 -6
View File
@@ -10,7 +10,9 @@ login in the `saslpasswd2` argv
2026-08-13 full-tree review against this document — send-log authorization for 2026-08-13 full-tree review against this document — send-log authorization for
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
session creation, and application-delete ordering: no findings, and nothing session creation, and application-delete ordering: no findings, and nothing
needed adding to the accepted risks. (2) **Accepted risks** needed adding to the accepted risks. The inbound-relay path (port 25, maps,
panel) is described under Mail path below and is **not yet** in that review
history — it awaits a separate Fable pass before 1.4.0. (2) **Accepted risks**
deliberate departures beyond the mandatory, recorded so the decision is not deliberate departures beyond the mandatory, recorded so the decision is not
lost. lost.
@@ -77,8 +79,17 @@ The panel is exposed to the internet — the items below are **not optional**.
### Mail path (security-relevant) ### Mail path (security-relevant)
- **Not an open relay** — SASL only; `reject_unauth_destination`; - **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. `smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
open relay either: SASL is off; `smtpd_relay_restrictions` /
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
`reject_unlisted_recipient`; maps list only configured domains and
recipients. Domains with no upstream host are omitted from the maps so mail
is never accepted with nowhere to send it. Prefer recipient mode `list` to
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
on inbound. An optional antispam milter is inbound-only; default action is
fail-open (`accept`).
- TLS is mandatory before credentials are transmitted (465 wrapper / 587 - TLS is mandatory before credentials are transmitted (465 wrapper / 587
`encrypt`). `encrypt`).
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply - `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
@@ -86,10 +97,11 @@ The panel is exposed to the internet — the items below are **not optional**.
### Backup and domain export ### Backup and domain export
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the - Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
administrator's password hash; a domain export carries the DKIM key and administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
**working** application passwords in the clear (otherwise a transfer without private key from `certs/` when present; a domain export carries the DKIM key
recreating credentials would be impossible). and **working** application passwords in the clear (otherwise a transfer
without recreating credentials would be impossible).
- Both downloads can be encrypted with a password (a checkbox on the form): - Both downloads 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 scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
authenticated with the header, the chunk number, and an end-of-stream flag — authenticated with the header, the chunk number, and an end-of-stream flag —
+116 -46
View File
@@ -1,15 +1,17 @@
// Package backup implements SelfPost's full-server backup and the restore // Package backup implements SelfPost's full-server backup and the restore
// version guard (architecture.md § Persistence). A full backup is a // version guard (architecture.md § Persistence). A full backup is a
// gzip-compressed tar of the consolidated persistent state under /data — the // gzip-compressed tar of the consolidated persistent state under /data — the
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the // SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
// SASL database — plus a manifest recording the SelfPost version that produced // SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix // certs/ from the operator's deploy directory, and a manifest recording the
// queue are deliberately excluded (architecture.md § Persistence). // SelfPost version that produced it. Postfix delivery logs under log/ are
// excluded (diagnostics, not state).
// //
// Restore is not a separate code path in the panel: a backup is extracted into // Restore is not a separate code path in the panel: a backup is extracted into
// the /data bind mount before first start, and the archive already carries // the operator's project directory (data/, docker-compose.yml, .env, certs/)
// everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender // before first start. The archive carries everything needed to bring the
// map — so the daemons can start on the extracted files alone. 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 // restore-specific steps the panel runs are CheckRestore, which refuses to
// boot if the manifest's version does not match the running binary so // boot if the manifest's version does not match the running binary so
// schema/format skew between versions cannot silently corrupt state // schema/format skew between versions cannot silently corrupt state
@@ -40,11 +42,21 @@ import (
// FormatFull identifies a full-server backup manifest. // FormatFull identifies a full-server backup manifest.
const FormatFull = "selfpost-full-backup" const FormatFull = "selfpost-full-backup"
// ManifestName is the manifest's filename, both inside the archive and, after a // ManifestName is the manifest's filename inside the data directory. After
// restore extraction, at the root of the data directory where CheckRestore // restore extraction it lives at data/manifest.json under the project root;
// looks for it. // CheckRestore reads it from the /data bind mount.
const ManifestName = "manifest.json" 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 // 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 // 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 // boot a data directory whose manifest version does not match its own binary
@@ -58,21 +70,23 @@ type Manifest struct {
// Params configures a backup. DataDir is the consolidated state root (/data); // Params configures a backup. DataDir is the consolidated state root (/data);
// DBPath is the live SQLite file within it, snapshotted consistently rather than // 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 // 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 { type Params struct {
DataDir string DataDir string
DBPath string DBPath string
Version string Version string
DeployRoot string
OnWarn func(string)
} }
// excludedFromArchive lists the data-directory entries a backup never carries. // excludedFromArchive lists the data-directory entries a backup never carries.
// The live database files are replaced by a consistent VACUUM INTO snapshot // The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap // 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 // 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 // fresh one is written instead); a "tls" directory under /data is skipped when
// certificates, which are explicitly out of scope for a SelfPost backup // an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// (architecture.md § Persistence) — excluding it keeps that guarantee even when
// an operator points TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// delivery log plus its fourteen rotated files, which is diagnostic output, not // delivery log plus its fourteen rotated files, which is diagnostic output, not
// state to restore, and by far the largest thing under /data. // state to restore, and by far the largest thing under /data.
var excludedFromArchive = map[string]bool{ var excludedFromArchive = map[string]bool{
@@ -86,16 +100,22 @@ var excludedFromArchive = map[string]bool{
ManifestName: true, ManifestName: true,
} }
// Create writes a gzip-compressed tar backup to w. Archive entries are named // Create writes a gzip-compressed tar backup to w. Archive layout:
// relative to DataDir, so extracting the archive into the /data bind mount //
// reconstructs the state in place (architecture.md § Persistence). The SQLite // data/manifest.json, data/selfpost.db, data/<rest of /data>
// database is added as a consistent snapshot under "selfpost.db"; everything // docker-compose.yml, .env, certs/...
// else under DataDir is copied as-is except the entries in //
// excludedFromArchive. // Extract the archive into an empty project directory, then docker compose up.
func Create(w io.Writer, p Params) error { func Create(w io.Writer, p Params) error {
if p.DataDir == "" || p.DBPath == "" { if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required") 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) snapshot, cleanup, err := snapshotDB(p.DBPath)
if err != nil { if err != nil {
@@ -115,17 +135,21 @@ func Create(w io.Writer, p Params) error {
if err != nil { if err != nil {
return fmt.Errorf("backup: encode manifest: %w", err) 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 return err
} }
// The consistent SQLite snapshot, under the canonical filename the panel // 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). // 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 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 return err
} }
@@ -138,33 +162,79 @@ func Create(w io.Writer, p Params) error {
return nil return nil
} }
// addTree walks dataDir and adds every regular file (and directory, to preserve // ValidateDeployRoot checks that the operator project directory is mounted and
// empty ones and modes) to tw under its path relative to dataDir, skipping the // contains the files a full backup requires. Call before streaming a response.
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are func ValidateDeployRoot(deployRoot string) error {
// skipped: /data holds none in normal operation, and copying them into a backup if deployRoot == "" {
// would be meaningless or unsafe. return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
func addTree(tw *tar.Writer, dataDir string) error { }
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error { 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 { if err != nil {
return err return err
} }
rel, err := filepath.Rel(dataDir, path) rel, err := filepath.Rel(root, path)
if err != nil { if err != nil {
return err return err
} }
if rel == "." { 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 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() info, err := d.Info()
if err != nil { if err != nil {
@@ -182,7 +252,7 @@ func addTree(tw *tar.Writer, dataDir string) error {
case info.Mode().IsRegular(): case info.Mode().IsRegular():
return writeTarFile(tw, name, info.Mode().Perm(), path) return writeTarFile(tw, name, info.Mode().Perm(), path)
default: default:
return nil // skip symlinks/sockets/devices return nil
} }
}) })
} }
+41 -15
View File
@@ -15,10 +15,15 @@ import (
) )
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the // seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
// DKIM key, SASL and transient files a backup must include or exclude. // DKIM key, SASL and transient files a backup must include or exclude. It also
func seedDataDir(t *testing.T) (dataDir, dbPath string) { // seeds a deploy root beside data/ with compose, .env, and certs/.
func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
t.Helper() 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") dbPath = filepath.Join(dataDir, "selfpost.db")
st, err := store.Open(dbPath) 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, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB") 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", "sender_login_maps"), "@example.com login")
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
// Transient files that must NOT be archived. // Transient files that must NOT be archived.
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token") writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal") 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. // 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=<a@example.net>, status=sent (ok)") writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older") 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) { 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) { func TestCreateIncludesStateExcludesTransient(t *testing.T) {
dataDir, dbPath := seedDataDir(t) dataDir, dbPath, deployRoot := seedDataDir(t)
var buf bytes.Buffer 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) t.Fatalf("Create: %v", err)
} }
files := readArchive(t, buf.Bytes()) files := readArchive(t, buf.Bytes())
// Present. // Present.
for _, name := range []string{ for _, name := range []string{
ManifestName, DataArchivePrefix + ManifestName,
"selfpost.db", DataArchivePrefix + "selfpost.db",
"opendkim/keys/example.com/selfpost.private", DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
"sasl/sasldb2", DataArchivePrefix + "sasl/sasldb2",
"postfix/sender_login_maps", DataArchivePrefix + "postfix/sender_login_maps",
DataArchivePrefix + "postfix/queue/deferred/sample",
ComposeFileName,
EnvFileName,
CertsDirName + "/fullchain.pem",
CertsDirName + "/privkey.pem",
} { } {
if _, ok := files[name]; !ok { if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name) t.Errorf("archive missing %s", name)
@@ -109,8 +124,11 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
} }
// Excluded. // Excluded.
for _, name := range []string{ for _, name := range []string{
"setup-token", "selfpost.db-wal", "selfpost.db-shm", DataArchivePrefix + "setup-token",
"log/mail.log", "log/mail.log.1", DataArchivePrefix + "selfpost.db-wal",
DataArchivePrefix + "selfpost.db-shm",
DataArchivePrefix + "log/mail.log",
DataArchivePrefix + "log/mail.log.1",
} { } {
if _, ok := files[name]; ok { if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name) 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. // Manifest is well-formed and carries the version.
var m Manifest 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) t.Fatalf("manifest json: %v", err)
} }
if m.Format != FormatFull || m.Version != "1.2.3" { 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. // The archived selfpost.db is a real, openable SQLite snapshot with our data.
snapPath := filepath.Join(t.TempDir(), "restored.db") 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) t.Fatalf("write snapshot: %v", err)
} }
st, err := store.Open(snapPath) 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 { func writeManifest(t *testing.T, dir, format, version string) string {
t.Helper() t.Helper()
path := filepath.Join(dir, ManifestName) path := filepath.Join(dir, ManifestName)
+71
View File
@@ -13,6 +13,7 @@ package dnscheck
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net" "net"
"strings" "strings"
"sync" "sync"
@@ -98,6 +99,7 @@ type Checker struct {
mu sync.Mutex mu sync.Mutex
servers map[string]cached[Server] servers map[string]cached[Server]
domains map[string]cached[Domain] domains map[string]cached[Domain]
inbound map[string]cached[Result]
} }
type cached[T any] struct { type cached[T any] struct {
@@ -120,6 +122,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
domainTTL: domTTL, domainTTL: domTTL,
servers: make(map[string]cached[Server]), servers: make(map[string]cached[Server]),
domains: make(map[string]cached[Domain]), domains: make(map[string]cached[Domain]),
inbound: make(map[string]cached[Result]),
} }
} }
@@ -172,9 +175,77 @@ func (c *Checker) Domain(q Query, force bool) Domain {
func (c *Checker) Forget(domainName string) { func (c *Checker) Forget(domainName string) {
c.mu.Lock() c.mu.Lock()
delete(c.domains, domainName) delete(c.domains, domainName)
delete(c.inbound, domainName)
c.mu.Unlock() c.mu.Unlock()
} }
// InboundMX reports whether any MX for name points at this server's hostname
// (the inbound-relay check). Other MX values are the domain's own primaries
// and are not an error. force skips the cache.
func (c *Checker) InboundMX(name, hostname string, force bool) Result {
key := name + "\x00" + hostname
if !force {
c.mu.Lock()
entry, ok := c.inbound[key]
c.mu.Unlock()
if ok && time.Now().Before(entry.expires) {
return entry.value
}
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
result := c.checkInboundMX(ctx, name, hostname)
c.mu.Lock()
c.inbound[key] = cached[Result]{value: result, expires: time.Now().Add(c.domainTTL)}
c.mu.Unlock()
return result
}
func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result {
mxs, err := c.resolver.LookupMX(ctx, name)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
}
}
return lookupFailed("the MX record", err)
}
if len(mxs) == 0 {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
}
}
want := normalizeName(hostname)
var records []string
matched := false
for _, mx := range mxs {
host := normalizeName(mx.Host)
records = append(records, fmt.Sprintf("%d %s.", mx.Pref, host))
if host == want {
matched = true
}
}
if matched {
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("An MX points at %s (this server). Other MX values are the domain's own primaries — they are not an error.", hostname),
Records: records,
}
}
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX points at %s (this server). Publish the record below, or wait for DNS to propagate and Re-check.", hostname),
Records: records,
}
}
// checkDomain runs the three record checks concurrently: they are independent, // checkDomain runs the three record checks concurrently: they are independent,
// and in series three timeouts would stack up into a page that looks hung. // and in series three timeouts would stack up into a page that looks hung.
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain { func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
+34
View File
@@ -374,3 +374,37 @@ func TestReportAuth(t *testing.T) {
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample()) t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
} }
} }
func TestInboundMXPointsAtServer(t *testing.T) {
f := &fakeResolver{
mx: map[string][]*net.MX{
"lists.example.com": {
{Host: "mail.primary.example.net.", Pref: 10},
{Host: "mail.example.org.", Pref: 20},
},
},
}
got := newTestChecker(f).InboundMX("lists.example.com", "mail.example.org", false)
if got.Status != health.StatusOK {
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
}
}
func TestInboundMXMissingThisServer(t *testing.T) {
f := &fakeResolver{
mx: map[string][]*net.MX{
"backup.example.net": {{Host: "mail.primary.example.net.", Pref: 10}},
},
}
got := newTestChecker(f).InboundMX("backup.example.net", "mail.example.org", false)
if got.Status != health.StatusError {
t.Fatalf("status = %q, want error", got.Status)
}
}
func TestInboundMXAbsent(t *testing.T) {
got := newTestChecker(&fakeResolver{}).InboundMX("none.example", "mail.example.org", false)
if got.Status != health.StatusError {
t.Fatalf("status = %q, want error", got.Status)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Package inbound owns backup-MX / relay-forwarder domains: the SQLite
// registry and the Postfix lookup tables (relay_domains, transport_maps,
// relay_recipient_maps, smtp_tls_policy_maps). It does not listen on port 25
// itself — postfix-config.sh does that when INBOUND_RELAY_ENABLE is true.
package inbound
import (
"fmt"
"strings"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// Maps is the slice of the Postfix manager the inbound service needs.
type Maps interface {
RebuildInboundMaps(routes []postfix.InboundRoute) error
}
// Service coordinates inbound-domain state across SQLite and the Postfix maps.
type Service struct {
store *store.Store
maps Maps
}
// NewService builds the inbound service over the shared store and Postfix maps.
func NewService(st *store.Store, maps Maps) *Service {
return &Service{store: st, maps: maps}
}
// List returns every inbound domain.
func (s *Service) List() ([]store.InboundDomain, error) {
return s.store.ListInboundDomains()
}
// Get returns one inbound domain by id.
func (s *Service) Get(id int64) (store.InboundDomain, error) {
return s.store.GetInboundDomain(id)
}
// Add validates the domain name, inserts it, and rebuilds the maps.
func (s *Service) Add(name string) (store.InboundDomain, error) {
name = normalizeDomain(name)
if err := checkDomain(name); err != nil {
return store.InboundDomain{}, err
}
d, err := s.store.AddInboundDomain(name)
if err != nil {
return store.InboundDomain{}, err
}
if err := s.Resync(); err != nil {
_ = s.store.DeleteInboundDomain(d.ID)
return store.InboundDomain{}, err
}
return d, nil
}
// SetTransport validates and saves the upstream, then rebuilds the maps.
func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
if _, err := s.store.GetInboundDomain(id); err != nil {
return err
}
host = normalizeHost(host)
if err := checkHost(host); err != nil {
return err
}
port, err := parsePort(portRaw)
if err != nil {
return err
}
if err := checkTLSMode(tlsMode); err != nil {
return err
}
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
return err
}
return s.Resync()
}
// SetRecipients validates the mode and, in list mode, every address, then
// rebuilds the maps.
func (s *Service) SetRecipients(id int64, mode string, rawAddresses []string) error {
d, err := s.store.GetInboundDomain(id)
if err != nil {
return err
}
if err := checkRecipientMode(mode); err != nil {
return err
}
var addrs []string
if mode == store.RecipientModeList {
addrs, err = parseRecipientAddresses(rawAddresses, d.Name)
if err != nil {
return err
}
}
if err := s.store.UpdateInboundRecipients(id, mode, addrs); err != nil {
return err
}
return s.Resync()
}
// Delete removes the domain and rebuilds the maps.
func (s *Service) Delete(id int64) error {
if err := s.store.DeleteInboundDomain(id); err != nil {
return err
}
return s.Resync()
}
// Resync rebuilds the inbound Postfix maps from SQLite.
func (s *Service) Resync() error {
list, err := s.store.ListInboundDomains()
if err != nil {
return err
}
routes := make([]postfix.InboundRoute, 0, len(list))
for _, d := range list {
full, err := s.store.GetInboundDomain(d.ID)
if err != nil {
return err
}
routes = append(routes, postfix.InboundRoute{
Domain: full.Name,
Host: full.Host,
Port: full.Port,
TLSMode: full.TLSMode,
RecipientMode: full.RecipientMode,
Recipients: full.Recipients,
})
}
return s.maps.RebuildInboundMaps(routes)
}
func parseRecipientAddresses(raw []string, domain string) ([]string, error) {
seen := make(map[string]bool)
out := make([]string, 0, len(raw))
for _, r := range raw {
addr := strings.ToLower(strings.TrimSpace(r))
if addr == "" {
continue
}
if err := checkMailbox(addr, domain); err != nil {
return nil, err
}
if seen[addr] {
continue
}
seen[addr] = true
out = append(out, addr)
}
if len(out) == 0 {
return nil, fmt.Errorf("listed-recipients mode requires at least one address")
}
return out, nil
}
+111
View File
@@ -0,0 +1,111 @@
package inbound
import (
"errors"
"path/filepath"
"testing"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
type fakeMaps struct {
routes []postfix.InboundRoute
err error
rebuild int
}
func (f *fakeMaps) RebuildInboundMaps(routes []postfix.InboundRoute) error {
f.rebuild++
f.routes = append([]postfix.InboundRoute(nil), routes...)
return f.err
}
func testService(t *testing.T) (*Service, *store.Store, *fakeMaps) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
m := &fakeMaps{}
return NewService(st, m), st, m
}
func TestAddAndSetTransport(t *testing.T) {
s, _, m := testService(t)
d, err := s.Add("Lists.Example.COM")
if err != nil {
t.Fatal(err)
}
if d.Name != "lists.example.com" {
t.Fatalf("name = %q", d.Name)
}
if m.rebuild != 1 {
t.Fatalf("rebuild after add = %d", m.rebuild)
}
// Empty host is omitted from maps.
if len(m.routes) != 1 || m.routes[0].Host != "" {
t.Fatalf("routes after add: %+v", m.routes)
}
if err := s.SetTransport(d.ID, "10.0.0.8", "25", store.TLSModeEncrypt); err != nil {
t.Fatal(err)
}
if m.routes[0].Host != "10.0.0.8" || m.routes[0].TLSMode != store.TLSModeEncrypt {
t.Fatalf("routes after transport: %+v", m.routes[0])
}
}
func TestSetRecipientsValidatesDomain(t *testing.T) {
s, _, _ := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
err = s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@other.com"})
if err == nil {
t.Fatal("expected foreign-domain error")
}
err = s.SetRecipients(d.ID, store.RecipientModeList, nil)
if err == nil {
t.Fatal("expected empty-list error")
}
if err := s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@lists.example.com"}); err != nil {
t.Fatal(err)
}
}
func TestRejectsUnsafeHost(t *testing.T) {
s, _, _ := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
if err := s.SetTransport(d.ID, "10.0.0.8; rm", "25", store.TLSModeMay); err == nil {
t.Fatal("expected unsafe host to be rejected")
}
}
func TestDeleteResyncs(t *testing.T) {
s, _, m := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
if err := s.Delete(d.ID); err != nil {
t.Fatal(err)
}
if !errors.Is(mustGet(t, s, d.ID), store.ErrInboundDomainNotFound) {
t.Fatal("domain still present")
}
if len(m.routes) != 0 {
t.Fatalf("maps after delete: %+v", m.routes)
}
}
func mustGet(t *testing.T, s *Service, id int64) error {
t.Helper()
_, err := s.Get(id)
return err
}
+131
View File
@@ -0,0 +1,131 @@
package inbound
import (
"fmt"
"net"
"strconv"
"strings"
)
func normalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func normalizeHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return host
}
func checkDomain(name string) error {
if name == "" {
return fmt.Errorf("domain is required")
}
if len(name) > 253 {
return fmt.Errorf("domain must be at most 253 characters")
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
}
for _, label := range labels {
if err := checkLabel(label); err != nil {
return err
}
}
return nil
}
func checkHost(host string) error {
if host == "" {
return fmt.Errorf("host is required")
}
if len(host) > 253 {
return fmt.Errorf("host must be at most 253 characters")
}
if ip := net.ParseIP(host); ip != nil {
return nil
}
for _, label := range strings.Split(host, ".") {
if err := checkLabel(label); err != nil {
return fmt.Errorf("host is invalid: %w", err)
}
}
return nil
}
func checkLabel(label string) error {
if len(label) == 0 {
return fmt.Errorf("must not contain an empty label")
}
if len(label) > 63 {
return fmt.Errorf("each label must be at most 63 characters")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("labels must not start or end with '-'")
}
for i := 0; i < len(label); i++ {
c := label[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '-' {
return fmt.Errorf("may contain only lower-case letters, digits, '.' and '-'")
}
}
return nil
}
func parsePort(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("port is required")
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 65535 {
return 0, fmt.Errorf("port must be between 1 and 65535")
}
return n, nil
}
func checkTLSMode(mode string) error {
switch mode {
case "may", "encrypt", "none":
return nil
default:
return fmt.Errorf("invalid TLS mode")
}
}
func checkRecipientMode(mode string) error {
switch mode {
case "list", "any":
return nil
default:
return fmt.Errorf("invalid recipient mode")
}
}
func checkMailbox(addr, domain string) error {
at := strings.LastIndexByte(addr, '@')
if at <= 0 || at >= len(addr)-1 {
return fmt.Errorf("%q is not a valid email address", addr)
}
local, host := addr[:at], addr[at+1:]
if host != domain {
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
}
if local == "" || local[0] == '.' || local[len(local)-1] == '.' {
return fmt.Errorf("%q: invalid local part", addr)
}
for i := 0; i < len(local); i++ {
c := local[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
return fmt.Errorf("%q: local part contains invalid characters", addr)
}
}
return nil
}
+151
View File
@@ -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"
}
+64
View File
@@ -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)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
package postfix
import (
"fmt"
"net"
"path/filepath"
"sort"
"strconv"
"strings"
)
// InboundRoute is one inbound domain's Postfix map material: the relay
// domain, the next-hop transport, the TLS policy for that hop, and the
// recipient list (or a domain catch-all).
type InboundRoute struct {
Domain string
Host string
Port int
TLSMode string
RecipientMode string
Recipients []string
}
func (p *Postfix) inboundMapPaths() (relayDomains, transport, recipients, tlsPolicy string) {
dir := filepath.Dir(p.senderLoginMapsPath)
return filepath.Join(dir, "relay_domains"),
filepath.Join(dir, "transport"),
filepath.Join(dir, "relay_recipients"),
filepath.Join(dir, "tls_policy")
}
// RebuildInboundMaps regenerates the inbound relay lookup tables from the full
// set of configured routes and reloads Postfix. Domains with an empty host are
// omitted so mail is never accepted with nowhere to send it. Full regeneration
// keeps the files a pure function of the registry (security.md).
func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes)
if err != nil {
return err
}
rd, tr, rc, tl := p.inboundMapPaths()
if err := writeFileAtomic(rd, relay, 0o640); err != nil {
return err
}
if err := writeFileAtomic(tr, transport, 0o640); err != nil {
return err
}
if err := writeFileAtomic(rc, recipients, 0o640); err != nil {
return err
}
if err := writeFileAtomic(tl, tlsPolicy, 0o640); err != nil {
return err
}
return p.reload()
}
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
var relayB, transportB, recipB, tlsB strings.Builder
for _, r := range routes {
if strings.TrimSpace(r.Host) == "" {
continue
}
if err := assertInboundRouteSafe(r); err != nil {
return nil, nil, nil, nil, err
}
nexthop := inboundNexthop(r.Host, r.Port)
fmt.Fprintf(&relayB, "%s OK\n", r.Domain)
fmt.Fprintf(&transportB, "%s smtp:%s\n", r.Domain, nexthop)
fmt.Fprintf(&tlsB, "%s %s\n", nexthop, r.TLSMode)
switch r.RecipientMode {
case "any":
fmt.Fprintf(&recipB, "@%s OK\n", r.Domain)
default:
addrs := append([]string(nil), r.Recipients...)
sort.Strings(addrs)
for _, addr := range addrs {
fmt.Fprintf(&recipB, "%s OK\n", addr)
}
}
}
return []byte(relayB.String()), []byte(transportB.String()), []byte(recipB.String()), []byte(tlsB.String()), nil
}
// inboundNexthop is the Postfix next-hop [host]:port form that disables MX
// lookup for the explicit upstream.
func inboundNexthop(host string, port int) string {
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
return "[" + host + "]:" + strconv.Itoa(port)
}
return "[" + host + "]:" + strconv.Itoa(port)
}
func assertInboundRouteSafe(r InboundRoute) error {
if err := assertMapToken(r.Domain, "domain"); err != nil {
return err
}
if err := assertMapToken(r.Host, "host"); err != nil {
return err
}
if r.Port < 1 || r.Port > 65535 {
return fmt.Errorf("postfix: invalid inbound port %d", r.Port)
}
switch r.TLSMode {
case "may", "encrypt", "none":
default:
return fmt.Errorf("postfix: invalid tls mode %q", r.TLSMode)
}
if r.RecipientMode != "list" && r.RecipientMode != "any" {
return fmt.Errorf("postfix: invalid recipient mode %q", r.RecipientMode)
}
for _, addr := range r.Recipients {
if err := assertMapToken(addr, "recipient"); err != nil {
return err
}
}
return nil
}
// assertMapToken rejects values that could break out of a texthash line.
func assertMapToken(v, what string) error {
if v == "" {
return fmt.Errorf("postfix: empty %s", what)
}
if strings.ContainsAny(v, " \t\r\n,\\") {
return fmt.Errorf("postfix: unsafe character in %s %q", what, v)
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package postfix
import (
"os"
"path/filepath"
"testing"
)
func TestRenderInboundMaps(t *testing.T) {
routes := []InboundRoute{
{
Domain: "zeta.example", Host: "192.0.2.20", Port: 25,
TLSMode: "none", RecipientMode: "any",
},
{
Domain: "lists.example.com", Host: "10.0.0.8", Port: 25,
TLSMode: "encrypt", RecipientMode: "list",
Recipients: []string{"staff@lists.example.com", "abuse@lists.example.com"},
},
{
Domain: "pending.example", Host: "", Port: 25,
TLSMode: "may", RecipientMode: "list",
},
}
relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes)
if err != nil {
t.Fatal(err)
}
wantRelay := "lists.example.com OK\nzeta.example OK\n"
if string(relay) != wantRelay {
t.Errorf("relay_domains =\n%q\nwant\n%q", relay, wantRelay)
}
wantTransport := "lists.example.com smtp:[10.0.0.8]:25\nzeta.example smtp:[192.0.2.20]:25\n"
if string(transport) != wantTransport {
t.Errorf("transport =\n%q\nwant\n%q", transport, wantTransport)
}
wantRecipients := "abuse@lists.example.com OK\nstaff@lists.example.com OK\n@zeta.example OK\n"
if string(recipients) != wantRecipients {
t.Errorf("relay_recipients =\n%q\nwant\n%q", recipients, wantRecipients)
}
wantTLS := "[10.0.0.8]:25 encrypt\n[192.0.2.20]:25 none\n"
if string(tlsPolicy) != wantTLS {
t.Errorf("tls_policy =\n%q\nwant\n%q", tlsPolicy, wantTLS)
}
}
func TestRenderInboundMapsIPv6(t *testing.T) {
routes := []InboundRoute{{
Domain: "v6.example", Host: "2001:db8::1", Port: 25,
TLSMode: "may", RecipientMode: "any",
}}
_, transport, _, tlsPolicy, err := renderInboundMaps(routes)
if err != nil {
t.Fatal(err)
}
if string(transport) != "v6.example smtp:[2001:db8::1]:25\n" {
t.Errorf("transport = %q", transport)
}
if string(tlsPolicy) != "[2001:db8::1]:25 may\n" {
t.Errorf("tls_policy = %q", tlsPolicy)
}
}
func TestRenderInboundMapsRejectsInjection(t *testing.T) {
bad := []InboundRoute{
{Domain: "ex ample.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "any"},
{Domain: "example.com", Host: "10.0.0.1\nrelay", Port: 25, TLSMode: "may", RecipientMode: "any"},
{Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "evil", RecipientMode: "any"},
{Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "list",
Recipients: []string{"a@example.com OK\nb@evil.com"}},
}
for i, r := range bad {
if _, _, _, _, err := renderInboundMaps([]InboundRoute{r}); err == nil {
t.Errorf("case %d: expected injection rejection", i)
}
}
}
func TestRebuildInboundMapsWritesAndReloads(t *testing.T) {
p, reloads := newTestPostfix(t)
err := p.RebuildInboundMaps([]InboundRoute{{
Domain: "lists.example.com", Host: "10.0.0.8", Port: 25,
TLSMode: "encrypt", RecipientMode: "any",
}})
if err != nil {
t.Fatal(err)
}
if *reloads != 1 {
t.Errorf("reload called %d times, want 1", *reloads)
}
rd, _, _, _ := p.inboundMapPaths()
data, err := os.ReadFile(rd)
if err != nil {
t.Fatal(err)
}
if string(data) != "lists.example.com OK\n" {
t.Errorf("relay_domains file = %q", data)
}
if filepath.Base(rd) != "relay_domains" {
t.Errorf("unexpected path %s", rd)
}
}
func TestRebuildInboundMapsEmptyOmitsPending(t *testing.T) {
p, _ := newTestPostfix(t)
if err := p.RebuildInboundMaps(nil); err != nil {
t.Fatal(err)
}
rd, tr, rc, tl := p.inboundMapPaths()
for _, path := range []string{rd, tr, rc, tl} {
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) != 0 {
t.Errorf("%s not empty: %q", path, data)
}
}
}
+135
View File
@@ -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)
}
+79
View File
@@ -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)
}
}
+249
View File
@@ -0,0 +1,249 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// Recipient modes for an inbound domain. Kept in sync with the CHECK constraint.
const (
RecipientModeList = "list" // only explicitly listed addresses
RecipientModeAny = "any" // any address at the domain
)
// TLS modes for the hand-off to the upstream. Values are Postfix
// smtp_tls_policy_maps levels: may (opportunistic), encrypt (required), none.
const (
TLSModeMay = "may"
TLSModeEncrypt = "encrypt"
TLSModeNone = "none"
)
// ErrInboundDomainExists is returned when the inbound domain is already configured.
var ErrInboundDomainExists = errors.New("inbound domain already exists")
// ErrInboundDomainNotFound is returned when an inbound domain id/name does not exist.
var ErrInboundDomainNotFound = errors.New("inbound domain not found")
// InboundDomain is a backup-MX / forwarder domain. Host may be empty until the
// operator saves an upstream; map generation skips those rows so mail is never
// accepted with nowhere to send it. RecipientCount is populated by listing
// queries; Recipients is populated by Get.
type InboundDomain struct {
ID int64
Name string
RecipientMode string
Host string
Port int
TLSMode string
CreatedAt time.Time
RecipientCount int
Recipients []string
}
// AddInboundDomain inserts a new inbound domain with a default transport
// (empty host, port 25, opportunistic TLS) and listed-recipients mode. The
// caller must have validated name (security.md).
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
now := time.Now().UTC()
tx, err := s.db.Begin()
if err != nil {
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT INTO inbound_domains (name, recipient_mode, created_at) VALUES (?, ?, ?)",
name, RecipientModeList, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return InboundDomain{}, ErrInboundDomainExists
}
return InboundDomain{}, fmt.Errorf("insert inbound domain: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return InboundDomain{}, fmt.Errorf("inbound domain id: %w", err)
}
if _, err := tx.Exec(
"INSERT INTO inbound_transports (inbound_domain_id, host, port, tls_mode) VALUES (?, '', 25, ?)",
id, TLSModeMay,
); err != nil {
return InboundDomain{}, fmt.Errorf("insert inbound transport: %w", err)
}
if err := tx.Commit(); err != nil {
return InboundDomain{}, fmt.Errorf("commit add inbound domain: %w", err)
}
return InboundDomain{
ID: id,
Name: name,
RecipientMode: RecipientModeList,
Port: 25,
TLSMode: TLSModeMay,
CreatedAt: now,
}, nil
}
// ListInboundDomains returns every inbound domain with its transport and
// recipient count, ordered by name.
func (s *Store) ListInboundDomains() ([]InboundDomain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
ORDER BY d.name`)
if err != nil {
return nil, fmt.Errorf("list inbound domains: %w", err)
}
defer rows.Close()
var out []InboundDomain
for rows.Next() {
d, err := scanInboundDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// GetInboundDomain returns one inbound domain with its recipient list, or
// ErrInboundDomainNotFound.
func (s *Store) GetInboundDomain(id int64) (InboundDomain, error) {
row := s.db.QueryRow(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
WHERE d.id = ?`, id)
d, err := scanInboundDomain(row)
if errors.Is(err, sql.ErrNoRows) {
return InboundDomain{}, ErrInboundDomainNotFound
}
if err != nil {
return InboundDomain{}, err
}
addrs, err := s.listInboundRecipients(id)
if err != nil {
return InboundDomain{}, err
}
d.Recipients = addrs
return d, nil
}
// UpdateInboundTransport sets the upstream host, port and TLS mode.
func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode string) error {
res, err := s.db.Exec(
"UPDATE inbound_transports SET host = ?, port = ?, tls_mode = ? WHERE inbound_domain_id = ?",
host, port, tlsMode, id,
)
if err != nil {
return fmt.Errorf("update inbound transport: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound transport rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the
// address list. In any mode the stored list is cleared.
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update inbound recipients: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec("UPDATE inbound_domains SET recipient_mode = ? WHERE id = ?", mode, id)
if err != nil {
return fmt.Errorf("update inbound recipient mode: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound recipient mode rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
if _, err := tx.Exec("DELETE FROM inbound_recipients WHERE inbound_domain_id = ?", id); err != nil {
return fmt.Errorf("clear inbound recipients: %w", err)
}
if mode == RecipientModeList {
for _, addr := range addresses {
if _, err := tx.Exec(
"INSERT INTO inbound_recipients (inbound_domain_id, address) VALUES (?, ?)",
id, addr,
); err != nil {
return fmt.Errorf("insert inbound recipient: %w", err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit update inbound recipients: %w", err)
}
return nil
}
// DeleteInboundDomain removes an inbound domain and its transport/recipients
// (ON DELETE CASCADE). Returns ErrInboundDomainNotFound if no such row existed.
func (s *Store) DeleteInboundDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM inbound_domains WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete inbound domain: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete inbound domain rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
func (s *Store) listInboundRecipients(id int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT address FROM inbound_recipients WHERE inbound_domain_id = ? ORDER BY address",
id,
)
if err != nil {
return nil, fmt.Errorf("list inbound recipients: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err != nil {
return nil, err
}
out = append(out, addr)
}
return out, rows.Err()
}
func scanInboundDomain(r scanRow) (InboundDomain, error) {
var (
d InboundDomain
createdAt string
)
if err := r.Scan(
&d.ID, &d.Name, &d.RecipientMode, &createdAt,
&d.Host, &d.Port, &d.TLSMode, &d.RecipientCount,
); err != nil {
return InboundDomain{}, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return d, nil
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"errors"
"testing"
)
func TestInboundDomainCRUD(t *testing.T) {
st := openTestStore(t)
d, err := st.AddInboundDomain("lists.example.com")
if err != nil {
t.Fatalf("AddInboundDomain: %v", err)
}
if d.ID == 0 || d.Name != "lists.example.com" || d.RecipientMode != RecipientModeList {
t.Fatalf("unexpected domain: %+v", d)
}
if d.Port != 25 || d.TLSMode != TLSModeMay || d.Host != "" {
t.Fatalf("unexpected default transport: %+v", d)
}
if _, err := st.AddInboundDomain("lists.example.com"); !errors.Is(err, ErrInboundDomainExists) {
t.Fatalf("duplicate error = %v, want ErrInboundDomainExists", err)
}
if err := st.UpdateInboundTransport(d.ID, "10.0.0.8", 25, TLSModeEncrypt); err != nil {
t.Fatalf("UpdateInboundTransport: %v", err)
}
addrs := []string{"staff@lists.example.com", "postmaster@lists.example.com"}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, addrs); err != nil {
t.Fatalf("UpdateInboundRecipients: %v", err)
}
got, err := st.GetInboundDomain(d.ID)
if err != nil {
t.Fatalf("GetInboundDomain: %v", err)
}
if got.Host != "10.0.0.8" || got.TLSMode != TLSModeEncrypt || got.RecipientCount != 2 {
t.Fatalf("get after update: %+v", got)
}
if len(got.Recipients) != 2 || got.Recipients[0] != "postmaster@lists.example.com" {
t.Fatalf("recipients not sorted: %v", got.Recipients)
}
list, err := st.ListInboundDomains()
if err != nil {
t.Fatalf("ListInboundDomains: %v", err)
}
if len(list) != 1 || list[0].RecipientCount != 2 {
t.Fatalf("list: %+v", list)
}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeAny, nil); err != nil {
t.Fatalf("switch to any: %v", err)
}
got, err = st.GetInboundDomain(d.ID)
if err != nil {
t.Fatal(err)
}
if got.RecipientMode != RecipientModeAny || got.RecipientCount != 0 || len(got.Recipients) != 0 {
t.Fatalf("any mode should clear the list: %+v", got)
}
if err := st.DeleteInboundDomain(d.ID); err != nil {
t.Fatalf("DeleteInboundDomain: %v", err)
}
assertCount(t, st, "inbound_domains", 0)
assertCount(t, st, "inbound_transports", 0)
assertCount(t, st, "inbound_recipients", 0)
if _, err := st.GetInboundDomain(d.ID); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("Get after delete = %v, want ErrInboundDomainNotFound", err)
}
}
func TestInboundDomainNotFound(t *testing.T) {
st := openTestStore(t)
if _, err := st.GetInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("GetInboundDomain(missing) = %v", err)
}
if err := st.UpdateInboundTransport(99, "10.0.0.1", 25, TLSModeNone); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("UpdateInboundTransport(missing) = %v", err)
}
if err := st.DeleteInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("DeleteInboundDomain(missing) = %v", err)
}
}
func TestInboundDeleteCascadesRecipients(t *testing.T) {
st := openTestStore(t)
d, err := st.AddInboundDomain("backup.example.net")
if err != nil {
t.Fatal(err)
}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, []string{"a@backup.example.net"}); err != nil {
t.Fatal(err)
}
if err := st.DeleteInboundDomain(d.ID); err != nil {
t.Fatal(err)
}
assertCount(t, st, "inbound_recipients", 0)
}
@@ -0,0 +1,26 @@
-- Optional inbound relay (backup-MX / forwarder). Separate from sending
-- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the
-- listener, Postfix maps and panel UI are generated only when that flag is on.
CREATE TABLE inbound_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
created_at TEXT NOT NULL
);
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
CREATE TABLE inbound_transports (
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
host TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
);
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
CREATE TABLE inbound_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
address TEXT NOT NULL,
UNIQUE (inbound_domain_id, address)
);
+3 -3
View File
@@ -1,8 +1,8 @@
// Package store owns the SelfPost SQLite database: the single file under /data // Package store owns the SelfPost SQLite database: the single file under /data
// that persists panel users (global administrators and domain-admins), sending // that persists panel users (global administrators and domain-admins), sending
// domains and applications, the send log and rate-limit settings // domains and applications, optional inbound-relay domains, the send log and
// (architecture.md § Persistence). It exposes typed queries so the rest of the // rate-limit settings (architecture.md § Persistence). It exposes typed queries
// panel never builds SQL by hand. // so the rest of the panel never builds SQL by hand.
package store package store
import ( import (
+9
View File
@@ -49,6 +49,15 @@ var globalOnlyRoutes = []route{
{"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}}, {"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", "/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}, {"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
{"GET", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleInboundList }, nil},
{"POST", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
{"GET", "/inbound/1", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDetail }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/dns-recheck", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDNSRecheck }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/upstream", func(h *Handlers) http.HandlerFunc { return h.HandleInboundTransport }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/recipients", func(h *Handlers) http.HandlerFunc { return h.HandleInboundRecipients }, map[string]string{"id": "1"}},
{"GET", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}},
} }
// A domain administrator has an account on the panel, so authentication is not // A domain administrator has an account on the panel, so authentication is not
+13
View File
@@ -8,6 +8,8 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view" "github.com/mixeme/selfpost/internal/web/view"
@@ -20,6 +22,7 @@ type Config struct {
MailLogPath string MailLogPath string
DataDir string DataDir string
DBPath string DBPath string
DeployRoot string
Version string Version string
TLSCertFile string TLSCertFile string
OpenDKIMSocket string OpenDKIMSocket string
@@ -28,6 +31,13 @@ type Config struct {
// and used to cap domain/app level-2 ceilings (guide § Rate limiting). // and used to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int RateLimitMessagesPerIP int
RateLimitWindowSeconds 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
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
// routes exist only when this is true.
InboundEnabled bool
} }
// Handlers holds dependencies for authenticated panel routes. // Handlers holds dependencies for authenticated panel routes.
@@ -35,6 +45,7 @@ type Handlers struct {
store *store.Store store *store.Store
domains *domain.Service domains *domain.Service
apps *app.Service apps *app.Service
inbound *inbound.Service
cfg Config cfg Config
view *view.Engine view *view.Engine
dns *dnscheck.Checker dns *dnscheck.Checker
@@ -47,6 +58,7 @@ func New(
st *store.Store, st *store.Store,
domains *domain.Service, domains *domain.Service,
apps *app.Service, apps *app.Service,
inboundSvc *inbound.Service,
cfg Config, cfg Config,
v *view.Engine, v *view.Engine,
dns *dnscheck.Checker, dns *dnscheck.Checker,
@@ -57,6 +69,7 @@ func New(
store: st, store: st,
domains: domains, domains: domains,
apps: apps, apps: apps,
inbound: inboundSvc,
cfg: cfg, cfg: cfg,
view: v, view: v,
dns: dns, dns: dns,
+21 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/mixeme/selfpost/internal/backup" "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) h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return 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") stamp := time.Now().UTC().Format("20060102-150405")
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp) 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{ if err := backup.Create(sink, backup.Params{
DataDir: h.cfg.DataDir, DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath, DBPath: h.cfg.DBPath,
Version: h.cfg.Version, Version: h.cfg.Version,
DeployRoot: h.cfg.DeployRoot,
OnWarn: func(msg string) {
logf("panel: full backup: %s", msg)
},
}); err != nil { }); err != nil {
logf("panel: full backup failed: %v", err) logf("panel: full backup failed: %v", err)
return 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 <code>.:/selfpost-deploy:ro</code> 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) // 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 // to an HTTP status and a user-facing message. Duplicate domain/login are called
// out specifically; other failures — validation errors describing what is wrong // out specifically; other failures — validation errors describing what is wrong
@@ -172,6 +172,13 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "reload failed", http.StatusInternalServerError) http.Error(w, "reload failed", http.StatusInternalServerError)
return return
} }
if h.cfg.InboundEnabled && h.inbound != nil {
if err := h.inbound.Resync(); err != nil {
logf("panel: manual reload (inbound): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther) http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
} }
+319
View File
@@ -0,0 +1,319 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
)
type inboundRow struct {
store.InboundDomain
DNS health.Status
Upstream string
TLSLabel string
RcptLabel string
}
func (h *Handlers) requireInbound(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
if !h.cfg.InboundEnabled || h.inbound == nil {
http.NotFound(w, r)
return auth.Principal{}, false
}
return h.requireGlobal(w, r)
}
func (h *Handlers) lookupInbound(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) {
if h.inbound == nil {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
d, err := h.inbound.Get(id)
if err != nil {
if errors.Is(err, store.ErrInboundDomainNotFound) {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
logf("panel: get inbound domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.InboundDomain{}, false
}
return d, true
}
// HandleInboundList is the inbound-relay domain list (global administrators only).
func (h *Handlers) HandleInboundList(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
h.renderInboundList(w, r, http.StatusOK, "", "")
}
func (h *Handlers) renderInboundList(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
list, err := h.inbound.List()
if err != nil {
logf("panel: inbound list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — inbound"
data["Active"] = "inbound"
data["Domains"] = h.inboundRows(list)
data["Error"] = formErr
data["FormName"] = formName
if r.URL.Query().Get("deleted") != "" {
data["Flash"] = "Inbound domain deleted."
}
h.view.Render(w, status, "inbound", data)
}
func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow {
rows := make([]inboundRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = inboundRow{
InboundDomain: d,
DNS: health.StatusUnknown,
Upstream: inboundUpstream(d),
TLSLabel: tlsLabel(d.TLSMode),
RcptLabel: rcptLabel(d),
}
wg.Add(1)
go func() {
defer wg.Done()
if h.dns != nil && h.cfg.Hostname != "" {
rows[i].DNS = h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status
}
}()
}
wg.Wait()
return rows
}
func inboundUpstream(d store.InboundDomain) string {
if d.Host == "" {
return "—"
}
return fmt.Sprintf("%s:%d", d.Host, d.Port)
}
func tlsLabel(mode string) string {
switch mode {
case store.TLSModeEncrypt:
return "required"
case store.TLSModeNone:
return "off"
default:
return "opportunistic"
}
}
func tlsStatusClass(mode string) string {
if mode == store.TLSModeEncrypt {
return "ok"
}
return "unknown"
}
func rcptLabel(d store.InboundDomain) string {
if d.RecipientMode == store.RecipientModeAny {
return "any"
}
n := d.RecipientCount
if n == 1 {
return "1 listed"
}
return fmt.Sprintf("%d listed", n)
}
// HandleAddInbound validates the name, creates the inbound domain, and
// redirects to its page.
func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundList(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := validate.NormalizeDomain(raw)
if err := validate.Domain(name); err != nil {
h.renderInboundList(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := h.inbound.Add(name)
if err != nil {
if errors.Is(err, store.ErrInboundDomainExists) {
h.renderInboundList(w, r, http.StatusConflict, "That inbound domain is already configured.", raw)
return
}
logf("panel: add inbound domain %q: %v", name, err)
h.renderInboundList(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
h.renderInboundDetail(w, r, http.StatusOK, d, inboundDetailView{})
}
type inboundDetailView struct {
FormErr string
TransportErr string
RecipientErr string
}
func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) {
mx := dnscheck.Result{Status: health.StatusUnknown}
if h.dns != nil && h.cfg.Hostname != "" {
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
}
data := h.pageBase(r)
data["Title"] = "SelfPost — " + d.Name
data["Active"] = "inbound"
data["Domain"] = d
data["MX"] = mx
data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "."
data["Hostname"] = h.cfg.Hostname
data["TLSLabel"] = tlsLabel(d.TLSMode)
data["TLSClass"] = tlsStatusClass(d.TLSMode)
data["RecipientText"] = strings.Join(d.Recipients, "\n")
data["Flash"] = inboundFlash(r)
data["FormErr"] = extra.FormErr
data["TransportErr"] = extra.TransportErr
data["RecipientErr"] = extra.RecipientErr
h.view.Render(w, status, "inbound_domain", data)
}
func inboundFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("saved") != "":
return "Upstream saved."
case r.URL.Query().Get("recipients") != "":
return "Recipients saved."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if h.dns != nil && h.cfg.Hostname != "" {
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: "Invalid form submission."})
return
}
host := r.PostFormValue("host")
port := r.PostFormValue("port")
tlsMode := r.PostFormValue("tls_mode")
if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?saved=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: "Invalid form submission."})
return
}
mode := r.PostFormValue("recipient_mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := h.inbound.SetRecipients(d.ID, mode, addrs); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?recipients=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — delete " + d.Name
data["Active"] = "inbound"
data["Domain"] = d
data["Upstream"] = inboundUpstream(d)
h.view.Render(w, http.StatusOK, "inbound_delete", data)
}
func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if h.dns != nil {
h.dns.Forget(d.Name)
}
if err := h.inbound.Delete(d.ID); err != nil {
if errors.Is(err, store.ErrInboundDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete inbound domain %d: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/inbound?deleted=1", http.StatusSeeOther)
}
@@ -0,0 +1,160 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
type recordingMaps struct {
n int
}
func (r *recordingMaps) RebuildInboundMaps(_ []postfix.InboundRoute) error {
r.n++
return nil
}
func inboundHandlers(t *testing.T) (*Handlers, *store.Store) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
v := mustView(t)
v.SetInboundEnabled(true)
h := &Handlers{
store: st,
inbound: inbound.NewService(st, &recordingMaps{}),
view: v,
cfg: Config{Version: "test", InboundEnabled: true, Hostname: "mail.example.org"},
}
return h, st
}
func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
var req *http.Request
if form != nil {
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest(method, target, nil)
}
req = auth.RequestWithPrincipal(req, p)
if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/"); ok {
id, _, _ := strings.Cut(rest, "/")
if id != "" && id != "delete" {
req.SetPathValue("id", id)
}
}
switch {
case method == http.MethodGet && target == "/inbound":
h.HandleInboundList(rec, req)
case method == http.MethodPost && target == "/inbound":
h.HandleAddInbound(rec, req)
case strings.HasSuffix(target, "/delete") && method == http.MethodGet:
h.HandleInboundDeleteConfirm(rec, req)
case strings.HasSuffix(target, "/delete") && method == http.MethodPost:
h.HandleInboundDelete(rec, req)
case strings.HasSuffix(target, "/upstream"):
h.HandleInboundTransport(rec, req)
case strings.HasSuffix(target, "/recipients"):
h.HandleInboundRecipients(rec, req)
default:
h.HandleInboundDetail(rec, req)
}
return rec
}
func TestInboundListAndAdd(t *testing.T) {
h, _ := inboundHandlers(t)
rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Add inbound domain") {
t.Fatal("list missing add form")
}
rec = inboundCall(h, http.MethodPost, "/inbound", url.Values{"name": {"lists.example.com"}}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("add = %d %s", rec.Code, rec.Body.String())
}
rec = inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
if !strings.Contains(rec.Body.String(), "lists.example.com") {
t.Fatalf("list missing domain:\n%s", rec.Body.String())
}
}
func TestInboundDisabledIs404(t *testing.T) {
h, _ := inboundHandlers(t)
h.cfg.InboundEnabled = false
rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
if rec.Code != http.StatusNotFound {
t.Fatalf("disabled inbound = %d, want 404", rec.Code)
}
}
func TestInboundTransportAndRecipients(t *testing.T) {
h, st := inboundHandlers(t)
d, err := st.AddInboundDomain("lists.example.com")
if err != nil {
t.Fatal(err)
}
id := itoa(d.ID)
rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/upstream", url.Values{
"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"encrypt"},
}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String())
}
rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com\nabuse@other.com"},
}, globalPrincipal)
if rec.Code != http.StatusBadRequest {
t.Fatalf("foreign recipient = %d, want 400", rec.Code)
}
rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com"},
}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String())
}
rec = inboundCall(h, http.MethodGet, "/inbound/"+id, nil, globalPrincipal)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "10.0.0.8") {
t.Fatalf("detail =\n%s", rec.Body.String())
}
}
func TestInboundDelete(t *testing.T) {
h, st := inboundHandlers(t)
d, err := st.AddInboundDomain("lists.example.com")
if err != nil {
t.Fatal(err)
}
id := itoa(d.ID)
rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/delete", nil, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
}
if _, err := st.GetInboundDomain(d.ID); err == nil {
t.Fatal("domain still present")
}
}
+20 -10
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"net/url" "net/url"
@@ -115,7 +116,7 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
// The status in the panel's own badge vocabulary, so the headline reads // The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel. // the same way as every other health signal in the panel.
"Level": deliveryLevel(row.Status), "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 // The mail.log lines for this message, and — when there are none — the
// reason, which is a normal outcome rather than a failure. // reason, which is a normal outcome rather than a failure.
"LogRows": logRows, "LogRows": logRows,
@@ -173,7 +174,9 @@ type deliveryEvent struct {
// timestamps *are* the history, and stating them as steps is what makes a row // 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 // 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. // 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 // 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. // acceptance: the journal-milter refused it, so Postfix never queued it.
if row.Status == store.StatusRejected { if row.Status == store.StatusRejected {
@@ -217,7 +220,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "warn", Level: "warn",
Status: store.StatusDeferred, Status: store.StatusDeferred,
Title: "Deferred, will be retried", 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: case store.StatusBounced:
return append(events, deliveryEvent{ return append(events, deliveryEvent{
@@ -225,7 +229,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "error", Level: "error",
Status: store.StatusBounced, Status: store.StatusBounced,
Title: "Bounced", 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: default:
// A status the log-tailer learns to write before this switch does. // A status the log-tailer learns to write before this switch does.
@@ -412,13 +417,18 @@ func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
return return
} }
out, errText := readQueue() out, errText := readQueue()
policy := h.cfg.RetryPolicy
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{ h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue", "Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r), "User": auth.CurrentUser(r),
"Active": "mail_queue", "Active": "mail_queue",
"IsGlobal": true, "IsGlobal": true,
"Output": out, "Output": out,
"Error": errText, "Error": errText,
"FirstRetry": policy.FirstRetry(),
"BackoffCap": policy.BackoffCap(),
"QueueLifetime": policy.QueueLifetime(),
"RetryFromDefaults": policy.FromDefaults,
}) })
} }
+96 -1
View File
@@ -8,7 +8,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
) )
@@ -345,7 +348,12 @@ func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
domains[d.name] = dom domains[d.name] = dom
} }
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains return &Handlers{
store: st,
inbound: inbound.NewService(st, &recordingMaps{}),
view: mustView(t),
cfg: Config{Version: "test", InboundEnabled: true},
}, domains
} }
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal} var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
@@ -450,3 +458,90 @@ func writeMailLog(t *testing.T, lines ...string) string {
} }
return path 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)
}
}
+103
View File
@@ -3,6 +3,8 @@ package validate
import ( import (
"fmt" "fmt"
"net"
"strconv"
"strings" "strings"
"unicode" "unicode"
) )
@@ -146,3 +148,104 @@ func Email(addr string) error {
} }
return nil return nil
} }
const maxHostLen = 253
// NormalizeHost trims, lower-cases, and strips wrapping IPv6 brackets so the
// stored value is a bare hostname or IP, safe to wrap again when writing maps.
func NormalizeHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return host
}
// Host enforces a whitelist for an upstream hostname or IP (security.md): a
// dotted domain, a single DNS label (LAN names), or an IPv4/IPv6 address.
func Host(host string) error {
if host == "" {
return fmt.Errorf("host is required")
}
if len(host) > maxHostLen {
return fmt.Errorf("host must be at most %d characters", maxHostLen)
}
if ip := net.ParseIP(host); ip != nil {
return nil
}
labels := strings.Split(host, ".")
for _, label := range labels {
if err := domainLabel(label); err != nil {
return fmt.Errorf("host is invalid: %w", err)
}
}
return nil
}
// Port checks a TCP port number parsed from form input.
func Port(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("port is required")
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 65535 {
return 0, fmt.Errorf("port must be between 1 and 65535")
}
return n, nil
}
// TLSMode checks a Postfix smtp_tls_policy_maps level.
func TLSMode(mode string) error {
switch mode {
case "may", "encrypt", "none":
return nil
default:
return fmt.Errorf("invalid TLS mode")
}
}
// RecipientMode checks an inbound-domain recipient policy.
func RecipientMode(mode string) error {
switch mode {
case "list", "any":
return nil
default:
return fmt.Errorf("invalid recipient mode")
}
}
// MailboxInDomain checks that addr is a conservative mailbox on domain
// (security.md). domain must already be normalised.
func MailboxInDomain(addr, domain string) error {
at := strings.LastIndexByte(addr, '@')
if at <= 0 || at >= len(addr)-1 {
return fmt.Errorf("%q is not a valid email address", addr)
}
local, host := addr[:at], addr[at+1:]
if host != domain {
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
}
if err := mailboxLocalPart(local); err != nil {
return fmt.Errorf("%q: %w", addr, err)
}
return nil
}
func mailboxLocalPart(local string) error {
if local == "" {
return fmt.Errorf("missing the part before '@'")
}
if local[0] == '.' || local[len(local)-1] == '.' {
return fmt.Errorf("local part must not start or end with '.'")
}
for i := 0; i < len(local); i++ {
c := local[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
}
}
return nil
}
+66
View File
@@ -65,3 +65,69 @@ func TestValidateDomainLongLabelRejected(t *testing.T) {
t.Error("expected error for over-long label") t.Error("expected error for over-long label")
} }
} }
func TestHostValid(t *testing.T) {
valid := []string{
"10.0.0.8",
"192.0.2.20",
"2001:db8::1",
"mail.internal.example",
"mx1",
"mail-1.lan",
}
for _, h := range valid {
if err := Host(h); err != nil {
t.Errorf("Host(%q) unexpected error: %v", h, err)
}
}
}
func TestHostInvalid(t *testing.T) {
invalid := []string{
"",
"exa mple",
"host/name",
"host;rm",
"-bad",
"bad-",
"host\nname",
}
for _, h := range invalid {
if err := Host(h); err == nil {
t.Errorf("Host(%q) = nil, want error", h)
}
}
}
func TestNormalizeHostStripsIPv6Brackets(t *testing.T) {
if got := NormalizeHost(" [2001:DB8::1] "); got != "2001:db8::1" {
t.Errorf("NormalizeHost IPv6 = %q", got)
}
if got := NormalizeHost("Mail.Example.COM"); got != "mail.example.com" {
t.Errorf("NormalizeHost hostname = %q", got)
}
}
func TestPort(t *testing.T) {
n, err := Port("25")
if err != nil || n != 25 {
t.Fatalf("Port(25) = %d, %v", n, err)
}
for _, raw := range []string{"", "0", "65536", "abc", "-1"} {
if _, err := Port(raw); err == nil {
t.Errorf("Port(%q) = nil, want error", raw)
}
}
}
func TestMailboxInDomain(t *testing.T) {
if err := MailboxInDomain("staff@lists.example.com", "lists.example.com"); err != nil {
t.Fatal(err)
}
if err := MailboxInDomain("staff@other.com", "lists.example.com"); err == nil {
t.Fatal("expected domain mismatch error")
}
if err := MailboxInDomain("bad addr@lists.example.com", "lists.example.com"); err == nil {
t.Fatal("expected local-part error")
}
}
+4
View File
@@ -663,6 +663,10 @@ h1.subject { overflow-wrap: anywhere; }
wider than the column it shares. */ wider than the column it shares. */
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; } .fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; } .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, /* 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 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 border and the dots sit on top of it, so nothing has to be positioned against
+8 -6
View File
@@ -3,13 +3,15 @@
<div class="card"> <div class="card">
<h2>Full backup</h2> <h2>Full backup</h2>
<p class="muted">Download a full backup of all persistent state — the database, <p class="muted">Download a self-contained backup of the whole instance —
every domain's DKIM key and the application credentials. Use it to move the <code>data/</code> (database, DKIM keys, application credentials, and the
whole server to a new machine: restore it into a container of the Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and
<strong>same SelfPost version</strong>, with the same data mount, before first <code>certs/</code>. Extract it into an empty project directory on a new
start. TLS certificates and the mail queue are not included.</p> machine, adjust hostname or proxy settings if needed, and start a container of
the <strong>same SelfPost version</strong> before first boot. The reverse-proxy
vhost is not included — set that up separately on the new host.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains <p class="muted"><strong>The backup file is a secret</strong> (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: once the restore succeeds. Encrypting it below is the simplest way to do that:
the download is then a <code>.spbk</code> file (SelfPost backup) that only the download is then a <code>.spbk</code> file (SelfPost backup) that only
the password opens.</p> the password opens.</p>
+50
View File
@@ -0,0 +1,50 @@
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Inbound</h1>
<p class="muted">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.</p>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Forwarding</h2>
<form method="post" action="/inbound">
<label for="name">Add inbound domain</label>
<div class="input-row">
<input id="name" name="name" type="text" placeholder="lists.example.com"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormName}}" autofocus required>
<button type="submit">Add domain</button>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
</form>
{{if .Domains}}
<table>
<thead>
<tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr>
</thead>
<tbody>
{{range .Domains}}
<tr>
<td><a href="/inbound/{{.ID}}">{{.Name}}</a></td>
<td><a class="st st-{{.DNS}}" href="/inbound/{{.ID}}">{{.DNS}}</a></td>
<td class="muted">{{.Upstream}}</td>
<td>{{.RcptLabel}}</td>
<td><span class="st st-{{if eq .TLSMode "encrypt"}}ok{{else}}unknown{{end}}">{{.TLSLabel}}</span></td>
<td class="actions"><a class="danger" href="/inbound/{{.ID}}/delete">Delete</a></td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">The <em>DNS</em> 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 <em>Re-check</em> button.</p>
{{else}}
<p class="muted">No inbound domains yet. Add one above, then set the upstream
on its page. Mail is not accepted until an upstream host is saved.</p>
{{end}}
</div>
{{end}}
@@ -0,0 +1,20 @@
{{define "content"}}
<h1>Delete {{.Domain.Name}}</h1>
{{template "back_link" (back (printf "/inbound/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to stop accepting inbound mail for <strong>{{.Domain.Name}}</strong>. This will:</p>
<ul>
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
<li>stop forwarding{{if ne .Upstream "—"}} to <strong>{{.Upstream}}</strong>{{end}};</li>
<li>leave outbound sending domains untouched.</li>
</ul>
<p class="muted">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.</p>
<form method="post" action="/inbound/{{.Domain.ID}}/delete">
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
</form>
</div>
{{end}}
@@ -0,0 +1,118 @@
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
{{template "back_link" (back "/inbound" "All inbound domains")}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card" id="dns-status">
<h2>DNS status <span class="st st-{{.MX.Status}}">{{.MX.Status}}</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing.
Unlike outbound, inbound needs an MX pointing at this server.</p>
<label>MX <span class="st st-{{.MX.Status}}">{{.MX.Status}}</span></label>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.Domain.Name}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
{{if .MX.Records}}
<span class="code">{{range .MX.Records}}{{.}}
{{end}}</span>
{{else}}
<span class="code muted">No MX records found.</span>
{{end}}
{{if eq .MX.Status "ok"}}
<p class="muted">{{.MX.Detail}}</p>
{{else}}
<p class="error">{{.MX.Detail}}</p>
{{end}}
<form method="post" action="/inbound/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="split">
<div class="card">
<h2>Upstream</h2>
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
<form method="post" action="/inbound/{{.Domain.ID}}/upstream">
<label for="host">Host</label>
<input id="host" name="host" type="text" value="{{.Domain.Host}}"
autocomplete="off" autocapitalize="none" spellcheck="false" required>
<label for="port">Port</label>
<input id="port" name="port" type="text" inputmode="numeric" value="{{.Domain.Port}}" required>
<label for="tls_mode">TLS to upstream</label>
<select id="tls_mode" name="tls_mode">
<option value="may" {{if eq .Domain.TLSMode "may"}}selected{{end}}>Opportunistic</option>
<option value="encrypt" {{if eq .Domain.TLSMode "encrypt"}}selected{{end}}>Required</option>
<option value="none" {{if eq .Domain.TLSMode "none"}}selected{{end}}>Off</option>
</select>
{{if .TransportErr}}<p class="error">{{.TransportErr}}</p>{{end}}
<button type="submit">Save upstream</button>
</form>
</div>
<div class="card">
<h2>MX record to publish</h2>
<p class="muted">Add this MX so the internet delivers here. Keep any existing
primary MX if this is backup-MX.</p>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Domain.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.MXValue}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
</div>
<div class="split">
<div class="card">
<h2>Valid recipients</h2>
<p class="muted">Who this domain accepts on port 25. Same idea as an
application's address mode: a list, or any address at the domain.</p>
<form method="post" action="/inbound/{{.Domain.ID}}/recipients">
<label for="recipient_mode">Who to accept</label>
<select id="recipient_mode" name="recipient_mode" data-list-mode="list">
<option value="list" {{if eq .Domain.RecipientMode "list"}}selected{{end}}>Listed addresses only</option>
<option value="any" {{if eq .Domain.RecipientMode "any"}}selected{{end}}>Any recipient at this domain</option>
</select>
<div data-addresses>
<label for="addresses">Addresses (one per line or comma-separated)</label>
<textarea id="addresses" name="addresses" rows="6">{{.RecipientText}}</textarea>
<p class="muted">Unknown recipients are rejected at RCPT so this relay
does not generate backscatter.</p>
</div>
<p class="muted">Every address at this domain is accepted and forwarded when
“any recipient” is selected. Prefer a list unless the upstream rejects
unknowns — otherwise this relay may generate backscatter.</p>
{{if .RecipientErr}}<p class="error">{{.RecipientErr}}</p>{{end}}
<button type="submit">Save recipients</button>
</form>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Stops accepting mail for this domain. Does not touch
outbound sending domains.</p>
<p><a class="danger" href="/inbound/{{.Domain.ID}}/delete">Delete inbound domain</a></p>
</div>
</div>
{{end}}
+4
View File
@@ -70,6 +70,9 @@
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}} {{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{end}} {{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}} {{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if and .IsGlobal .InboundEnabled}}
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
{{end}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}} {{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if .IsGlobal}} {{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}} {{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
@@ -112,6 +115,7 @@
templates so the nav above stays one readable line per page. */}} templates so the nav above stays one readable line per page. */}}
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}} {{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}} {{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}} {{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}} {{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}} {{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
+32 -2
View File
@@ -1,10 +1,40 @@
{{/* A table of queue ids, sizes, senders and recipients: the whole column {{/* Mail queue is two cards in the whole column: the retry-policy snapshot
rather than the reading measure (see the "wide" block in layout.html). */}} (static, outside the HTMX poll) and the live postqueue listing. */}}
{{define "wide"}}wide{{end}} {{define "wide"}}wide{{end}}
{{define "content"}} {{define "content"}}
<h1>Mail queue</h1> <h1>Mail queue</h1>
<div class="card" id="retry-policy">
<h2>How delivery retries work</h2>
<p class="muted">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.</p>
{{if .RetryFromDefaults}}
<p class="muted">Could not read the effective Postfix configuration;
showing compiled-in defaults. A live change is visible after the next
panel restart.</p>
{{end}}
<div class="facts retry-facts">
<div class="fact">
<span class="fact-label">First retry</span>
<span class="fact-value">{{.FirstRetry}}</span>
</div>
<div class="fact">
<span class="fact-label">Later retries</span>
<span class="fact-value">doubling, cap {{.BackoffCap}}</span>
</div>
<div class="fact">
<span class="fact-label">Kept in queue</span>
<span class="fact-value">{{.QueueLifetime}}</span>
</div>
<div class="fact">
<span class="fact-label">Then</span>
<span class="fact-value">bounced</span>
</div>
</div>
</div>
<div class="card"> <div class="card">
<h2>Pending messages</h2> <h2>Pending messages</h2>
{{template "mail_queue_body" .}} {{template "mail_queue_body" .}}
+77 -6
View File
@@ -178,6 +178,35 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
if strings.Index(out, "Status") > strings.Index(out, "Domains") { if strings.Index(out, "Status") > strings.Index(out, "Domains") {
t.Errorf("Status is not the first navigation entry:\n%s", out) t.Errorf("Status is not the first navigation entry:\n%s", out)
} }
if strings.Contains(out, `href="/inbound"`) || strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
}
}
func TestNavShowsInboundWhenEnabled(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
engine.SetInboundEnabled(true)
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
"InboundEnabled": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `href="/inbound"`) || !strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
}
dom := strings.Index(out, `href="/domains"`)
inb := strings.Index(out, `href="/inbound"`)
if dom < 0 || inb < 0 || inb < dom {
t.Errorf("Inbound should follow Domains:\n%s", out)
}
} }
// Whether a page takes the whole column or the reading measure is declared by // Whether a page takes the whole column or the reading measure is declared by
@@ -190,7 +219,11 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true} wide := map[string]bool{
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
"status": true, "system_log": true, "domain_detail": true,
"inbound": true, "inbound_domain": true,
}
for name, page := range engine.Pages() { for name, page := range engine.Pages() {
var buf bytes.Buffer var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil { if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
@@ -281,11 +314,13 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) { func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{ drillDown := map[string]bool{
"user_form.html": true, "user_form.html": true,
"user_delete.html": true, "user_delete.html": true,
"domain_detail.html": true, "domain_detail.html": true,
"domain_delete.html": true, "domain_delete.html": true,
"delivery.html": true, "inbound_domain.html": true,
"inbound_delete.html": true,
"delivery.html": true,
} }
forEachTemplate(t, func(name, body string) { forEachTemplate(t, func(name, body string) {
if !drillDown[name] { if !drillDown[name] {
@@ -527,3 +562,39 @@ func forEachTemplate(t *testing.T, fn func(name, body string)) {
fn(e.Name(), string(body)) 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")
}
}
+29 -18
View File
@@ -18,9 +18,10 @@ var assetsFS embed.FS
// Engine holds parsed page and fragment templates. // Engine holds parsed page and fragment templates.
type Engine struct { type Engine struct {
pages map[string]*template.Template pages map[string]*template.Template
fragments map[string]*template.Template fragments map[string]*template.Template
version string version string
inboundEnabled bool
} }
// pageFiles maps a logical page name to its template files. Every page // pageFiles maps a logical page name to its template files. Every page
@@ -31,21 +32,24 @@ type Engine struct {
// encryption fields on the two secret downloads) list that partial the same // encryption fields on the two secret downloads) list that partial the same
// way. // way.
var pageFiles = map[string][]string{ var pageFiles = map[string][]string{
"setup": {"templates/setup.html"}, "setup": {"templates/setup.html"},
"login": {"templates/login.html"}, "login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"}, "dashboard": {"templates/dashboard.html"},
"settings": {"templates/settings.html"}, "settings": {"templates/settings.html"},
"users": {"templates/users.html"}, "users": {"templates/users.html"},
"user_form": {"templates/user_form.html"}, "user_form": {"templates/user_form.html"},
"user_delete": {"templates/user_delete.html"}, "user_delete": {"templates/user_delete.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"}, "backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"}, "domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"}, "domain_delete": {"templates/domain_delete.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"}, "inbound": {"templates/inbound.html"},
"delivery": {"templates/delivery.html"}, "inbound_domain": {"templates/inbound_domain.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"}, "inbound_delete": {"templates/inbound_delete.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"}, "deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"status": {"templates/status.html", "templates/status_body.html"}, "delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"status": {"templates/status.html", "templates/status_body.html"},
} }
// fragmentFiles maps a fragment name (also its {{define}} block name) to its // fragmentFiles maps a fragment name (also its {{define}} block name) to its
@@ -82,6 +86,12 @@ func New(version string) (*Engine, error) {
return e, nil return e, nil
} }
// SetInboundEnabled controls whether the Inbound nav item is shown. The
// listener and routes are gated the same way (INBOUND_RELAY_ENABLE).
func (e *Engine) SetInboundEnabled(v bool) {
e.inboundEnabled = v
}
// templateFuncs supplies helpers shared across page templates. // templateFuncs supplies helpers shared across page templates.
func templateFuncs() template.FuncMap { func templateFuncs() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
@@ -124,6 +134,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
m["Version"] = e.version m["Version"] = e.version
m["Copyright"] = legal.CopyrightLine m["Copyright"] = legal.CopyrightLine
m["SourceURL"] = legal.SourceURL m["SourceURL"] = legal.SourceURL
m["InboundEnabled"] = e.inboundEnabled
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
+32 -7
View File
@@ -12,7 +12,9 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/legal" "github.com/mixeme/selfpost/internal/legal"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/handlers" "github.com/mixeme/selfpost/internal/web/handlers"
@@ -40,11 +42,13 @@ type Config struct {
// the log-tailer role follows in cmd/panel. // the log-tailer role follows in cmd/panel.
MailLogPath string MailLogPath string
// DataDir and DBPath locate the persistent state a full backup archives // DataDir and DBPath locate the persistent state a full backup archives
// (architecture.md § Persistence); Version is stamped into the backup // (architecture.md § Persistence); DeployRoot is the operator project
// manifest. They mirror the panel's own configuration. // directory (docker-compose.yml, .env, certs/); Version is stamped into
DataDir string // the backup manifest. They mirror the panel's own configuration.
DBPath string DataDir string
Version string DBPath string
DeployRoot string
Version string
// TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply // TrustedProxyCIDRs are the reverse-proxy addresses allowed to supply
// X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose // X-Forwarded-For (env TRUSTED_PROXY_CIDR). A request whose
// direct peer (RemoteAddr) is not in this list never has its XFF header // direct peer (RemoteAddr) is not in this list never has its XFF header
@@ -75,6 +79,12 @@ type Config struct {
// display and to cap domain/app level-2 ceilings (guide § Rate limiting). // display and to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int RateLimitMessagesPerIP int
RateLimitWindowSeconds 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
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
InboundEnabled bool
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
@@ -89,29 +99,33 @@ type Server struct {
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); // that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
// apps owns application SASL accounts and the Postfix sender map // apps owns application SASL accounts and the Postfix sender map
// (architecture.md § Mail path). // (architecture.md § Mail path).
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) {
v, err := view.New(cfg.Version) v, err := view.New(cfg.Version)
if err != nil { if err != nil {
return nil, err return nil, err
} }
v.SetInboundEnabled(cfg.InboundEnabled)
a := auth.New(st, auth.Config{ a := auth.New(st, auth.Config{
CookieSecure: cfg.CookieSecure, CookieSecure: cfg.CookieSecure,
Hostname: cfg.Hostname, Hostname: cfg.Hostname,
SessionIdleDays: cfg.SessionIdleDays, SessionIdleDays: cfg.SessionIdleDays,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}, v, setupTokenPath) }, v, setupTokenPath)
h := handlers.New(st, domains, apps, handlers.Config{ h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{
Hostname: cfg.Hostname, Hostname: cfg.Hostname,
SubmissionEnabled: cfg.SubmissionEnabled, SubmissionEnabled: cfg.SubmissionEnabled,
MailLogPath: cfg.MailLogPath, MailLogPath: cfg.MailLogPath,
DataDir: cfg.DataDir, DataDir: cfg.DataDir,
DBPath: cfg.DBPath, DBPath: cfg.DBPath,
DeployRoot: cfg.DeployRoot,
Version: cfg.Version, Version: cfg.Version,
TLSCertFile: cfg.TLSCertFile, TLSCertFile: cfg.TLSCertFile,
OpenDKIMSocket: cfg.OpenDKIMSocket, OpenDKIMSocket: cfg.OpenDKIMSocket,
JournalSocket: cfg.JournalSocket, JournalSocket: cfg.JournalSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy,
InboundEnabled: cfg.InboundEnabled,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil return &Server{cfg: cfg, auth: a, handlers: h}, nil
} }
@@ -158,6 +172,17 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", h.HandleReload) authed.HandleFunc("POST /reload", h.HandleReload)
if s.cfg.InboundEnabled {
authed.HandleFunc("GET /inbound", h.HandleInboundList)
authed.HandleFunc("POST /inbound", h.HandleAddInbound)
authed.HandleFunc("GET /inbound/{id}", h.HandleInboundDetail)
authed.HandleFunc("POST /inbound/{id}/dns-recheck", h.HandleInboundDNSRecheck)
authed.HandleFunc("POST /inbound/{id}/upstream", h.HandleInboundTransport)
authed.HandleFunc("POST /inbound/{id}/recipients", h.HandleInboundRecipients)
authed.HandleFunc("GET /inbound/{id}/delete", h.HandleInboundDeleteConfirm)
authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete)
}
authed.HandleFunc("/settings", h.HandleSettings) authed.HandleFunc("/settings", h.HandleSettings)
authed.HandleFunc("/account", redirectSettings) authed.HandleFunc("/account", redirectSettings)
+1
View File
@@ -47,6 +47,7 @@ services:
ports: !override ports: !override
- "20465:465" - "20465:465"
- "20587:587" - "20587:587"
- "20025:25"
- "127.0.0.1:20080:8080" - "127.0.0.1:20080:8080"
dns: dns:
- 10.77.0.10 - 10.77.0.10
+23
View File
@@ -105,6 +105,9 @@ func TestE2E(t *testing.T) {
if err := checkSupervisorProcesses(h); err != nil { if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := checkInboundRelayOff(h); err != nil {
t.Fatal(err)
}
if err := checkLogrotateConfigMode(h); err != nil { if err := checkLogrotateConfigMode(h); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -134,6 +137,26 @@ func TestE2E(t *testing.T) {
sc.panel = p sc.panel = p
}) })
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
resp, body, err := sc.panel.get("/status")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
if strings.Contains(body, `href="/inbound"`) {
t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off")
}
resp, _, err = sc.panel.get("/inbound")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 404 {
t.Fatalf("GET /inbound = %d, want 404 with inbound relay off", resp.StatusCode)
}
})
run("add_domain_and_publish_dkim", func(t *testing.T) { run("add_domain_and_publish_dkim", func(t *testing.T) {
id, err := sc.panel.addDomain(senderDomain) id, err := sc.panel.addDomain(senderDomain)
if err != nil { if err != nil {

Some files were not shown because too many files have changed in this diff Show More