release: 1.3.0: 1.3.0-1.3.1: security pass, queue persist, full backup layout

[1.3.0]
Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration.
Added:
  - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
  - docs: agreed roadmap item **queue-retries** — show this Postfix's retry
Security:
  - The independent security review of the send-log authorization and
Fixed:
  - test (e2e): send-log status scrapers follow the badge markup in
  - security (panel): the Deliveries list is scoped to a domain administrator's
  - mail (level-2 rate limit): the ceiling is no longer overshot by messages that
  - panel (sign-in): a session that cannot be written to the database no longer
  - panel (applications): deleting an application removes its SASL credentials
  … (2 more in CHANGELOG)
Changed:
  - docs: operator and as-built docs aligned with the code after a full
  - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
  - docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7)
  - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
  - ci: the release image is published only for a **published** GitHub Release
  … (9 more in CHANGELOG)
[1.3.1]
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
  - docs: **Plan checklists** in [development.md](docs/development.md) — format,
  - docs: **Implementation checklists** with model routing in
  - docs: roadmap candidates **send-log-retention** (panel Settings for delivery
  - Postfix queue under `/data/postfix/queue` — deferred and active mail survive
  … (1 more in CHANGELOG)
Changed:
  - **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
  - docs: operator guide, architecture, security, and backup UI updated for
  - docs: HTML mockups for a full panel UI refresh
Changed:
  - docs: panel UI mockups after review — Status restored to live readings
  - docs: panel UI design system and mockups rebuilt as separate pages
  - docs: panel UI mockups — add-domain sits in the list card (Domains and
Fixed:
  - ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped

Squashed CHANGELOG sections: [1.3.0], [1.3.1].

Full detail: CHANGELOG.md (1.3.0, 1.3.1).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent 60e08e802e
commit 0730b2090e
124 changed files with 8464 additions and 1121 deletions
+82 -26
View File
@@ -78,7 +78,14 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
One process, three roles:
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only.
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`delay_warning_time`) and caches the snapshot on the handlers config. The
Mail queue card and a delivery's `deferred` / `bounced` history print those
numbers; they never call `postconf` per request. If `postconf` is missing,
the panel logs a warning and uses Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain
@@ -154,32 +161,39 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
unless noted. The table below is a summary — HTMX fragment endpoints
unless noted. Routes marked **global** return **404** for domain administrators
(`requireGlobal()` in
[internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
below is a summary — HTMX fragment endpoints
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`.
| Route | Purpose |
|---|---|
| `/healthz` | Liveness (no auth) |
| `/license` | Embedded `LICENSE` text (no auth) |
| `/setup/*` | One-time admin bootstrap |
| `/login`, `/logout` | Session auth |
| `/status` | Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains`, `/domains/*` | Domain and application CRUD, DKIM, L2 limits |
| `/domains/import` | Domain import (`POST`; form on the Backup page) |
| `/deliveries` | Send log with filters |
| `/deliveries/{id}` | One send-log row in full, with its `mail.log` lines |
| `/mail-queue` | Postfix queue view |
| `/system-log` | `mail.log` tail |
| `/reload` | Reload OpenDKIM + Postfix maps |
| `/backup` | Full backup download (page also hosts the import form) |
| `/settings` | Admin username/password and DMARC report address |
| `/users`, `/users/*` | Panel user CRUD (global admin only) |
| `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
| `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
session idle timeout (only non-`HX-Request` GET and mutating requests count as
activity).
activity). The Mail queue retry-policy card is outside that fragment: it is the
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
not a live re-read.
### Sessions
@@ -192,13 +206,16 @@ holds the cookie works after process restart, redeploy, or full backup restore.
absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
- **Password change** — all other sessions are deleted; the current session stays
active ([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
- **Password change on `/settings`** — changing your own password deletes
every other session for that user; the current session stays active
([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
A global administrator resetting another user's password on `/users` updates
the hash but does not delete that user's existing sessions.
Restoring an **older** backup also restores session rows: a session invalidated
after that backup was taken can become valid again if the browser still has the
cookie and idle timeout has not expired.
Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still holds
the cookie and the restored row's `expires_at` has not passed.
---
@@ -283,19 +300,27 @@ single-connection trade-off that follows from it.
| `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials |
| `postfix/sender_login_maps` | Login → From binding |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `manifest.json` | Backup version stamp (consumed on restore) |
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
(transit mail not migrated by design).
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
full backups also archive the operator's `./certs` PEM files when present.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above).
**Backup:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
restore. Stopped-container `tar` of `./data` is safe (see guide).
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
tables and Postfix's sender map are re-derived from SQLite and both daemons are
reloaded, so drift between the extracted archive and the database is healed
before mail flows (same step as `POST /reload` on demand). Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
@@ -317,5 +342,36 @@ origin check, no CSRF tokens) are documented there separately.
## Configuration
Public and internal env vars: [guide § Environment variables](guide.md#environment-variables).
Public env vars: [guide § Environment variables](guide.md#environment-variables).
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
**Internal env vars.** The following are read by the panel or startup scripts
but are not part of the operator interface — not meant to be changed in a
normal deployment; documented here so an accidental override reads as
unsupported rather than as a missing doc:
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
full backups; mount `.:/selfpost-deploy:ro` in compose).
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds).
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
`postfix reload` to pick up renewed certificates),
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
hours; logrotate keeps 14 rotated files on a daily schedule, and each
rotation triggers `postfix reload`).
+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 card’s «?»)</li>
</ul>
<p class="muted">The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.</p>
</div>
<div class="card">
<h2>Machine</h2>
<p>CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.</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 application’s 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 domain’s 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 application’s 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 Postfix’s 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 domain’s 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 container’s 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>
+123 -14
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.3.1`; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
| 1 | queue-retries | `1.3.1` (PATCH) |
| 2 | inbound-relay | `1.4.0` |
| 3 | send-log-retention | `1.5.0` |
| 4 | domain-stats-auto-ratelimit | `1.6.0` |
| 5 | dmarc-reports | `1.7.0` |
| 6 | panel-docs | `1.8.0` |
Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships.
---
## Technology stack and tools
| Component | Version / notes |
@@ -93,8 +145,9 @@ mechanics → Haiku. Reviewers must not be the author of the code under review.
## External libraries
The project is **AGPL-3.0** ([LICENSE](../LICENSE)). Copyright holder and
third-party notices: [NOTICE](../NOTICE). New Go dependencies must be
permissive or GPL-family (see
third-party notices: [NOTICE](../NOTICE). The tree does not use per-file
`SPDX-License-Identifier` headers; AGPL-3.0 does not require them. New Go
dependencies must be permissive or GPL-family (see
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)).
### Main module (`go.mod`)
@@ -113,6 +166,7 @@ the tree are AGPL-3.0-compatible.
| Asset | Version | Repository | License |
|---|---|---|---|
| `internal/web/view/static/htmx.min.js` | 2.0.4 | <https://github.com/bigskysoftware/htmx> | 0BSD |
| `internal/web/view/static/ibm-plex-*.woff2` | latin subset | <https://github.com/IBM/plex> | SIL OFL 1.1 (`OFL.txt` beside the files) |
### E2e module (`test/e2e/go.mod`)
@@ -128,8 +182,10 @@ the image.
Postfix, OpenDKIM, `supervisord`, `sasl2-bin`, `logrotate`, and others come
from Debian bookworm repositories; licenses are in each package's `copyright`
file on <https://packages.debian.org/bookworm/>.
The image also ships [LICENSE](../LICENSE) and [NOTICE](../NOTICE) under
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license`.
The image also ships [LICENSE](../LICENSE), [NOTICE](../NOTICE), and the IBM
Plex [OFL.txt](../internal/web/view/static/OFL.txt) under
`/usr/share/doc/selfpost/`. The panel serves the AGPL text at `/license` and
the OFL text at `/static/OFL.txt`.
---
@@ -187,9 +243,12 @@ tag / push only on explicit request (see `release.yml`).
### Release image
The release image is published **only on tag** `vX.Y.Z` (not on every push to
`main`). The tag is the single source of version: it drives the image tag and
`-ldflags` in the binaries so they cannot drift apart.
The release image is published **only** for a SemVer version `X.Y.Z`: a
**published** GitHub Release whose tag is `vX.Y.Z`, or a `workflow_dispatch`
that supplies that version. Pushing a git tag alone does not publish. Ordinary
commits, and a dispatch from `main` without a version input, do not publish.
The version is the single source that drives the image tag and `-ldflags` in
the binaries so they cannot drift apart.
**Steps (on explicit request):**
@@ -197,8 +256,42 @@ The release image is published **only on tag** `vX.Y.Z` (not on every push to
tag in [deploy/docker-compose.yml](../deploy/docker-compose.yml) (and any
local-trial image references) in the **same** release commit.
2. Create and push git tag `vX.Y.Z` on that commit.
3. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z`.
3. Publish the GitHub Release for `vX.Y.Z` (not a draft).
4. Workflow [release.yml](../.github/workflows/release.yml) builds, e2e-gates,
and publishes `ghcr.io/mixeme/selfpost:X.Y.Z` (checks out tag `vX.Y.Z`).
**GitHub Release vs GHCR.** The public [Releases](https://github.com/mixeme/selfpost/releases)
page lists only **published** releases. A draft is visible to maintainers only —
it looks like “no releases” to everyone else. CI does not create or publish the
GitHub Release; you do that in the UI. Deleting a release’s git tag on GitHub
(or re-pushing tags while cleaning the registry) converts a published release
back into a **draft** — that matches “I published three times and it keeps
disappearing”. After publish, leave the tag on GitHub; clean up only unwanted
GHCR package versions, not the git tag.
Push workflow and source changes to **github.com/mixeme/selfpost** before
publishing — Actions reads that repo, not Gitea.
**Gitea → GitHub tag mirror.** If every tag push from Gitea is mirrored to
GitHub, two things follow:
1. **GitHub Release tags must not be deleted on GitHub.** Many mirror setups
prune remote tags that are absent on Gitea (or re-push with `--force` /
`--prune`). Deleting `v1.0.0` / `v1.3.0` on GitHub converts a published
Release back to draft. Mirror **branches and new tags forward**; do not
delete release tags on the GitHub side. GHCR cleanup is package versions in
the UI — not `git push github --delete` and not tag prune on the mirror.
2. **Tag push runs the workflow file at that tag's commit**, not `main`. `v1.0.0`
still points at a commit whose `release.yml` has `on: push: tags` and no
per-arch GHCR cleanup — every mirror (re)push of that tag can republish
`1.0.0-amd64` / `1.0.0-arm64`. Tags from `v1.3.0` onward only run
`release.yml` on **Publish release** (`release: published`), so mirroring
those tags alone does not start the image build.
Safe mirror: push tags to GitHub without deleting existing ones; keep release
tags on Gitea; publish the GitHub Release on github.com after the mirror has
the tag.
Ordinary commits **do not** publish an image. The compose pin and the git tag
must match (`1.0.0` / `v1.0.0` for the first published release). Intermediate
@@ -265,7 +358,11 @@ operator would actually use.
**Coverage (summary):** bootstrap → SMTP AUTH → delivery → DKIM verify →
send-log `queued → sent`; negatives (no AUTH, relay, sender/login mismatch,
L1/L2 limits, milter fail-open, bad `SELFPOST_HOSTNAME`, session survives
`docker restart`). Polling with timeouts only — no fixed `sleep`.
`docker restart`); startup checks that supervisord actually brought up
OpenDKIM, the panel, and Postfix (`checkSupervisorProcesses`), plus logrotate
config-mode and forced-rotation checks (`checkLogrotateConfigMode`,
`checkLogrotateRotation` — [test/e2e/logrotate_check.go](../test/e2e/logrotate_check.go)).
Polling with timeouts only — no fixed `sleep`.
Requires **Docker + Compose v2** on the machine running the suite.
@@ -280,21 +377,33 @@ Workflows in [.github/workflows/](../.github/workflows/). What each job runs —
`gofmt -l` → `go vet ./...` → `go test ./...` (main module, no e2e).
### `release.yml` — push of tag `vX.Y.Z` or `workflow_dispatch`
### `release.yml` — published GitHub Release, or `workflow_dispatch` with SemVer
Publishing a GitHub Release runs `release.yml` directly (`release: published`,
same pattern as gosentry / imap-scrub). You can also run it manually via
`workflow_dispatch` with an explicit `X.Y.Z` input. A bare git tag push does not
run the workflow. The build always checks out `vX.Y.Z`, not `main` HEAD.
`prepare` takes the version from `github.event.release.tag_name` on a release
event, or from the `workflow_dispatch` `version` input. A dispatch whose input
is missing or not `X.Y.Z` fails in `prepare`.
```
prepare (version from tag)
release: published
prepare (version from release tag or workflow_dispatch input; checkout vX.Y.Z)
→ build [matrix: ubuntu-latest / ubuntu-24.04-arm]
→ docker build --load (VERSION from tag)
→ docker build --load (VERSION from prepare)
→ e2e (test/e2e)
→ push ghcr.io/...:X.Y.Z-amd64 | X.Y.Z-arm64
→ merge
→ docker buildx imagetools create → unified manifest X.Y.Z
→ GitHub Packages API → drop X.Y.Z-amd64 and X.Y.Z-arm64 from GHCR
```
Native per-arch matrix (no QEMU): running the full Postfix/OpenDKIM stack under
emulation for e2e is impractical. E2e first, then push — the registry receives
the bytes that passed the gate.
the bytes that passed the gate. Only `ghcr.io/mixeme/selfpost:X.Y.Z` remains
tagged in GHCR; per-arch names exist briefly during the merge job.
A failed e2e **blocks** image publication.
+547 -245
View File
@@ -3,55 +3,53 @@
Detailed install, configuration, and day-to-day operations. For a short
overview and quick start, see [README.md](../README.md).
This guide has three parts: **[Installation](#installation)** (getting a
container running with a working reverse proxy and TLS), **[Instance
administration](#instance-administration)** (running and maintaining the
SelfPost server itself — status, backups, users, upgrades), and **[Domain
administration](#domain-administration)** (day-to-day work on the sending
domains hosted on that instance — DNS, deliveries, rate limits, applications).
## Table of contents
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
- [Local trial](#local-trial)
- [Environment variables](#environment-variables)
- [DNS setup](#dns-setup)
- [IP warmup](#ip-warmup)
- [Operations](#operations)
- [Rate limiting](#rate-limiting)
- [Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain)
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
- [Published ports](#published-ports)
- [Fixed image tag](#fixed-image-tag)
- [Installation](#installation)
- [Ports](#ports)
- [Local trial](#local-trial)
- [Initial setup](#initial-setup)
- [Full deployment](#full-deployment)
- [Fixed image tag](#fixed-image-tag)
- [Environment variables](#environment-variables)
- [Reverse proxy (mandatory)](#reverse-proxy-mandatory)
- [Instance administration](#instance-administration)
- [Status](#status)
- [Mail queue and System log](#mail-queue-and-system-log)
- [Settings](#settings)
- [Users](#users)
- [Sessions](#sessions)
- [Upgrading](#upgrading)
- [Container health](#container-health)
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
- [Full backup and restore](#full-backup-and-restore)
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
- [Domain administration](#domain-administration)
- [Domains page](#domains-page)
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
- [IP warmup](#ip-warmup)
- [Rate limiting — level 2 (domain and application)](#rate-limiting--level-2-domain-and-application)
- [Deliveries](#deliveries)
- [Exporting and importing a single domain](#exporting-and-importing-a-single-domain)
## Reverse proxy (mandatory)
## Installation
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
proxy in front of it is not optional. The proxy is also the project's only
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
service share one hostname — the common case — it's genuinely one certificate
serving both.
### Ports
SelfPost isn't tied to a specific proxy; pick whichever fits your host:
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
(smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
but it can look like an open port in external scans.
| Proxy | Where certs live | Fragment |
|---|---|---|
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
Apache is the recommended default because the certbot Apache plugin already
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
extra moving parts between "certificate issued" and "Postfix can read it."
**The proxy needs no security configuration of its own.** The panel emits its
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
that's easy to get wrong lives in the service rather than in a config file
somebody edits under pressure. There is exactly one thing the proxy must do:
**pass the original `Host` header through**. All four fragments above already
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
reject every form submission as cross-origin — the log says so explicitly,
printing the `Origin` and `Host` it compared.
## Local trial
### Local trial
The [README quick start](../README.md#quick-start) runs a single container
with `PANEL_COOKIE_SECURE=false` and port 8080 published on localhost. No
@@ -85,7 +83,99 @@ Add `-v /tmp/selfpost-certs:/etc/postfix/tls:ro` to the `docker run` command
(and keep `SELFPOST_HOSTNAME=mail.local.test` so it matches the certificate CN).
Clients must skip TLS verification — the cert is not from a public CA.
## Environment variables
### Initial setup
On first start the one-time setup URL is printed in the container log
(`docker compose logs -f`) and written to `/data/setup-token` inside the
container — `./data/setup-token` on the host, mode `0600` — then deleted when
setup completes. The link is `https://<SELFPOST_HOSTNAME>/setup/<token>` (path
token, not a query string), valid for ten minutes. Open it to choose the
administrator username and password — until then the panel has no login. If
this host ships container logs to a central aggregator, prefer reading the
file:
```sh
docker compose exec selfpost cat /data/setup-token
```
### Full deployment
Production layout: one `docker-compose.yml`, a `.env`, persistent `./data`, and
TLS PEM files at `./certs` (read by Postfix on 465/587). The panel is reached
only through a [reverse proxy](#reverse-proxy-mandatory) on 443 — port 8080 is
bound to localhost in the default compose file.
| Artefact | Path |
|---|---|
| Compose file (fixed image tag) | [deploy/docker-compose.yml](../deploy/docker-compose.yml) |
| Environment template | [deploy/.env.example](../deploy/.env.example) |
| Apache vhost (recommended) | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
| nginx | [deploy/nginx/](../deploy/nginx/) |
| Caddy | [deploy/caddy/](../deploy/caddy/) |
| Traefik | [deploy/traefik/](../deploy/traefik/) |
**1. Fetch the base files.**
```sh
mkdir -p selfpost/data selfpost/certs && cd selfpost
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
cp .env.example .env
```
Edit `.env` — at minimum set `SELFPOST_HOSTNAME` to your mail hostname (bare
FQDN, e.g. `mail.example.com`). It must match the PTR record you request from
your provider and the certificate your proxy will obtain. See [Environment
variables](#environment-variables) for the full list.
**2. Reverse proxy and TLS.** Pick and set up one proxy — see [Reverse proxy
(mandatory)](#reverse-proxy-mandatory) for the per-proxy commands. The same
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`
so Postfix can serve it on 465 (and 587 if enabled).
**3. Start SelfPost.** If you used Apache on the host (the recommended
option), start only the base compose file from your `selfpost/` directory:
```sh
docker compose up -d
```
The nginx/Caddy/Traefik fragments already include `docker compose up -d` —
skip this if you ran one of those.
**Get the setup URL** — open it in a browser to create the admin account (see
[Initial setup](#initial-setup)):
```sh
docker compose logs selfpost 2>&1 | grep -m1 'http'
```
```sh
cat ./data/setup-token
```
**4. DNS and sending.** Before sending real mail:
1. Confirm PTR/rDNS for the server IP points at `SELFPOST_HOSTNAME` (Status
page → *Re-check*) — see [Server-level DNS](#server-level-dns-ptrrdns).
2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the
same time ([Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)).
3. Warm up a new IP gradually ([IP warmup](#ip-warmup)).
#### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.3.1`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's
embedded version and the image tag that produced it are the same value by
construction (the release CI stamps both from one git tag — see
`.github/workflows/release.yml`), so the pin is what makes "restore into the
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
deliberately, not by riding a moving target — see [Upgrading](#upgrading).
### Environment variables
Copy [deploy/.env.example](../deploy/.env.example) to `.env` next to your
`docker-compose.yml`. The table below lists every variable an operator is
@@ -95,7 +185,7 @@ expected to set; defaults match the code exactly.
|---|---|---|---|
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting](#rate-limiting). | `100` | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` |
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
@@ -106,176 +196,223 @@ TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
[deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs`
bind mount — configure the mount, not these variables.
**Internal variables (not part of the operator interface).** The following are
read by the panel or startup scripts but are not meant to be changed in a
normal deployment; documenting them here avoids treating accidental overrides as
supported configuration:
The image also reads a number of internal, non-operator env vars (paths,
timeouts, tuning) — not part of this interface; see
[architecture.md § Configuration](architecture.md#configuration) if you need
them.
- **Panel paths and tuning:** `SELFPOST_DATA_DIR` (`/data`), `SELFPOST_DB_PATH`
(`/data/selfpost.db`), `SELFPOST_SETUP_TOKEN_FILE`
(`/data/setup-token`), `PANEL_HTTP_ADDR` (`:8080`),
`JOURNAL_MILTER_SOCKET` (`/run/selfpost/journal.sock`), `MAIL_LOG`
(`/data/log/mail.log` — read by the panel and written by Postfix, so a change
here has to be matched in `build/postfix-config.sh`),
`PANEL_COOKIE_SECURE` (`true`), `OPENDKIM_SOCKET`
(`/run/opendkim/opendkim.sock`), `OPENDKIM_DIR` (`/data/opendkim`),
`DKIM_SELECTOR_DEFAULT` (`selfpost`), `SASL_DB_PATH`
(`/data/sasl/sasldb2`), `SASL_REALM` (defaults to `SELFPOST_HOSTNAME`),
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains).
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds).
- **Background maintenance:** `TLS_RELOAD_INTERVAL_SECONDS` (`86400` — daily
`postfix reload` to pick up renewed certificates),
`LOGROTATE_INTERVAL_SECONDS` (`21600` — check `mail.log` rotation every six
hours; logrotate keeps 14 rotated files on a daily schedule, and each
rotation triggers `postfix reload`).
### Reverse proxy (mandatory)
## DNS setup
SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse
proxy in front of it is not optional. The proxy is also the project's only
source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets
bind-mounted **read-only** into the SelfPost container, and Postfix uses those
same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail
service share one hostname — the common case — it's genuinely one certificate
serving both.
Two different scopes — don't confuse them:
SelfPost isn't tied to a specific proxy; pick whichever fits your host:
**Server level (once, for the machine itself):**
- **PTR/rDNS** for the server's IP, pointing at its mail hostname. Most
receiving mail servers weigh this heavily; get it from whoever assigns the IP
(hosting provider's panel/support), not from your own DNS zone.
| Proxy | Where certs live | Fragment |
|---|---|---|
| **Apache** (default/recommended) | Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. | [deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) |
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](../deploy/nginx/) |
| Caddy | Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. | [deploy/caddy/](../deploy/caddy/) |
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](../deploy/traefik/) |
**Domain level (for *every* sending domain you add in the panel):**
- **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
- **DKIM** — a TXT record with the exact value the panel shows on that
domain's page (`domain page → DKIM TXT record`), one selector per domain.
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
only, safe to publish immediately). On a send-only relay the sending domain
often has no inbox, so `rua=` is optional — configure a default report address
in *Settings* or per domain when you have a mailbox that receives inbound mail
elsewhere. If `rua=` points at another domain, publish `_report._dmarc` on that
hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …)
cannot be used as external report destinations.
Apache is the recommended default because the certbot Apache plugin already
writes plain `fullchain.pem`/`privkey.pem` files to a predictable path with no
extra moving parts between "certificate issued" and "Postfix can read it."
Skipping any of the three per-domain records is the single most common reason
mail lands in spam even though SelfPost delivered it correctly — DKIM passing
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
panel, add its DNS records at the same time**, not later.
**The proxy needs no security configuration of its own.** The panel emits its
own `Content-Security-Policy`, `Strict-Transport-Security`, `X-Frame-Options`,
`X-Content-Type-Options` and `Referrer-Policy` — deliberately, so the part
that's easy to get wrong lives in the service rather than in a config file
somebody edits under pressure. There is exactly one thing the proxy must do:
**pass the original `Host` header through**. All four fragments above already
do (Apache `ProxyPreserveHost On`, nginx `proxy_set_header Host $host`, Caddy
and Traefik by default). A proxy that rewrites `Host` instead makes the panel
reject every form submission as cross-origin — the log says so explicitly,
printing the `Origin` and `Host` it compared.
The panel checks both scopes for you and tells you what is actually published:
the *Status* page verifies the server's hostname and its reverse record
(forward-confirmed reverse DNS), and each domain's page shows a *DNS status*
card comparing the published DKIM record against the key this server signs with,
plus the domain's SPF, DMARC, and (when configured) DMARC report-authorisation
records. Results are cached for a few minutes;
use *Re-check* right after publishing a record. The SPF check is deliberately
shallow — it looks for a mechanism that literally covers this server's address
and does not follow `include:` or `redirect=`, so a record that authorizes the
server through an include is reported as "cannot tell" rather than as a failure.
In every case the proxy terminates HTTPS for the panel; the resulting
certificate must end up under `./certs` as `fullchain.pem` and `privkey.pem`.
## IP warmup
**Apache (recommended, on the host).** Install Apache with `ssl`, `proxy`, and
`proxy_http` enabled. Copy
[deploy/apache/selfpost-vhost.conf](../deploy/apache/selfpost-vhost.conf) into your
vhost directory, replace `mail.example.com` with your hostname, enable the site,
then issue a certificate:
A brand-new IP has no sending history, so receiving servers are cautious with
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
a domain, increase gradually over days/weeks rather than sending everything on
day one, and check the IP against major blocklists (Spamhaus and similar)
before and during warmup. This is inherent to how mail reputation works on the
public internet, not something SelfPost's configuration can shortcut.
```sh
sudo certbot --apache -d mail.example.com
```
## Operations
Point `./certs` at the PEM files certbot wrote (symlink is fine):
```sh
ln -s /etc/letsencrypt/live/mail.example.com certs
```
**nginx (containerised).** From the `deploy/` directory, merge the nginx
fragment and issue the first certificate before nginx can serve HTTPS:
```sh
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
run --rm certbot certonly --webroot -w /var/www/certbot \
-d mail.example.com --email you@example.com --agree-tos --no-eff-email
docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
```
Edit [deploy/nginx/nginx.conf.example](../deploy/nginx/nginx.conf.example) and
replace `mail.example.com` first. The fragment bind-mounts certbot's output into
both nginx and SelfPost.
**Caddy (containerised, automatic ACME).** Edit
[deploy/caddy/Caddyfile](../deploy/caddy/Caddyfile) and the `<hostname>` placeholders
in [deploy/caddy/docker-compose.caddy.yml](../deploy/caddy/docker-compose.caddy.yml),
then:
```sh
docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
```
Verify Caddy's on-disk cert path for your version before relying on the
default mount — see the comment at the top of the Caddy compose fragment.
**Traefik (containerised).** Edit the `Host(...)` label and ACME email in
[deploy/traefik/docker-compose.traefik.yml](../deploy/traefik/docker-compose.traefik.yml),
start the stack, then extract PEM files for Postfix whenever Traefik issues or
renews a certificate:
```sh
docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs
```
Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
## Instance administration
After sign-in the panel opens on **Status** — the place to answer "is the
service healthy and will mail be accepted?"
- **Status** (`/status`) — supervised processes (Postfix, OpenDKIM, panel),
TLS certificate validity and expiry, milter socket presence, and a short
Postfix queue summary. The **Machine** card adds the resource usage of the
host underneath — processor (core and thread counts), memory and swap, and
per-interface network throughput and totals — read from the kernel's
counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning here, because both delay or kill the mail path;
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS); use *Re-check* after changing DNS. The
**Reload configuration** button re-applies OpenDKIM tables and the Postfix
sender map from the database — use it if daemons drifted from what the panel
shows after manual edits under `/data`.
- **Domains** (`/domains`) — add sending domains, inspect each domain's DKIM
TXT value, SPF/DMARC checks, and SASL applications. Per-domain rate limits
(level 2) and trusted-IP application overrides are configured here.
*Export domain* writes a single-domain archive;
*Import a domain* on the Backup page reads one back in.
- **Deliveries** (`/deliveries`) — searchable send log with server-side filters
by domain and application. A row identifies its message and nothing more —
time, sender, recipient, subject and status `queued` (accepted, not yet
delivered), `sent` (handed off successfully), `deferred` (Postfix is retrying),
`bounced` (final failure), or `rejected` (refused — for example by a level-2
rate limit); *Details* opens that row's own page
(`/deliveries/{id}`). That page carries the sending domain, the application it
was submitted under, the Postfix queue id and the journal id, beside the
message's history — when it was accepted and what Postfix later reported for
the recipient — and, under both, the `mail.log` lines for its queue id: the
connection to the receiving server, the server's reply, and the status that
reply was filed as. Rows outlive `mail.log`, so an older message's lines may
have rotated away; the page says so. Retention is controlled by
`SEND_LOG_RETENTION_DAYS`.
### Status
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
certificate validity and expiry, milter socket presence, and a short Postfix
queue summary. The **Machine** card adds the resource usage of the host
underneath — processor (core and thread counts), memory and swap, and
per-interface network throughput and totals — read from the kernel's
counters; CPU and throughput are measured between refreshes, so they appear
one refresh after the page opens. A fully busy processor or a machine out of
memory is a warning here, because both delay or kill the mail path;
throughput is only reported. The hostname block compares `SELFPOST_HOSTNAME`
against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS) — see
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
Postfix sender map from the database — use it if daemons drifted from what
the panel shows after manual edits under `/data`.
### Mail queue and System log
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
trying to deliver or deferring.
trying to deliver or deferring. A card at the top states this instance's
retry policy — first retry delay, later backoff cap, how long a message
stays in the queue — from `postconf -h`, read once when the panel starts.
A `postconf -e` override inside the container is visible after the next
panel (or container) restart. There is no maximum attempt count: Postfix
retries until the message is delivered or the queue lifetime runs out.
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
related daemon lines). The log rotates daily (14 files kept) with a
`postfix reload` after each rotation; a background loop checks every six
hours. It lives in the data volume, so it survives a container recreate along
with the rest of the state — `./data/log/` on the host — but it is *not*
included in backups: it is diagnostics, not state.
- **Backup** (`/backup`) — download a full-server backup; the same page hosts
the domain-import form (`POST /domains/import`). See
[Backup, restore, and moving a single domain](#backup-restore-and-moving-a-single-domain).
- **Settings** (`/settings`) — change the administrator username and/or password.
Application SASL logins are separate and are not changed here.
**Sessions.** A login survives a container restart: sessions live in SQLite, not
in memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
### Settings
`/settings` changes the signed-in user's username and/or password. **Global
administrators** also set the panel-wide default DMARC report address (`rua=`)
offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
are separate and are not changed here.
### Users
`/users` (global administrator only) creates, edits, and deletes panel users.
There are two roles:
- **Global administrator** — full access to every page and every domain,
including Users, Backup, Status, Mail queue, and System log.
- **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
reachable (404). A domain-admin can *export* the
domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
The panel refuses to remove or demote the **last** global administrator, so
it can never end up with none.
### Sessions
A login survives a container restart: sessions live in SQLite, not in
memory. Expiry is a sliding idle window (`PANEL_SESSION_IDLE_DAYS`, default
seven days) with no absolute lifetime cap — an admin who keeps using the panel
stays signed in indefinitely. HTMX polling on the monitoring screens
(Deliveries, Mail queue, System log, and the Status health fragment) does
**not** count as activity, so an auto-refreshing tab left open will not keep a
session alive forever. Changing the password signs out every other session but
leaves the current browser signed in.
session alive forever. Changing **your own** password on `/settings` signs out
every other session for that user but leaves the current browser signed in.
Signing out (`POST /logout`) ends only the current session — other browsers or
tabs for the same user keep working until their session rows expire.
**Upgrading.** Bump the pinned image tag in `docker-compose.yml` to the target
release, then `docker compose up -d`. The backup version check requires the
running image to match the version that created a full backup — see [Fixed image
### Upgrading
Bump the pinned image tag in `docker-compose.yml` to the target release, then
`docker compose up -d`. The backup version check requires the running image
to match the version that created a full backup — see [Fixed image
tag](#fixed-image-tag).
**Container health.** The image declares a Docker `HEALTHCHECK` that probes
`GET /healthz` on port 8080 (unauthenticated). It returns `200 ok` when
OpenDKIM, the panel, and Postfix are all `RUNNING` under supervisord;
otherwise `503 unhealthy`. This catches a dead mail path that would still leave
the HTTP server up, but it does **not** verify TLS certificates, DNS records,
or end-to-end delivery — use the authenticated **Status** page for that. External
monitoring can use the same endpoint through the reverse proxy if you expose it,
or poll `docker inspect` health state on the host.
### Container health
**First-time setup link.** On first start the one-time setup URL is printed in
the container log (`docker compose logs -f`) and written to `/data/setup-token`
inside the container — `./data/setup-token` on the host, mode `0600` — then
deleted when setup completes. The link is
`https://<SELFPOST_HOSTNAME>/setup/<token>` (path token, not a query string),
valid for ten minutes. If this host ships container logs to a central
aggregator, prefer reading the file:
The image declares a Docker `HEALTHCHECK` that probes `GET /healthz` on port
8080 (unauthenticated). It returns `200 ok` when OpenDKIM, the panel, and
Postfix are all `RUNNING` under supervisord; otherwise `503 unhealthy`. This
catches a dead mail path that would still leave the HTTP server up, but it
does **not** verify TLS certificates, DNS records, or end-to-end delivery —
use the authenticated [Status](#status) page for that. External monitoring
can use the same endpoint through the reverse proxy if you expose it, or poll
`docker inspect` health state on the host.
```sh
docker compose exec selfpost cat /data/setup-token
```
### Server-level DNS (PTR/rDNS)
## Rate limiting
Once, for the machine itself: **PTR/rDNS** for the server's IP, pointing at
its mail hostname. Most receiving mail servers weigh this heavily; get it
from whoever assigns the IP (hosting provider's panel/support), not from
your own DNS zone.
SelfPost applies two independent layers; both can refuse a submission, but only
level 2 writes a `rejected` row in the send log. Level-2 ceilings set in the
panel cannot exceed level 1 (the panel shows the level-1 values and rejects
higher numbers).
The [Status](#status) page verifies the server's hostname against this
record (forward-confirmed reverse DNS). Results are cached for about one
minute; use *Re-check* right after publishing a record.
**Level 1 (IP backstop)** — always on, configured via `.env`:
Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc).
### Rate limiting — level 1 (IP backstop)
SelfPost applies two independent layers of rate limiting; both can refuse a
submission, but only level 2 (domain/application, see
[Domain administration](#rate-limiting--level-2-domain-and-application))
writes a `rejected` row in the send log. Level-2 ceilings set in the panel
cannot exceed level 1 (the panel shows the level-1 values and rejects higher
numbers).
Level 1 is always on, configured via `.env`:
- `RATE_LIMIT_MESSAGES_PER_IP` → Postfix `smtpd_client_message_rate_limit`
- `RATE_LIMIT_WINDOW_SECONDS` → Postfix `anvil_rate_time_unit`
@@ -283,64 +420,126 @@ higher numbers).
This is an anvil limit per connecting client IP. It keeps working even if the
journal-milter (level 2) is down. There is no per-IP bypass.
**Level 2 — domain** — optional, on each domain's page. A message ceiling and
window for **every** client IP sending as that domain. When unset, only
level 1 applies for non-privileged senders.
### Full backup and restore
**Level 2 — application (trusted IPs)** — optional override on an application:
list one or more client IPs and a ceiling **strictly above** the domain limit
(still ≤ level 1). Connections from those IPs use the application ceiling and
skip the domain check. Other IPs stay under the domain limit (or level 1 alone).
An application override without trusted IPs is inactive.
**Full backup** is a self-contained project archive: `data/` (SQLite, all
domains' DKIM keys, all applications' SASL credentials, the Postfix queue,
`manifest.json` with the version that created it), plus `docker-compose.yml`,
`.env`, and `certs/` from the operator directory next to `./data`. Delivery
logs under `data/log/` are excluded. The base compose file mounts the project
directory read-only at `/selfpost-deploy` so the panel and CLI can read those
deploy files — without that mount, *Full backup* refuses with an error.
When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is
recorded in Deliveries as `rejected`.
Take a backup from the panel (*Backup* → *Full backup*) or from the host:
## Backup, restore, and moving a single domain
```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
```
Two related but distinct operations
([architecture.md](architecture.md) § Persistence):
**Restore** means unpacking that archive into an **empty project directory**
(not into `./data` alone) and starting a container of the **exact same image
version** that created it — SelfPost refuses to start otherwise and tells you
which tag to use. On the first successful start after restore, `data/manifest.json`
from the archive is **deleted** — it guards only that one boot, so a later
in-place upgrade is not blocked. On that same first boot the panel also runs
one **Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
SQLite and both daemons are reloaded, healing any drift between the extracted
files and the database (the Status page's *Reload configuration* button runs
the same step on demand). This is why the compose file pins a fixed tag rather
than `:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires (see [Fixed image
tag](#fixed-image-tag)).
- **Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
all applications' SASL credentials, `manifest.json` with the version that
created it): panel button (*Backup* → *Full backup*), or from the
host:
```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
```
**Restore** means unpacking that archive into a fresh `/data` bind mount and
starting a container of the **exact same image version** that created it —
SelfPost refuses to start otherwise and tells you which tag to use. On the
first successful start after restore, `manifest.json` from the archive is
**deleted** — it guards only that one boot, so a later in-place upgrade is
not blocked. This is why the compose file pins a fixed tag rather than
`:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires.
**Restoring in place** (same host — recovering from data loss, or rolling
back after a bad change):
**Alternative: archive `./data` while stopped.** If the service can be taken
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
bulk of the archive; add `--exclude=./data/log` if you only want the state.
Do **not** tar `./data` while
the container is running: the database uses WAL mode and a naive copy can
capture an inconsistent snapshot. The panel/CLI backup remains preferable when
you cannot afford downtime because it takes a consistent SQLite snapshot via
the Backup API on a live container.
```sh
# 1. Stop the instance being replaced
docker compose down
- **Export/import a single domain** (domain page → *Export domain* to write the
file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working**
SASL passwords — to a different SelfPost instance without regenerating
anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full
restore, this works across different hostnames/instances.
# 2. Move the current project aside rather than deleting it
mv . ../selfpost.before-restore
mkdir selfpost && cd selfpost
Both files are **secrets** — they contain the admin password hash (full
backup) or working application credentials (domain export) in the clear or in
directly reversible form. Treat them like any other credential material:
restrict who can read them, don't email them around — and encrypt them, which
SelfPost can do for you.
# 3. Unpack the backup into the fresh directory
tar xzf ../selfpost-backup.tar.gz
### Encrypting a backup or export
# 4. docker-compose.yml in the archive must pin the exact tag the backup was
# made with — check if unsure:
tar xzf ../selfpost-backup.tar.gz -O data/manifest.json
# 5. Start it and watch the boot
docker compose up -d
docker compose logs -f selfpost
```
A version mismatch at step 5 refuses to start and leaves `/data` untouched —
the panel exits with a message naming the tag to use, e.g.:
```
backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.1 — restore into the matching image (selfpost:1.2.3)
```
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
-d` again — the manifest is still there because the failed boot never got to
delete it.
**Moving to a different host** is the same flow: create an empty project
directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if
needed) for the new hostname or proxy, then `docker compose up -d`. The archive
carries `certs/` from the old host — re-issue certificates when the hostname or
IP changes. Set up the reverse-proxy vhost separately (not in the backup).
**Restoring an encrypted (`.spbk`) backup** needs a running container to
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
does not read `/data` and performs no version check. Start one normally
(step 5, but on an empty project you have not unpacked yet), then:
```sh
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
```
Stop it, wipe the project directory again, and continue from step 2 above with
the resulting `.tar.gz` — see [Encrypting a backup or
export](#encrypting-a-backup-or-export) for the decrypt command's password
options.
**Archives from older SelfPost versions** (flat layout: `manifest.json` and
`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore
with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project
that already has `docker-compose.yml` and `.env`.
Restoring an archive taken **before** a session row was removed can bring
that session back: session rows travel with the backup, and a browser that
still holds the matching cookie is signed in again on the next request if the
restored row's idle expiry has not passed. `POST /logout` removes only the
current session; there is no "logout everywhere". Changing your own password
on `/settings` deletes your other sessions, but a global administrator
resetting another user's password on `/users` does not invalidate that user's
existing sessions.
**Alternative: archive `./data` while stopped.** If the service can be taken
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
bulk of the archive; add `--exclude=./data/log` if you only want the state.
Do **not** tar `./data` while the container is running: the database uses
WAL mode and a naive copy can capture an inconsistent snapshot. The
panel/CLI backup remains preferable when you cannot afford downtime because
it takes a consistent SQLite snapshot via the Backup API on a live
container.
See also [Exporting and importing a single
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
operation that also lives on the *Backup* page (`/backup`).
Both a full backup and a domain export are **secrets** — they contain the
admin password hash (full backup), TLS private keys and `.env` (full backup),
or working application credentials (domain export) in the clear or in
directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them
around — and encrypt them, which SelfPost can do for you.
#### Encrypting a backup or export
Both download forms carry an **Encrypt with a password** checkbox. Ticked, the
file that comes down is an encrypted envelope instead of the plain archive:
@@ -380,21 +579,124 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > back
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
## Published ports
## Domain administration
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
(smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
but it can look like an open port in external scans.
### Domains page
## Fixed image tag
`/domains` lists sending domains and hosts the add-domain form (**global
administrator only**). Domain administrators see only domains assigned to
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
applications. Per-domain rate limits (level 2) and trusted-IP application
overrides are configured here — see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads
one back in (**global administrator only**) — see [Exporting and importing a single
domain](#exporting-and-importing-a-single-domain).
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.2.5`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check above:
the panel binary's embedded version and the image tag that produced it are the
same value by construction (the release CI stamps both from one git tag — see
`.github/workflows/release.yml`), so the pin is what makes "restore into the
same version" a checkable fact rather than a guess. Upgrade by bumping the tag
deliberately, not by riding a moving target.
### Domain-level DNS (SPF, DKIM, DMARC)
For *every* sending domain you add in the panel:
- **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
- **DKIM** — a TXT record with the exact value the panel shows on that
domain's page (`domain page → DKIM TXT record`), one selector per domain.
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
only, safe to publish immediately). On a send-only relay the sending domain
often has no inbox, so `rua=` is optional — configure a default report address
in *Settings* (see [Settings](#settings)) or per domain when you have a
mailbox that receives inbound mail elsewhere. If `rua=` points at another
domain, publish `_report._dmarc` on that hub domain too; the panel checks
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
report destinations.
Skipping any of the three records is the single most common reason mail
lands in spam even though SelfPost delivered it correctly — DKIM passing
doesn't help if SPF/DMARC are absent. **Whenever you add a new domain in the
panel, add its DNS records at the same time**, not later.
Each domain's page shows a *DNS status* card comparing the published DKIM
record against the key this server signs with, plus the domain's SPF,
DMARC, and (when configured) DMARC report-authorisation records. Results are
cached for a few minutes; use *Re-check* right after publishing a record.
The SPF check is deliberately shallow — it looks for a mechanism that
literally covers this server's address and does not follow `include:` or
`redirect=`, so a record that authorizes the server through an include is
reported as "cannot tell" rather than as a failure.
Server-level DNS (the PTR/rDNS record) is a separate, once-per-machine scope
— see [Server-level DNS](#server-level-dns-ptrrdns).
### IP warmup
A brand-new IP has no sending history, so receiving servers are cautious with
it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to
a domain, increase gradually over days/weeks rather than sending everything on
day one, and check the IP against major blocklists (Spamhaus and similar)
before and during warmup. This is inherent to how mail reputation works on the
public internet, not something SelfPost's configuration can shortcut.
### Rate limiting — level 2 (domain and application)
Level 2 is optional, configured on each domain's page, and layers on top of
the always-on [level-1 IP backstop](#rate-limiting--level-1-ip-backstop).
Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values
and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix
returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
`rejected`.
**Level 2 — domain** — a message ceiling and window for **every** client IP
sending as that domain. When unset, only level 1 applies for non-privileged
senders.
**Level 2 — application (trusted IPs)** — optional override on an
application: list one or more client IPs and a ceiling **strictly above**
the domain limit (still ≤ level 1). Connections from those IPs use the
application ceiling and skip the domain check. Other IPs stay under the
domain limit (or level 1 alone). An application override without trusted
IPs is inactive.
**Level 2 is best-effort, not a guarantee.** It runs inside the
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
a store error, or the connecting client's IP is not available to the
milter, level 2 is skipped and the message is accepted rather than held up.
Level 1 is the backstop that keeps working even when level 2 cannot run.
### Deliveries
`/deliveries` is a searchable send log with server-side filters by domain
and application. A row identifies its message and nothing more — time,
sender, recipient, subject and status `queued` (accepted, not yet
delivered), `sent` (handed off successfully), `deferred` (Postfix is
retrying), `bounced` (final failure), or `rejected` (refused — for example
by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
*Details* opens that row's own page (`/deliveries/{id}`). That page carries
the sending domain, the application it was submitted under, the Postfix
queue id and the journal id, beside the message's history — when it was
accepted and what Postfix later reported for the recipient. A `deferred`
or `bounced` row includes this Postfix's retry intervals (first delay,
backoff cap, queue lifetime), the same numbers Mail queue shows; domain
administrators see them here because they cannot open Mail queue. Under
both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed
as. Rows outlive `mail.log`, so an older message's lines may have rotated
away; the page says so. Retention is controlled by
`SEND_LOG_RETENTION_DAYS`.
### Exporting and importing a single domain
Domain page → *Export domain* to write the file, *Backup* → *Import a
domain* to read it back in. This moves one domain — its DKIM key and its
applications' **working** SASL passwords — to a different SelfPost instance
without regenerating anything, so DNS (the DKIM TXT record) doesn't need to
change. Unlike a full restore (see [Full backup and
restore](#full-backup-and-restore)), this works across different
hostnames/instances. *Import* is global-administrator only; *export* is
available to any user who can access the domain, **including a domain-admin**
for a domain assigned to them — so a domain-admin can walk away with that
domain's working SASL passwords in the clear. Weigh that when deciding which
domains to assign to a domain-admin account.
A domain export is a secret in the same way a full backup is, and can be
encrypted the same way — see [Encrypting a backup or
export](#encrypting-a-backup-or-export).
+17 -1
View File
@@ -18,7 +18,8 @@ operator.
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
- Panel page and/or per-domain section: recent reports, third-party senders,
delivery health of report ingestion.
- Reuse `admin.dmarc_report_email` and `domains.dmarc_rua` for DNS templates;
- Reuse the `dmarc_report_email` setting (moved off the old `admin` table into
`settings` by migration `0005`) and `domains.dmarc_rua` for DNS templates;
when enabled, suggest a SelfPost-hosted report address.
**Out:**
@@ -47,3 +48,18 @@ remain a separate, opt-in feature that does not forward mail upstream.
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
- Report volume and retention (mitigate: caps + pruning).
## Implementation checklist
Target version cut: **`1.7.0`** (MINOR). One commit per step; code only after
roadmap status is **agreed**. Expand the sketch sections above before step 1
if still thin. See [development.md](../development.md) § Plan checklists.
- [ ] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet**
- [ ] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus**
- [ ] Worker: gzip/XML parse → SQLite — **Opus**
- [ ] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet**
- [ ] Tie-in `dmarc_report_email` / `domains.dmarc_rua` — **Sonnet**
- [ ] Tests and [guide.md](../guide.md) — **Sonnet**
- [ ] Security review ingest path — **Fable**
- [ ] `go vet`, `go test` on touched packages — **Haiku**
+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.5–5.0, default 2.5), read-only
computed max/window in auto mode, «Recalculate now».
- Optional later: «30d» column on domain list (global admin only).
## Tests
- `internal/store/stats_test.go` — fixtures → total / peak / avg.
- `internal/store/ratelimits_test.go` — auto recalc, L1 cap, app &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 -3
View File
@@ -3,9 +3,10 @@
**Status:** agreed
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
the implementation lands (do not fix a major in advance).
**Order:** recommended next among agreed 1.x+ items (after `internal/web` split
and the domain-admin role, both shipped — see [CHANGELOG](../CHANGELOG.md)
`[1.2.0]`).
**Order:** queue-retries shipped in `[1.3.1]`; this is next as the largest
remaining 1.x+ **feature**. The 2026-08-13 full-tree review follow-ups closed
in `[1.3.0]`. The domain-admin role and `internal/web` split have shipped —
see [CHANGELOG](../../CHANGELOG.md) `[1.2.0]`.
---
@@ -167,3 +168,25 @@ the SelfPost image, brought up by the operator when the option is enabled.
A finished outbound path (already implemented). Agreement obtained — see the
status above.
## Implementation checklist
Target version cut: **`1.4.0`** (MINOR). One commit per step; see
[development.md](../development.md) § Plan checklists. UI reference:
[panel-ui inbound mockups](../assets/panel-ui/inbound.html).
- [ ] Migration: inbound domain / recipient / transport tables under `/data` — **Opus**
- [ ] `INBOUND_RELAY_ENABLE` (default false) in entrypoint + `postfix-config.sh` — **Opus**
- [ ] `master.cf`: inbound `smtp inet` on 25; separate from 465/587 — **Opus**
- [ ] Generate `relay_domains`, `transport_maps`, `relay_recipient_maps` (injection-safe) — **Opus**
- [ ] `smtpd_relay_restrictions` / recipient maps — no open relay, no backscatter — **Opus**
- [ ] `internal/store` CRUD + validation (domain, host, port) — **Opus**
- [ ] Panel: list, add, domain detail, recipients, danger zone (mockups) — **Sonnet**
- [ ] Rate limit + `message_size_limit` on inbound smtpd — **Opus**
- [ ] Optional `INBOUND_ANTISPAM_MILTER` + compose fragment — **Opus**
- [ ] DNS MX copy in README/guide; `.env.example` — **Sonnet**
- [ ] Backup/export inbound config (per plan optional flag) — **Opus**
- [ ] Unit + handler tests; image build and container smoke — **Opus**
- [ ] [guide.md](../guide.md) and [security.md](../security.md) — **Sonnet**
- [ ] Security review inbound path — **Fable**
- [ ] `go vet`, `go test`, e2e if applicable — **Haiku**
-28
View File
@@ -1,28 +0,0 @@
# Plan: logrotate-mode (mail.log stops rotating in some images)
**Status:** done
**Version:** patch; no schema, no configuration surface.
---
## Summary
`mail.log` stopped rotating when `/etc/logrotate.d/mail` landed in the image
with group/other write permission (e.g. build context from a Windows tar sync).
logrotate ignores such configs but exits 0, so the loop looked healthy while
the log grew without bound.
## What shipped
- [`build/Dockerfile`](../../build/Dockerfile): `COPY --chmod` pins config
(`0644`) and script (`0755`) modes so the image no longer depends on checkout
file modes.
- [`build/logrotate-loop.sh`](../../build/logrotate-loop.sh): preflight and
per-iteration checks refuse group/other-writable configs; logrotate stderr
mentioning `Ignoring` is fatal.
- [`test/e2e/logrotate_check.go`](../../test/e2e/logrotate_check.go): asserts
mode `644`, forced `logrotate -f`, and that a group-writable context file
still produces `644` in the image.
`create 0640 postfix selfpost` in [`build/logrotate-mail.conf`](../../build/logrotate-mail.conf)
is unchanged — required for panel readability after rotation.
+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**
+156
View File
@@ -0,0 +1,156 @@
# Plan: queue-retries (Postfix retry policy in the panel)
**Status:** done — shipped in `[1.3.1]` (2026-08-17)
**Date:** 2026-08-13
**Version:** patch; no schema, no configuration surface.
**Order:** small panel item; does not wait on inbound-relay.
---
## Goal
Show the operator how Postfix retries deferred mail: first retry delay, later
backoff cap, and how long a message stays in the queue before it bounces. The
numbers come from this container's effective Postfix config, not from
hard-coded copy.
## Scope
**In:**
- A static «How delivery retries work» card on `/mail-queue` (global
administrator). Not inside the HTMX poll fragment — the snapshot is taken at
panel start.
- The same human-readable intervals in `/deliveries/{id}` history for
`deferred` and `bounced` (domain administrators never see Mail queue).
- Operator docs: [guide.md](../guide.md) Mail queue bullet;
[architecture.md](../architecture.md) notes the one-shot `postconf -h` at
panel start. [CHANGELOG.md](../../CHANGELOG.md) `### Added`.
**Out:**
- Changing Postfix retry parameters, or exposing them as panel settings
([product.md](../product.md): Postfix is used as-is).
- An `attempts` column on `send_log`, or «attempt 3 of N» — Postfix has no
attempt budget; it is time-based.
- Counting `status=deferred` lines in `mail.log` or reading `postcat`. Each
attempt is already on the delivery page's log table; the journal stores only
the last status.
- Duplicating the card on Status (summary + link to Mail queue already exist).
- Re-reading `postconf` on every HTTP request.
## Architecture
Acceptance is still synchronous SMTP. Delivery stays in Postfix's on-disk
queue. SelfPost does not enqueue, retry, or deliver.
```mermaid
flowchart LR
smtpAccept["SMTP 250 accepted"] --> postfixQueue["Postfix queue"]
postfixQueue -->|"deferred: backoff until queue lifetime"| retry["Retry MX"]
retry -->|sent| done["sent"]
retry -->|lifetime elapsed or 5xx| bounce["bounced"]
postfixQueue --> mailQueuePage["/mail-queue policy card"]
postfixQueue --> deliveryPage["/deliveries/id history copy"]
```
[build/postfix-config.sh](../../build/postfix-config.sh) does not set
`queue_run_delay`, `minimal_backoff_time`, `maximal_backoff_time`,
`maximal_queue_lifetime`, `bounce_queue_lifetime`, or `delay_warning_time`.
Debian/Postfix 3.x compiled-in defaults therefore apply unless the operator
overrides them (`postconf -e` inside the container).
### Loading the numbers
Once, when the HTTP role starts ([cmd/panel/httpserver.go](../../cmd/panel/httpserver.go),
after `postfix-config.sh` has run):
```
postconf -h queue_run_delay minimal_backoff_time maximal_backoff_time maximal_queue_lifetime bounce_queue_lifetime delay_warning_time
```
`postconf -h`, not a parse of `/etc/postfix/main.cf`: stock values are not
written to the file. `postconf` is the effective config, including a manual
override.
- Fixed argv, no user input — same pattern as
[postfix.Queue](../../internal/postfix/queue.go) /
[security.md](../security.md).
- Cache on `handlers.Config` (via `web.Config`). The HTMX fragment does not
call `postconf`.
- A live `postconf -e` is visible after the next panel (or container) restart.
While the process is up, the panel shows the start-up snapshot.
- Parse Postfix time units (`300s`, `5d`, `1h`, a bare number is seconds) in
`internal/postfix`. Format human strings (`5 minutes`, `5 days`,
`about 1 hour 7 minutes`) in one place so the Mail queue card and
`deliveryEvents` cannot drift.
- If `postconf` is missing (unit tests on Windows, binary outside the
container): log a warning, fall back to Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`), and put a muted note on the card. Tests
stub the lookup (as `queueIDs` in the log-tailer) or pass a fixture on
`Config`. Do not fail panel start.
Typical stock values, for orientation only — the UI prints whatever
`postconf` returned:
| Parameter | Stock | Meaning |
|---|---|---|
| `queue_run_delay` / `minimal_backoff_time` | `300s` | First retry and deferred-queue scan |
| `maximal_backoff_time` | `4000s` | Cap on the doubling gap (~1 h 7 min) |
| `maximal_queue_lifetime` | `5d` | Then bounce |
| `delay_warning_time` | `0` | No delay warning to the sender |
## Panel copy
Mail queue card facts: first retry; later retries (doubling, capped);
kept in queue; then bounced. Short prose: there is no fixed attempt count; a
`deferred` message stays in this listing until it is delivered or the queue
lifetime runs out.
`deliveryEvents(row, policy)`:
- `deferred`: retries, first after X, then with increasing gaps up to Y, for
up to Z.
- `bounced`: or Postfix gave up after Z in the queue.
## Tests
- Duration parser: `5d`, `300s`, `4000s`, `1h`, `0`, bare number.
- `/mail-queue` handler: card shows the fixture policy's human strings, not a
live `postconf`.
- Delivery page / `deliveryEvents`: `deferred` and `bounced` contain those
strings ([handlers_monitor_test.go](../../internal/web/handlers/handlers_monitor_test.go)).
- [templates_test.go](../../internal/web/view/templates_test.go): pass the new
fields if rendering `mail_queue` requires them.
`go test` / `go vet` on the touched packages.
## Done when
- `/mail-queue` states this Postfix's first retry, backoff cap, and queue
lifetime.
- A `deferred` / `bounced` delivery page uses the same intervals.
- A manual `postconf -e maximal_queue_lifetime=2d` followed by a panel restart
changes what the panel prints.
- Guide and architecture describe the snapshot; CHANGELOG has an Added entry.
## Risks
- Showing compiled-in fallbacks when `postconf` failed would mislead if the
operator had overridden them — mitigate with the muted note on the card.
- Inventing a max-attempt count would be false; the copy must stay time-based.
## Implementation checklist
Target version cut: **`1.3.1`** (PATCH). One commit per step; see
[development.md](../development.md) § Plan checklists.
- [x] `internal/postfix`: parse Postfix time units (`5d`, `300s`, bare seconds) + tests — **Opus**
- [x] `internal/postfix`: one-shot `postconf -h` (six keys), fallback + warn — **Opus**
- [x] Load policy at HTTP start in `cmd/panel/httpserver.go`; cache on handlers config — **Opus**
- [x] Human-readable duration formatter (shared by Mail queue card and delivery history) — **Sonnet**
- [x] «How delivery retries work» card on `/mail-queue` (outside HTMX fragment) — **Sonnet**
- [x] `deliveryEvents(row, policy)` — intervals in deferred/bounced copy — **Sonnet**
- [x] Handler and template tests (`handlers_monitor_test.go`, `templates_test.go`) — **Sonnet**
- [x] [guide.md](../guide.md) and [architecture.md](../architecture.md) — **Sonnet**
- [x] `go vet`, `go test` on touched packages — **Haiku**
+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 **7–365** (exact bounds fixed at implementation).
- On first use: if setting missing, seed from env
(`SEND_LOG_RETENTION_DAYS`, default 90) at panel start or first save.
- Log-tailer reads the setting **each prune cycle** (no container restart).
- Delivery pages and guide copy show the **current** retention, not a hardcoded
90.
- Tests; [guide.md](../guide.md); [CHANGELOG.md](../../CHANGELOG.md).
**Out:**
- Per-domain retention (instance-wide only).
- `mail.log` rotation (logrotate, 14 daily files — unchanged).
- Immediate prune on save when lowering retention (next 6 h cycle is enough;
optional «Prune now» not in v1).
- Domain-admin access to this setting.
## Architecture
```mermaid
flowchart LR
settingsPage["/settings form"] --> sqlite["settings.send_log_retention_days"]
env["SEND_LOG_RETENTION_DAYS bootstrap"] --> sqlite
sqlite --> retentionLoop["logtail retentionLoop"]
retentionLoop --> prune["DeleteSendLogBefore"]
```
1. **Read path** — `GetSendLogRetentionDays()`: settings value if valid, else env
default.
2. **Write path** — POST `/settings` (global admin): validate, `SetSetting`,
flash confirmation.
3. **Prune path** — change [`logtail.retentionLoop`](../../internal/logtail/logtail.go)
to accept `func() int` or `RetentionReader` that queries settings each cycle
(same 6 h ticker).
4. **Copy** — inject retention days into delivery list/detail templates and
remove hardcoded «ninety days».
`SEND_LOG_RETENTION_DAYS` remains documented in [guide.md](../guide.md) as the
**initial default** until changed in Settings.
## Relation to domain-stats-auto-ratelimit
[domain-stats-auto-ratelimit](domain-stats-auto-ratelimit.md) uses a **30-day**
stats window. Requires effective retention ≥ 30 for full accuracy. When
retention &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 (7–365).
- Muted copy: rows older than this are deleted from `/deliveries`; main driver
of `/data` growth; does not affect `mail.log` rotation.
Domain administrators keep the narrow credentials-only settings page.
## Tests
- Save/load setting; reject out-of-range values.
- `retentionLoop` uses updated value without process restart (mock reader).
- Bootstrap: empty settings → env default used for prune.
- Template/delivery copy reflects configured days.
`go test` / `go vet` on touched packages.
## Done when
- Global admin can set retention on `/settings`; value persists in SQLite.
- Prune uses the panel value on the next cycle; env remains bootstrap default.
- Guide documents panel vs env; CHANGELOG entry added.
- Hardcoded «ninety days» removed from delivery UI.
## Risks
- Operator lowers retention while bookmarking old delivery URLs — existing
behaviour; copy already notes pruned rows are gone.
- Settings change without restart — must be tested so log-tailer never keeps a
stale int from panel start only.
**Version:** `1.x` MINOR.
## Implementation checklist
Target version cut: **`1.5.0`** (MINOR). One commit per step; code only after
roadmap status is **agreed**. See [development.md](../development.md) § Plan
checklists.
- [ ] `GetSendLogRetentionDays` / `SetSetting` key `send_log_retention_days` (7–365) — **Opus**
- [ ] Bootstrap from `SEND_LOG_RETENTION_DAYS` when settings empty — **Opus**
- [ ] `logtail.retentionLoop`: read setting each prune cycle — **Opus**
- [ ] Settings card on `/settings` (`settings.html`) — **Sonnet**
- [ ] Remove hardcoded «ninety days» in handlers and templates — **Sonnet**
- [ ] Tests: save/load, range, loop without restart — **Sonnet**
- [ ] [guide.md](../guide.md) — **Sonnet**
- [ ] `go vet`, `go test` on touched packages — **Haiku**
+3 -1
View File
@@ -59,7 +59,9 @@ domain administrators with assigned domains). The optional **inbound relay** is
the main agreed **1.x+** extension still on the
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
remains possible pending implementation. Items marked *candidate* in the
roadmap require explicit approval before coding.
roadmap require explicit approval before coding (including **send-log retention
in Settings**, **30-day send statistics**, and **auto level-2 rate limits** —
see linked plans there).
---
+100 -14
View File
@@ -15,7 +15,9 @@ planned but unlisted.
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
an explicit break.
an explicit break. One such break, when 2.x is cut for any reason, is
[schema-squash](#schema-squash) — replacing the 1.x SQLite migration chain
with a baseline. That item does not by itself justify a major.
**Process:** [development.md](development.md). The history of closed phases is
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
@@ -24,19 +26,27 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
## Index
| ID | Topic | Status | Plan |
|---|---|---|---|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
| contributing | `CONTRIBUTING.md` | candidate | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
| panel-docs | In-panel operator documentation | candidate | — |
| ID | Topic | Status | Progress | Plan |
|---|---|---|---|---|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 0/15 | [plans/inbound-relay.md](plans/inbound-relay.md) |
| send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) |
| domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) |
| contributing | `CONTRIBUTING.md` | candidate | — | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | 0/8 | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
| panel-docs | In-panel operator documentation | candidate | 0/6 | [plans/panel-docs.md](plans/panel-docs.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): **inbound-relay** first among agreed
items — it is the largest remaining 1.x+ extension. Candidates need explicit
agreement before they join the queue.
**Recommended order** (not binding): the next feature is **inbound-relay**.
**send-log-retention** can land before or beside **domain-stats-auto-ratelimit**
(panel retention ≥ 30 days helps the stats window). queue-retries shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.3.1]`; the 2026-08-13 full-tree review
follow-ups are in `[1.3.0]`. Candidates need explicit agreement before they
join the queue.
After a context reset, pick an item marked `agreed` or `in progress`, then work
the checklist in its linked plan.
the **Implementation checklist** in its linked plan. The `Progress` column above
is `done/total` checklist steps in that plan ([development.md](development.md)
§ Plan checklists).
---
@@ -61,6 +71,44 @@ implementation lands.
---
## send-log-retention
**Goal:** global administrator sets how many days of delivery journal rows
(`send_log`, `/deliveries`) are kept, from `/settings` — stored in SQLite
`settings`, with `SEND_LOG_RETENTION_DAYS` as bootstrap default only.
**Boundary:** instance-wide retention; not per-domain. Does not change
`mail.log` rotation. Today retention is env-only (default 90 days); the panel
has no control.
**Done when:** see [plans/send-log-retention.md](plans/send-log-retention.md).
**Dependencies / risks:** log-tailer must re-read settings each prune cycle;
delivery UI must stop hardcoding «ninety days».
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
---
## domain-stats-auto-ratelimit
**Goal:** 30-day sending statistics per domain and application (total, peak and
average msg/h) on the domain page, plus optional **auto** level-2 rate limits
computed as `ceil(avg_hourly × multiplier)` over the level-1 window.
**Boundary:** extension of v1.0 manual L2 limits; does not change Postfix
level 1. Stats exclude level-1 refusals (not in send log). Auto with zero
traffic stays inactive.
**Done when:** see
[plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md).
**Dependencies / risks:** send log data and preferably
[send-log-retention](#send-log-retention) ≥ 30 days; aggregate query cost on
large logs; rate-limit path needs security review.
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
---
## contributing
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
@@ -94,9 +142,10 @@ reports (`ruf=`) out of scope for v1.
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
storage and retention of parsed summaries; the `admin.dmarc_report_email` and
`domains.dmarc_rua` settings added in the DMARC template work must stay the
source of truth for `rua=` in DNS guidance.
storage and retention of parsed summaries; the `dmarc_report_email` setting
(migration `0005` moved it off the old `admin` table into `settings`) and
`domains.dmarc_rua` added in the DMARC template work must stay the source of
truth for `rua=` in DNS guidance.
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
inbound-relay depending on how port 25 acceptance is structured.
@@ -127,3 +176,40 @@ when checks change; not bloating every page with a second column of prose.
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
---
## schema-squash
**Goal:** when 2.x is cut, stop shipping the 1.x migration files
(`0001_init.sql` … `0005_panel_users.sql`) in the binary and replace them with
one baseline that is the schema as of `user_version = 5`. Fresh 2.x data
directories no longer create-then-drop the historical `admin` table.
**Boundary:** 1.x keeps the full chain so a 1.0.0 data directory still boots.
Do not delete, rename, or reorder those files while MINOR compatibility with
`1.0.0` holds. `migrate()` maps **file order** to `PRAGMA user_version` (`target
= i + 1`); dropping a file in 1.x would skip or mis-apply steps on existing
databases. Git history keeps the old files either way; only the embedded set
in the 2.x image changes.
**Upgrade gate (required with the squash):**
| `user_version` | 2.x behaviour |
|---|---|
| `0` (empty file) | Apply the baseline; set `user_version` to the new chain’s head |
| `>= 5` (fully migrated 1.x) | Skip; schema is already the baseline |
| `1`…`4` (mid-chain 1.x) | **Refuse to start** — boot the last 1.x once, then 2.x |
Restore remains a separate lock: the backup manifest version must match the
running binary ([architecture.md](architecture.md) § Persistence). It does not
replace this gate.
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
it); the gate above is tested; the operator guide says a 2.x image will not
open an unfinished 1.x database.
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
explicit major). Squashing five short files is not a reason to cut 2.x on its
own. A missed gate leaves a `user_version = 3` database silently stuck.
**Version:** `2.x` major only; not a 1.x item.
+64 -28
View File
@@ -5,7 +5,12 @@ meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06)
covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist
in full: no exploitable findings; one defence-in-depth change — `--` before the
login in the `saslpasswd2` argv
([internal/app/sasl.go](../internal/app/sasl.go)). (2) **Accepted risks** —
([internal/app/sasl.go](../internal/app/sasl.go)). The 2026-08-14 review
(code-review plan § P7, Fable; reviewer ≠ author) covered the P0–P1 diff of the
2026-08-13 full-tree review against this document — send-log authorization for
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
session creation, and application-delete ordering: no findings, and nothing
needed adding to the accepted risks. (2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not
lost.
@@ -34,8 +39,8 @@ The panel is exposed to the internet — the items below are **not optional**.
- Failed attempts do **not** invalidate the token early (protects setup from
being DoS-ed).
- Once the administrator exists the token is void forever, `/setup/*` → 404.
- The administrator password is bcrypt (or argon2) in SQLite only; no plaintext
and no MD5.
- The administrator password is bcrypt in SQLite only; no plaintext and no
MD5.
- `PANEL_USERNAME` / `PANEL_PASSWORD_HASH` in env are **not used**.
### Application SASL passwords
@@ -81,10 +86,11 @@ The panel is exposed to the internet — the items below are **not optional**.
### Backup and domain export
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
administrator's password hash; a domain export carries the DKIM key and
**working** application passwords in the clear (otherwise a transfer without
recreating credentials would be impossible).
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
private key from `certs/` when present; a domain export carries the DKIM key
and **working** application passwords in the clear (otherwise a transfer
without recreating credentials would be impossible).
- Both downloads can be encrypted with a password (a checkbox on the form):
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
authenticated with the header, the chunk number, and an end-of-stream flag —
@@ -112,19 +118,34 @@ deferred item from the roadmap.
- **A `POST` with neither `Sec-Fetch-Site` nor `Origin` is allowed through.**
A client that sends neither — a genuinely old browser, or a webview with a
frozen engine — stays vulnerable to CSRF from any site. Accepted
deliberately: the panel is single-user, the administrator picks the browser,
and a strict mode would not "protect" such a client, it would simply break the
panel in it. Tightening is one line in `originAllowed`
deliberately: every panel user (global or domain-admin) is an operator who
picks their own browser, not an untrusted party the panel needs to defend
against, and a strict mode would not "protect" such a client, it would
simply break the panel in it. Tightening is one line in `originAllowed`
([internal/web/security.go](../internal/web/security.go)): return `false`
instead of `true` in the "neither header present" branch.
- **Session-bound CSRF tokens are not implemented.** The origin check closes the
neighbouring-subdomain case but depends on browser behaviour; a token does
not. The price is a hidden field in roughly two dozen forms. The trigger to
revisit is a requirement for protection that holds regardless of the browser.
A token would not save the panel from XSS inside it either: code executing in
the panel's origin sends the request itself — against that, `html/template`
auto-escaping and CSP do the work, which is why templates must contain no
inline scripts and no inline styles.
revisit is a requirement for protection that holds regardless of the browser,
or a domain-admin population the global administrator does not fully trust
(see the ADR below). A token would not save the panel from XSS inside it
either: code executing in the panel's origin sends the request itself —
against that, `html/template` auto-escaping and CSP do the work, which is why
templates must contain no inline scripts and no inline styles.
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
Delete, regenerate-password, and clear-rate-limit forms carry a
`data-confirm` prompt handled entirely in
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
or blocked the form submits immediately, exactly as it did before the
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
not an authorization boundary — the same origin check and session/RBAC
gate every one of these `POST`s whether or not JavaScript ran. Progressive
enhancement means the panel must work with JavaScript off; a
server-rendered confirmation step would need a second page (or a `?confirm=1`
round trip) for every one of these forms, which is what
[`user_delete.html`](../internal/web/view/templates/user_delete.html) and
`domain_delete.html` already do for the two highest-blast-radius deletes.
- **Encrypting backups and exports is an option, not the default.** With the
checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an
operator with nowhere to keep a password would lose the ability to take a
@@ -158,24 +179,35 @@ deferred item from the roadmap.
**Context.** The panel is forms (`POST`) with a cookie session — the classic
CSRF surface. What is needed is a way to tell a request from the panel's own
page apart from one initiated by a third-party site in the logged-in
administrator's browser.
page apart from one initiated by a third-party site in a logged-in user's
browser. The panel is multi-user since 1.2.0 (a global administrator plus
zero or more domain-admin users, each scoped to their assigned domains), but
that is an authorization boundary (who can see or change what), not a change
to the CSRF threat: the attacker in scope here is still an external site
riding a legitimate user's cookie, not one panel user attacking another
through the browser.
**Decision.** `originAllowed` in
[internal/web/security.go](../internal/web/security.go) checks `Sec-Fetch-Site`
(when the browser sends it) or `Origin` (fallback) against the panel's host; a
request carrying neither header is **allowed through** rather than rejected.
There are no session-bound tokens embedded in forms.
There are no session-bound tokens embedded in forms. The check applies the same
way regardless of the requesting user's role.
**Why not tokens.** The panel is single-user (one administrator per instance) —
the threat model does not include cross-user CSRF inside the panel itself, only
an external site making the administrator's browser send a request. The origin
check covers that without touching a single template: a token would need a
hidden field in roughly two dozen forms and synchronisation with every new form,
and it would still not protect against XSS inside the panel — code executing in
the panel's origin reads the token and sends the request itself. XSS is handled
by `html/template` auto-escaping and CSP, so that is a separate line of defence,
not a CSRF token.
**Why not tokens.** Cross-user CSRF is not the threat model here: a
domain-admin's browser sending a request still needs that domain-admin's own
cookie, so a token would not add a boundary between roles that the
authorization checks (`Principal.CanAccessDomain`,
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
don't already enforce. The remaining case is an external site making a
logged-in user's browser send a request, which the origin check covers without
touching a single template.
A token would need a hidden field in roughly two dozen forms and
synchronisation with every new form, and it would still not protect against
XSS inside the panel — code executing in the panel's origin reads the token
and sends the request itself. XSS is handled by `html/template` auto-escaping
and CSP, so that is a separate line of defence, not a CSRF token.
**Trade-off.** A client that sends neither `Sec-Fetch-Site` nor `Origin` (a
genuinely old browser, or a webview with a frozen engine) stays vulnerable — see
@@ -183,7 +215,11 @@ genuinely old browser, or a webview with a frozen engine) stays vulnerable — s
such a client, at the price of a narrow residual surface.
**Revisit if:** a requirement appears for protection that does not depend on
browser behaviour, or the panel becomes multi-user.
browser behaviour, or domain-admin accounts stop being trusted operators (for
example, if a future release lets a global administrator invite domain-admins
whose browsers/devices are not vetted) — at that point cross-role request
forgery inside the panel would need its own analysis, separate from the
external-site case this ADR covers.
## How this list grows