231 Commits
Author SHA1 Message Date
mixandCursor 2f888dc7be ci: drop per-arch GHCR tags via GitHub Packages API
test / test (push) Has been cancelled
imagetools has no rm subcommand; the merge cleanup had been failing silently. Document Gitea-to-GitHub tag mirror pitfalls in development.md. CHANGELOG [1.3.0] updated.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:47:09 +03:00
mixandCursor 5601f73622 ci: consolidate release workflow and trim GHCR tags
test / test (push) Has been cancelled
Run release.yml directly on release:published instead of a dispatcher
workflow. Remove per-arch tags from GHCR after the manifest merge so only
X.Y.Z remains visible to operators.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:44:33 +03:00
mixandCursor e17b1680bd ci: dispatch release build from release-on-publish workflow
test / test (push) Has been cancelled
GitHub recorded release published at 16:44:21Z but no release.yml run
started. A thin release-on-publish workflow listens for publish and starts
release.yml via workflow_dispatch (always creates a run). release.yml now
checks out vX.Y.Z from the version input, not main HEAD.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-14 19:48:20 +03:00
mixandCursor 5b63da0e49 ci: trigger release workflow on published GitHub Release
test / test (push) Has been cancelled
Bare git tag pushes no longer start the image build; publishing a GitHub
Release does. workflow_dispatch still requires an explicit X.Y.Z version.
Docs explain draft vs published releases and that deleting a tag on GitHub
converts a published release back to draft.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-14 19:43:06 +03:00
mixandCursor e9aaed1c7b docs: reconcile [Unreleased] with full git history since 1.2.5
test / test (push) Has been cancelled
Expand the doc-alignment entry to cover guide RBAC and restore Resync
wording; trim the P2 entry of claims superseded by that pass while keeping
its /license route-table addition with a cross-reference.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:40:46 +03:00
mixandCursor 1bd71c10d0 docs: align operator docs with routes, sessions, and restore
test / test (push) Has been cancelled
Fix architecture route RBAC, restore Resync, and session/password wording;
correct guide restore/session/PTR claims and README port-587 text; point
the CSRF ADR at authz.go for global route gating.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:36:24 +03:00
mixandCursor 74acf09212 docs: verify [Unreleased] against git since 1.2.5
test / test (push) Has been cancelled
Move restore Resync under Fixed to match e70ba90, and extend the restore
test bullet with the resync boot step and TestResyncAfterRestoreHealsDriftedMaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:30:12 +03:00
mixandCursor ba07decbb0 docs: bring [Unreleased] up to date with guide and review closure
Record the operator-guide restructure, merge the stale code-review plan
entry with its completion note, and align the restore Resync bullet with
the current backup package comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 19:24:29 +03:00
mixandClaude Sonnet 5 4fc5cff266 docs(guide): add worked restore commands to Full backup and restore
test / test (push) Has been cancelled
The section explained what a restore means conceptually but gave no
runnable commands. Add a step-by-step in-place restore, a
move-to-a-new-host variant, and the encrypted-backup decrypt-first
sequence, plus the actual version-mismatch error text so operators
know what to expect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:18:54 +03:00
mixandClaude Sonnet 5 ac8c2af500 docs(guide,architecture): move internal env vars out of the operator guide
test / test (push) Has been cancelled
Internal (non-operator) env vars were listed in guide.md's Environment
variables section even though the guide's own text says operators
aren't meant to touch them. They now live in architecture.md §
Configuration, the as-built/internals doc; guide.md keeps a one-line
pointer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:13:33 +03:00
mixandClaude Sonnet 5 9e3f0d0fad docs(guide): reorder Installation and add a Full deployment walkthrough
test / test (push) Has been cancelled
Installation now reads Ports, Local trial, Initial setup, Full
deployment (with Fixed image tag nested under it), Environment
variables, Reverse proxy. The step-by-step production deploy and the
per-proxy TLS commands move from README's "Reference deploy" into the
operator guide; README keeps a short pointer instead of duplicating
the walkthrough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 15:49:58 +03:00
mixandClaude Sonnet 5 403c76d17d docs(guide): restructure into Installation / Instance admin / Domain admin
test / test (push) Has been cancelled
Splits mixed DNS-setup, Operations, Rate-limiting, and Backup sections
along the instance/domain boundary and fixes README anchors that
pointed at the old #dns-setup heading.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 15:45:38 +03:00
mixandClaude Fable 5 d9ab7bfa45 docs: retire the completed full-tree review plan
test / test (push) Has been cancelled
Every phase (P0-P7) is closed, so the plan file goes per its own exit criteria; history lives in git and the CHANGELOG. The roadmap's recommended order returns to queue-retries and then inbound-relay, and the order notes in both plans stop pointing at the deleted file, as does the 1.2.5 changelog entry (same treatment as logrotate-mode.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:23:52 +03:00
mixandClaude Fable 5 18bfd148ec docs: close P7 — security review of the P0–P1 diff, no findings
The Fable review of the send-log authorization, tryAdmit, session create and app-delete ordering against security.md found no further issues: every send-log query path states its scope and an empty scope matches nothing, rejected rows cannot consume rate-limit budget, the milter's nil-registry fail-open is test-only, and each failure residue of the reordered delete fails safe. Outcome recorded in security.md's header and the plan; nothing added to the accepted risks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:02:07 +03:00
mixandCursor e70ba9046e fix(panel): resync mail-path maps once after restore
When CheckRestore accepts a backup manifest, the panel re-derives OpenDKIM tables and the Postfix sender map from SQLite on that first boot and reloads both daemons, so archive/database drift is healed before mail flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 14:58:08 +03:00
mixandCursor 02afa0fa80 fix(panel): code-review P6 optional cleanup
test / test (push) Has been cancelled
Close the remaining low-risk items from the full-tree review: rename the settings handler, query assigned domains in SQL, bound the login limiter map, collapse panel.js show/hide helpers, and soften DMARC copy that promised a future in-panel receiver.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 14:48:43 +03:00
97a0c7e508 licence,ci: ship OFL with Plex fonts and guard release dispatch (P5)
test / test (push) Has been cancelled
IBM Plex WOFF2 files were shipped without the SIL OFL 1.1 text, NOTICE
told modifiers to edit layout.html for a Source URL that lives in
legal.go, and workflow_dispatch took GITHUB_REF_NAME as the version so a
run from main would publish ghcr.io/...:main.

Closes P5 of docs/plans/code-review.md. CHANGELOG updated.

Co-Authored-By: Cursor Grok 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:47:33 +03:00
efdc2d63fb docs: agree queue-retries (Postfix retry policy in the panel)
test / test (push) Has been cancelled
Roadmap item and plan: show first retry delay, backoff cap and queue
lifetime on Mail queue and the delivery page, reading postconf -h once
at panel start so a manual override is visible. Explanation only; not
implemented yet. CHANGELOG updated.

Co-Authored-By: Cursor Grok 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:51:13 +03:00
8dfb483244 test: boot a panel on a data directory restored from its own backup (P4)
test / test (push) Has been cancelled
Restore is not a code path in the panel — the operator extracts the archive
onto /data and starts the image — so it had no test. cmd/panel/restore_test.go
now performs that path in process: download a backup from a running panel
through POST /backup (plain and encrypted), unpack it the way tar -xzf does,
and boot a second panel on the result through run()'s own startup order
(CheckRestore, store.Open, newPanel, Start).

Covered: the restored panel shows the domain and journal the archive carried
and finds the DKIM key, sasldb2 and Postfix sender map where its configuration
says they are; the setup link is not reopened by a restore; a session that
predates the backup still works, as the guide documents; an encrypted download
restores identically; a data directory from another version is refused with
both versions named and the manifest kept for the retry.

serveHTTP is split so the composition it performs (newPanel) can be started
without binding a port. No behaviour change.

Closes the optional P4 item in docs/plans/code-review.md, and with it the
"HandleBackup POST untested" gap from the review's test section. CHANGELOG
updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:46:01 +03:00
e6af1a7866 test: cover the auth, sign-in and RBAC surfaces P0 shipped through (P4)
test / test (push) Has been cancelled
- internal/web/auth/ratelimit_test.go: the login/setup limiter's ceiling,
  per-address scope, window reset and the sweep that keeps finished
  buckets out of memory.
- internal/web/auth/handlers_test.go: sign-in (session issued, refusals
  that do not reveal which usernames exist, a lockout a correct password
  cannot bypass, the setup hint before the first administrator) and the
  one-time setup link (creates the first global administrator and then
  closes, rejects a wrong or expired token, refuses credentials the panel
  would not accept later, rate-limited).
- internal/web/handlers/authz_test.go: every global-only route answers a
  domain administrator — and a request with no principal — with 404, with
  a positive control so the table cannot pass on a handler that always
  404s. This is the check that would have caught the send-log leak.
- test/e2e: CoreDNS pinned to 1.14.6 instead of latest; the level-1
  failure message quoted RATE_LIMIT_MESSAGES_PER_IP=5 while the stand
  sets 50.

docs/plans/code-review.md P4 checked off except the optional backup-boot
test; CHANGELOG updated. No production code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:35:46 +03:00
mixandCursor aab35f0679 fix(panel): P3 optional items — urlquery, aria-live, confirm-without-JS note
- Deliveries fragment/pagination links urlquery-encode the domain/app
  filters instead of splicing them into the query string raw.
- The four polled regions (deliveries rows, status, mail queue, system
  log) carry aria-live="polite" so a screen reader announces refreshes.
- security.md accepted risks note that data-confirm prompts are
  JavaScript-only and why that is acceptable.

docs/plans/code-review.md P3 fully checked off; CHANGELOG updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 22:54:31 +03:00
mixandCursor 0570608738 fix(panel): GUI defects from the 1.2.x layout pass (P3)
- .flash.error now renders on the danger surface instead of the success
  one; RateLimitErr previously showed as green with red text.
- User delete goes through a confirmation page (GET/POST
  /users/{uid}/delete), matching the domain-delete pattern, instead of a
  plain submit button next to Save with no confirmation.
- Extracted the repeated DNS Host/Type/Value markup on a domain's page and
  the duplicated Settings credentials form into shared partials. No
  behaviour change.

docs/plans/code-review.md P3 checked off; CHANGELOG updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 21:50:42 +03:00
1cf8bfcbe2 docs: rewrite security/operator docs for the multi-user panel (P2)
test / test (push) Has been cancelled
The CSRF ADR, guide.md, architecture.md route table, the internal/backup
package comment, roadmap/dmarc-reports admin.dmarc_report_email references,
the e2e coverage summary, and the setupManager/store package comments still
described the pre-1.2.0 single-administrator panel or stale implementation
details. Closes P2 of docs/plans/code-review.md; no behaviour change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 16:15:14 +03:00
4761991dd5 panel,mail: fail closed on the rate-limit race, session create and app delete
test / test (push) Has been cancelled
The level-2 limiter counted stored plus in-flight messages and reserved its own slot in two critical sections, so SMTP sessions that overlapped could each take the last free slot; tryAdmit now does both under one lock. A session that cannot be written no longer yields a cookie the browser would carry while every request bounced to /login. Deleting an application clears its SASL account before its registry row, matching domain delete, so a saslpasswd2 failure leaves a retryable application rather than an account that still authenticates.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:50:04 +03:00
mixandCursor 8355479e03 docs: note e2e send-log badge scraper fix in Unreleased
test / test (push) Has been cancelled
The release gate timed out waiting for status=sent because its HTML scrapers had drifted from the panel's badge markup; record the fix and the handler regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:36:11 +03:00
mixandCursor 6216e430e9 test(e2e): follow send-log status badge markup in scrapers
test / test (push) Has been cancelled
The deliveries_rows template wraps status in badge spans, but the e2e gate still looked for bare td cells, so send_verify_dkim_and_status timed out even when mail was delivered and logged. Add a handler regression test so go test ./... catches this drift without Docker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 14:19:31 +03:00
mixandCursor a1e7e05d26 ci: gofmt files that failed the formatting workflow check
test / test (push) Has been cancelled
Whitespace-only alignment from gofmt; removes a stray BOM in web.go.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:24:34 +03:00
fae0068c4a panel: scope the send log to a domain admin's own domains
test / test (push) Has been cancelled
The Deliveries list narrowed the journal only when exactly one domain was assigned, so an administrator with none or with two or more read every domain's rows. The domain scope is now an IN constraint the store query carries, a filter that states no scope matches nothing, and the domain/app query parameters are validated against the principal before the query runs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 12:20:51 +03:00
mixandCursor b829625066 docs: record full-tree review and 2.x schema squash
test / test (push) Has been cancelled
Capture the 2026-08-13 review as the active plan, queue it ahead of inbound-relay, and defer SQLite migration squash to 2.x with a user_version gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:36:08 +03:00
mixandCursor 71048922b9 release: 1.2.5
test / test (push) Has been cancelled
Close Unreleased for the rate-limit form polish; pin compose and docs to 1.2.5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:04:19 +03:00
mixandCursor 5caca024c3 panel: restore natural Export and Danger card layout
test / test (push) Has been cancelled
Drop the shared-baseline flex pin on .split cards; mismatched body
lengths made Export / Danger look worse than the plain stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:02:15 +03:00
mixandCursor 8289ed3916 panel: align paired domain settings and export actions
test / test (push) Has been cancelled
Subgrid Domain settings rows, match Address mode and trusted-IP controls,
and pin Export/Danger buttons to a shared baseline in split cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 00:39:55 +03:00
mixandCursor 958d07fcdd panel: polish rate-limit form copy and field alignment
test / test (push) Has been cancelled
Drop the app L1 backstop line, move trusted-IP help under the IP field,
shorten the domain rate-limit title, and align field-pair labels with DMARC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 00:26:17 +03:00
mixandCursor f0cd804cb3 panel: tighten rate-limit and address-mode copy
Restore muted L1 backstop line, move domain limit help above Save, and drop
the duplicate Address mode field label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 00:13:34 +03:00
mixandCursor 3950bd333b docs: put rate-limit UI polish back under Unreleased
test / test (push) Has been cancelled
1.2.4 stays as cut at the release commit; the L1 display and badge work
belongs in Unreleased until the next version cut (development.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:40:33 +03:00
mixandCursor 584f8cbc1c panel: clearer L1 rate-limit display and status badges
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Show the level-1 backstop as its own line and code-row, use st badges for
active/inactive domain and app limits, and fold the polish into 1.2.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:37:12 +03:00
mixandCursor 6671bd3393 release: 1.2.4
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Close Unreleased for the level-2 rate-limit semantics change and DNS Type
field height fix; pin compose and docs to 1.2.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:21:28 +03:00
mixandCursor 00e36df553 rate limit: domain ceiling for all IPs, trusted app override
Invert level-2 semantics so domain limits apply to every client IP and
application limits with trusted IPs raise the ceiling above the domain
(still capped by level 1). Panel shows L1, validates maxima, and documents
the model on Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:19:51 +03:00
mixandCursor b0ebe061b5 panel: match DNS Type TXT height to Host fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 23:06:09 +03:00
mixandCursor 601e183e0c release: 1.2.3
Close CHANGELOG [Unreleased] as 1.2.3; bump the compose pin and image references. Includes domain detail layout polish and Domains add-row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:52:19 +03:00
mixandCursor f9e259a66d panel: refine domain detail layout and drop section index
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:16:03 +03:00
mixandCursor 44e79c4120 panel: pair domain detail cards like Status (SPF | DMARC)
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:33:41 +03:00
1d30605533 panel: align page URLs, titles, and headings
test / test (push) Has been cancelled
Settings moves to /settings with a 308 redirect from /account; domains, Status, Users, and user forms get matching browser titles and h1 text; backup page title reflects domain import.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:24:14 +03:00
b87baa6dd6 fix: pin logrotate config mode in image and fail loud on bad permissions
test / test (push) Has been cancelled
COPY --chmod makes /etc/logrotate.d/mail 0644 regardless of build context
file modes (Windows tar sync). logrotate-loop preflight exits non-zero when
logrotate would ignore the config. E2e covers mode, forced rotation, and a
group-writable context build.

Co-Authored-By: Composer 2.5 <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 00:51:37 +03:00
9420e30d6f release: 1.2.2
test / test (push) Has been cancelled
Close CHANGELOG [Unreleased] as 1.2.2; bump the compose pin and image references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 00:34:09 +03:00
mixandCursor 93cf1de3b7 panel: denser Status layout with true two-column cards
test / test (push) Has been cancelled
Pair Machine|Processes, queue|cert, and sockets|hostname; fix .split
auto-margins so cards fill half the row; trim Status prose and machine
details; drop the page section index; note panel-docs on the roadmap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 00:29:34 +03:00
mixandCursor 6b8658602d panel: place mail queue and TLS cert side by side on Status
test / test (push) Has been cancelled
Widen the status page and wrap the short queue/certificate cards in
.split so the stack is shorter; other checks stay full width.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:24:24 +03:00
mixandCursor 3a520d9ec7 panel: fix Assigned domains checkbox layout on user form
test / test (push) Has been cancelled
Form-wide block labels and full-width inputs stacked the box above the
domain name; checkbox rows now share label.check and fieldset spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:15:37 +03:00
4015ecd70d release: 1.2.1
test / test (push) Has been cancelled
Close CHANGELOG [Unreleased] as 1.2.1; bump the compose pin and image references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 23:03:54 +03:00
mixandCursor ad014ecdc1 panel: centre status badge text optically
test / test (push) Has been cancelled
IBM Plex Mono sits low in its em square; top-heavy badge padding left
ok/warn sunk below the adjacent heading. Bottom padding is now heavier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:58:45 +03:00
mixandCursor de6f7a88da panel: disable demote and delete for the only global admin
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:42:01 +03:00
mixandCursor 5985977f21 panel: hide domain pick for global administrators on user form
test / test (push) Has been cancelled
Global administrators manage every domain; the assignment checkboxes are
now hidden when that role is selected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:37:04 +03:00
mixandCursor 9ff7514621 panel: tidy the Users nav icon
test / test (push) Has been cancelled
Two full silhouettes with staggered baselines read lopsided at 16 px; the
rear person is now a right-side crescent behind an aligned front silhouette.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:31:07 +03:00
mixandCursor ae7f5258a6 panel: use a gear for the Settings nav icon
The icon was a sun-with-rays (circle plus spokes), not a cog, despite the
1.2.0 note that Settings used a gear.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-11 22:13:29 +03:00
mixClaude <Composer 2.5> <noreply@anthropic.com>Cursor
5bd632c8e0 panel: place drill-down back links under the heading
test / test (push) Has been cancelled
The user form had Back to users at the bottom of the card; all drill-down
pages now use a shared back_link template with a structural test.

Co-Authored-By: Claude <Composer 2.5> <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 21:58:08 +03:00
mixClaude <Composer 2.5> <noreply@anthropic.com>Cursor
f1074eb526 panel: show Settings credentials and DMARC side by side
test / test (push) Has been cancelled
Global administrators get two cards in the .split layout; domain-scoped
users keep the single narrow form.

Co-Authored-By: Claude <Composer 2.5> <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:40:59 +03:00
bc005dea85 release: 1.2.0
test / test (push) Has been cancelled
Close CHANGELOG [Unreleased] as 1.2.0; correct missing entries; bump the
compose pin and image references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:39:11 +03:00
mixandCursor 4b2f3880ef panel: align heading with narrow cards on signed-in pages
Settings and the user form centred the title on the 48rem measure while
.card.narrow centred on 24rem, so their left edges drifted by 12rem. Cap every
direct child of main when a narrow card is present, without shrinking the
column, so navigation stays put and login/setup stay unchanged.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-11 17:34:04 +03:00
mixandCursor fae930a37a panel: give Settings, Users, and the session user distinct nav icons
test / test (push) Has been cancelled
Settings had been sharing the account silhouette with Users and the signed-in user label, so three different concepts read as the same icon. Settings now uses a gear, the user line the single-user icon, and Users a two-person group mark.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-11 16:06:38 +03:00
mixandClaude Sonnet 5 c443149623 docs: plan the narrow pages' heading alignment
test / test (push) Has been cancelled
Settings and the user form leave their heading at the left of the reading
measure while the card floats 12rem to the right of it. main > * caps every
child at 48rem and centres it; .card.narrow overrides the max-width down to
24rem but not the auto margins it never mentions, so the two centre on
different measures and half the difference is the offset.

Records what the question was actually about: not what is wrong with Settings
but what it shares with exactly one other page. Four templates use the narrow
card and eleven use a full-width one, which sorts every page into three groups
— full-width card, narrow card without navigation, narrow card with it — and
only the last has the problem. Settings and the user form are the only pages
that are both signed-in and built from a single narrow card; every other page
has one of those properties, never both.

Also records why the obvious fix is unavailable, so it is not tried a fourth
time: the shell centres the navigation and the page as a pair, so anything that
changes the column width is visible from the navigation (296px, measured). The
fix has to work inside the column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:19:11 +03:00
mixandCursor 5eaf665c01 docs: remove completed plans and trim the open-work tracker
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 01:34:04 +03:00
mixandClaude Opus 5 7cc499d57c panel: show the send log's status as a badge
test / test (push) Has been cancelled
The send log was the one place in the panel where a status was bare text, next
to a delivery's own page where the same value is a badge, and a domain list
where the DNS verdict is one too — so a message's outcome looked like a
different kind of fact depending on which page you read it from.

The level travels on the row rather than being worked out in the template:
deliveryLevel is the one place that decides what a status means, the delivery
page already reads it, and a second mapping written into the template or the
stylesheet would have been free to drift from it. sent is ok, deferred a
warning, bounced and rejected errors, and queued unknown — nothing has gone
wrong, nothing has been reported yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:23:52 +03:00
mixandClaude Opus 5 aeba3f8db1 panel: put the narrow pages' column back, and centre the badge text
Two fixes in one file, both from looking at the result.

Settings and the user form were narrowed to their card's width so their heading
would stop floating beside it. The shell centres the navigation column and the
page as a pair, so narrowing the page moved the navigation with it — 296px
sideways between Settings and Domains, which is the jump the width rules at the
top of this file exist to prevent, arrived at from the other direction. Every
way of aligning the heading with the card moves something else, so the geometry
goes back to what it was before the restyle, rendered against the previous
stylesheet page by page to confirm it. Those two headings are still not aligned
with their cards; that is worth fixing on its own terms rather than by making
the chrome move on every page.

And the badge text sat above the middle of its box: the badge inherited the
body's 1.5 line-height, and a lowercase word with no descender — ok, warn,
unknown — sits high in a line box that tall, which inside a border reads as
text stuck to the top. The box now hugs its line and the padding does the
centring, uneven on purpose to pay for the descender space the words rarely
use. The one that does, queued, keeps the same box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 01:23:37 +03:00
mixandClaude Opus 5 d6e67ceb61 panel: set Sign out like the entries it stands among
The compact button rule is written for controls that cluster — several beside a
value or inside a table cell — where 0.8rem/600 is what stops four of them
shouting at once. Sign out borrows that rule for its colours, and was taking
the type with it: in the navigation column it has no cluster to belong to, so
it stood under Settings among entries set 0.95rem/400 as the one smaller,
heavier word on the column, reading as a different kind of object rather than
as the last item of the list.

Type and padding go back to the entries'. The red, the border and the tinted
background stay — those are what say it acts, and the restyle only made the
mismatch easier to see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:59:08 +03:00
mixandClaude Opus 5 8ed20b0c98 docs: plan the logrotate mode defect
test / test (push) Has been cancelled
An image whose /etc/logrotate.d/mail is group-writable makes logrotate ignore
the file, so mail.log never rotates and grows until the volume does. The file
is 0644 in git and is widened on the way into the build context: COPY takes the
mode it finds, and an archive made from a checkout without POSIX permissions
carries the umask-widened one. Measured at 0644, 0664 and 0666 across three
images built on one host from the same commit range.

Written up rather than patched because three things are wrong and fixing the
visible one hides the rest: every COPY in the image trusts the build context,
logrotate exits 0 while ignoring the file so the rotate loop reports success,
and nothing anywhere notices that a rotation did not happen. Whether the
release workflow's own images are affected is stated as unverified — the
published image could not be pulled to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:56:49 +03:00
mixandClaude Opus 5 bd7a3b123d docs: close the CSP and font-ETag check against the test server
Deployed at 1.1.0-post.669f928: the policy is untouched and still admits the
fonts, each comes back as font/woff2 with a content ETag that answers a
matching If-None-Match with 304, and the signed-out page renders in Plex over
the network. The remaining open items all need a signed-in session with real
mail behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:49:45 +03:00
mixandClaude Opus 5 669f928b4d docs: record what the visual-style pass left open
test / test (push) Has been cancelled
Five things, each written down so the next pass does not rediscover them: the
send log's bare-text status and the mapping question that keeps it from being a
pure repaint, the 375px horizontal overflow that predates this work, the three
views only ever seen empty because they need a running Postfix, the CSP and
ETag checks that a file:// render cannot make, and font-display: swap, which
never had a cold load to show itself in.

Also records how the pages were reached at all — the stubs and seeded rows
without which half the panel does not exist on a development machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:40:34 +03:00
mixandClaude Opus 5 f44f5333b3 panel: stop the send log breaking short words across lines
test / test (push) Has been cancelled
The last cell in a row is the one the automatic table layout squeezes first,
and in the send log it holds a fixed two-word link: "Details" was being split
after "Detail" on every row. Holding that cell on one line costs the row
nothing and hands the width back to the status column, which was breaking
"deferred" into "deferre" and "d" for the same reason — the status gets the
same nowrap the timestamp already had, through a class of its own since the
cell had nothing to select on.

Both predate the restyle; they surfaced while screenshotting the send log
against a seeded journal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:35:05 +03:00
mixandClaude Opus 5 652f1fe438 panel: restyle on the mark's own palette and type
The stamp was approved on its own sheet and the panel kept the blue-on-cool-grey
it shipped with, so the mark read as pasted onto someone else's page. Brick
becomes the accent, the greys warm to paper, and the corners square up towards
the stamp's own edge.

Brick is two tokens rather than one. #7A3B2E under white text is 8.4:1, but as
text on the dark scheme's background it is about 2:1 — a link nobody can read.
So --accent-fill carries controls and --accent-text carries links and the active
navigation entry; in the light scheme they coincide, in the dark one they do not.

IBM Plex ships with the panel instead of being named and hoped for. The mark is
Plex converted to outlines, so a system stack left the panel's own name as the
only Plex on the page — and ui-monospace resolves to Consolas, SF Mono or DejaVu
Sans Mono depending on the machine, which laid the send log and mail.log tables
out differently for every operator. Three WOFF2 files, 76 KB, served from the
panel's own origin so default-src 'self' still covers them.

Two things changed shape rather than colour. Links had no rule at all and ran on
the browser's blue, invisible while the accent was blue and wrong the moment it
was not. And the column-narrowing that login and setup had by name now keys off
the narrow card itself, so Settings and the user form stop splitting their
heading from the card it belongs to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:24:17 +03:00
mixandClaude Opus 5 c9d726dd64 docs: agree the visual-style plan
test / test (push) Has been cancelled
The mark was approved on its own sheet and the panel was left on the default
blue-on-cool-grey it shipped with, so the two read as different designs. Write
down what the panel takes from the proof — brick as the accent, warm paper,
IBM Plex self-hosted — and what it deliberately does not: the proof's mock puts
navigation in a dark top bar, which has nowhere to keep the per-page section
index the left column carries.

Records the decisions that cost something to reverse: three font files (76 KB)
served from the panel's own origin so the CSP stays default-src 'self', brick
split into a fill and a text value because #7A3B2E is unreadable as a link on
the dark scheme, and the test that outranks all of it — the accent must not be
mistakable for a status badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:06:12 +03:00
mixandCursor f59befdb00 fix: include LICENSE in the Docker build context
The runtime stage copies LICENSE and NOTICE into /usr/share/doc/selfpost/
for AGPL-3.0 conveyance. Excluding LICENSE in .dockerignore broke clean
docker build once the cached layer was invalidated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 00:06:11 +03:00
mixandCursor 15baa1e5d0 feat(panel): add domain-admin role with per-domain authorization
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:43:59 +03:00
mixandCursor c9076655b9 docs: expand domain-admin plan with schema, routes, and UI spec
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:36:09 +03:00
mixandCursor e34d81498e release: 1.1.0
Close CHANGELOG [Unreleased] as 1.1.0; remove erroneous DMARC entries
retroactively copied into older sections; bump the compose pin and image
references.

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-10 23:30:46 +03:00
mixandCursor 155b721438 Split internal/web into subpackages before domain-admin growth.
test / test (push) Has been cancelled
Lay out view, auth, validate, and handlers under internal/web while keeping
the cmd/panel API unchanged; update roadmap and changelog for web-split closure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 23:19:09 +03:00
mixandCursor efaf016c5f Fix DMARC guidance for send-only relays with optional rua= settings.
test / test (push) Has been cancelled
The panel now suggests policy-only DMARC by default, lets operators configure a default and per-domain report address, and DNS-checks hub _report._dmarc records. Future in-panel report ingestion is tracked as dmarc-reports in the roadmap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 22:47:34 +03:00
mixandCursor 2bdc0ea9a8 docs: add visual-style candidate to roadmap
test / test (push) Has been cancelled
Track optional panel visual refresh in the 1.x+ tracker; changelog under Unreleased.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 22:02:24 +03:00
mixandCursor 6c8bf0d3b3 legal: close AGPL packaging gaps
test / test (push) Has been cancelled
Name the copyright holder, ship NOTICE with the image, serve the licence
from the panel footer on every page, and record the vendored htmx licence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 21:56:51 +03:00
mixandCursor c713f51f24 release: retag 1.1.0 as 1.0.1
test / test (push) Has been cancelled
The cut is docs and packaging plus one bugfix — PATCH, not MINOR. v1.1.0 was never tagged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 21:42:40 +03:00
mixandClaude Sonnet 5 0ed36e9624 release: 1.1.0
test / test (push) Has been cancelled
Close the [Unreleased] section as 1.1.0 and move the deploy pin, the README
trial command, and the guide's stated pin to that tag.

Corrections to the section while cutting it:

- The GHCR "unknown blob" retry was filed under Unreleased, but the commit
  carrying it is what v1.0.0 points at. Moved into the 1.0.0 section.
- docs/plans/ and the roadmap's restructure into a 1.x+ tracker were missing
  entirely; the plans were described only as "translated", which reads as if
  they predated this release. Filed under Added.
- docs/product.md's reframing of the future line -- agreed 1.x+ extensions,
  inbound relay targeting a MINOR by default, approval needed only for
  candidates -- was unrecorded.
- The Account/Settings rename also touched the operator guide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:39:53 +03:00
mixandClaude Sonnet 5 5178f80fdf docs: changelog entry for the static-asset ETag
test / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:37:06 +03:00
mixandClaude Sonnet 5 10c41677bd web: give the embedded static assets a content ETag
test / test (push) Has been cancelled
The assets are baked into the binary, so their FS modification times are the
zero value and http.FileServer sent no Last-Modified. With no ETag either, a
static response carried no validator at all and the browser was free to guess
how long to keep it -- which is how the panel kept showing the previous favicon
in the tab well after the new mark was deployed.

Hash each asset once at startup and serve that as a strong ETag, with
Cache-Control: no-cache so the browser keeps its copy but revalidates. An
unchanged asset then costs a bodyless 304 and a changed one is picked up on the
next load.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:35:42 +03:00
mixandClaude Sonnet 5 01c754f857 docs: record the decision to disclose AI authorship
test / test (push) Has been cancelled
The question kept resurfacing while preparing the repository for publication,
so it is written down as settled rather than re-argued: SelfPost is written by
AI agents under a maintainer's direction and the project says so.

What that commits to is listed explicitly — the Co-Authored-By trailers, the
model routing table, the .cursor rules file, and the agent-facing process notes
in the roadmap all stay, and none of them is an oversight to be tidied away.
The reasoning is that with the trailers already in ~140 commits, removing the
surrounding material would not conceal authorship, only signal an attempt at
it.

The revisit condition is deliberately narrow: a conflict with the licence or a
downstream obligation, not a shift in convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:14:27 +03:00
mixandClaude Sonnet 5 691d3c255f docs: point the README at SECURITY.md and disambiguate the two security docs
test / test (push) Has been cancelled
A reader looking for where to report a flaw had no path from the README: the
only security row led to docs/security.md, which is the design rationale, not
a reporting channel. That row is now "Security design", and a line under the
table sends vulnerability reports to SECURITY.md instead of the issue tracker.

development.md gains SECURITY.md in its user-facing deliverables table and
drops "internal" from the roadmap row, which stopped being true when the
roadmap was reframed for outside readers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:58:56 +03:00
mixandClaude Sonnet 5 295d1afb7d docs: translate the three plans
test / test (push) Has been cancelled
The roadmap links straight into these files, so a reader following an item
landed in Russian one click after an English page. Translated in full; goals,
boundaries, done-when criteria and risks are unchanged in substance.

The model assigned to inbound-relay is dropped, as it was from the roadmap
item: model routing lives in development.md.

Figures and code references were checked against the tree while translating
rather than copied forward — internal/web is still 50 files, 25 .go, ~4300
lines, and requireAuth(authed) and sendLogData are still where domain-admin.md
says they are.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:50:38 +03:00
mixandClaude Sonnet 5 06c2014384 docs: translate the roadmap and frame it for an outside reader
test / test (push) Has been cancelled
roadmap.md is linked from the README table, so it is a page strangers land on.
Translated in full, with a note up front that none of it is a commitment:
there are no dates, the order is a recommendation, and an item can be dropped
once its plan is written. The note also points at product.md for the opposite
question — what the project deliberately will not do — so absence from the
roadmap is not read as a silent plan.

The model assigned to inbound-relay is dropped from the item; model routing
belongs in development.md, not in a statement of direction.

The README row described the file as internal and Russian. It is now neither.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:46:58 +03:00
mixandClaude Sonnet 5 a4cfa11323 docs: retarget the CHANGELOG pointer to the renamed security.md section
test / test (push) Has been cancelled
The 0.5.0 entry pointed at docs/security.md § "Резервная копия и экспорт
домена", a heading that no longer exists after the file was translated. The
name is updated to the current heading; the entry otherwise stands as written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:45:19 +03:00
mixandClaude Sonnet 5 236cb07769 docs: translate security.md and the last Russian source comments
security.md is linked from the README documentation table and now from
SECURITY.md, so a reader following either link landed in a Russian document
while everything around it was English. Translated in full; the requirements,
the accepted risks, and the CSRF ADR are unchanged in substance.

The reviewing model is no longer named in the text — that the pre-release
review ran, and when, is what a reader needs; who ran it is process detail
kept in development.md.

extract-cert.sh keeps its spec 10.3 quotation, translated. In sasl.go the
quotation from the closed plan is dropped rather than translated: rendered in
English it restated the sentence it hung off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:39:53 +03:00
mixandClaude Opus 5 04993e0da3 docs: add security policy
A public repository with no stated disclosure channel routes a finder into
opening a public issue, which discloses a relay flaw to everyone the moment it
is filed. SECURITY.md points at the repository private vulnerability reporting
instead, with public@mixeme.ru as fallback, and states scope so operator-side
configuration (blocked port 25, missing PTR, proxy TLS) does not arrive as a
report.

No response time is promised: a deadline that cannot be honoured by a single
maintainer is worse than none. Silence is explicitly not a request for a
continued embargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:32:09 +03:00
mixandClaude Sonnet 5 7eb168f418 feat(panel): label the nav username with "User:"
test / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:18:49 +03:00
mixandClaude Sonnet 5 a1b6209470 feat(panel): rename Account to Settings
The nav entry, page heading, and browser title now read Settings. The
route, template name, and Active key stay `account`, so existing links
and bookmarks keep working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:17:11 +03:00
mixemeandCursor 44683a4996 docs: domain-admin may cover multiple assigned domains
Clarify that the global administrator sets which domains a domain-admin can manage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:21:43 +03:00
mixemeandCursor 870012514a docs: recommend web-split before domain-admin and inbound-relay
Document the preferred implementation order in the roadmap and linked plans.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:17:54 +03:00
mixemeandCursor 012802d83d docs: restructure roadmap as 1.x+ tracker with plan files
Split detailed design into docs/plans/ and keep roadmap as a status index;
align product, development, and README with the 1.x+ release line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 13:14:43 +03:00
e163fe123d fix(ci): retry transient GHCR unknown blob on release push
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Layers often upload successfully; the final manifest push fails with
unknown blob. Retry docker push and imagetools create a few times with
backoff so the same local image can land without retagging.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 12:41:43 +03:00
178424eaf6 release: cut 1.0.0
Pin compose and local trial to ghcr.io/mixeme/selfpost:1.0.0, close the
CHANGELOG cut, and retire implementation-plan / v1.x-closure-plan.

Includes the post-cut startup fixes needed for a green release e2e gate:
root-owned TLS copies for postfix check, maillog_file_prefixes for /data,
hostname gate and traversable /data, panel /healthz before setup, and
setup-token / TempDir reclaim via docker exec.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 12:33:29 +03:00
18880a8286 docs: spell out .spbk and .spde extensions
SelfPost backup / SelfPost domain export — in the guide, security notes,
architecture, and Backup/Export panel copy.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:49:43 +03:00
1dd0aa9fea docs: align operator and as-built docs with the code
Fix setup URL shape, import encryption UI, architecture layering/routes,
and stale plan/roadmap pointers so the prose matches what the tree does.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:46:03 +03:00
7c79c085e7 docs: consolidate process docs into development.md (v1.x closure phase 3)
Fold documentation-plan and progress into development.md, drop docs/archive,
retarget live links, and point README plus agent-rules at the new home.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:37:35 +03:00
1f548dfc7a fix(logtail): keep mail.log in /data and reconcile stuck rows (v1.x closure phase 2)
Move the delivery log from the ephemeral /var/log to /data/log/mail.log so
the lines that resolve a queued send-log row survive a container recreate.
postlogd writes it as postfix, the panel reads it through the selfpost group
(dir 2750, file 0640, normalised every start); backups exclude log/.

Close the residual gap with a queue sweep: rows queued for over two minutes
whose id postqueue -p no longer lists are marked bounced. The sweep waits
until the tailer has read the log to its end and does nothing when the queue
cannot be listed, so a message in flight is never touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:35:27 +03:00
7e4ecf1191 feat(panel): adaptive monitoring poll intervals (v1.x closure phase 1)
Replace fixed 5 s hx-trigger polling with data-poll markers and panel.js
scheduling: 5 s while active, 30 s when idle, none when tab is hidden.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:13:02 +03:00
1faf91055f docs: add v1.x closure plan with checklists and model routing
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:10:04 +03:00
mixemeandCursor d46920bb02 docs: translate development guide and agent rules to English
development.md and agent-rules.mdc in English; progress.md and roadmap.md stay Russian as internal docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:58:38 +03:00
mixemeandCursor 1a8603b887 docs: restructure development guide and move agent rules to .cursor
Reorganize development.md (stack, deps, build, release, testing, CI); relocate agent rules to .cursor/rules; remove dev-host and example.com references from docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:56:22 +03:00
mixemeandCursor 0cf618438b docs: streamline README quick start section
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:48:46 +03:00
mixemeandCursor 977d16494b docs: improve quick start and reference deploy instructions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:44:37 +03:00
mixemeandCursor 5a5b642eac docs: split README into overview and operator guide for release
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:36:24 +03:00
mixemeandCursor ebb214b1ef docs: add clickable table of contents to README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:27:47 +03:00
mixemeandClaude Opus 5 43841790d4 feat(panel): lay a delivery's log lines out as a table
The lines came out as one block of preformatted text, which is what the
system log page does with a tail of mail.log — right there, where the
lines are unrelated to each other and the block is the log itself. Here
they are one message's six or seven lines, and what is read off them is
the pace: the second between the connection and the banner, the ten
between DATA and the reply. Run together, every line began with a
different-width stamp and none of those numbers lined up.

So they are two columns now, when and what, the same shape the send log
itself has. logtail.SplitTimestamp takes the stamp off the head of a
line: postlogd's format, which is what this server writes, and syslog's
traditional one for a deployment that routes the log through syslogd
instead. The stamp loses its microseconds and its offset — five decimal
places are the widest part of the column and the least worth reading —
but is not converted, so the page shows the log's own wall clock rather
than a claim about which zone it was in.

A line whose head is not a stamp either parser recognises keeps its whole
text in the second column and leaves the first empty. The format is the
log's, not ours; a line we cannot split is a line we must not drop, and
the test says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:22:29 +03:00
mixemeandClaude Opus 5 cf7587754f feat(panel): give a delivery its history and its own log lines
The delivery page was a list of the fields the send-log table has no
column for, stacked one per line down the reading measure. Six values of
a few characters each — domain, application, queue id, journal id and two
timestamps — came to a page of mostly empty rows, and none of them
answered the question the log raises when a row is opened: what actually
happened to this message.

So the page states that instead. The subject heads it and the sender,
recipient and outcome are the line under it, which puts what the message
was and how it ended on the first line. Below, two columns: what the
journal recorded on the left, as a grid of tiles rather than a stack, and
on the right the two timestamps stated as the steps they stand for —
accepted and queued, then delivered, deferred, bounced, or refused before
queueing. Each step carries its status in the panel's own
ok/warn/error/unknown vocabulary, so a colour means here what it means on
the status page. A message still queued shows the report it is waiting
for as a step that has not happened, rather than dating it with the
moment the row was written.

Under both, at full width, the mail.log lines for the message's queue id.
The queue id was printed on this page as something to go and search the
system log for by hand; logtail.QueueLines does that search. It scans a
bounded tail of the current file — finding one message's lines means
reading rather than seeking — and anchors the match on the character
before the id, since queue ids are hexadecimal runs and a shorter one is
regularly the tail of a longer one. Send-log rows outlive mail.log
(retention ninety days, rotation fourteen files), so a message with
nothing left to show says so; that is the normal end state, not a fault,
and only a log that cannot be read at all is reported as one.

Two cards abreast and a block of raw log lines do not fit the reading
measure, so the page now declares itself wide — the opposite of what it
did when the column width was unified, where it was the page that stayed
prose. The mechanism is unchanged and is why the reversal costs one line:
how wide a page needs to be is the page's own property, not the
navigation entry's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:14:27 +03:00
mixemeandClaude Sonnet 5 d03ea43969 docs: cut CHANGELOG 0.6.0, reopen Unreleased
Everything under Unreleased goes out as 0.6.0: the per-delivery page, the
DNS badge in the domain list, the machine metrics card, the section index,
the navigation column, and the layout, brand and import-form changes.
Added entries, hence a minor bump.

The import form reading the file extension instead of an "is it encrypted"
checkbox was never written down; it is added under Changed, together with
the follow-up that keeps the field hidden while no file is chosen.

The heading is the version: there is no constant in the tree, only the
VERSION build arg that ldflags stamps into internal/buildinfo.Version, so
the image this deploys under is tagged from here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 09:50:50 +03:00
mixemeandClaude Opus 5 142c1defbb fix(panel): lay every page out in one column width
The page column was the 48rem reading measure, which the send log, the mail
queue and the system log widened to 64rem for their tables. The navigation and
the page are centred as a pair, so that difference did not only change the
page's width: it moved the navigation column and the left edge of every card
on the way between two pages. The column is now 64rem throughout and the
measure lives inside it — a page's heading, cards, back link and version footer
are held to 48rem and centred in the column, and the pages made of data opt out
and take the column whole.

Which pages those are is declared by the page (a "wide" block in its template,
the same mechanism as the section index) rather than derived from the
navigation entry: .Active cannot tell the send log from a single delivery's
page, which is prose and now keeps the measure instead of inheriting the log's
width.

Two rules follow from centring the children rather than the cards. The heading
takes its margins as longhands, since the shorthand re-zeroed the auto side
margins and pinned it a measure's width left of the card under it; and the back
link is a block, since auto margins centre a block and do nothing for an
inline-block.

The scrollbar's width is reserved on every page as well. Without it a short
page and a long one are laid out in viewports differing by that width, which
moved the same things again, in the same direction, for a second reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:17:13 +03:00
mixemeandClaude Opus 5 2c8c305c75 fix(brand): set the small-size variant's S in Medium
The tab icon carries the initials rather than the two-line wordmark
because the wordmark stops resolving below 32px, but it kept the
wordmark's weights, and those do not survive the size either. The S is
ExtraLight against the P's SemiBold: a 0.90 stem against 3.40, which at
16px is a quarter of a pixel against most of one. The pair rasterised to
a P with a smudge beside it, the S reaching no solid pixel at all at 16
or 32.

It is Medium now. That gives up the Self/Post weight play inside this
one variant, which is the right trade: the contrast needs more pixels
than the variant exists to work in, and the variants big enough to carry
it keep it.

The outlines come from IBM Plex Sans as before, at the same font-size
26, letter-spacing -1 and baseline — only the S's weight moved, and the
pair re-centres on its advances the way live text would, which shifts
the P 0.57 right. The reconstruction was checked by regenerating the
committed ExtraLight/SemiBold outlines from the same pipeline first;
they came back identical, so the new S is the font's, not a thickened
copy of the old one. A stroke was tried before the font was to hand and
is not what shipped: it thickens uniformly, where Medium is modulated at
the joins and keeps the apertures open, which is visible by 64px.

favicon.png is regenerated from the same source. Its border is unchanged
to the pixel and its alpha to the count; only the lettering moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:24:16 +03:00
mixemeandClaude Opus 5 d502611123 fix(brand): make the stamp's tagline survive rasterisation
SELF-HOSTED SMTP RELAY was set at font-size 7.2 with letter-spacing 2.8 —
a cap height of 5.2 in a 330-unit artwork, which puts its stems at about
half a device pixel where the mark is actually used. More than half the
line's ink came out as antialiasing: measured against the brown, the
typical tagline pixel reached 2.1:1 where the two colours are worth
7.3:1, and at 330px not one pixel reached full strength. The wordmark
above it renders 66% of its pixels solid.

It is now 11.5/0.15. The line keeps its footprint (154 units against
153) and its monospaced cells, fitted from the grid the outlines were
generated on; the width the tracking was spending went to the glyphs
instead, taking cap height to 8.3. Mean ink coverage goes 0.39 to 0.60
at the README's width and 0.33 to 0.47 at the 330px the login and setup
pages use.

opacity=".78" is gone with it. It cost 30% of the available contrast to
mark the line as secondary, which a 3.4:1 difference in size already
does, and it was multiplying the alpha of stems that were mostly alpha
to begin with.

A stroke to thicken the stems was measured and rejected: at 0.12-0.30 it
lifted mean coverage 0.59 to 0.63 while dropping the share of solid
pixels, since it adds antialiased edges rather than filling stems.

internal/web/static/logo.svg is a byte copy of the docs asset and stays
one. selfpost-proof.html carries the lockup as live text and is where
the metrics come from, so it moves too, or the next regeneration would
put the old spec back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:05:31 +03:00
mixemeandClaude Opus 5 256d370206 test(e2e): follow the panel's markup for applications and the send log
Two scrapes had drifted from the pages they read, and the suite has not
been run since either page changed.

applicationID still looked for an application as a table row
(<td class="code">login</td> ... /applications/N/mode). Applications became
a list of blocks in d35b309, so the lookup had been failing for several
commits, including the one currently deployed — this is stale test, not a
regression. It now anchors on the login heading and takes the id from the
first action posted under it, whichever that is, so reordering a block's
controls will not break it again.

The level-2 rate-limit check looked for the application's login among the
send-log rows. 997af18 took that column off the log — the log identifies a
message and names the application only on a row's own page — so the check
now filters the log by application instead. That is the same attribution
through a server-side WHERE app_login rather than a substring match on
rendered HTML.

Verified on selfpost.example.com: make e2e green, all subtests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:59:12 +03:00
mixemeandClaude Sonnet 5 6e990cda01 docs: center the readme logo and set its display width
Markdown's image syntax carries neither alignment nor size, so the stamp
rendered flush left at its intrinsic 330px. A centred `<p>` with an
`<img width>` is the usual way around that on GitHub, and survives its
HTML sanitiser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:46:40 +03:00
mixemeandClaude Opus 5 997af18065 feat(panel): move a delivery's details onto its own page
The delivery log now lists what identifies a message and nothing else —
time, sender, recipient, subject, status — and links each row to
/deliveries/{id}, which carries the rest: the sending domain, the
application it was submitted under, the Postfix queue id to search the
system log for, and when the status was last reported. Domain and
application were a column each; they were the widest thing in the table
after the addresses and repeat down every filtered page, and they remain
the log's two filters. Back returns to the page and filters the row was
opened from, rebuilt from the log's own parameters only.

Subjects are now decoded for display as well as on the way in. The milter
has decoded them since d35b309, but the rows it wrote before that still
hold the raw =?utf-8?Q?...?= header, and those are the ones an operator is
most likely to still be reading. The decoder moves to internal/mailhdr,
shared by the milter and the panel; it is idempotent, so a row decoded
once passes through unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:43:07 +03:00
mixemeandClaude Sonnet 5 ef57d705d4 feat(panel): show each domain's DNS status in the domain list
The list gave no hint which domains still needed records published — the
verdict lived only on the domain page, one click away per domain. Each row
now carries a badge with the worst of that domain's DKIM, SPF and DMARC
checks, in the panel's shared ok/warn/error/unknown vocabulary, linking to
that domain's DNS status card.

The checks run concurrently across the listed domains: each carries its own
timeout, so in series a dead resolver would multiply that wait by the number
of domains and the list would look hung. They share the checker's cache with
the domain page, so a repeat view costs no lookups and opening a domain
after the list is free.

A domain whose DKIM key cannot be read stays "unknown" rather than being
reported as misconfigured — the missing half of the comparison is this
server's, not the domain's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:33:06 +03:00
mixemeandClaude Opus 5 c4c1f12f3c style(panel): give the navigation column's mark the column's full width
The mark kept the 110px it had as part of the bar, which was all there was
room for in a row; in a column it ended halfway across, sharing an edge with
nothing below it. At the column's width its edges line up with the page
entries, and the SVG's own inset puts the drawn mark on the entries' icons.
The width/height attributes carry the file's real proportions so the column
still reserves the right height before the SVG loads.

Where the column lies back down into a bar, the mark returns to the compact
size: at full width it would take a row to itself above the entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:30:21 +03:00
mixemeandClaude Sonnet 5 9179818262 fix(panel): open an application's panels under its row of controls
Mode and rate limit were <details>, so each opened where its own toggle sat
and split the row of four controls in two, pushing New password and Delete
below a block of fields. The toggle is now a hidden checkbox with its label
drawn as the button and the panel is the last child of the row, so the
controls keep their places and the fields are laid out beneath all of them.
Pure CSS, so it still works with JavaScript blocked, as the disclosure did.

The panel buttons take back the form spacing the compact row style zeroes
out, and Save limit and Remove limit share one row: two posts mean two
forms, so the first button is bound to its form by the form attribute
instead of sitting inside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:23:39 +03:00
mixemeandClaude Sonnet 5 76f04f93d1 fix(panel): keep the import password field hidden until a file is chosen
The field was revealed whenever no file was selected, so the import card
opened asking for a password it had no use for yet. Hide it in that case
too; an unrecognised extension still reveals it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:12:05 +03:00
mix 9745edd132 feat(panel): detect import encryption from file extension, not a checkbox
The import form used to ask users to tick "the file is encrypted" before
showing the password field, even though the server already decides purely
from the envelope magic bytes. Reveal the password field automatically for
a .spde file (hide it for .json) so the checkbox is no longer needed.
2026-08-07 04:30:24 +03:00
mix c0c66dfcdb feat(panel): navigation as a left column, with a section index on the long pages
The navigation was a bar across the top that did not fit on one row — six page
entries and the session block against the panel's width — and had to be split
into two, costing the top of every page. It is now a column down the left edge:
one left edge to scan, the current entry marked down its leading edge, sticky so
it stays in view, and room under the entries for the current page's own
sections. Below the width the two columns need it lies back down into the same
wrapping rows as before; six entries need no drawer.

The section index is for the two pages long enough to need one — the domain page
(nine cards) and the status page (eight). Each card carries an id and the page's
template defines the list by overriding an empty "sections" block in the layout,
so a page that defines nothing renders no index. panel.js marks the section in
view, looking targets up by id on each pass so the status page swapping its
cards out every five seconds cannot leave it measuring boxes that have left the
document; the links themselves are plain fragment links and need no script.

Verified against the real pages rendered by a local panel at 1300px, 924px and
481px wide.
2026-08-07 04:11:32 +03:00
mixandClaude Opus 5 76ad20efdf feat: machine metrics (CPU, memory, network) on the status page
The status page answered "are the components running" but said nothing
about the machine underneath them, so a server slowed to a crawl by a
busy processor or one about to have Postfix OOM-killed looked entirely
healthy until the queue backed up.

internal/health/machine.go reads the kernel's counters in /proc: the
aggregate processor times and core count from /proc/stat, the load
average from /proc/loadavg, memory and swap from /proc/meminfo, and
per-interface byte counters from /proc/net/dev.

CPU busy time and network throughput are rates, so a MachineSampler holds
the previous reading and each call reports the difference — one shared
sampler on the Server, since a per-request one would never have anything
to subtract. A window longer than a minute only re-baselines: a page
opened after the panel sat idle would otherwise average that whole
stretch and present it as the current load.

Memory is derived from MemAvailable rather than MemFree, because Linux
spends every spare page on cache and MemFree would report a permanent
emergency. A fully busy processor (>=90%) warns and an exhausted machine
(>=97%) errors, both counting towards the page's headline verdict, since
either delays or kills the mail path. Throughput has no comparable
threshold — what counts as a lot depends on the link — so it is reported
and never graded. Loopback is excluded: that traffic is the container
talking to itself.

Like every other check here, an unreadable counter degrades to "unknown"
with an explanation instead of failing the page, so the panel still runs
outside Linux for development.

The usage bars are <meter> elements. The panel's CSP has no inline-style
exemption, so a bar's length has to travel on an attribute; the element
also grades its own colour from low/high/optimum, and the percentage is
printed beside it for anything that does not render meters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 03:49:23 +03:00
mixandClaude Sonnet 5 4118e1a8d5 docs: cut CHANGELOG 0.5.0, reopen Unreleased
Everything under Unreleased goes out as 0.5.0: the log-tailer bounce
mis-parse and offset-persistence fixes, the L2 rate-limit in-flight
accounting, the GitHub-only module path move, the code-review.md
retirement, the pre-release security pass (saslpasswd2 argv hardening),
and optional password encryption for backup/domain-export (Added, hence
a minor bump).

The heading is the version: there is no constant in the tree, only the
VERSION build arg that ldflags stamps into internal/buildinfo.Version,
so the image this deploys under is tagged from here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:29:02 +03:00
mixandClaude Opus 5 0e29acb955 docs: remove code-review.md, carry the open items into roadmap
The review's plan is finished — phase 0 (bar the two release-commit steps),
1, 1.5, 2 and 3 are all closed — and what remained in the document was a second
copy of things that already live in architecture.md, security.md, roadmap.md or
the code comments: the GUI compromise table is in panel.css/panel.js/
middleware.go/handlers_auth.go, the single SQLite connection and the dual
cookie names are explained where they are implemented, the accepted gaps are in
security.md, and the model-routing table names progress.md and development.md
as its own source. A second copy of a fact is a place for it to go stale.

Four items were genuinely open and had no other home, so they moved to
roadmap.md rather than disappearing:

- splitting internal/web into subpackages (2.x) — with the reason to wait: the
  flat package still reads at 47 files, and both 2.x features grow it, so the
  cut is worth making before that growth, not now;
- a consolidated documentation index in the README (v1.x tail);
- the adaptive polling interval for a tab that is visible but idle — the hidden
  case is already handled, and the remainder is explicitly allowed to end as
  "decided not to";
- CONTRIBUTING.md, already moved to 2.x in the previous commit.

References retargeted: progress.md (7), roadmap.md (5), implementation-plan.md
(1). The CHANGELOG entries that cite the document are left as written — they
describe what happened at the time. The review text stays in git history at
aaf0711.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:22:52 +03:00
mixandClaude Opus 5 c0d9aa7518 chore/docs: move to GitHub as the single home; drop archived-spec references
Codeberg is being retired as the project's public site, so every reference now
points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost →
github.com/mixeme/selfpost): leaving an import path on a host that is going
away would break `go get` and `go install`, so this is not only a docs change.
Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags
version stamp in build/Dockerfile and docs/development.md, the licence headers
in the SVG/HTML assets, and README (no more primary/mirror pair).

Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1"
and friends pointed into docs/archive/specification-v1.0.md, which is marked as
not a source of truth; each is now a reference to the live document that owns
the subject — architecture.md (with section), product.md, security.md or the
README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/
8/9 had the same defect, so they went too. Comments only, no behaviour change.

Also closes the remaining review items: architecture.md gained a Code layers
section with the layer diagram (A2), and TestParseDelivery gained the exotic
mail.log cases (§ 3).

Fixes a bug that last test found: the delivery-line pattern matched status=
greedily, taking the *last* occurrence on the line. Postfix appends the remote
server's reply verbatim, so a rejection whose reply quoted "status=sent" was
filed as a delivered message in the send log. It now takes the first status=
after the recipient, which is the real field.

R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow,
so the file would have no audience yet. R1 (compose image tag) and the git tag
stay in roadmap § v1.x as the release-commit steps.

gofmt/go vet clean on both modules; go test ./... green except the three known
Windows-only failures (file perms, backslash paths, renaming an open file).
Not exercised on the dev server — no Docker locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:14:13 +03:00
mixandClaude Opus 5 a92d583053 feat: log-tailer offset persistence + in-flight L2 rate-limit accounting (code-review.md § Phase 3)
- logtail: persist the read position (offset + fingerprint of the log's
  first 512 bytes) in a new logtail_state table (migration 0003) and
  resume from it on start, so delivery lines written while the panel was
  down are parsed instead of skipped and their send-log rows no longer
  stay "queued" forever. Fingerprint mismatch (rotated/recreated while
  down) reads the file from the start — re-parsing is idempotent; a
  first-ever start with nothing stored still begins at end-of-file.
  Writes are throttled to one per 5s, forced on rotation and shutdown.

- milter: count messages that passed the level-2 check but have not
  reached the send log yet (internal/milter/inflight.go), so concurrent
  SMTP sessions cannot each spend the same last slot. A literal
  count+insert transaction, as the review suggested, is not possible:
  the count happens at MAIL FROM and the insert at end-of-message.
  Reservations are released after the insert, on ABORT, and after a
  10-minute TTL — a client that drops mid-transaction must not be able
  to hold a slot, since the limiter is fail-open by design.

Docs: architecture.md (log tailer, persistence, L2 counting),
security.md and roadmap.md (restart gap closed, container recreate
remains), CHANGELOG, progress.md, code-review.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:12:37 +03:00
mixandClaude Sonnet 5 0093878eea style: GUI polish — visibility-aware polling, CSS vars for dark mode (code-review.md § Phase 2)
- panel.js: skip HTMX polling requests while the tab is hidden, via
  htmx:beforeRequest rather than htmx's eval-based trigger filter (the
  panel's CSP allows no unsafe-eval).
- panel.css: replace all dark-mode !important overrides with CSS custom
  properties reassigned once under prefers-color-scheme: dark.
- panel.css: consolidate the duplicate main{max-width} rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:57:05 +03:00
mixandClaude Opus 5 6a7d010868 feat: optional password encryption for backup and domain export (code-review.md § Phase 1.5)
Both secret-bearing downloads can now be sealed with a password. Unticked, the
forms produce exactly the files they did before.

- internal/secretfile: envelope format — magic/type/scrypt params/salt/nonce
  prefix header, then 64 KiB AES-256-GCM chunks each authenticated with the
  header, its counter and an end-of-stream flag, so truncation, reordering and
  tampering fail to open instead of restoring a plausible prefix. Streams both
  ways, so a full backup never sits in memory.
- Panel: "Encrypt with a password" checkbox on the full-backup and
  domain-export forms (shared partial, toggled from panel.js — no inline
  script); domain import detects an encrypted export by magic bytes, not by
  extension, and asks for the password.
- selfpost-backup: writes .spbk when given a password and converts one back
  with -decrypt, which a restore needs. The password comes from
  SELFPOST_BACKUP_PASSWORD or -password-file, never argv.
- Docs: README, security.md (+ accepted risk: encryption stays opt-in),
  architecture.md, progress.md, CHANGELOG.

Verified locally: panel-encrypted archive decrypts through the CLI and unpacks;
wrong password and password mismatch are refused; UI checked in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:43:28 +03:00
mixandClaude Sonnet 5 670982fb3e docs/chore: Phase 1 doc/code hygiene (code-review.md § Phase 1)
Removes ~30 stale "Phase N" / historical-staging comment references from
code and shell scripts now that v1.0 is done; fixes a stale dashboard
comment claiming applications/send-log were unimplemented; adds a CSRF ADR
to security.md documenting the Origin-check-over-tokens decision; resolves
docs/logo in roadmap.md (directory doesn't exist, criterion already met);
adds a gofmt -l check to CI so unformatted Go fails the build.

The known-limitations write-up for the log-tailer offset gap (the other
Phase 1 item) was already present in architecture.md § Log tailer, so no
change was needed there.

gofmt/go vet/go test clean on both Go modules (main + test/e2e), verified
on the dev server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:13:36 +03:00
mixandClaude Opus 5 ecc4184324 docs: schedule implementation-plan.md retirement in roadmap
The document is closed: no unique content remains — § D (pre-release
security review) is duplicated in progress.md, security.md and the
CHANGELOG, and B.1-B.3/C.4 were trimmed in 60addd5. It stays until the
tag only because it describes the release gate.

Record the retirement as a v1.x tail item in roadmap.md with the
concrete steps: archive the file and retarget its references, including
the stale "plan C.4" pointers in Makefile, release.yml and the e2e test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:36:51 +03:00
mixandClaude Fable 5 e93a277ee7 security: phase D pre-release review — pass; harden saslpasswd2 argv
Fable review of the full diff from the v1.0 audit (Phase 11, 65a420d) to
HEAD plus a complete pass over the docs/security.md checklist (former spec
7.6). No exploitable findings. One defence-in-depth fix: the application
login is passed to saslpasswd2 behind a -- end-of-options marker so a
login starting with - can never be parsed as a flag. Accepted risks
unchanged; plan § D closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:32:52 +03:00
mixandCursor 00983cce39 docs: plan backup encryption and drop session-restore risk
Update code-review with phase 1.5 (optional .spbk/.spde encryption, checkbox UI). Remove session resurrection from backup as an accepted risk in security.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:07:48 +03:00
mixandCursor aaf0711326 docs: add full codebase review and implementation plan
Record comprehensive code review in docs/code-review.md covering architecture, quality, documentation, GUI, legacy, and risks. Link from implementation-plan and progress; update CHANGELOG.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:31:15 +03:00
mixandCursor 60addd56c8 docs: trim implementation-plan to open release gate
Leave only pre-release security review (section D) in implementation-plan.md. Move closed B.1-C.4 as-built detail to architecture.md and development.md; document accepted risks in security.md; optional send-log follow-ups in roadmap.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:50:13 +03:00
mixandClaude Opus 5 67875e51f3 docs: close documentation plan; move v1.x tail to roadmap
Mark D1-D9 complete in a slim maintenance documentation-plan; defer
Codeberg Quick start, compose tag bump, and docs/logo to roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:45:07 +03:00
mixandClaude <claude-opus-5-thinking-high@noreply@anthropic.com> baaed5991b docs: D6-D9 — HEALTHCHECK, env regression test, new docs, archive spec
Add Docker HEALTHCHECK and mail-path /healthz liveness; env-doc regression
test; architecture.md and development.md; product.md and expanded security.md;
retire live specification.md to docs/archive/.

Co-Authored-By: Claude <claude-opus-5-thinking-high@noreply@anthropic.com>
2026-08-05 00:33:49 +03:00
mixandCursor 7f24b2923c docs: D3 backup tar path, D4 README/compose fixes, D5 plan sync
Document stopped-container tar backup with WAL warning and manifest
consumption; refresh status banner and port-587 note; align
implementation-plan B.1 with actual session behaviour on password change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:28:59 +03:00
mixandCursor b40e14d65c docs: D1 Operations/Rate limiting and D2 env reference (README)
Close documentation-plan findings 1-3 and part of 10: panel operations
guide, two-level rate limits, public env table with TRUSTED_PROXY_CIDR
warning, and compose wiring for the proxy CIDR variable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:26:03 +03:00
mixandCursor 865cf67966 docs: plan specification retirement via D9 migration map
After the documentation pass, specification.md moves to archive once its content lives in product, architecture, development, and security docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:24:13 +03:00
mixandClaude Sonnet 5 2640ef4fd6 docs: plan for architecture.md and development.md
Records the decision to add these two docs (out of ТЗ scope but needed
so project structure and the dev loop don't live only in memory/context),
with a new D8 task and non-blocking release-gate note.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 23:16:46 +03:00
mixandClaude Sonnet 5 192ab5a1d5 docs: split 2.x.x scope out of implementation-plan into roadmap.md
Keeps implementation-plan.md focused on unresolved v1.0/v1.x questions;
inbound relay (Phase O1) and the domain-admin role now live in
docs/roadmap.md, cross-linked from progress.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 23:13:54 +03:00
mixandClaude Opus 5 0c7f2432f5 docs: cut CHANGELOG 0.4.0, reopen Unreleased
Everything under Unreleased goes out as 0.4.0: the mark now in use on the
README and in the panel (an addition, so a minor bump rather than a patch),
the signed-out pages' column width, the monitoring pages' width, and the
Deliveries subject/table-overflow fixes.

The heading is the version: there is no constant in the tree, only the VERSION
build arg that ldflags stamps into internal/buildinfo.Version, so the image
this deploys under is tagged from here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:38:21 +03:00
mixandClaude Opus 5 827fea6ea8 brand: put the mark on the README and in the panel
The stamp existed as files and appeared nowhere. It now opens the README, sits
at the left of the panel's navigation bar in its compact form, and stands above
the card on the two pages that carry no navigation — sign-in and first-run setup,
where it is the only thing telling an administrator which service just asked
them for a password. The tab icon moves to the stamp's small-size variant, so
the tab, the panel and the README stop being two different identities; the
earlier envelope drawing stays in docs/assets/icon.png, unreferenced.

The panel serves its own copies under internal/web/static/ because go:embed
cannot reach outside the package; the proof sheet says which file feeds which
slot. favicon.png is regenerated from the same icon at 32x32 for browsers with
no SVG favicon support, and is declared before the SVG so those that do take
the vector.

Sign-in and setup also get a column the width of their own card. Both are a
single .card.narrow, which centres itself while the heading above it stays at
the panel's left edge — with the mark added that would have been three
alignments on a page with four elements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:24:48 +03:00
mixandClaude Opus 5 0cac2f5ca2 brand: convert the wordmark to outlines
The four brand files were authored with live <text> set in IBM Plex Sans, and
the proof sheet closed on that being the one thing still to do before they were
used anywhere. It matters more than it sounds: the mark *is* the weight contrast
between Self at 200 and Post at 600, and a machine without Plex resolves neither
weight — browsers synthesise bold but never light, so the two words come out the
same and the wordmark stops being one. Almost no viewer has the font installed.

Laid out from the font's own metrics the way a browser would (advance widths
from hmtx, CSS letter-spacing after every character including the last, the
anchor centred on the result) and emitted as one path per text element, so the
files now render as drawn with no font installed at all. The note at the foot of
the proof sheet records that, and its file list is corrected to docs/assets/,
where these have lived since they were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:24:33 +03:00
mixandClaude Opus 5 3cbdb48fd2 docs: note the Applications layout fix in the CHANGELOG
The change itself landed in d35b309, which swept up an unrelated tree while
the layout work was still in progress, so its message says nothing about
Applications. Splitting that commit would mean rewriting a branch already
pushed to origin; the entry here stands in for it instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 13:17:48 +03:00
mixandClaude Opus 5 8c54bfd5bd panel: give the monitoring pages a width that fits their data
48rem is a reading measure, right for the panel's forms and prose and the width
the nav bar's two rows were sized for. Deliveries, Mail queue and System log
hold data instead: seven columns of send-log, and mail.log lines that are long
by nature. They get 64rem; every other page keeps the narrow measure. The page
name travels onto <main> as a class so the stylesheet can tell them apart
without every handler having to pass a second field.

Width alone does not make a table fit, though — a column is at least as wide as
the longest unbreakable run in it, and one 40-character recipient still hung
Status over the card's edge. Cells may now break mid word, with timestamps
exempt, so a column can always be squeezed into the room available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:21:49 +03:00
mixandClaude Opus 5 d35b309714 panel: show the subject as text, not as its MIME encoding
A non-Latin subject arrives as RFC 2047 encoded-words, which the send-log
printed verbatim: unreadable, and one unbreakable run wide enough to push the
Status column out of its card. Decode at journal time (UTF-8/ASCII; exotic
charsets keep the raw header) and cap at 200 characters, then clip the column
to one line with the full text in the tooltip so no subject can widen the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:13:34 +03:00
mixandClaude Opus 5 669867cd2d docs: cut CHANGELOG 0.3.0, reopen Unreleased
Renames the Unreleased section to 0.3.0 per the versioning convention in
docs/progress.md. What accumulated since 0.2.0 is a coherent release: the
PTR check fix, the monitoring pages moving to URLs that match their nav
labels, and the navigation bar getting icons and a two-row layout. A
SemVer minor bump -- the URL move is breaking for bookmarks, but the
project is still pre-1.0.

The version lives only in the build arg (ldflags into
internal/buildinfo.Version), so this heading is the whole bump; there is
no constant in the tree to follow it.

No git tag and no ghcr push here -- a vX.Y.Z tag is what makes CI publish
images, and that stays an explicit step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:57:24 +03:00
mixandClaude Opus 5 50d2624985 panel: put the session row on top of the nav bar
The two rows were the right call; the order was not. The session block
belongs at the top right, where a signed-in user expects it, with the
page entries under it and the active one sitting against the bar's
bottom border.

Done by moving the session div ahead of the links div in the layout
rather than with a CSS `order`, so the reading and tab order still follow
what the eye sees. That does make Sign out the bar's first tab stop --
the same as on any site with a user menu up there, and nothing activates
on focus, so it is a reordering rather than a hazard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:43:52 +03:00
mixandClaude Opus 5 08943caae1 panel: lay the nav bar out as two rows on purpose
It stopped fitting on one line once the entries grew icons: six of them
need about 660px and the session block another 260px, against the 738px
the panel is wide. Flex was already wrapping it, but the wrap put the
session left-aligned directly under the entries, where it read as a third
row of navigation.

So: entries on the first row, session pushed to the right edge on the
second. The two blocks now read as two blocks. Buying the ~200px back by
shortening labels was the other option and would have undone the recent
work naming each entry after its page.

Rows rather than columns -- a two-column grid of entries measured a third
taller (107px against 83px) and was no easier to read. Entries stay first
in document order, so tabbing still reaches the pages before Sign out.

Checked at 1200, 768, 600 and 375px: no horizontal overflow, and below
768 the entries wrap to two rows as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:39:08 +03:00
mixandClaude Opus 5 38e9252697 panel: give Sign out an icon too
It was the last bare label in the bar once every page entry had one. An
open door with the arrow leading out -- the one icon here that marks an
action rather than a page.

It needs no colour rule of its own: the compact danger button already
sets the red, and the icon draws in currentColor, so it follows the
button into dark mode with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:18:25 +03:00
mixandClaude Opus 5 64e2ea264c panel: draw Backup as a filing cabinet
The storage box read as a box, not as an archive -- nothing in it said
what the page is for. Two drawers with handles is the picture of an
archive, and at 16px it holds together better than the box did: no lid
seam to lose, no small detail to smear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:15:23 +03:00
mixandClaude Opus 5 610f74d6a4 panel: give each nav entry an icon
The bar was six similar-length words; nothing distinguished them at a
glance. Each entry now leads with a 16px inline SVG.

Inline rather than an icon font or a sprite file: the icons inherit the
entry's colour through currentColor, so the active pill's darker text and
a link's blue need no second rule, they cost no request, and they need no
exemption from the panel's default-src 'self' policy. Each is aria-hidden
because the entry's text is already its accessible name.

Account is included -- it is a page like the others and would otherwise
be the one bare word left in the bar -- so the highlight and padding
rules now cover the session block too, and the bar centres its items
instead of aligning them on the text baseline now that an entry is an
icon-plus-label row rather than a run of text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:11:51 +03:00
mixandClaude Opus 5 75606dfbe9 panel: finish the monitoring rename below the URL layer
The previous two commits renamed the routes and the page titles but left
every identifier underneath still called sendlog/queue/logtail, so a
grep for "deliveries" found the route and nothing that serves it.

Renamed together, since they have to agree for a page to render at all:
the six template files, their {{define}} blocks, the pageFiles and
fragmentFiles keys, the .Active values the nav compares against, the
HTMX target ids, and the six page handlers.

Names that describe the data rather than the page keep their old form:
the send_log table and its store methods, internal/logtail, and the
sendLogData/readQueue/readLogTail helpers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:04:33 +03:00
mixandClaude Opus 5 4003a299a6 dnscheck: query recursive resolvers directly, not the system one
The PTR check reported a correctly published record as wrong. The lookups
went through the container's resolver (127.0.0.11) which forwards to the
host's systemd-resolved, and systemd-resolved synthesises the reverse
lookup of the machine's own addresses from the local hostname rather than
asking public DNS. On the production host that meant

    203.0.113.10 -> provider-assigned-hostname (does not match)

while public DNS has had 203.0.113.10 -> selfpost.example.com all along.

These checks exist to report what a receiving mail server sees, so they
now dial recursive resolvers themselves, defaulting to 1.1.1.1, 8.8.8.8
and 9.9.9.9 and overridable with SELFPOST_DNS_RESOLVERS. The e2e stand
sets it to its CoreDNS, which the `dns:` directive alone no longer covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:53:24 +03:00
mixandClaude Opus 5 5e8a330cf1 panel: match monitoring page titles to their nav labels
The System log page still said "Mail log" in its heading and browser
title, and Deliveries titled itself "send log".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:42:58 +03:00
mixandClaude Opus 5 bd4b226953 panel: match monitoring URLs to their nav labels
/sendlog -> /deliveries, /queue -> /mail-queue, /logtail -> /system-log,
along with the HTMX polling fragments under each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:41:52 +03:00
mixandClaude Sonnet 5 9c1dc4050f docs: cut CHANGELOG 0.2.0, reopen Unreleased
Renames the Unreleased section to 0.2.0 per the versioning convention in
docs/progress.md — the accumulated panel/CI/ops work since 0.1.0 is a
coherent release (SemVer minor bump; project is still pre-1.0). No git
tag/image push yet; that stays an explicit step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:35:41 +03:00
mix 419af4af95 deploy: pin compose image tag to the actual 0.1.0 release
It was still pointing at 1.0.0, a version that was never tagged or
released (CHANGELOG and git tags top out at 0.1.0/v0.0.1).
2026-08-03 21:25:03 +03:00
mix 888fc5cbf9 panel: clearer nav labels and card headings, danger-style Sign out
Rename technical labels (postqueue -p, mail.log tail, Send log/Queue/Log
in nav) to plain descriptions, and style Sign out as a deliberate danger
action consistent with other destructive buttons.
2026-08-03 21:19:46 +03:00
mixandClaude Opus 5 f113f44d65 panel: show the running version in the layout footer
Nothing in the UI said which build was running, though it is the value a
backup manifest is compared against on restore and the first thing worth
knowing when the panel misbehaves — it was only in the startup log line
and `panel -version`.

Add it as a small footer in the shared layout, supplied from render()
alongside .Active so no handler has to pass it, and gated on .User: the
login and setup pages face the internet and should not advertise a
version. Tests cover both the footer and render() supplying the key,
since neither is visible from any single handler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:32:58 +03:00
mixandClaude Opus 5 e87a55b657 panel: make the status page's "Full queue" a button
It was a bare link trailing the queue-summary sentence, while the two
other card actions on the same page (Re-check DNS, Reload configuration)
are buttons. Pull it out of the paragraph and give it the filled button
style through a new a.btn class — the same base rule a.danger already
used, so an action that happens to be a navigation still looks like
every other action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:26:20 +03:00
mixandClaude Opus 5 337c8e8f0e panel: stack Add an application above the list, drop the two-column row
The side-by-side .split row read badly: main is capped at 48rem, so the
applications table had to live in roughly 27rem and its actions column
squeezed four controls into it.

Put the create form directly above the list instead — the order the
domains page already uses for "Add a sending domain" above "Domains" —
and delete .split, which nothing else used. The empty-state text follows
the same page's wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:21:09 +03:00
mixandClaude Opus 5 f5f4bd4551 docs: documentation plan with a code cross-check pass
The documentation is part of the deliverable (spec 11.5/11.7/11.9), so it has
to describe what the code does, not what was intended. Adds
docs/documentation-plan.md: the package inventory against the spec, the
per-claim sources of truth in the tree, the results of a first cross-check
pass (11 findings, most notably the missing "operations" section required by
spec 11.7, the absent env-var reference, .env.example's dangling link to a
README "Rate limiting" section, and the unwritten "tar while stopped" backup
path from spec 9), and tasks D1-D7 gating the next release tag.

progress.md points at it so it survives a context reset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:18:32 +03:00
mixandClaude Opus 5 e6910c2c2d panel: show the SPF and DMARC records the domain page expects
The domain page generated and displayed the DKIM record but said only
"also configure SPF and DMARC for the domain (see the documentation)".
The concrete example existed — buried in the check's remediation text,
and only visible once the check had already failed. Show both records up
front, host and value with a Copy button, the way the DKIM record is
shown, plus the two things that actually bite: a domain may carry only
one SPF record (add the mechanism to the existing one rather than
publishing a second), and p=none is safe to publish immediately.

The SPF value names the addresses SELFPOST_HOSTNAME resolves to, taken
from the hostname check the page already runs, and falls back to an "a:"
mechanism when it does not resolve. New dnscheck.SPFExample/DMARCExample
are the single source for both the page and the checks' advice, so the
two cannot drift into recommending different records; dnscheck.Query
gains Hostname for the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:08:48 +03:00
mixandClaude Opus 5 6cf8b7b631 panel: draw every action as a button, not bold text
Same kind of control, two appearances: a POST wrapped in form.inline
(Re-check, Export domain, Sign out, New password, Delete), the <details>
toggles in the applications table and the a.danger delete links all
rendered as bold blue/red text, while every other action was a filled
button — sometimes both within one card, as on the backup page where
"Download full backup" was text and "Import domain" right below it was a
button.

Give them one vocabulary: filled for a card's own action, and a compact
outlined variant (the style the Copy buttons already used) where actions
cluster in a table row or the nav bar. An <a> is now only used for
navigation. The <details> summary keeps the pressed background instead of
a disclosure marker, and the row buttons are nowrap so a narrow actions
column widens rather than wrapping every label onto two lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:07:26 +03:00
mixandClaude Opus 5 a6df2ebceb panel: put Applications and its create form side by side
On the domain page the "Add an application" card sat below the domain rate
limit, so adding a login meant scrolling past the list and back. Move it up
next to the Applications table and wrap the pair in a .split grid (1.5fr /
1fr, so the table keeps the wider column). The columns collapse to one below
52rem, list first, and the grid gap keeps the same vertical rhythm as
.card + .card.

The empty-state text said "Create one below", which is no longer where the
form is; it now names the card instead of its position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:50:47 +03:00
mixandClaude Opus 5 f3bc24b638 feat: implement C.4 — hermetic container e2e gate + native release build
Separate test/e2e Go module drives the shipped deploy/docker-compose.yml
(plus a test-only override: self-signed cert, low ports, isolated compose
project) against a fake DNS zone (CoreDNS) and an smtp-sink MX, exactly as
an administrator and their applications would over HTTP/SMTP — covering the
class of failure unit tests can't see (container wiring). Positive path:
setup -> login -> domain -> DKIM record published into the fake zone ->
application -> SMTP AUTH send -> DKIM verified against the DNS-published
key -> send-log queued->sent. Negative: no-AUTH/unauthenticated relay,
sender/login mismatch, L1 (anvil) and L2 (panel) rate limits, journal-milter
fail-open, SELFPOST_HOSTNAME gate, session survives docker restart.

release.yml moves off qemu to a native per-arch build (amd64/arm64), each
gated by this suite before its tag is pushed and merged into the version
manifest.

Verified green on selfpost.example.com via `make e2e`; go vet/gofmt clean in
both modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:29:14 +03:00
mixandClaude Sonnet 5 3eb3e94bce feat: implement B.3 — fatal SELFPOST_HOSTNAME check in entrypoint.sh
Unset or malformed hostname makes the panel and Postfix diverge on SASL
realm silently (auth breaks for every application) and breaks HELO/PTR
matching (spam), so entrypoint.sh now exits before postfix-config.sh /
supervisord with an explanatory error, plus a syntax check rejecting
missing dots, schemes, ports, and whitespace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:53:14 +03:00
mixandClaude Opus 5 db1572d7ad feat: implement B.2 — rotate mail.log by rename + postfix reload
Replaces copytruncate with rename + `postfix reload` (the same mechanism
`postfix logrotate` itself uses), closing the up-to-one-second window where
copytruncate could drop in-flight delivery lines and leave a send-log row
stuck at "queued" forever.

logrotate-mail.conf keeps `create 0644 root root` rather than `nocreate` as
originally planned: verified on a live container that Postfix recreates the
file itself only lazily, on the next write after reload, and at mode 0600 —
unreadable by the unprivileged panel process. `create` hands the file back at
0644 immediately after rename, before Postfix ever touches it.

logtail.follow() re-drains the old file descriptor once more right before
switching to the rotated file, closing the residual gap between the last
poll's drain and the rotation check. readLogTail() treats a momentarily
missing mail.log as an empty screen rather than a logged error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:45:15 +03:00
mixandClaude Sonnet 5 538a4b6603 feat: implement B.1 — persist login sessions in SQLite with sliding idle timeout
Sessions move from an in-memory map (absolute 12h TTL) to a `sessions`
table (migration 0002), storing only the SHA-256 of the token. Expiry is
now a sliding idle window (PANEL_SESSION_IDLE_DAYS, default 7, no
absolute cap), extended at most once an hour and never by the
monitoring screens' background polling (GET + HX-Request), so a
forgotten open tab doesn't keep a session alive indefinitely. A login
now survives a container restart or redeploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:21:49 +03:00
mixandClaude Opus 5 db6abaefc7 docs: move the accepted security risks into docs/security.md
The plan holds undone work; an accepted risk is a decision, not a task
— it has no place in a queue, only a condition for revisiting it. Both
risks (POST with neither Sec-Fetch-Site nor Origin, no session-bound
CSRF tokens) move verbatim into a new docs/security.md, which also
states where D.5 findings land. Section letters and item numbering in
the plan stay as they were, since progress.md and the commit history
reference them; a note in their place points at the new file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:12:50 +03:00
mixandClaude Opus 5 cfd546000a docs: add item D.5 — pre-release vulnerability review by Fable
A single security pass once B.1-B.3 and C.4 are in, not four per-item
passes: those four rewrite authentication, log-file handling, the
entrypoint gate and the release workflow, so what matters is the final
state. Scope is the whole diff since v1.0.0 (phases 12-14 included)
plus a fresh walk over spec 7.6, run by Fable rather than Opus so the
reviewer is not the author. Findings are either fixed before the tag
or recorded in section A as accepted risks; together with the C.4 e2e
this gates tagging. The old section D (2.x pointer) becomes E, its
items renumbered 6 and 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:09:26 +03:00
mixandClaude Opus 5 e8605e68fa docs: decide item D.6 — domain-administrator role instead of 2FA
2FA is dropped from the 2.x candidate list. "Multiple administrators"
is narrowed to one concrete role: an admin scoped to a single domain
(its applications, DKIM/DNS status, filtered send log), with the
globally scoped actions — domain add/delete, reload, full backup,
queue and mail.log tail — left out of it. Still 2.x: a second panel
subject contradicts the out-of-scope list in spec section 3, so it
needs agreement and a spec change before any code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:05:23 +03:00
mixandClaude Opus 5 de4cc26cdb docs: decide item C.4 — hermetic container e2e as a release gate
The unit tests all fake the process boundary, so the class of failure that
actually broke this project — container wiring: chroot vs DNS, milter socket
permissions, the SASL realm, the Postfix reload path, cap_add — is invisible
to them. Record the decision to close it with a hermetic containerised e2e:
a separate Go module under test/e2e/, driven against the shipped compose file
plus an override, with a fake DNS zone and an smtp-sink standing in for the
outside world.

It runs before tagging (make e2e on the dev server, plus workflow_dispatch)
and gates image publication on the tag itself, which pulls release.yml off
qemu onto a native amd64/arm64 matrix: build, test, push per-arch tags, merge
the manifest — so the bytes published are the bytes that were tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:01:19 +03:00
mixandClaude Opus 5 7fd7b1f1de docs: decide item B.3 — fail fast when SELFPOST_HOSTNAME is unset
The soft fallback is worse than the plan's wording implied: the panel
falls back to realm "localhost" while postfix-config.sh falls back to the
container hostname, so with the variable unset accounts are written under
one realm and looked up under another — SMTP auth fails for every
application while the panel looks healthy. The second failure (EHLO =
container id, no PTR/SPF match) is invisible entirely. Both are silent
and delayed, which is exactly what a log warning cannot fix.

Decision: entrypoint.sh refuses to start without the variable, with an
explanatory message rather than a one-liner, plus a syntax check on the
value. Records why the "panel up with a banner, mail dead" variant was
rejected: it contradicts the Phase-4 crashexit invariant, cannot be fixed
without a restart anyway, and would let the panel persist SASL accounts
under the wrong realm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:40:59 +03:00
mixandClaude Opus 5 25cc34426c docs: decide item B.2 — rotate mail.log by rename + postfix reload
copytruncate loses log records twice per rotation: everything written
since the tailer's last poll (kept in mail.log.1, but skipped because the
descriptor points at the truncated inode) and whatever lands between the
copy and the truncate (gone for good). Those records carry the final
delivery statuses the send log is reconciled from, so a dropped line
means a row stuck in "queued" — not just a gap in the monitoring view,
as the item previously assumed.

Decision recorded, implementation deferred to its own step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:23:35 +03:00
mixandClaude Opus 5 e51917fcb5 docs: decide item B.1 — persistent sessions with a sliding idle window
Sessions move from the in-memory map to a `sessions` table (migration
0002), so a restart, a redeploy or a restore from a full backup no longer
signs the administrator out. The row holds a SHA-256 of the token rather
than the token itself: a stolen database file or backup archive cannot be
replayed into a login, while the browser that still holds the cookie keeps
working across a restore.

The 12-hour absolute TTL becomes a sliding 7-day idle window, configurable
through PANEL_SESSION_IDLE_DAYS (whole days, mirroring
SEND_LOG_RETENTION_DAYS). No absolute cap: for an administrator who visits
regularly the session lasts indefinitely, which is the accepted trade-off.
The four `every 5s` monitoring fragments deliberately do not renew it —
otherwise a forgotten open tab would hold the session open forever and the
window would mean "seven days without an open tab" rather than "seven days
without the administrator".

Decision only; no code yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:04:25 +03:00
mixandClaude Opus 5 934972ce48 docs: drop the closed phase-14 hardening out of the plan
The plan holds only open work, but section A still retold what phase 14
implemented — security headers, the origin check, the __Host- cookie, the
setup-token documentation — which the CHANGELOG and git history already
describe in full. What is left of A is the two things deliberately *not*
closed: the POST that carries neither Sec-Fetch-Site nor Origin, and the
absence of session-bound CSRF tokens. The XSS note folds into the token item,
which is the only place it was doing work; the "proxy must pass Host through"
requirement is documented in the README and in the CHANGELOG, so it goes.

Section A's numbered items are gone with it, so B-D renumber from 1. Nothing
else in the file has been implemented: the README still says nothing about
restarts signing the admin out or about the copytruncate rotation window,
SELFPOST_HOSTNAME still falls back to localhost without a warning, and CI
still runs only vet and unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:38:40 +03:00
mixandClaude Opus 5 6c61d53239 docs: document /data/setup-token and close phase 14
14.C needed no code: the setup link is already mirrored to /data/setup-token
at 0600 and removed once setup completes. What was missing is the reason to
prefer it — a deployment whose container logs ship to a central aggregator
otherwise leaves a live bearer token in that pipeline for ten minutes, and in
whatever retains it afterwards.

The reverse-proxy section gains the one requirement 14.A introduces: pass the
original Host header through. Everything else about security stays the
proxy's non-problem, which is the point of emitting the headers from the
panel.

Phase 14 leaves the plan (the file describes only unfinished work), but its
section A keeps what was deliberately left open: the accepted risk for clients
sending neither Sec-Fetch-Site nor Origin, the decision not to add
session-bound CSRF tokens and what would justify revisiting it, and the fact
that XSS inside the panel's own origin is answered by html/template and the
CSP rather than by either of those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:02:26 +03:00
mixandClaude Opus 5 cdb018d9f1 panel: __Host- session cookie and duplicate-cookie detection
Phase 14.B. The cookie already satisfied everything the __Host- prefix
requires — Secure, Path=/, no Domain — but as a promise the server makes, not
one the browser enforces. With the prefix the browser refuses to store a
same-named cookie carrying a Domain attribute, which is the second lever the
same-site neighbour from 14.A has: set selfpost_session for the parent domain,
and the browser sends two cookies of that name, oldest first. r.Cookie
returned that first one, so the admin logged in successfully and landed back
on the login form, for as long as the planted cookie lived.

The name has to stay conditional: __Host- is only valid on a Secure cookie, so
with PANEL_COOKIE_SECURE=false the browser would discard the Set-Cookie and
the dev instance would fail to log in with no visible reason. Hence the test
on that branch specifically, not just the production one.

requireAuth now reads r.Cookies() and refuses a request carrying more than one
cookie of the name, with a log line naming the cause. That is the only place
the overwrite becomes visible at all, and unlike the prefix it also works in
the dev shape. Sign-out clears both names, so the upgrade does not leave the
old cookie behind; it does sign the administrator out once, which costs
nothing given sessions live in memory and die on restart anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:02:15 +03:00
mixandClaude Opus 5 6ff353b94a panel: security headers and an origin check on unsafe methods
Phase 14.A, both halves of it in one middleware wrapped around the whole
router — outside requireAuth, so POST /login and POST /setup/{token} are
covered too.

Headers: CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and
HSTS only where the deployment is HTTPS-only (the same CookieSecure condition
that gates the cookie's Secure attribute; on the plain-HTTP dev instance HSTS
would pin the browser to a scheme that instance does not speak). HSTS goes
without includeSubDomains on purpose: the panel may sit at an apex, and
forcing HTTPS on every unrelated subdomain of the operator's domain for a year
is not this project's call. They are emitted here rather than delegated to the
reverse proxy, so the part that is easy to get wrong lives in the service.

Origin check: this is what SameSite=Lax cannot do. SameSite is judged per site
(registrable domain), so a neighbouring host — a CMS, a forgotten staging
subdomain — is same-site and its forged POST arrives with the session cookie
attached. Sec-Fetch-Site and Origin are judged per origin and tell it apart.
For the typical deployment, where the panel is a subdomain of a domain used
for other things, that neighbour is the realistic attacker, not a theoretical
one; POST /domains/import is the case that turns a blind write into working
credentials on someone else's relay.

A request carrying neither header is still let through — the risk accepted in
the plan, since a client that old cannot be checked at all and the strict mode
would not protect it, only break the panel in it.

The check compares Origin's host against r.Host, not the full origin: the
panel speaks plain HTTP behind the proxy and does not know its own external
scheme. That makes it depend on the proxy preserving Host. All four shipped
fragments do, but one that rewrites it would turn every form submission into a
403, so the rejection logs both sides of the comparison — otherwise the
symptom reads as "the panel stopped saving anything".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:02:02 +03:00
mixandClaude Opus 5 14b4933917 panel: move inline styles and confirmations out of the templates
Groundwork for the Content-Security-Policy of phase 14.A. A policy that has to
allow inline script is not worth writing — script-src 'unsafe-inline' gives
back exactly the XSS foothold the policy exists to remove — so the three
inline constructs the templates still had are moved out first:

  - the layout's <style> block becomes /static/panel.css;
  - the one style="background:#b42318" attribute becomes the .danger class
    that already existed for it;
  - the four onsubmit="return confirm(...)" handlers become data-confirm,
    handled by a delegated listener in panel.js. Delegation matters: the
    application rows are also delivered by HTMX swaps.

htmx would otherwise inject a <style> of its own for the request-indicator
classes and become the single reason the policy needs an exemption; the panel
uses no hx-indicator, so the meta config switches it off.

A guard test keeps this from silently regressing later, which it otherwise
would: an inline handler added to a template does not fail, it just quietly
stops working in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:01:46 +03:00
mixandClaude Opus 5 7a09e62bf1 docs: decide cookie item A.3 — __Host- prefix plus duplicate detection
Records both decisions and, more usefully, what the item was actually about.
The prefix was filed as a free nicety ("мелочь, но бесплатная"), which is why
it sat undecided: nothing said what it prevents. It prevents the same-site
neighbour from the CSRF item using its other lever — setting a Domain-scoped
cookie of the same name. The browser then sends two, r.Cookie returns the
older one, and the admin logs in successfully into an endless login loop. That
is denial of service rather than compromise (no valid token can be forged with
a single account), but it is close to undiagnosable from the panel's side, and
the origin check decided in A.2 does nothing about it — the request comes from
the admin's own origin.

Phase 14 gains section B: the cookie name becomes conditional on CookieSecure,
because a __Host- cookie over plain HTTP is rejected outright and would break
the dev mode silently; logout clears both names; and requireAuth switches to
r.Cookies() so a duplicate is refused and logged instead of silently picked.
The setup-token documentation moves to 14.C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:33:31 +03:00
mixandClaude Opus 5 75e9fef037 docs: decide CSRF item A.2 in favour of the origin check
Records variant (b): the panel will check Origin / Sec-Fetch-Site in the same
middleware as the security headers, and will not carry CSRF tokens. The
coverage table above the decision already says what that buys; what the item
was missing is what it does not buy, so both are now written down — the
accepted risk (a client sending neither header still gets through, which is
exactly the old-browser row) and the two escalation paths with their price,
tightening the policy to reject those requests, or session-bound tokens.

Phase 14.A grows the implementation rules: which requests are checked, the
three-way decision, and the fact that only the host is compared because the
panel sits behind a proxy and never sees its own external scheme. The rule
depends on r.Host being the external name — all four shipped proxy fragments
preserve it (checked), but a proxy that rewrites Host would turn every POST
into a 403, so the rejection has to log both sides of the comparison and the
container test has to run through a real proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:24:59 +03:00
mixandClaude Opus 5 23ecc5d5af docs: restructure plan item A.2 around what each option closes
The options were buried in three prose bullets that mixed mechanism, cost and
coverage, so the one question that matters — which attacker each variant stops
— could not be read off the page. They are now a table: four scenarios by
three variants, with the cost and the caveats underneath and the scenario
prose moved below the table for whoever wants the detail.

No change of substance: same variants, same recommendation (the Origin /
Sec-Fetch-Site check in the phase 14.A middleware), same caveat that a naive
double-submit token leaves the subdomain row open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:20:57 +03:00
mixandClaude Opus 5 c938ed20f8 docs: spell out the CSRF risk in plan item A.2
The item said SameSite=Lax was "enough for modern browsers" but wrong "on a
downgrade to an old browser or unusual proxies", which named the least likely
scenario and missed the most likely one: SameSite is scoped to the registrable
domain, not the origin. The panel runs on a subdomain, so any page anywhere
under the operator's domain — the CMS on www, a stale CNAME, a neighbouring
service — is same-site and its POST carries the session cookie.

It also said nothing about what a successful CSRF would actually buy. Almost
everything is a blind write the attacker cannot read, except POST
/domains/import: multipart is a CORS-simple content type, and a domain export
carries a DKIM key and working SASL passwords, so an attacker uploads
credentials they already know and gains a sending identity on someone else's
relay. That single endpoint, not the destructive ones, is what sets the bar.

The options now carry their cost and their limits: an Origin/Sec-Fetch-Site
check in the phase 14.A middleware closes the subdomain case for ~15 lines,
while a naive double-submit token does not close it at all, since a same-site
neighbour can write the parent domain's cookie. Route facts, cookie
attributes, the export struct and the absence of any hx-post were checked
against the code rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:16:36 +03:00
mixandClaude Opus 5 51e20ffc22 docs: drop completed work from the plan and progress tracker
The plan is meant to hold only what is still open, but three of its numbered
items had already been implemented and were still being read as pending work:
the TRUSTED_PROXY_CIDR-gated X-Forwarded-For handling (A.1), the account
settings page (A.6) and the go vet/go test CI workflow (C.10). Remove them
and renumber; the residual scope note from A.6 (2FA, multiple admins) moves
to section D, which is where deliberately deferred scope belongs.

Same for the "done" notices at the top of the plan and the phase-by-phase
retellings in progress.md: phases 12 and 13 are described in full in the
CHANGELOG and git history, so the tracker now states what is closed and what
is next, and nothing else.

Three code comments cited plan item numbers that this renumbering would have
silently pointed at a different item, and one cited a phase 13 section that
no longer exists; they now state the fact instead of the reference. The CI
test workflow was never recorded in the CHANGELOG, so its entry is added
there before the plan item describing it goes away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:10:56 +03:00
mixandClaude Opus 4.8 7b4549a35d panel: server status page, per-domain DNS checks, /domains move
Phase 13. Two new packages and one new screen.

internal/health owns the shared status vocabulary (ok/warn/error/unknown)
and the local checks: supervisord's process table, TLS certificate expiry
and the two milter sockets. Each check reports a problem as a status rather
than an error, so one broken component costs a line and not the page.

internal/dnscheck does the read-only lookups: forward-confirmed reverse DNS
for SELFPOST_HOSTNAME, and per-domain DKIM (compared against the key this
server actually signs with), SPF and DMARC. Every check is bounded by a
timeout and cached, and the resolver sits behind an interface so the tests
drive every branch without touching the network. The SPF check is
deliberately shallow: it looks for a mechanism literally covering the
server's address and does not follow include:/redirect=, so a record that
authorises us through an include is reported as "cannot tell" rather than
as a failure.

/status renders both, with the local checks in an HTMX-polled fragment and
the DNS lookups behind a Re-check button, and becomes the panel's landing
page: / now redirects there and the domain list lives at /domains. The
Reload button moves onto /status, where it reads as what it is — a
drift-recovery for the daemons — with text explaining what it regenerates.
A template test fails on any remaining href="/" so a stale link cannot
silently land on the wrong screen.

Also fixes a defect this made visible: the panel could never read the mail
queue in the documented deployment. postqueue relies on its setgid-postdrop
bit, which the compose file's no-new-privileges disables, so the Queue
screen always said "Could not read the mail queue" — including in the
released 1.0.0 image. The panel user is now a real member of postdrop,
which needs no setgid transition.

Verified in a container on the dev server against real DNS: PTR matching
(selfpost.example.com) and not matching (example.com), DKIM absent and
mismatched, SPF absent and via include:, DMARC p=quarantine/p=reject/absent,
and a resolver timeout degrading to "unknown" without hanging the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 22:04:37 +03:00
mixandClaude Opus 5 fc53ae1314 panel: shared nav, account settings, backup page, connection settings
Phase 12 (UI/UX). The navigation bar now renders once from layout.html
instead of being copied into each content template, so it is present on
every authenticated page — including the domain page and its delete
confirmation, which had no links at all — and the current page is
highlighted via .Active rather than quietly dropping out of the list.

New /account page changes the administrator's username and/or password:
the current password is required and the attempt is throttled on the same
limiter as the login form, so this route cannot be used to brute-force
past that limit. A password change invalidates every other session while
keeping the one performing it; a rename carries that session over.

Backup and domain import move from a card in the middle of the domain
list to their own /backup page, one card each; the handlers themselves
are unchanged, only the page the import form renders its errors on.

The domain page gains a "Sending server settings" card (server, port,
encryption) so a client can be configured without reading the docs; 587
is listed only when SUBMISSION_ENABLE is true for this deployment, which
is a deploy-time flag the panel cannot verify at runtime.

Client-side (static/panel.js, no libraries): Copy buttons on the values
that get carried elsewhere (DKIM record, new application credentials,
server name), and the Addresses field is hidden while the address mode is
wildcard, where the server ignores it.

Verified in a container on the dev server: setup, login, every page's
nav and active item, domain and application creation, all account-form
paths including cross-session invalidation, import errors, full backup
download. gofmt/vet/test/docker build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:34:59 +03:00
mix 558c059341 Add logo 2026-07-17 13:52:50 +03:00
mixandCursor 55fe38ba5a assets: keep brand icon in docs, serve compact panel favicon
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 21:30:02 +03:00
mixandCursor 9dd2b47e6f assets: add web app icon
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 00:22:53 +03:00
mix 6fe333204b docs: split accepted A.2/A.5 decisions into implementable Phase 14
Security headers and setup-token docs were already decided but had no
concrete implementation phase; also mark A.1 rate-limit and CI test
workflow as done since they landed in recent commits.
2026-07-16 00:05:23 +03:00
mix ead0638ee2 docs: record decisions for A.2 (security headers) and A.5 (setup-link stdout)
A.2: headers emitted from the panel, not reverse-proxy — keep proxy config
minimal and hard to break, push complexity into the service.
A.5: keep stdout as the base setup-link delivery per spec; document the
/data/setup-token file as a more secure alternative for centralized-logging
setups.
2026-07-15 23:59:17 +03:00
mixandClaude Sonnet 5 dca83e9671 security: parse X-Forwarded-For from trusted proxies for rate-limit key
Resolves plan item A.1 (option б): login/setup rate-limiting used
RemoteAddr only, which behind the default reverse proxy is the proxy's own
address, making the limiter effectively global and enabling a lockout-DoS.
Now, when the request's direct peer matches the new TRUSTED_PROXY_CIDR list
(comma-separated CIDRs, env, empty by default), the last X-Forwarded-For
entry is used instead, giving a real per-client limit. Unset behaviour is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:53:59 +03:00
mix ee8d5f65d9 docs: plan UI/UX cleanup phase (nav, account, backup, connection info)
Fold user feedback into a new Phase 12 covering the panel's UX gaps:
structural nav header on every page with active-state highlighting,
an account settings page for admin login/password, a dedicated
backup/migration page split from domain import, connection settings
on the domain page, copy-to-clipboard for values meant to be pasted
elsewhere, hiding the unused addresses field in wildcard mode, and
moving the Reload button to the new /status landing page (Phase 13,
renumbered from 12).
2026-07-15 23:42:53 +03:00
mixandClaude Sonnet 5 4adbde1948 ci: run go vet and go test on push/PR
Tests (including the rate-limit suite) previously only ran manually
on the build server; now every push to main and every PR triggers
them via GitHub Actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:26:42 +03:00
mixandClaude Sonnet 5 d82d9736bd docs: plan Phase 12 - service status page + domain DNS checks
Adds a new agreed-upon phase covering /status (supervisord processes,
Postfix queue, TLS cert expiry, milter sockets, PTR/FCrDNS check) and
per-domain DNS correctness status (DKIM/SPF-heuristic/DMARC) on the
domain page. Not part of v1.0 spec scope; scoped and agreed with the
user before implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:12:56 +03:00
mixandClaude Sonnet 5 278a88d1e1 docs: add CHANGELOG.md, trim completed work out of plan/progress
CHANGELOG.md now tracks version history (0.1.0 baseline); progress.md and
implementation-plan.md keep only live process and unfinished work (open
questions, optional 2.x.x phase O1) since phases 0-11 are fully closed and
already covered by git history and the changelog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 23:06:10 +03:00
mixandClaude Sonnet 5 9dc192d495 ci: disable provenance attestation on release image push
Avoids a spurious unknown/unknown platform entry in the ghcr.io
manifest list alongside linux/amd64 and linux/arm64.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 22:48:45 +03:00
mix cc01e44ab8 deploy: add CAP_KILL so opendkim reload on domain add/remove works
supervisord runs as root inside the container but cap_drop: ALL still
blocked it from signaling opendkim (a different uid) — cross-uid
kill() checks CAP_KILL regardless of the caller's uid. Domain add was
failing in prod with "unknown problem sending sig opendkim ...
PermissionError: Operation not permitted".
2026-07-15 22:30:20 +03:00
mixandClaude Opus 4.8 fe2844441b deploy: add CAP_FOWNER/CAP_FSETID so entrypoint permission-fix works
Bringing up the production Apache stack for real surfaced a latent bug
in the Phase 10 hardening: cap_drop: ALL with only NET_BIND_SERVICE/
CHOWN/SETUID/SETGID/DAC_OVERRIDE left the root startup phase unable to
chmod the /data dirs it had just chowned to the panel user (needs
CAP_FOWNER) or set their setgid bit (needs CAP_FSETID). The container
crash-looped on "chmod: Operation not permitted". Phase 10 never caught
this because its compose up hit a port conflict before full boot.

Add FOWNER and FSETID to cap_add and document what each capability is
for. Verified: container now starts clean under the hardened compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:18:44 +03:00
mixandClaude Opus 4.8 e8b558eb3b docs: open-questions backlog for v1.0 (attention & discussion)
Capture conscious tradeoffs and hardening candidates that go beyond the
mandatory 7.6 requirements: reverse-proxy rate-limit keying, missing
security response headers, CSRF/SameSite stance, __Host- cookie prefix,
session/ops notes, and the gap that CI does not run go test. None are
compliance defects; each is a decide-later item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:49:15 +03:00
mixandClaude Opus 4.8 65a420d230 Phase 11: final security pass + acceptance (spec 7.6, 12.2-3)
Line-by-line audit of all 8 points of spec 7.6 against the code: full
compliance, no code changes required. Acceptance verified on the dev
server (image selfpost:p11): gofmt/vet/build/test green, docker build
ok, clean container start (all processes RUNNING, panel as non-root
uid 999, setup link + 0600 token, bogus token 404, unauth 303, healthz
200). Baseline v1.0 plan (phases 0->11) complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:42:27 +03:00
mixandClaude Sonnet 5 61f525e2d7 Phase 10: deployment (Apache compose + proxy fragments, CI release) + docs
- deploy/docker-compose.yml: pinned-tag ghcr image, hardened (cap_drop ALL +
  minimal cap_add, no-new-privileges, panel bound to 127.0.0.1 only). Apache
  itself runs on the host (spec 10.5), fragment at deploy/apache/.
- Alternative reverse-proxy fragments: nginx (+certbot sidecar), Caddy
  (automatic ACME), Traefik (+acme.json PEM extraction script).
- .github/workflows/release.yml: tag-triggered ghcr.io publish, version piped
  from the git tag into both the binary ldflags and the image tag (spec 10.1).
- Closed a gap from Phase 1: logrotate was installed but never invoked;
  wired up build/logrotate-mail.conf + logrotate-loop.sh + a supervisor
  program (copytruncate, since postlogd holds mail.log open with nothing to
  signal on rotation).
- README rewritten: site requirements checklist, reverse-proxy comparison,
  DNS setup (server- vs domain-level), IP warmup, backup/restore vs domain
  export/import, fixed-tag rationale, machine requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 21:23:19 +03:00
mixandClaude Opus 4.8 f88d8dabcb Phase 9: full backup/restore + domain export/import (spec 7.5, 11.6)
Full server backup (spec 7.5.A): internal/backup produces a tar.gz of all of
/data — a consistent SQLite snapshot via VACUUM INTO, DKIM keys, sasldb2 and a
version manifest; TLS certs (tls/) and the Postfix queue are excluded. Two equal
paths: the panel button (POST /backup, no-store) and the selfpost-backup CLI via
docker exec (spec 11.6). CheckRestore runs before store.Open: a manifest version
mismatch refuses to boot with the image tag to use; a match consumes the
manifest so it only guards the first post-restore boot. Restore is not a
separate branch — Postfix/OpenDKIM regenerate from the restored SQLite as on any
start.

Domain export/import (spec 7.5.B): DomainExport carries the DKIM private key and
each application's working password. SASL secrets are read from sasldb2 via
db_dump (the userPassword property is plaintext) and, on import, re-keyed under
the local realm with saslpasswd2 — so credentials keep working on an instance
with a different hostname, with no DKIM DNS change. Import validates and rolls
back atomically on any failure. db-util (db_dump) is now an explicit image dep.

Verified on the server (selfpost:p9): gofmt/vet/test green; container e2e for
cross-realm domain export/import (SMTP AUTH 235 under the new realm), CLI and
panel backups, same-version restore, and version-mismatch refusal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:33:07 +03:00
mixandClaude Opus 4.8 56a4fa892d Phase 8: level-2 differentiated rate limits (spec 7.4)
The journal-milter, until now a pure monitor, now refuses a message with a
4xx tempfail (RespTempFail/451) at MAIL FROM when a per-domain or per-
application limit is exceeded. Key is the client IP; the count is
COUNT(DISTINCT queue_id) over a sliding window reusing the send log; the
limit applies only when a non-empty IP binding matches the client (empty
binding => level-1 only, per spec 7.4). Enforcement is fail-open on the
milter's own errors — a limiter malfunction never blocks mail, and Postfix's
level-1 anvil limit stays the independent backstop. Refused messages are
recorded in send_log with status "rejected" for UI visibility.

- store/ratelimits.go: RateLimit type (+Active/AllowsIP), id-keyed get/set/
  delete for the panel, name/login-keyed lookup + windowed distinct-message
  count for the milter, DeleteRateLimitsForDomain. No migration — the
  rate_limits table has existed since Phase 2.
- milter: enforce at MailFrom, fail-open helper overLimit, InsertRejected.
- web: server-side validated IP/ceiling/window forms on the domain page and
  per application; routes POST /domains/{id}/ratelimit and
  /applications/{aid}/ratelimit. Milter reads rows live, so no reload.
- domain/app services clear limits on deletion (rate_limits has no FK cascade).

Unit tests + container e2e (p8) green: refusal on both scopes, unregistered
IP ignored, fail-open with the panel stopped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:57:38 +03:00
mixandClaude Sonnet 5 cb25923a7b Phase 7: monitoring UI — send log, queue, mail.log tail
Three HTMX-polled monitoring screens (spec 7.2.11-13): send log with
server-side domain/application filters and pagination, Postfix queue
(postqueue -p), and a mail.log tail. Fragment endpoints return HTML
snippets, not JSON (spec 7.1); all output is auto-escaped via
html/template (spec 7.6.7).

Adds store.QuerySendLog/CountSendLog/ListApplicationLogins,
postfix.Queue(), and logtail.TailLines (a point-in-time reverse read,
independent of the background follow loop). Verified on the dev server:
gofmt/vet/test green, docker build green, container e2e (filters,
60-row pagination, <script> escaping, real postqueue/mail.log output,
existing Reload button unaffected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:22:08 +03:00
mixandClaude Opus 4.8 ab25d24706 docs: roadmap 2.x.x — optional inbound relay (backup-MX/forward) + pluggable antispam
Add optional Phase O1 (targeted at the 2.x.x release line, outside the
v1.0 baseline) covering inbound relay as an opt-in/plugin: accept on :25
for explicit relay_domains and forward to an upstream backend, with
strict anti-open-relay/backscatter (relay_domains + relay_recipient_maps
+ reject_unauth_destination). Use cases: backup-MX and fronting a mail
server with no external IP.

Antispam is an important but optional capability: blind forwarding stays
valid. Since a blind relay hides the origin IP from the backend (breaking
downstream DNSBL/SPF), filtering must be attachable at the inbound hop —
provided as a milter hook to an external engine running in a separate
optional container, plus native Postfix DNSBL as a dependency-free
backstop. SelfPost neither bundles nor runs the engine, keeping the image
and the "one container, three processes" model intact.

Requires explicit sign-off (spec 12.6) as it extends beyond out-of-scope
section 3; plan-only, no implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:05:27 +03:00
mixandClaude Opus 4.8 745f1c03d2 docs: close Phase 6 — journal-milter verified (records, statuses, fail-open x2, retention)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:58:44 +03:00
mixandClaude Opus 4.8 6ebb6f56d6 Phase 6: journal-milter + send-log status tailer + retention
Implement the structured send log (spec 7.3), the project's highest-risk
component since a milter bug can break the relay itself.

- internal/milter: go-milter v0.4.1 journal-milter. Per-connection session
  collects SASL login, From, recipients and Subject across callbacks and
  writes one send_log "queued" row per (queue-id, recipient) at EOM
  (spec 7.3.3). Monitoring only: callbacks return Continue/Accept, recorder
  errors are logged never propagated, so it can never block mail.
- internal/logtail: polling mail.log tailer with rotation handling (inode
  change / truncation), parses sent/deferred/bounced/expired by queue-id +
  recipient and advances rows; background retention sweep prunes rows past
  SEND_LOG_RETENTION_DAYS (default 90) at startup and every 6h.
- internal/store/sendlog.go: InsertQueued, UpdateStatus (case-insensitive
  recipient match), DeleteSendLogBefore + status constants.
- cmd/panel: open the store once and share it across http/milter/tailer;
  replace the journal/logtail stubs with the real roles.
- build/postfix-config.sh: bounded milter timeouts (15/15/30s) so a hung
  milter also fails open in seconds, not the 300s default.

Fix found in-container: SASL login (app_login) was empty because go-milter
keys macros exactly as Postfix sends them, and multi-character macro names
arrive brace-wrapped ({auth_authen}); the SASL-less Phase 0 spike could not
observe this. Added a brace-tolerant macro lookup.

Verified on selfpost.example.com: gofmt/vet/unit tests green; container e2e
records rows with correct fields and advances status via the tailer; fail-open
confirmed for both an unreachable and a hung milter; retention prunes at start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:58:34 +03:00
mixandClaude Opus 4.8 4f1f7761a2 docs: close Phase 5 — real delivery confirmed (dkim=pass, spf=pass)
Live delivery verified end-to-end: dtester@test.example.org -> selfpost@mixeme.ru
accepted by mx.example.net with Authentication-Results dkim=pass
(d=test.example.org s=selfpost) and spf=pass, read back over IMAP. All Phase 5
"done when" criteria met. Records the own-domain-policy pitfall (can't test
delivery from a domain the receiver itself hosts) and the test.example.org sender
workaround for future delivery tests. Next: Phase 6 (journal-milter) on Opus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:20:36 +03:00
mixandClaude Opus 4.8 49ab29b1ce docs: record Phase 5 state — relay verified, live delivery auto-retrying
Phase 5 code is complete, committed (2c7f0da) and verified on the server.
Everything at SelfPost's boundary is proven (auth, sender binding, no open
relay, valid DKIM signing, delivery to the recipient MX over TLS). The one
open item — the receiver accepting the message into its inbox — is gated by
mx.example.net's DNS cache / own-domain policy, not a relay defect; a background
loop retries until it lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:59:39 +03:00
mixandClaude Opus 4.8 2c7f0da3d8 Phase 5: full outbound Postfix relay (465/587 SASL+TLS, sender binding, milters)
Generate the relay config from the environment at container start
(build/postfix-config.sh, run from entrypoint.sh):

- smtps 465 (implicit TLS) primary + optional submission 587 (STARTTLS),
  chroot=n so smtpd reaches the sasldb2/sender map under /data.
- Cyrus SASL against the panel-maintained sasldb2; realm left implicit so the
  authenticated name equals the bare login in smtpd_sender_login_maps.
- reject_sender_login_mismatch + relay/recipient restrictions with no
  permit_mynetworks: credentials-only, open relay impossible (spec 5, 5.1).
- TLS cert/key from TLS_CERT_FILE/TLS_KEY_FILE; daily postfix reload picks up
  renewed certs (postfix-cert-reload.sh under supervisord, spec 5.2).
- anvil level-1 rate limit from env (spec 5 p.5).
- Milter chain with per-milter action: OpenDKIM strict (tempfail), journal
  fail-open (accept) so monitoring never blocks the relay (spec 7.3).

Two integration fixes found on the server:
- postconf -F '*/*/chroot=n': Debian's chrooted delivery agent can't read
  /etc/resolv.conf, so MX lookups failed and mail never left.
- entrypoint sets /run/opendkim and /run/selfpost to group selfpost + setgid,
  and the journal stub chmods its socket 0660, so postfix can connect to both
  milter sockets (strict OpenDKIM was milter-rejecting all mail otherwise).

Verified on selfpost.example.com: gofmt/vet/test green, image builds; container
e2e — 465 auth+send DKIM-signed (d=domain,s=selfpost), 587 STARTTLS auth,
cross-domain sender 553, list-mode per-address binding, unauth relay 554,
real outbound delivery reaching the recipient MX over TLS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:37:16 +03:00
mixandClaude Opus 4.8 c6eeb30258 Phase 4: applications + SASL (sasldb2) + sender_login_maps
Adds application accounts bound to domains: a SASL login/password in
sasldb2, a per-application address mode (wildcard @domain or an explicit
list), and matching smtpd_sender_login_maps bindings — with create,
list, edit-mode, delete and password regeneration (spec 4.1, 5.1,
7.2.5-9). Generated passwords are shown exactly once and never stored in
plaintext (7.6.1).

- internal/store/applications.go: transactional CRUD; globally unique
  login; ListBindings (address->login) as the map source; logins-by-
  domain for pre-cascade SASL cleanup.
- internal/app: saslpasswd2 wrapper (password via stdin, login as a
  whitelisted argv element, no shell — 7.6.3); strong base64url password;
  address validation that enforces domain ownership before any config
  write (7.6.2); service orchestrating store + sasldb2 + map with full
  rollback on partial failure.
- internal/postfix: sender_login_maps regenerated as a pure function of
  the registry (many-to-one logins merged per address), atomic write,
  injection backstop (7.6.4).
- Postfix reload, corrected: `postfix start-fg` forks a separate master,
  so signalling the supervised process never reaches it. Reload now runs
  the canonical `postfix reload` via a one-shot supervisord program the
  unprivileged panel triggers over the group control socket. Verified in
  mail.log.
- domain.Service.Delete purges the domain's SASL accounts, then cascades,
  then rebuilds the sender map and reloads; manual reload now covers both
  OpenDKIM and Postfix.
- web: application management in the domain page, one-time credential
  shown inline; postfix joins the selfpost group and entrypoint normalises
  /data/sasl and /data/postfix (setgid, group-readable) with self-heal.

Verified on the dev server: gofmt/vet/test green, image builds, and a
container e2e covers the full application lifecycle, domain-delete
cascade, restart persistence, and a real postfix reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:18:10 +03:00
mixandClaude Opus 4.8 a7a5ad3f91 Phase 3: sending domains + per-domain OpenDKIM signing
Add/list/delete of sending domains with per-domain DKIM keys and the
OpenDKIM tables that drive signing (spec 6, 7.2.2-4, 7.2.10).

internal/domain:
- Pure-Go RSA-2048 keygen; PKCS#1 PEM written atomically at 0640; the
  published DNS TXT record is derived from the key on disk (single source
  of truth) rather than persisted. No os/exec for key generation.
- KeyTable/SigningTable fully regenerated from the registry on every
  add/delete (idempotent), written atomically; SigningTable via refile:
  with *@domain, KeyTable with absolute key paths. Table writer refuses
  any unsafe character as a backstop (spec 7.6.4).
- Reload without root: the unprivileged panel signals OpenDKIM through
  supervisord (`supervisorctl signal USR1 opendkim`, fixed args, no
  shell, no user input — spec 7.6.3). An existing key is reused, never
  overwritten, so re-adding a domain keeps its published DNS valid.
- Service orchestrates registry -> key -> table rebuild -> reload, with
  rollback of the row if a downstream step fails; delete cascades apps
  via the DB FK and removes the key + table entries.

Infra:
- Shared `selfpost` group bridges panel (writes keys) and opendkim
  (reads them); /data/opendkim is setgid so panel-created files inherit
  the group, keys are 0640, RequireSafeKeys is disabled by design.
- opendkim.conf moves from verify-only (Mode v) to signing (Mode s).
- entrypoint.sh normalises the DKIM tree on every start (ownership,
  setgid, perms, empty tables before opendkim starts) — self-healing
  after a restore.
- supervisord control socket opened to the `selfpost` group so the panel
  can request the reload.

web/store:
- Strict domain-name validation (whitelist [a-z0-9.-], DNS shape, >=2
  labels), lower-case normalisation (spec 7.6.2).
- Domain queries with application counts; delete relies on ON DELETE
  CASCADE. Dashboard lists domains + add form; domain page shows the
  DKIM record; a dedicated confirm page warns about the app cascade
  before deletion (spec 7.2.4); manual reload button (spec 7.2.12,
  OpenDKIM side; Postfix reload lands in Phase 5).
- Authenticated routes moved to a sub-mux using Go 1.22 method/wildcard
  patterns.

Tests: validateDomain, DKIM keygen/record roundtrip, table rendering +
injection-safety, key reuse, store cascade. Verified on the dev server:
gofmt/vet/test green, image builds, container e2e (add/delete a domain,
DKIM record shown, OpenDKIM reads panel keys and reloads, keys and
tables persist across a restart).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:20:35 +03:00
mixandClaude Opus 4.8 048be22ded docs: specify CI image build and ghcr.io publishing
Add section 10.1 covering tag-triggered CI build, version from git tag
flowing into both ldflags and the image tag (enforcing the 7.5.A restore
invariant), and publishing to ghcr.io. Document Quay.io as an alternative
registry. Update 11.7 (GitHub is no longer a dumb mirror) and add the
workflow as deliverable 11.10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:45:04 +03:00
mixandClaude Opus 4.8 d72a383904 Phase 2: SQLite persistence, admin setup-link, login/sessions
Implements the secure single-admin panel entry (spec 7.6).

- internal/store: modernc.org/sqlite (pure Go, static build), WAL +
  foreign keys, embedded PRAGMA user_version migrations; schema 0001
  covers admin/settings/domains/applications/send_log/rate_limits (spec 9).
- Setup secret-link (spec 7.6.1): 128-bit crypto/rand token, printed to
  log + /data/setup-token (0600), 10-min TTL with regeneration, per-IP
  rate limit, subtle.ConstantTimeCompare, failures don't invalidate,
  one-time admin form, permanent invalidation once admin exists (/setup 404).
- bcrypt admin password; server-side username/password validation.
- Login + in-memory sessions, crypto-random token, cookie
  HttpOnly/Secure/SameSite (Secure toggleable for dev HTTP), login
  rate limit, auth middleware.
- html/template base layout + setup/login/dashboard, vendored htmx 2.0.4.
- build/entrypoint.sh: fix bind-mounted /data ownership as root before
  supervisord drops to the unprivileged panel user (found via container test).

Verified on selfpost.example.com: go vet/build/test/gofmt clean; e2e curl
of setup+login flows; docker build + run with -v ./data:/data creates the
DB and 0600 token owned by panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:24:09 +03:00
mixandClaude Opus 4.8 85f3374ce1 Phase 1 done: close out and record verification results
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:55 +03:00
mixandClaude Opus 4.8 e6aceeb811 Phase 1: Docker image, supervisord, three-process cold start
Single bookworm-slim image running opendkim + panel + postfix under
supervisord with enforced start ordering (spec 4):

- build/Dockerfile: multi-stage static Go build; runtime installs postfix,
  opendkim, cyrus-sasl, supervisor, logrotate; unprivileged panel user (7.6.8).
- build/supervisord.conf: priority ordering opendkim -> panel -> postfix;
  crashexit event listener terminates the container on any FATAL process.
- build/postfix-wrapper.sh: waits for both milter sockets (test -S, 30s
  timeout) before `postfix start-fg`, exits non-zero on timeout.
- panel: HTTP :8080 stub + /healthz, journal-milter socket stub (so the
  wrapper's readiness probe passes), log-tailer stub; SIGTERM graceful stop.

Verified on the dev server: image builds, three processes live, panel serves
the stub, wrapper waits for sockets, and an unrecoverable panel failure brings
the container down cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:14:08 +03:00
mixandClaude Opus 4.8 cc2d9d43c0 Phase 0 done: close out and record milter spike results
De-risk spike confirmed emersion/go-milter v0.4.1 (BSD-2) interoperates with
Postfix 3.7.11 (bookworm) over protocol v6: reads From/To(per-rcpt)/Subject/
queue-id, gets client IP from Connect(), and fails open when the milter dies.
Progress tracker updated; Phase 1 (Docker + supervisord) is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:58:34 +03:00
mixandClaude Opus 4.8 b39d1d0302 Phase 0: project scaffold and build pipeline
Go module (codeberg.org/mix/selfpost), two command skeletons (panel,
selfpost-backup) sharing internal/buildinfo for the -ldflags version stamp,
Makefile (static CGO_ENABLED=0 build), AGPL-3.0 LICENSE, README skeleton and
.gitattributes forcing LF (container scripts must not get CRLF).

Verified on the dev server: go vet clean, make build produces statically
linked binaries, version stamping works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:50:51 +03:00
mixandClaude Opus 4.8 9c31649941 Add implementation plan and phase progress tracker
12-phase plan derived from the spec, plus a durable progress tracker
(model-per-phase, resume-after-reset protocol, commit conventions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:44:24 +03:00
mix 75d0329569 Update specification.md 2026-07-11 13:11:40 +03:00
mix 68c710e99a Create .gitignore 2026-07-11 12:58:57 +03:00
mix 8ad56ed739 Initial commit with project specification 2026-07-10 15:54:35 +03:00
577 changed files with 6500 additions and 77820 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mockups",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1"],
"port": 8765
}
]
}
-39
View File
@@ -1,39 +0,0 @@
---
description: The panel's design is fixed by accepted mockups; implement it, do not reinterpret it.
globs: internal/web/view/**,docs/assets/panel-redesign/**
alwaysApply: false
---
# Panel design contract
Source of truth, in this order: the mockups in
[docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html),
the [components page](docs/assets/panel-redesign/panel/components.html),
`panel/outlines.json`, then
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md). Read the plan's
**The contract** and **Enforcement** sections before touching a template.
You are implementing a finished design. You are not designing.
- Build pages only from Bulma classes in the `BULMA` list of `panel/check.py`
and from the `sp-` components on the components page, through their
partials. No other class, no hand-written component markup, no inline style.
- No selector in `panel.css` that names a page or an id, uses `!important`, or
sets a width outside `WIDTH_OK`. If a page looks wrong, the layout choice is
wrong — do not patch it locally.
- If a screen seems to need a component, state or layout that does not exist:
**stop and ask.** Do not approximate it, and do not substitute a look of
your own.
- Never edit a mockup, `outlines.json`, `check.py`, the vendored Bulma or a
guard test to make your work pass. A failing guard means the implementation
is wrong. Design changes go mockup-first in a separate `design:` commit on
the owner's explicit instruction.
- "Done" is evidence, not a description: passing `go test ./internal/web/...`
output plus a screenshot of each restyled page from the running container,
saved under `docs/assets/panel-redesign/evidence/` beside its mockup's name.
Report every difference from the mockup you could not remove; do not
describe a different result as the requested one.
After editing a fragment in `panel/src/`: `python panel/build.py`, then
`python panel/check.py` (add `--write` only when the structure change is the
point of the commit).
-22
View File
@@ -1,22 +0,0 @@
---
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
globs: docs/assets/panel-ui/**
alwaysApply: false
---
# Panel UI system
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
- Two peer jobs → `pair` (one child shrinks to `measure`).
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
- Confirm/login/user form → `measure`.
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
- Phone: `pair` stacks; `field-row` does not; tables become lists.
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
-3
View File
@@ -4,9 +4,6 @@
.gitattributes
dev
docs
# test/e2e/.stage holds what an e2e run leaves behind (DKIM keys, sasldb2);
# the e2e's own sink image is built from test/e2e/sink as its own context.
test
bin
*.exe
README.md
-129
View File
@@ -1,129 +0,0 @@
#!/usr/bin/env bash
# GUARD FILE — docs/plans/panel-redesign.md § Enforcement, "design-first".
#
# Checks every commit of a push or pull request, one by one:
#
# 1. Design first. A commit that touches the accepted design — the mockups
# under docs/assets/panel-redesign/panel/, the guard tests, this script —
# touches nothing else under internal/web/. The guards are not adjusted
# in the commit they are supposed to judge.
# 2. The ratchet. internal/web/view/legacy_pages.txt only shrinks: a commit's
# list is a subset of its parent's. A page that was restyled stays held
# to its mockup; its name cannot be put back.
# 3. No blob from the past. A template a commit adds or changes is not,
# whitespace aside, a template that already existed anywhere in the
# history before it. Taking an old page out of git is not a redesign.
#
# Usage: design-first.sh <base> <head> (commits base..head are checked)
# Needs the full history: actions/checkout with fetch-depth: 0.
set -euo pipefail
base="${1:?usage: design-first.sh <base> <head>}"
head="${2:?usage: design-first.sh <base> <head>}"
RATCHET=internal/web/view/legacy_pages.txt
TEMPLATES=internal/web/view/templates/
ZERO=0000000000000000000000000000000000000000
# A new branch, or a force push whose old tip is gone, has no usable base:
# check the tip commit alone rather than nothing.
if [ "$base" = "$ZERO" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base="$(git rev-parse "$head^" 2>/dev/null || true)"
fi
if [ -n "$base" ]; then
commits="$(git rev-list --reverse --no-merges "$base..$head")"
else
commits="$(git rev-list --reverse --no-merges "$head")"
fi
is_guard() {
case "$1" in
docs/assets/panel-redesign/panel/*) return 0 ;;
.github/scripts/design-first.sh) return 0 ;;
internal/web/guard_*_test.go | internal/web/*/guard_*_test.go) return 0 ;;
esac
return 1
}
entries() { # the ratchet's entries at a commit, sorted; nothing if the file is absent
git show "$1:$RATCHET" 2>/dev/null | tr -d '\r' | sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' | sort -u
}
normalised() { # a blob's hash with all whitespace removed
git cat-file blob "$1" | tr -d ' \t\r\n' | git hash-object --stdin
}
cache="$(mktemp -d)"
trap 'rm -rf "$cache"' EXIT
past_hash() { # normalised hash of a historical blob, computed once
if [ ! -f "$cache/$1" ]; then
normalised "$1" > "$cache/$1"
fi
cat "$cache/$1"
}
fail=0
say() {
echo "::error::design-first: $*"
fail=1
}
for c in $commits; do
short="$(git rev-parse --short "$c")"
parent="$(git rev-parse --verify --quiet "$c^" || true)"
if [ -n "$parent" ]; then
changed="$(git diff --name-only --no-renames "$parent" "$c")"
else
changed="$(git ls-tree -r --name-only "$c")"
fi
# 1. design first
guards="" impl=""
while IFS= read -r f; do
[ -n "$f" ] || continue
if is_guard "$f"; then
guards="$guards $f"
elif [ "${f#internal/web/}" != "$f" ]; then
impl="$impl $f"
fi
done <<< "$changed"
if [ -n "$guards" ] && [ -n "$impl" ]; then
say "$short changes the design or its guards ($(echo $guards | cut -c1-200)) together with the implementation ($(echo $impl | cut -c1-200)). A design change is its own 'design:' commit, made and accepted first."
fi
# 2. the ratchet
if [ -n "$parent" ] && grep -qx "$RATCHET" <<< "$changed"; then
# Once the file has existed, a parent without it means an empty list.
if git cat-file -e "$parent:$RATCHET" 2>/dev/null || [ -n "$(git log -1 --format=%h "$parent" -- "$RATCHET")" ]; then
added="$(comm -13 <(entries "$parent") <(entries "$c"))"
if [ -n "$added" ]; then
say "$short adds to $RATCHET: $(echo $added). The list only shrinks — what was held to the design stays held."
fi
fi
fi
# 3. no blob from the past
if [ -n "$parent" ]; then
touched="$(git diff --name-only --no-renames --diff-filter=AM "$parent" "$c" -- "$TEMPLATES")"
if [ -n "$touched" ]; then
past="$(git log --format= --raw --no-abbrev --no-renames "$parent" -- "$TEMPLATES" | awk '$4 != "'$ZERO'" {print $4}' | sort -u)"
while IFS= read -r f; do
[ -n "$f" ] || continue
now="$(normalised "$(git rev-parse "$c:$f")")"
for blob in $past; do
if [ "$(past_hash "$blob")" = "$now" ]; then
say "$short: $f is, whitespace aside, a template that already existed in history (blob ${blob:0:12}). An old template is not a redesigned page."
break
fi
done
done <<< "$touched"
fi
fi
done
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "design-first: $(echo "$commits" | grep -c . || true) commit(s) checked, nothing to report"
+26 -52
View File
@@ -1,19 +1,19 @@
name: release
# Publishes an immutable, version-tagged image on ghcr.io.
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1).
# Ordinary commits and bare git tag pushes do not publish anything. A published
# GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
#
# Native per-architecture builds (see docs/development.md), not qemu: running
# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is
# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
# own native runner; a merge job then combines them into the one manifest tag
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
# then test) is deliberate: it means the bytes that get tagged are exactly the
# bytes that passed e2e. The per-arch tags stay in GHCR next to the version
# tag: they are the images the multi-arch manifest points at, and GHCR cannot
# drop a tag without deleting the image behind it.
# Native per-architecture builds (see docs/development.md), not qemu:
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
# tag on its own native runner; a merge job then combines them into the one
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
# push-by-digest then test) is deliberate: it means the bytes that get tagged
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR
# so operators see a single version tag (spec 10.1).
on:
release:
types: [published]
@@ -89,27 +89,8 @@ jobs:
cache-dependency-path: test/e2e/go.sum
- name: e2e (gates publishing — see docs/development.md)
# The e2e builds selfpost:e2e again through compose.override.yml, and
# that rebuild is the image pushed below. Without the version here it
# was stamped "e2e" (audit 2026-10-12, H1); the suite now checks the
# stamp against this variable.
env:
SELFPOST_E2E_VERSION: ${{ needs.prepare.outputs.version }}
run: cd test/e2e && go test -v -timeout 20m ./...
- name: Check the version stamp of the image about to be pushed
env:
WANT: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
for bin in panel selfpost-backup; do
got=$(docker run --rm --entrypoint "$bin" selfpost:e2e -version)
if [ "$got" != "$WANT" ]; then
echo "::error::selfpost:e2e $bin -version = '$got', want '$WANT'; refusing to push"
exit 1
fi
done
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
@@ -196,35 +177,28 @@ jobs:
backoff=$((backoff * 2))
done
- name: Remove orphaned untagged versions from GHCR
# Re-publishing a version moves its tags to new manifests and leaves the
# old ones behind as untagged "sha256:..." package versions. Delete only
# those that no tagged manifest references: the images behind a
# multi-arch tag are package versions too, and deleting one breaks pulls
# of that tag. Any failed lookup aborts the step before anything is
# deleted.
- name: Remove per-arch tags from GHCR
# Side-effect tags for imagetools assembly only — not part of the public
# version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
# imagetools has no "rm" subcommand; delete via the GitHub Packages API.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
image="ghcr.io/${{ github.repository }}"
version="${{ needs.prepare.outputs.version }}"
owner="${{ github.repository_owner }}"
pkg="${{ github.event.repository.name }}"
api="/users/${owner}/packages/container/${pkg}/versions"
versions=$(gh api "${api}?per_page=100" --paginate \
--jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv')
keep=$(mktemp)
while IFS=$'\t' read -r id digest ntags; do
[ "$ntags" -gt 0 ] || continue
echo "$digest" >> "$keep"
raw=$(docker buildx imagetools inspect --raw "${image}@${digest}")
jq -r '.manifests[]?.digest' <<<"$raw" >> "$keep"
done <<<"$versions"
while IFS=$'\t' read -r id digest ntags; do
[ "$ntags" -eq 0 ] || continue
if grep -qxF "$digest" "$keep"; then
for suffix in amd64 arm64; do
tag="${version}-${suffix}"
mapfile -t ids < <(gh api "$api" --paginate \
--jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
if [ "${#ids[@]}" -eq 0 ]; then
echo "no GHCR package version for tag ${tag}"
continue
fi
echo "deleting orphaned GHCR package version ${id} (${digest})"
gh api -X DELETE "${api}/${id}"
done <<<"$versions"
for id in "${ids[@]}"; do
echo "deleting GHCR package version ${id} (tag ${tag})"
gh api -X DELETE "${api}/${id}"
done
done
-22
View File
@@ -10,23 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# design-first.sh reads each commit's parent and the template history.
fetch-depth: 0
- name: Design first
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
- uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: |
go.sum
test/e2e/go.sum
- name: Format
run: |
@@ -42,13 +30,3 @@ jobs:
- name: Test
run: go test ./...
# The e2e module runs only in release.yml, after a release is published;
# a type error there would first show up then. Compile it here without
# running it: `go test -run` would still run TestMain, which brings up
# the whole stand.
- name: Compile the e2e
working-directory: test/e2e
run: |
go vet ./...
go test -c -o /dev/null .
+20 -1907
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,13 +1,12 @@
# SelfPost build.
#
# The version is stamped into both binaries and MUST match the Docker image tag
# (architecture.md § Persistence: backup/restore compatibility check). Override
# on the command line:
# (spec 7.5.A: backup/restore compatibility check). Override on the command line:
#
# make build VERSION=1.3.0
#
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
# Go, so no cgo is required).
# Go, so no cgo is required) — see spec 7.1.
VERSION ?= dev
MODULE := github.com/mixeme/selfpost
-16
View File
@@ -21,22 +21,6 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
Licence: Zero-Clause BSD (0BSD)
https://github.com/bigskysoftware/htmx
Bulma 1.0.4 (internal/web/view/static/bulma.min.css)
Copyright (c) 2023 Jeremy Thomas
Licence: MIT
https://github.com/jgthms/bulma
Distributed unmodified.
Tabler Icons 3.49.0
Copyright (c) 2020-2026 Paweł Kuna
Licence: MIT
https://github.com/tabler/tabler-icons
Distributed as two files:
internal/web/view/static/tabler-icons.css
internal/web/view/static/tabler-icons.woff2
Webfont subset to the 57 icons the panel uses, made with fontTools
pyftsubset.
IBM Plex Sans / IBM Plex Mono
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
Licence: SIL Open Font License 1.1
+10 -38
View File
@@ -4,16 +4,13 @@
# SelfPost
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
Self-hosted outbound SMTP relay with a web control panel, shipped as a single
Docker image. Postfix, OpenDKIM, and a small Go panel run together under
`supervisord`; you configure domains, DKIM keys, and SASL applications once,
then point your apps at the SMTP endpoint.
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
configure domains, DKIM keys, and SASL applications once, then point your apps
at the SMTP endpoint.
SelfPost sends mail straight to the internet from **your own IP**, with
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
(backup-MX / forwarder on port 25) forwards to an upstream; it does not store
mail.
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
**For:** operators who run their own VPS or home server and want a simple relay
they control, without a third-party SMTP provider.
@@ -24,11 +21,7 @@ send log and DNS checks in the panel, encrypted backups.
## Features
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help: a page of topics, opened from the `?` in the head of a box
- Per-application client IP allow-list for SMTP AUTH (optional)
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings)
- Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
@@ -85,15 +78,6 @@ For every domain you add in the panel:
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
### Per inbound domain (optional)
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
if this host is backup-MX)
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
## Quick start
> **First boot — create the admin account.** On a fresh container SelfPost prints
@@ -106,23 +90,13 @@ One container, panel at `http://127.0.0.1:8080` — no reverse proxy, no TLS
files, no compose files. Good for clicking through the UI on your machine;
outbound mail will not reach the real internet without DNS, PTR, and port 25.
> **The 2.x line is not published on ghcr.io yet** (the newest image there is
> `1.3.0`). Until it is, build the image from this checkout under the name the
> commands below and `deploy/docker-compose.yml` use; Docker then takes the
> local image and pulls nothing:
>
> ```sh
> docker build -f build/Dockerfile --build-arg VERSION=2.5.4 \
> -t ghcr.io/mixeme/selfpost:2.5.4 .
> ```
```sh
docker run --rm -d --name selfpost-try \
-p 127.0.0.1:8080:8080 \
-e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.5.4
ghcr.io/mixeme/selfpost:1.3.0
```
**Get the setup URL** (pick one):
@@ -162,10 +136,8 @@ DNS — lives in the operator guide's [Full
deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file always publishes **465**, **587**, and **25**; Postfix listens
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
`INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_ENABLE=true` (see
[Ports](docs/guide.md#ports)). Bump the
The compose file always publishes **465** and **587**; Postfix listens on 587
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables).
+24 -50
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
#
# SelfPost — single Debian-slim image running postfix + opendkim + panel under
# supervisord. Build from the repository root:
# supervisord (spec 4). Build from the repository root:
#
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .
@@ -10,7 +10,7 @@ FROM golang:1.26-bookworm AS build
WORKDIR /src
# Version stamped into both binaries; MUST match the image tag.
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A).
ARG VERSION=dev
# Module metadata first for layer caching.
@@ -36,16 +36,6 @@ ENV DEBIAN_FRONTEND=noninteractive
# Preseed Postfix so its install is non-interactive and yields a working
# main.cf. The real relay configuration is generated by the panel.
# (postfix 3.7.11-0+deb12u1, debian/templates: postfix/main_mailer_type and
# postfix/mailname are the questions the install asks.)
# What else arrives through Depends, and is used here: openssl, by
# ca-certificates ("Depends: openssl (>= 1.1.1)", packages.debian.org/bookworm/
# ca-certificates) - postfix-cert-reload.sh; python3, by supervisor (supervisor
# 4.2.5-1, debian/control, ${python3:Depends}) - crashexit.py. sasl2-bin brings
# saslpasswd2 and libsasl2-modules the PLAIN and LOGIN plugins; the sasldb
# auxprop plugin is in libsasl2-modules-db, a Depends of libsasl2-2
# (cyrus-sasl2 2.1.28+dfsg-10, debian/control and debian/*.install). db-util
# ships /usr/bin/db_dump (packages.debian.org/bookworm/amd64/db-util/filelist).
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
&& apt-get update \
@@ -62,46 +52,31 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
curl \
&& rm -rf /var/lib/apt/lists/*
# Unprivileged user for the panel process.
# Unprivileged user for the panel process (spec 7.6.8).
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services: the panel generates
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
# sender map, while OpenDKIM and Postfix (different users) must read them.
# Membership in this group — plus setgid dirs under /data (set up in
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
# them. Membership in this group — plus setgid dirs under /data (set up in
# entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
# the sasldb2/sender map. (The supervisor control socket has a group of its
# own, `supervisor`, below.)
# The id is fixed, and it is the one the group has always had: an operator
# gives the TLS private key on the host to this group by number, so that the
# panel can put it into a full backup (guide § Taking a full backup).
# groupadd(8) refuses a --gid already in use, so a package that took 998 would
# fail this build rather than share the group.
RUN groupadd --system --gid 998 selfpost \
# the sasldb2/sender map, and lets the panel reach the supervisor control socket
# to signal OpenDKIM/Postfix reloads without any process running as root.
RUN groupadd --system selfpost \
&& usermod -aG selfpost panel \
&& usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (and the status page's
# queue card). postqueue is setgid postdrop, which normally gives it the group
# needed to reach Postfix's showq socket — but the documented deployment runs
# with `no-new-privileges`, which disables setgid transitions, so the panel
# would always see "Permission denied". Making `panel` a real member of postdrop
# grants the same access without relying on a setgid escalation the hardening
# deliberately forbids. The same membership is what lets the panel submit its
# own notifications with sendmail(1) - postdrop, too, is setgid and would fail
# to write the maildrop directory (internal/notify/mailer.go). It does not let
# the panel bypass any Postfix restriction that a local user does not already
# have through the world-executable sendmail.
# (Postfix 3.7.11, conf/postfix-files: postdrop and postqueue are
# root:$setgid_group 2755. Linux, Documentation/userspace-api/no_new_privs.rst:
# with no_new_privs, execve() no longer changes the gid for a setgid file.)
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
# status page's queue card). postqueue is setgid postdrop, which normally gives
# it the group needed to reach Postfix's showq socket — but the documented
# deployment runs with `no-new-privileges`, which disables setgid transitions,
# so the panel would always see "Permission denied". Making `panel` a real
# member of postdrop grants the same access without relying on a setgid
# escalation the hardening deliberately forbids. postdrop membership is
# read-side only: it does not let the panel bypass any Postfix restriction that
# a local user does not already have through the world-executable sendmail.
RUN usermod -aG postdrop panel
# The supervisord control socket's group (supervisord.conf, [unix_http_server]):
# the panel alone - not OpenDKIM's or Postfix's users, which share `selfpost`
# for the files under /data.
RUN groupadd --system supervisor \
&& usermod -aG supervisor panel
# Runtime directories: milter sockets and the consolidated persistent root.
RUN mkdir -p /run/opendkim /run/selfpost /data \
@@ -125,7 +100,6 @@ COPY --chmod=0644 internal/web/view/static/OFL.txt /usr/share/doc/selfpost/OFL.t
COPY --chmod=0644 build/opendkim.conf /etc/opendkim.conf
COPY --chmod=0644 build/logrotate-mail.conf /etc/logrotate.d/mail
COPY --chmod=0755 build/postfix-wrapper.sh /usr/local/bin/postfix-wrapper.sh
COPY --chmod=0755 build/postfix-reload.sh /usr/local/bin/postfix-reload.sh
COPY --chmod=0755 build/postfix-config.sh /usr/local/bin/postfix-config.sh
COPY --chmod=0755 build/postfix-cert-reload.sh /usr/local/bin/postfix-cert-reload.sh
COPY --chmod=0755 build/logrotate-loop.sh /usr/local/bin/logrotate-loop.sh
@@ -133,10 +107,10 @@ COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 25
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
# which needs no inbound listener or EXPOSE.
EXPOSE 8080 465 587
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
@@ -144,5 +118,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
# The entrypoint fixes /data ownership (bind mount) as root, then execs
# supervisord, which becomes PID 1 and owns process supervision.
# supervisord, which becomes PID 1 and owns process supervision (spec 4).
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+13 -82
View File
@@ -1,52 +1,17 @@
#!/usr/bin/env python3
"""Supervisor event listener: bring the container down on unrecoverable failure.
If a managed program of the mail path exhausts its restart retries and
enters FATAL, or keeps crashing - CRASH_LIMIT unexpected exits within
CRASH_WINDOW seconds - this listener signals supervisord (its parent) to
terminate, so the whole container exits and Docker's `restart` policy starts it
again, rather than lingering "alive" with a dead Postfix/OpenDKIM/panel.
FATAL alone did not cover the second: a program reaches FATAL only through
exits before its startsecs, and one that crashed after it was restarted for
ever (audit 2026-10-12, L32; supervisor 4.2.5, supervisor/process.py,
Subprocess.finish()). logrotate is not on the mail path: its FATAL (a config
it would ignore) stays on Server > Health, and mail keeps flowing
(assumptions C-V5).
If any managed program exhausts its restart retries and enters FATAL, this
listener signals supervisord (its parent) to terminate, so the whole container
exits and Docker's `restart` policy can recreate it cleanly — rather than
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
Communication with supervisord uses the event listener protocol over stdin and
stdout, so stdout must carry only protocol tokens.
supervisor 4.2.5, docs/events.rst, "Event Listener Notification Protocol": the
listener writes READY\\n, reads one header line of key:value tokens whose len
is the payload's length and eventname the event's type, reads the payload, and
answers "RESULT 2\\nOK". A PROCESS_STATE_FATAL payload is a token set,
"processname:cat groupname:cat from_state:BACKOFF"; a PROCESS_STATE_EXITED one
adds "expected:0" for an exit code the program's exitcodes do not list, "1"
otherwise (docs/events.rst; supervisor/events.py, ProcessStateEvent.payload()).
supervisord starts the listener as its own child, and on SIGTERM it shuts down
(docs/running.rst, "Signals"); it is PID 1 here (build/entrypoint.sh, exec).
CRASHEXIT_DRYRUN=1 writes "crashexit: would shut down" instead of signalling,
for the test (build/crashexit_test.go).
"""
import os
import signal
import sys
import time
# Programs whose failure does not take the container down
# (build/supervisord.conf): logrotate is not on the mail path, postfix-reload
# runs on demand - a reload it could not start is reported to whoever asked for
# it - syslog-relay carries OpenDKIM's log lines, not mail, and cert-reload
# looks for a renewed certificate once a day (a crash of it leaves the one
# Postfix serves).
SPARED = {"logrotate", "postfix-reload", "syslog-relay", "cert-reload"}
# CRASH_LIMIT unexpected exits of one program within CRASH_WINDOW seconds are a
# program that does not stay up. A program on the mail path restarts in
# seconds; five times in ten minutes is no passing fault.
CRASH_LIMIT = 5
CRASH_WINDOW = 600
def write_stdout(s):
@@ -54,20 +19,7 @@ def write_stdout(s):
sys.stdout.flush()
def say(s):
sys.stderr.write("crashexit: " + s + "\n")
sys.stderr.flush()
def shut_down():
if os.environ.get("CRASHEXIT_DRYRUN") == "1":
say("would shut down")
return
os.kill(os.getppid(), signal.SIGTERM)
def main():
crashes = {} # program -> times of its unexpected exits in the window
while True:
# Tell supervisord we are ready for the next event.
write_stdout("READY\n")
@@ -77,37 +29,16 @@ def main():
return
headers = dict(pair.split(":", 1) for pair in line.split())
payload_len = int(headers.get("len", 0))
payload = sys.stdin.read(payload_len) if payload_len else ""
tokens = dict(
pair.split(":", 1) for pair in payload.split() if ":" in pair
)
name = tokens.get("processname", "")
event = headers.get("eventname", "PROCESS_STATE_FATAL")
if payload_len:
sys.stdin.read(payload_len)
if event == "PROCESS_STATE_EXITED":
if name not in SPARED and tokens.get("expected") == "0":
now = time.monotonic()
times = [t for t in crashes.get(name, []) if now - t < CRASH_WINDOW]
times.append(now)
crashes[name] = times
if len(times) >= CRASH_LIMIT:
say(
"%s exited unexpectedly %d times in %d seconds; shutting down container"
% (name, len(times), CRASH_WINDOW)
)
shut_down()
crashes[name] = []
elif name in SPARED:
say(
"%s entered FATAL; it is not on the mail path, the container stays up"
% name
)
else:
say(
"%s entered FATAL; shutting down container"
% (name or "a managed process")
)
shut_down()
# We only subscribe to PROCESS_STATE_FATAL, so any event means a managed
# program can no longer be restarted. Take the container down.
sys.stderr.write(
"crashexit: a managed process entered FATAL; shutting down container\n"
)
sys.stderr.flush()
os.kill(os.getppid(), signal.SIGTERM)
write_stdout("RESULT 2\nOK")
-95
View File
@@ -1,95 +0,0 @@
// Package build holds the image's scripts; this file tests crashexit.py, the
// supervisor event listener that takes the container down.
package build
import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"testing"
)
// event is one event as supervisord writes it to a listener's stdin
// (supervisor 4.2.5, docs/events.rst: a header line whose len is the
// payload's length, then the payload).
func event(name, payload string) string {
return fmt.Sprintf("ver:3.0 server:supervisor serial:1 pool:crashexit poolserial:1 eventname:%s len:%d\n%s", name, len(payload), payload)
}
// runListener feeds events to crashexit.py in dry run and returns what it
// wrote to stderr.
func runListener(t *testing.T, events ...string) string {
t.Helper()
// python3 on Linux; on Windows "python3" may be the Store's stub, which
// runs nothing.
names := []string{"python3", "python"}
if runtime.GOOS == "windows" {
names = []string{"python", "python3"}
}
python := ""
for _, n := range names {
if p, err := exec.LookPath(n); err == nil {
python = p
break
}
}
if python == "" {
t.Skip("no python")
}
cmd := exec.Command(python, "crashexit.py")
cmd.Env = append(os.Environ(), "CRASHEXIT_DRYRUN=1")
cmd.Stdin = strings.NewReader(strings.Join(events, ""))
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
t.Fatalf("crashexit.py: %v\n%s", err, stderr.String())
}
// Python on Windows writes its text stdout with \r\n.
if n := strings.Count(strings.ReplaceAll(string(out), "\r\n", "\n"), "RESULT 2\nOK"); n != len(events) {
t.Errorf("answered %d of %d events:\n%q", n, len(events), out)
}
return stderr.String()
}
// A program on the mail path that keeps crashing after its startsecs - never
// FATAL - takes the container down at the fifth unexpected exit (audit
// 2026-10-12, L32); expected exits, spared programs and fewer crashes do not.
func TestCrashexitCountsCrashes(t *testing.T) {
exit := func(name, expected string) string {
return event("PROCESS_STATE_EXITED", "processname:"+name+" groupname:"+name+" from_state:RUNNING expected:"+expected+" pid:7")
}
var four []string
for i := 0; i < 4; i++ {
four = append(four, exit("panel", "0"))
}
if got := runListener(t, four...); strings.Contains(got, "would shut down") {
t.Errorf("four crashes shut down:\n%s", got)
}
if got := runListener(t, append(four, exit("panel", "0"))...); !strings.Contains(got, "panel exited unexpectedly 5 times") || !strings.Contains(got, "would shut down") {
t.Errorf("five crashes did not shut down:\n%s", got)
}
var calm []string
for i := 0; i < 6; i++ {
calm = append(calm, exit("panel", "1"), exit("logrotate", "0"))
}
calm = append(calm, exit("opendkim", "0"), exit("opendkim", "0"), exit("opendkim", "0"))
if got := runListener(t, calm...); strings.Contains(got, "would shut down") {
t.Errorf("expected exits, spared programs and three crashes of another shut down:\n%s", got)
}
}
// FATAL of a mail-path program shuts down at once; of a spared one, not.
func TestCrashexitFatal(t *testing.T) {
fatal := func(name string) string {
return event("PROCESS_STATE_FATAL", "processname:"+name+" groupname:"+name+" from_state:BACKOFF")
}
if got := runListener(t, fatal("postfix")); !strings.Contains(got, "postfix entered FATAL; shutting down") || !strings.Contains(got, "would shut down") {
t.Errorf("postfix FATAL:\n%s", got)
}
if got := runListener(t, fatal("logrotate"), fatal("syslog-relay")); strings.Contains(got, "would shut down") {
t.Errorf("a spared program's FATAL shut down:\n%s", got)
}
}
+31 -122
View File
@@ -9,14 +9,14 @@ set -e
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must
# simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
# Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
# fall back independently when it's unset — to `localhost` and to the container
# hostname respectively — so accounts get written under one realm and looked up
# under another and authentication silently fails for every application, while
# HELO also stops matching the PTR record and mail that does go out lands in
# spam. No fallback can be correct, so fail loudly here, before either side of
# that split has a chance to run, rather than leave a green panel with broken
# mail.
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and
# postfix-config.sh each fall back independently when it's unset — to
# `localhost` and to the container hostname respectively — so accounts get
# written under one realm and looked up under another and authentication
# silently fails for every application, while HELO also stops matching the
# PTR record and mail that does go out lands in spam. No fallback can be
# correct, so fail loudly here, before either side of that split has a chance
# to run, rather than leave a green panel with broken mail.
if [ -z "$SELFPOST_HOSTNAME" ]; then
cat >&2 <<'EOF'
FATAL: SELFPOST_HOSTNAME is not set.
@@ -52,18 +52,16 @@ case "$SELFPOST_HOSTNAME" in
;;
esac
# The persistent root /data is a host bind mount, so it arrives owned by the
# host user (typically root), not by the unprivileged panel user that actually
# writes the SQLite database, setup token and DKIM keys. Fix its ownership here
# — the one place still running as root — before handing off to supervisord,
# which starts the panel as the panel user.
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
# by the host user (typically root), not by the unprivileged panel user that
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
# Fix its ownership here — the one place still running as root — before handing
# off to supervisord, which starts the panel as the panel user.
#
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their
# trees under /data as other users. Go's testing.TempDir is 0700, and a bare
# chown would leave that mode in place — opendkim then cannot read KeyTable and
# the container crash-loops (e2e TestHostnameGate/valid_hostname_starts).
# (Go: testing.TempDir uses os.MkdirTemp, src/os/tempfile.go: "created with
# mode 0o700".)
chown panel:panel /data
chmod 755 /data
# Restored backups or previously-created state may contain panel-owned files
@@ -71,15 +69,14 @@ chmod 755 /data
# later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below.
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} +
# DKIM key tree. The panel (user `panel`) generates keys and writes
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
# tree on every start so it is correct whether /data is fresh, restarted, or
# just restored from a backup:
# - group `selfpost` + setgid on directories so anything the panel creates
# inherits the shared group OpenDKIM traverses (inode(7), "The
# set-group-ID bit": files created in such a directory take its group);
# inherits the shared group OpenDKIM traverses;
# - private keys and tables group-readable (0640);
# - both table files present (empty is fine) BEFORE OpenDKIM starts, so the
# daemon comes up cleanly with no domains yet.
@@ -92,7 +89,7 @@ find /data/opendkim -type d -exec chmod 2750 {} +
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
# Application SASL accounts. The panel (user `panel`) writes the
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP
# clients. Share it through the `selfpost` group the same way as the DKIM tree:
# setgid directory so new files inherit the group, and the database itself
@@ -101,40 +98,15 @@ mkdir -p /data/sasl
chown -R panel:selfpost /data/sasl
chmod 2750 /data/sasl
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
# The copy a password change is made on (internal/app, replaceSASLDB), left
# by a panel killed mid-change: a second copy of every password, which the
# next change would remove anyway.
rm -f /data/sasl/sasldb2.new
# Postfix state under /data (architecture.md § Persistence). The panel writes
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
mkdir -p /data/postfix/queue
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
# references it never fails on a missing file, and keep it group-readable.
mkdir -p /data/postfix
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
# Empty files keep postfix check happy if the flag is on before any domain exists.
for f in relay_domains transport relay_recipients tls_policy; do
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
done
# Every map here is the panel's to write and Postfix's to read - the sender
# map, the inbound and the DMARC maps alike - so each file beside queue/ is
# handed to the panel, group selfpost, whatever its name. A restored archive's
# files arrive owned by whoever unpacked them (a backup records no owner, and
# tar run as root makes them root's), and the first start after a restore
# reads every one of them to compare it with the archive's record: one left
# to root alone is a restore refused on every start (review of audit stage 3,
# H1 - the DMARC maps were not among the names listed here).
find /data/postfix -mindepth 1 -maxdepth 1 -type f -exec chown panel:selfpost {} + -exec chmod 0640 {} +
chown panel:selfpost /data/postfix
chown -R panel:selfpost /data/postfix
chmod 2750 /data/postfix
# Filter lists for the external spam filter (written by the panel when
# INBOUND_RELAY_ENABLE=true, read by the antispam sidecar, which mounts this
# directory). The sidecar runs under a user of its own in another container,
# so the directory and its files are readable by anyone: they hold sender
# addresses and networks, no secrets.
mkdir -p /data/antispam
chown panel:selfpost /data/antispam
chmod 0755 /data/antispam
chmod 0640 /data/postfix/sender_login_maps
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
# `postfix`; the panel reads it for the log-tailer and the System log page. It
@@ -147,49 +119,24 @@ chmod 0755 /data/antispam
# directory keeps the shared group on anything created inside it later, and
# 2750 keeps it group-traversable but not group-writable — logrotate refuses to
# rotate a log whose directory is writable by a non-root group.
# Postfix 3.7.11: src/util/logwriter.c, logwriter_open_or_die(), creates the
# file 0644, under master's umask(077) (src/master/master.c) - 0600. logrotate
# 3.21.0, logrotate.c, findNeedRotating(): "skipping ... because parent
# directory has insecure permissions" when the directory's group is not root
# and has write permission.
mkdir -p /data/log
[ -e /data/log/mail.log ] || : > /data/log/mail.log
chown -R postfix:selfpost /data/log
chmod 2750 /data/log
find /data/log -type f -exec chmod 0640 {} +
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
# the milter sockets — OpenDKIM's and the panel's — not just probe them at
# start-up. The sockets are created by the opendkim and panel
# users respectively, so bridge them to `postfix` through the shared `selfpost`
# group: group-owned + setgid dirs mean each socket created inside inherits
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
# this, smtpd cannot talk to OpenDKIM and, because signing is strict
# (default_action=tempfail), rejects all mail.
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's
# journal-milter — not just probe them at start-up. The sockets are
# created by the opendkim and panel users respectively, so bridge them to
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean
# each socket created inside inherits group `selfpost`, and group-traversable
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and,
# because signing is strict (default_action=tempfail), rejects all mail.
mkdir -p /run/opendkim /run/selfpost
chown opendkim:selfpost /run/opendkim
chown panel:selfpost /run/selfpost
chmod 2750 /run/opendkim /run/selfpost
# A container that was killed leaves its sockets behind, and a file nobody
# listens on would satisfy postfix-wrapper.sh's wait: Postfix would start
# before the sender check does and defer mail until it is there. The check's
# alive mark goes too: it tells the downtime of the panel restarting inside a
# running container, and a restart of the whole container is not the check's
# downtime (guide § Health) - a mark left from before would count the whole
# outage when supervisord cannot say since when Postfix runs.
# (test(1), -S: true for any socket file, whether or not anything listens.)
rm -f /run/opendkim/*.sock /run/selfpost/*.sock /run/selfpost/*.alive
# The container's /dev/log: a link to the socket the syslog-relay program (the
# panel's binary, as `panel`) listens on, so what OpenDKIM logs through
# syslog(3) reaches the container's log (build/supervisord.conf; audit
# 2026-10-12, L30). The directory is the relay's and reachable by all: the
# socket is the container's local log, as /dev/log is on a host.
mkdir -p /run/syslog
chown panel:panel /run/syslog
chmod 0755 /run/syslog
rm -f /run/syslog/log
ln -sfn /run/syslog/log /dev/log
# Generate the outbound-relay Postfix configuration from the environment (spec
# 5). Kept out of the image build so cert paths, rate limits, hostname and the
@@ -197,42 +144,4 @@ ln -sfn /run/syslog/log /dev/log
# start the same way the /data normalisation above is.
/usr/local/bin/postfix-config.sh
# The queue tree, on a first start or after a restore, is already built here:
# postfix-config.sh ends with "postfix check", whose check-fatal runs
# "post-install create-missing" (Postfix 3.7.11, conf/postfix-script;
# conf/postfix-files, "$queue_directory/active:d:$mail_owner:-:700:ucr").
# A "postfix set-permissions" for a missing active/ stood here and never ran
# (assumptions C-P34).
chown -R postfix:postfix /data/postfix/queue
# The recursive chown above would leave public/ and maildrop/ in group postfix.
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
# postdrop and reach the showq socket and the maildrop directory through that
# group alone. With the wrong group `postqueue -p` run by the panel answers
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
# read the queue (the Queue card and the Outbound queue page).
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start.
# (Postfix 3.7.11, conf/postfix-files: $queue_directory root 755, maildrop
# $setgid_group 730, public $setgid_group 710, pid root 755, the other queue
# directories $mail_owner 700; conf/postfix-script, "check-warn": "not owned by
# root".)
chown root /data/postfix/queue /data/postfix/queue/pid
# [program:postfix]'s startsecs (build/supervisord.conf): longer than the
# wrapper's wait for the milter sockets, so its exit at the end of the wait is
# one supervisord counts as too quick. A value that is not a number stops the
# start here.
wait_timeout="${MILTER_WAIT_TIMEOUT:-30}"
case "$wait_timeout" in
'' | *[!0-9]*)
echo "entrypoint: MILTER_WAIT_TIMEOUT must be a whole number of seconds, got '$wait_timeout'" >&2
exit 1
;;
esac
# expr, not $((...)): dash's arithmetic reads "010" as octal and refuses
# "08"; expr takes decimal (review of stage 2c, F11).
export POSTFIX_STARTSECS=$(expr "$wait_timeout" + 10)
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+11 -33
View File
@@ -1,46 +1,24 @@
#!/bin/sh
# Periodic logrotate for /data/log/mail.log. Rotation renames the file,
# recreates it (`create 0640 postfix selfpost`, matching a cold container
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
# uses): postlogd keeps writing to the renamed inode until reload, and the
# panel's log-tailer holds its own descriptor on that inode, so nothing written
# before the reload is lost. `create` (rather than `nocreate`) matters here
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
# postfix, which the unprivileged panel process cannot read — confirmed on a
# live container (date not recorded), and so in the source: Postfix 3.7.11,
# src/util/logwriter.c, logwriter_open_or_die(), creates it 0644 under master's
# umask(077), src/master/master.c — so logrotate must be the one to create it.
# The reload: Postfix 3.7.11, conf/postfix-script, "logrotate" (mv, then kill
# -HUP master); master_conf.c, master_config() -> master_restart_service() ->
# master_status_cleanup(), on which postlogd exits (src/master/dgram_server.c,
# dgram_server_abort() on MASTER_STATUS_FD), and the next postlogd opens the
# path anew (src/postlogd/postlogd.c, logwriter_open_or_die()).
# panel's log-tailer holds its own descriptor on that inode, so nothing
# written before the reload is lost. `create` (rather than `nocreate`) matters
# here beyond timing: a postlogd-triggered recreate lands the file at 0600
# owned by postfix, which the unprivileged panel process cannot read —
# confirmed on a live container — so logrotate must be the one to create it.
#
# logrotate itself only rotates once the configured "daily" period has elapsed
# (tracked in its state file), so it is safe to invoke this more often than
# daily — polling merely bounds how late a legitimate rotation runs. The state
# file is beside the log, under /data, not at the default
# /var/lib/logrotate/status (logrotate 3.21.0-1: debian/rules,
# --with-state-file-path) in the container's own filesystem: a recreated
# container started without it, logrotate.c, newState(), records a log it has
# no state for as rotated today, and so a host that recreates the container
# daily - an .env change, an image bump - never rotated mail.log at all (audit
# 2026-10-12, M19). (logrotate 3.21.0, logrotate.8.in: "-s, --state
# statefile".) The directory is postfix:selfpost 2750 and logrotate runs as
# root.
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
# often than daily — polling merely bounds how late a legitimate rotation runs.
set -eu
CONFIG=/etc/logrotate.d/mail
STATE=/data/log/.logrotate.status
INTERVAL="${LOGROTATE_INTERVAL_SECONDS:-21600}"
# logrotate refuses configs writable by group or others and exits 0 while
# ignoring them — fail here so supervisord reports the fault. (logrotate
# 3.21.0, config.c, readConfigFile(): as root, mode & 0022 gives "Ignoring ...
# because it is writable by group or others." and returns 0, not an error; a
# config not owned by root is ignored the same way. "Potentially dangerous
# mode" is only a warning there, for any mode & 07533 other than 0400 - this
# loop chooses to treat it as fatal too.)
# ignoring them — fail here so supervisord reports the fault.
logrotate_config_ok() {
mode=$(stat -c '%a' "$CONFIG")
mode=${mode#0}
@@ -61,7 +39,7 @@ if ! logrotate_config_ok; then
fi
run_logrotate() {
out=$(logrotate -s "$STATE" "$CONFIG" 2>&1) || {
out=$(logrotate "$CONFIG" 2>&1) || {
echo "$out" >&2
return 1
}
-3
View File
@@ -4,9 +4,6 @@
# build/postfix-config.sh (maillog_file) and build/entrypoint.sh: postlogd
# writes it as user postfix, the unprivileged panel reads it through the shared
# selfpost group, hence create 0640 postfix selfpost rather than 0644 root root.
# (logrotate 3.21.0, logrotate.8: "create mode owner group" makes the new log
# right after the rotation; "delaycompress" leaves the previous log, .1,
# uncompressed until the next cycle.)
/data/log/mail.log {
daily
rotate 14
+5 -35
View File
@@ -1,59 +1,29 @@
# OpenDKIM — per-domain signing.
# OpenDKIM — per-domain signing (spec 6).
#
# Signing is strictly per-domain: each sending domain has its own key pair and
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
# supervisord to send this process SIGUSR1 to reload the tables. (OpenDKIM
# 2.11.0~beta2, opendkim/opendkim.8.in: on SIGUSR1 the configuration file is
# re-read; opendkim/opendkim.c, dkimf_config_reload() -> dkimf_config_load(),
# opens SigningTable and KeyTable again with dkimf_db_open().)
# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5).
#
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts,
# so the daemon comes up cleanly on a fresh /data with no domains yet and simply
# signs nothing until the first domain is added. (opendkim/opendkim-db.c,
# dkimf_db_open(): a file or refile table is read line by line; an empty file
# is an empty table, not an error.)
# signs nothing until the first domain is added.
# Logged through syslog(3) to /dev/log, which the syslog-relay program passes
# to the container's log (build/supervisord.conf; audit 2026-10-12, L30). The
# errors are what is wanted - a key that cannot be read, a table that cannot
# be opened; a line per signed message (SyslogSuccess) is not.
Syslog yes
Syslog no
UMask 007
Mode s
# relaxed/simple: the header fields relaxed, so a relay that refolds or
# respaces one does not break the signature; the body simple, as signed
# (opendkim.conf.5.in, Canonicalization: header/body, the default
# simple/simple; RFC 6376 § 3.4). OversignHeaders From lists From: in h= once
# more than it is present, so a From: added on the way fails the signature
# instead of being shown above the signed one (opendkim.conf.5.in,
# OversignHeaders; RFC 6376 § 8.15) - the replay 2.4.0's sender check is
# about.
Canonicalization relaxed/simple
OversignHeaders From
# SigningTable uses refile: so the "*@example.com" left-hand patterns match any
# local-part for a domain; KeyTable maps each domain to its key and selector.
# (opendkim/opendkim.conf.5.in, SigningTable: with "refile" the keys are
# wildcard patterns matched against the From: address, in file order.)
KeyTable /data/opendkim/KeyTable
SigningTable refile:/data/opendkim/SigningTable
# The private keys are owned by `panel` and read by `opendkim` through the shared
# `selfpost` group, so they are deliberately group-readable (mode 0640). That is
# safe on this single-tenant, private bind mount, but it trips OpenDKIM's default
# key-safety check, so the check is disabled here by design. (opendkim/
# opendkim.c, dkimf_checkfsnode(): a key file must be owned by root or by the
# executing uid, and a group-readable one must be in a group of no other user -
# `panel` owns these and `selfpost` has three members.)
# key-safety check, so the check is disabled here by design.
RequireSafeKeys no
# UMask 007 above leaves the socket group read-write (opendkim.conf.5.in,
# UMask: it applies to the socket); its group is selfpost by the setgid
# /run/opendkim (build/entrypoint.sh). UserID: the process takes the user's
# groups (opendkim.conf.5.in, UserID); with Background no and -f
# (build/supervisord.conf, opendkim.8.in) the supervised pid is OpenDKIM itself.
Socket local:/run/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
UserID opendkim
+17 -86
View File
@@ -1,94 +1,25 @@
#!/bin/sh
# Picks up a renewed TLS certificate while the container runs (spec 5.2 p.4;
# audit 2026-10-12, H2).
# Periodic `postfix reload` so refreshed TLS certificates are picked up (spec
# 5.2 p.4). The reverse-proxy renews the PEM files in the read-only mount every
# few months; Postfix only re-reads them on reload. A simple daily reload is
# more than enough (a day of staleness is harmless) and far simpler than an
# inotify watcher — the spec explicitly prefers this.
#
# Postfix does not read the mounted files: postfix-config.sh copies them at
# start into /etc/postfix/tls-internal, because Postfix wants the key owned by
# root with mode 0600, which a read-only bind mount owned by the host's user
# cannot give. A renewal of the mounted files therefore reaches Postfix only
# through a new copy. Every interval this compares the mounted files with the
# copy; when they differ it copies both beside their targets, checks that the
# copies - not the mounted files, which a renewer may be rewriting - are a
# certificate and the key that belongs to it, renames both over the targets
# and reloads Postfix. When nothing changed it does nothing.
# (Postfix 3.7.11: smtpd loads the certificate in pre_jail_init(), once per
# process, src/smtpd/smtpd.c; "postfix reload" is SIGHUP to master,
# conf/postfix-script, after which the old smtpd processes exit on the closed
# status pipe, src/master/master_conf.c, master_config() ->
# master_restart_service(), and new ones load the files again.)
#
# Runs under supervisord as root. It sleeps first: at container start the
# wrapper is still bringing Postfix up. It never exits non-zero, so a failure
# does not trip the crashexit listener; it is logged and tried again.
set -u
umask 077 # the key's copy is never readable by others, not even for an instant
# Runs under supervisord as root, so it can reload Postfix directly. It sleeps
# first, then reloads in a loop: no reload at container start (the wrapper is
# still bringing Postfix up then) and none until at least one interval has
# passed. A reload is harmless when nothing changed.
set -eu
INTERVAL="${TLS_RELOAD_INTERVAL_SECONDS:-86400}"
SRC_CERT="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
SRC_KEY="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
DIR=/etc/postfix/tls-internal
CERT="$DIR/fullchain.pem"
KEY="$DIR/privkey.pem"
PENDING=0 # a copy was renewed and Postfix has not been reloaded since
reload() {
if postfix reload; then
echo "cert-reload: postfix reloaded ($1)"
PENDING=0
else
echo "cert-reload: postfix reload failed, will try again after ${INTERVAL}s" >&2
fi
}
# same_pair says whether a certificate file and a private key file belong
# together, by comparing the public key each one prints. openssl-x509(1) and
# openssl-pkey(1) do not say the two print it byte for byte alike; measured
# in the image, 2026-10-15, OpenSSL 3.0.22: identical for RSA-2048, P-256 and
# P-384 keys, and for a P-256 key written in SEC1 form ("EC PRIVATE KEY") -
# certbot 2.0 and later issue ECDSA keys by default (assumptions C-X1).
same_pair() {
cert_pub=$(openssl x509 -noout -pubkey -in "$1" 2>/dev/null) || return 1
key_pub=$(openssl pkey -pubout -in "$2" 2>/dev/null) || return 1
[ -n "$cert_pub" ] && [ "$cert_pub" = "$key_pub" ]
}
refresh() {
if [ "$PENDING" = 1 ]; then
reload "renewed certificate, earlier reload had failed"
fi
if [ "$(postconf -h smtpd_tls_cert_file 2>/dev/null)" != "$CERT" ]; then
# Postfix reads the mounted files themselves (they were missing at
# start, so no copy was made): a reload is what picks up a change.
reload "periodic TLS refresh"
return
fi
if [ ! -r "$SRC_CERT" ] || [ ! -r "$SRC_KEY" ]; then
echo "cert-reload: cannot read ${SRC_CERT} or ${SRC_KEY}; Postfix keeps the certificate it has" >&2
return
fi
if cmp -s "$SRC_CERT" "$CERT" && cmp -s "$SRC_KEY" "$KEY"; then
return
fi
rm -f "$CERT.new" "$KEY.new"
if ! cp "$SRC_CERT" "$CERT.new" || ! cp "$SRC_KEY" "$KEY.new"; then
rm -f "$CERT.new" "$KEY.new"
echo "cert-reload: copying the renewed certificate failed, will try again after ${INTERVAL}s" >&2
return
fi
if ! same_pair "$CERT.new" "$KEY.new"; then
rm -f "$CERT.new" "$KEY.new"
echo "cert-reload: the mounted certificate and key differ from Postfix's copy but do not belong together; will look again after ${INTERVAL}s" >&2
return
fi
chown root:root "$CERT.new" "$KEY.new"
chmod 0644 "$CERT.new"
chmod 0600 "$KEY.new"
mv -f "$KEY.new" "$KEY" && mv -f "$CERT.new" "$CERT"
PENDING=1
reload "renewed certificate"
}
while true; do
sleep "${INTERVAL}"
refresh
if postfix reload; then
echo "cert-reload: postfix reloaded (periodic TLS refresh)"
else
# Never exit non-zero: a transient reload failure must not trip the
# crashexit listener and take the container down. Log and retry next cycle.
echo "cert-reload: postfix reload failed, will retry after ${INTERVAL}s" >&2
fi
done
+64 -561
View File
@@ -1,101 +1,33 @@
#!/bin/sh
# Generate the outbound-relay Postfix configuration.
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2).
#
# Run as root from entrypoint.sh on every container start, BEFORE supervisord
# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is
# re-derived from the environment on each start rather than persisted, so a
# fresh, restarted or restored container always ends up with the same config
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
# is generated separately by the panel).
# is generated separately by the panel — spec 5.1).
#
# main.cf is written with `postconf -e`, the master.cf submission services with
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from
# a fixed literal or a container environment variable.
# a fixed literal or a container environment variable (spec 7.6.3).
set -eu
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
#
# A master.cf -o value must not contain whitespace: master splits the line on
# it, so "-o name=check_recipient_access texthash:/x,reject" reaches smtpd as
# the option "name=check_recipient_access" plus a stray argument, and smtpd dies
# at start with "fatal: unexpected command-line argument" - port 25 then
# answers nobody. (postconf -P refuses such values for the same reason.) Postfix
# splits a restriction list on commas as well as on whitespace, so every value
# passed here is written with commas only; a value with whitespace is a bug in
# this script and stops the container.
#
# Postfix 3.7.11, man/man5/master.5, "-o name=value (short form)", NOTE 2: no
# whitespace in a short-form value (the long form "-o { name = value }" would
# keep it; this script does not write it). src/postconf/postconf_edit.c,
# pcf_edit_master(): "-Pe does not accept whitespace in parameter value".
# src/smtpd/smtpd_check.c, smtpd_check_parse(), and src/milter/milter.c,
# milter_new(), split their lists on CHARS_COMMA_SP: commas or whitespace.
#
# master.5, SYNTAX: a line that starts with whitespace continues a logical
# line, so the "-o" line awk inserts below the header belongs to the service.
# A later postconf -F/-P re-parses a matched entry with its continuation lines
# (postconf_edit.c, pcf_gobble_cf_line()) and prints it whole again, keeping
# these options.
master_cf_set_smtp_inet_o() {
param="$1"
value="$2"
case "$value" in
*[[:space:]]*)
echo "FATAL: master.cf -o ${param} would contain whitespace: ${value}" >&2
exit 1
;;
esac
awk -v p="$param" -v v="$value" '
/^smtp[ \t]+inet[ \t]/ && !done {
print
print " -o " p "=" v
done = 1
in_smtp_inet = 1
next
}
in_smtp_inet && /^[ \t]+-o / {
line = $0
sub(/^[ \t]+-o /, "", line)
eq = index(line, "=")
on = substr(line, 1, eq - 1)
if (on == p) {
next
}
}
in_smtp_inet && /^[^ \t#-]/ {
in_smtp_inet = 0
}
{ print }
' /etc/postfix/master.cf > /etc/postfix/master.cf.new
mv /etc/postfix/master.cf.new /etc/postfix/master.cf
}
# --- environment knobs -------------------------------------------------------
# --- environment knobs (spec 8) ----------------------------------------------
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the
# sasldb2 accounts are looked up under. The panel creates accounts under realm
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
# fails. Fall back to the container hostname only outside a real deployment.
# Postfix 3.7.11, src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create():
# sasl_server_new() gets var_myhostname as serverFQDN; Cyrus SASL 2.1.28,
# plugins/sasldb.c, sasldb_auxprop_lookup(): with no user realm the account is
# looked up under serverFQDN.
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
# relay requires TLS on 465; if these files are absent the master still starts
# but TLS handshakes on 465 fail until they appear. Postfix 3.7.11,
# src/smtpd/smtpd.c: pre_jail_init() only warns when the TLS context cannot be
# made, and smtpd_proto() then drops each wrapper-mode connection ("Wrapper-mode
# request dropped ... TLS context initialization failed"); every new smtpd
# process loads the files again in pre_jail_init().
# TLS material supplied by the reverse-proxy through a read-only bind mount
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
# master still starts but TLS handshakes on 465 fail until they appear.
#
# Postfix's TLS_README advises a private key owned by root and readable by no
# other user; Postfix does not enforce it (smtpd loads the key as root in
# pre_jail_init(), Postfix 3.7.11 src/smtpd/smtpd.c), but the mount cannot
# give that mode - it is :ro and owned by the host's user, often readable by
# group 998 for the panel's backup. So the pair is copied into a directory of
# Postfix's own, the key root:root 0600, before postconf and `postfix check`
# (assumptions D-arch:154).
# Postfix insists the private key is root-owned and mode 0600. The bind mount
# is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
# copy into a writable internal dir and normalise ownership before postconf
# and `postfix check`.
TLS_CERT_SRC="${TLS_CERT_FILE:-/etc/postfix/tls/fullchain.pem}"
TLS_KEY_SRC="${TLS_KEY_FILE:-/etc/postfix/tls/privkey.pem}"
TLS_INTERNAL_DIR=/etc/postfix/tls-internal
@@ -112,117 +44,25 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
fi
# Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
# during IP warm-up.
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
# defaults, sensible during IP warm-up (spec 10).
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
# The settings below reach main.cf and master.cf as numbers or time values.
# A value that is neither, or one under the parameter's minimum, passes
# `postfix check` and kills the daemon that reads it - every smtpd at its
# first connection, or anvil and with it the rate limit (audit 2026-10-12,
# L31; review of stage 5) - so the start stops here, saying which and why.
# Counts and sizes are whole numbers from min up to 2147483647, an int's top
# (Postfix 3.7.11 reads them with get_mail_conf_int*, src/global/
# mail_conf_int.c: an int, held to the parameter's own minimum). A time value
# is a whole number from 1 with an optional unit, s m h d or w, at most
# 2147483647 seconds
# (proto/postconf.proto: "Specify a non-zero time value (an integral value
# plus an optional one-letter suffix ...). Time units: s (seconds), m
# (minutes), h (hours), d (days), w (weeks)"; one unit - "1h30m" is read as
# 1h, src/global/conv_time.c - so it is refused here).
need_number() {
case "$2" in
'' | *[!0-9]*)
echo "postfix-config: $1 must be a whole number, got '$2'" >&2
exit 1
;;
esac
if [ "${#2}" -gt 10 ] || [ "$2" -lt "$3" ] || [ "$2" -gt 2147483647 ]; then
echo "postfix-config: $1 must be from $3 to 2147483647, got '$2'" >&2
exit 1
fi
}
need_time() {
if ! printf '%s' "$2" | grep -Eq '^[1-9][0-9]{0,8}[smhdw]?$'; then
echo "postfix-config: $1 must be a time value - a whole number from 1 with an optional s, m, h, d or w - got '$2'" >&2
exit 1
fi
# conv_time multiplies by the unit and refuses what passes an int.
n=${2%[smhdw]}
case "$2" in
*w) n=$((n * 604800)) ;;
*d) n=$((n * 86400)) ;;
*h) n=$((n * 3600)) ;;
*m) n=$((n * 60)) ;;
esac
if [ "$n" -gt 2147483647 ]; then
echo "postfix-config: $1 is more than 2147483647 seconds, got '$2'" >&2
exit 1
fi
}
# A rate of 0 is no limit, a size of 0 none (proto/postconf.proto,
# smtpd_client_message_rate_limit, message_size_limit); the window is
# anvil_rate_time_unit, at least 1 second (src/anvil/anvil.c, main(): its
# time table's minimum 1).
need_number RATE_LIMIT_MESSAGES_PER_IP "$RATE_MSGS" 0
need_number RATE_LIMIT_WINDOW_SECONDS "$RATE_WINDOW" 1
need_number INBOUND_RATE_LIMIT_MESSAGES_PER_IP "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
need_number INBOUND_MESSAGE_SIZE_LIMIT "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}" 0
need_number DMARC_RATE_LIMIT_MESSAGES_PER_IP "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}" 0
need_number DMARC_MESSAGE_SIZE_LIMIT "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}" 0
need_time MILTER_CONNECT_TIMEOUT "${MILTER_CONNECT_TIMEOUT:-15s}"
need_time MILTER_COMMAND_TIMEOUT "${MILTER_COMMAND_TIMEOUT:-15s}"
need_time MILTER_CONTENT_TIMEOUT "${MILTER_CONTENT_TIMEOUT:-30s}"
# Milter sockets: OpenDKIM (signing, strict), the panel's sender check
# (strict) and its journal-milter (monitoring, fail-open). Fixed container
# paths, matched by postfix-wrapper.sh.
# Milter sockets: OpenDKIM (signing, strict) and the panel journal-milter
# (monitoring, fail-open). Fixed container paths, matched by postfix-wrapper.sh.
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# The panel's sender check (the From: line against the login, strict).
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
# Persistent panel-managed sender map; texthash needs no postmap, so the
# unprivileged panel can rewrite it and just ask for a reload. Postfix 3.7.11,
# html/DATABASE_README.html, "texthash": no postmap(1), and the file is read
# once by each process - a reload makes the new processes read it again.
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
# the unprivileged panel can rewrite it and just ask for a reload.
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
# Transit mail queue under /data so deferred/active messages survive container
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
# Optional submission service on 587 (off by default, enabled only when a client
# library needs STARTTLS on 587 instead of implicit TLS on 465).
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
# absent. When true, smtp inet on 25 accepts only relay_domains + known
# recipients (docs/plans/inbound-relay.md).
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
# The panel's inbound journal milter (monitoring, fail-open), on port 25 only
# and only while the inbound relay is on. Matched by postfix-wrapper.sh.
INBOUND_JOURNAL_SOCK="${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default:
# port 25 does not accept report mail and the panel ingest maps stay empty.
DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}"
DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}"
DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}"
DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}"
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
# for messages still marked "queued" survive a container recreate — without
@@ -231,22 +71,10 @@ DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_rela
# directory and the file with the ownership postlogd writes and the panel reads.
MAIL_LOG_PATH="${MAIL_LOG:-/data/log/mail.log}"
# /etc/postfix outlives a restart of the container, so a cleanup limit set for
# port 25 before is taken back here and set again below when it still applies.
postconf -PX "cleanup/unix/message_size_limit" 2>/dev/null || true
# --- main.cf -----------------------------------------------------------------
# maillog_file lives under the persistent /data bind mount (architecture.md §
# Log tailer). Postfix's default maillog_file_prefixes are only /var and
# /dev/stdout — without /data, `postfix check` rejects the path.
# inet_protocols=all in a bridge network without IPv6: Postfix 3.7.11,
# src/util/inet_proto.c, inet_proto_init(), keeps IPv6 when socket(PF_INET6)
# succeeds, and src/smtp/smtp_connect.c, smtp_connect_inet(), goes on to
# the next address when a connection fails - an AAAA that cannot be reached
# costs one failed attempt, then IPv4. Postfix
# 3.7.11, src/global/mail_params.h, DEF_MAILLOG_FILE_PFXS "/var, /dev/stdout";
# src/global/maillog_client.c, maillog_client_init(): fatal "does not match any
# prefix", in every Postfix program that logs, postfix(1) itself included.
postconf -e \
"myhostname=${HOSTNAME_VALUE}" \
"maillog_file=${MAIL_LOG_PATH}" \
@@ -256,37 +84,25 @@ postconf -e \
"inet_interfaces=all" \
"inet_protocols=all"
# postlogd is mandatory whenever maillog_file is set (Postfix 3.7.11,
# html/MAILLOG_README.html, "Configuring logging to file"; conf/postfix-script,
# "check-fatal": "missing 'postlog' service in master.cf"). Debian's
# postlogd is mandatory whenever maillog_file is set (MAILLOG_README). Debian's
# stock master.cf usually has it; pin it explicitly so a stripped/upgraded
# image cannot lose the service.
postconf -M "postlog/unix-dgram=postlog unix-dgram n - n - 1 postlogd"
# This is an outbound relay: no local delivery, no per-user aliases. Empty
# these so a misfiled recipient never gets delivered locally. An empty
# mydestination is not enough: an address literal of an interface the
# container listens on - x@[127.0.0.1] - is local too (Postfix 3.7.11,
# src/global/resolve_local.c, resolve_local(), own_inet_addr()), so local(8)
# itself is replaced by a refusal, as Postfix's null-client example does
# (README_FILES/STANDARD_CONFIGURATION_README, "local_transport = error:local
# mail delivery is disabled"; assumptions C-P8).
# these so a misfiled recipient never gets delivered locally.
postconf -e \
"local_recipient_maps=" \
"alias_maps=" \
"alias_database=" \
"local_transport=error:local mail delivery is disabled"
"alias_database="
# Outbound delivery: straight to the recipient MX, opportunistic TLS.
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2).
postconf -e \
"smtp_tls_security_level=may" \
"smtp_tls_note_starttls_offer=yes"
# TLS server material shared by every inbound service. auth_only guarantees
# credentials are never accepted before TLS is up on any port. Postfix 3.7.11,
# src/smtpd/smtpd_sasl_proto.c, smtpd_sasl_auth_cmd(): "504 5.5.4 Encryption
# required" while auth_only is set and TLS is not up; src/smtpd/smtpd.c,
# pre_jail_init(): wrappermode implies enforce, and enforce implies auth_only.
# TLS server material shared by every inbound service (spec 5.2). auth_only
# guarantees credentials are never accepted before TLS is up on any port.
postconf -e \
"smtpd_tls_cert_file=${TLS_CERT}" \
"smtpd_tls_key_file=${TLS_KEY}" \
@@ -294,29 +110,11 @@ postconf -e \
"smtpd_tls_auth_only=yes" \
"smtpd_tls_loglevel=1"
# TLS 1.2 at least, opportunistic or required - and the client's too where an
# inbound domain requires TLS to its upstream (smtp_tls_mandatory_protocols,
# the security review of stage 5) (audit 2026-10-12, L31; Postfix
# 3.7.11, proto/postconf.proto, smtpd_tls_protocols: since 3.6 the default is
# ">=TLSv1", and a lower bound is written ">=version"). A sender that offers
# less on port 25 sends in the clear, as one without TLS does. VRFY answers
# no question about who is accepted on port 25 (relay_recipient_maps), and
# the banner names no distribution (Debian's adds "(Debian/GNU)"; the
# upstream default is "$myhostname ESMTP $mail_name").
postconf -e \
"smtpd_tls_protocols=>=TLSv1.2" \
"smtpd_tls_mandatory_protocols=>=TLSv1.2" \
"smtp_tls_mandatory_protocols=>=TLSv1.2" \
"disable_vrfy_command=yes" \
'smtpd_banner=$myhostname ESMTP'
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
# uses for sender_login_maps is the BARE login the panel writes into the map;
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
# the server realm, which equals the realm the accounts were created under.
# (Postfix 3.7.11, src/smtpd/smtpd_sasl_glue.c, smtpd_sasl_activate(): an empty
# smtpd_sasl_local_domain is a NULL user realm; see HOSTNAME_VALUE above.)
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name
# Postfix uses for sender_login_maps is the BARE login the panel writes into the
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname
# as the server realm, which equals the realm the accounts were created under.
postconf -e \
"smtpd_sasl_auth_enable=yes" \
"smtpd_sasl_type=cyrus" \
@@ -326,121 +124,65 @@ postconf -e \
"smtpd_sasl_tls_security_options=noanonymous" \
"broken_sasl_auth_clients=yes"
# Sender binding (the critical anti-spoofing control). smtpd looks the sender
# up in this map with mail_addr_find(), the order being smtpd's and not
# texthash's: the full address, the address without its extension, then the
# "@domain" wildcard - so both address modes work from the same map (Postfix
# 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch();
# src/global/mail_addr_find.c, mail_addr_find_opt() with MA_FIND_DEFAULT).
#
# recipient_delimiter is said here and not left to the distribution's main.cf:
# it is what lets an application listed as invoices@ send as invoices+march@,
# and the panel's sender check makes the same second try for the From: line
# (extensionDelimiter in internal/milter/sender.go) - the two must agree.
# (mail_addr_find_opt() strips the extension at var_rcpt_delim,
# strip_addr_internal(), for its second try.)
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash
# resolves the full address first, then the "@domain" wildcard, so both address
# modes work from the same map.
postconf -e \
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
"recipient_delimiter=+" \
"queue_directory=${QUEUE_DIR}"
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
# Restrictions: authenticated clients only, no relay to foreign destinations,
# and every authenticated sender address must be owned by its login. NO
# permit_mynetworks anywhere — authorisation is by credentials, never by
# network. This is what makes an open relay impossible. (Postfix 3.7.11,
# src/smtpd/smtpd_check.c, smtpd_check_rcpt(): the relay and recipient lists
# are evaluated in turn and the first REJECT ends them.)
# permit_mynetworks anywhere — authorisation is by credentials, never by network
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
postconf -e \
"smtpd_helo_required=yes" \
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
# Level-1 rate limit by client IP. Backstop that keeps working even if the
# journal-milter (level 2) is down. Postfix 3.7.11, src/smtpd/smtpd.c,
# mail_cmd(): "450 4.7.1 Error: too much mail from", except for clients in
# smtpd_client_event_limit_exceptions (default $mynetworks; Debian's debconf
# default, debian/templates "postfix/mynetworks", is loopback only).
# anvil_rate_time_unit is one global window for every anvil limit (postconf(5)).
# "Client IP" is the address the container sees: an IPv4 client's own, but
# for a client over IPv6 the compose network's gateway, through Docker's
# proxy - measured on a live server, 2026-10-15, Docker 29.6.1 (assumptions
# C-K5; guide, "Rate limiting - level 1").
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
# if the journal-milter (level 2) is down.
postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s"
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
# if it is unreachable, defer rather than send unsigned). The journal-milter is
# monitoring only and is fail-open (default_action=accept): its failure must
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
#
# The sender check comes first and is strict as well: it holds the From: line
# against the login, which is what OpenDKIM then chooses the key by, so a
# message it could not judge must not reach the signer. While the panel does
# not answer on that socket, mail is deferred (docs/security.md).
#
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
# the panel's own notifications, sent as postmaster@ the hostname. OpenDKIM
# signs them with the key of the server's own name; the journal-milter is not
# on this chain, since they are not an application's mail. Bounces Postfix
# generates itself do not pass it (internal_mail_filter_classes is empty).
#
# Postfix 3.7.11: html/MILTER_README.html, "Different settings for different
# Milter applications" (the braces, Postfix 3.0 and later) and "Signing
# internally-generated bounce messages"; src/milter/milter.c, milter_*_event()
# and milter_message(): milters are asked in list order and the first non-empty
# reply ends the round; src/global/mail_params.h, DEF_INT_FILT_CLASSES
# INT_FILT_CLASS_NONE.
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly
# (default_action=tempfail: if it is unreachable, defer rather than send
# unsigned). The journal-milter is monitoring only and is fail-open
# (default_action=accept): its failure must never block the relay. Per-milter
# settings use Postfix 3.0+ brace syntax.
postconf -e \
"milter_protocol=6" \
"milter_default_action=tempfail" \
"smtpd_milters={ unix:${SENDER_SOCK}, default_action=tempfail }, { unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
"non_smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }"
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
"non_smtpd_milters="
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's
# address. The panel's milters judge every message by that address (an
# application's client-address list) and record it (the inbound journal), and
# the address given once at connect is not there for a connection's later
# messages - see MailFrom in internal/milter/milter.go. The defaults: Postfix
# 3.7.11, src/global/mail_params.h, DEF_MILT_MAIL_MACROS.
postconf -e \
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
# must fail open just like a crash, not stall mail acceptance until the Postfix
# defaults (300s content) elapse. With default_action per milter, a
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
# replies) must fail open just like a crash, not stall mail acceptance until the
# Postfix defaults (300s content) elapse. With default_action per milter, a
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
# but within seconds rather than minutes. Values are well above any healthy
# response time (signing/DB insert are sub-second), so they never fire in normal
# operation. The defaults: Postfix 3.7.11, src/global/mail_params.h,
# DEF_MILT_CONN_TIME "30s", DEF_MILT_CMD_TIME "30s", DEF_MILT_MSG_TIME "300s".
# operation.
postconf -e \
"milter_connect_timeout=${MILTER_CONNECT_TIMEOUT:-15s}" \
"milter_command_timeout=${MILTER_COMMAND_TIMEOUT:-15s}" \
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
# --- master.cf: inbound submission services ----------------------------------
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
# and the Cyrus config outside any chroot.
# The names smtps, submission and smtp need no /etc/services: Postfix 3.7.11,
# src/global/mail_params.h, DEF_KNOWN_TCP_PORTS "lmtp=24, smtp=25,
# smtps=submissions=465, submission=587" (src/util/find_inet.c,
# find_inet_port() via filter_known_tcp_port()).
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P \
"smtps/inet/smtpd_tls_wrappermode=yes" \
"smtps/inet/smtpd_sasl_auth_enable=yes" \
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
# difference is TLS is negotiated via STARTTLS, so require encryption before
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
# after disabling it does not leave the port listening. (Postfix 3.7.11,
# src/smtpd/smtpd.c, pre_jail_init(): smtpd_tls_security_level=encrypt sets
# enforce_tls, which implies smtpd_tls_auth_only; src/postconf/postconf_edit.c,
# pcf_edit_master(): -MX drops the matched entry, continuation lines included.)
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits;
# the only difference is TLS is negotiated via STARTTLS, so require encryption
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a
# restart after disabling it does not leave the port listening.
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
postconf -M "submission/inet=submission inet n - n - - smtpd"
postconf -P \
@@ -451,256 +193,19 @@ else
postconf -MX "submission/inet" 2>/dev/null || true
fi
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
# Debian's stock master.cf enables smtp/inet (postfix 3.7.11-0+deb12u1,
# debian/patches/40_chroot_by_default.diff: "smtp inet n - y - - smtpd",
# uncommented). When both flags are off, remove
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_DOMAINS_SETTING=""
TRANSPORT_SETTING=""
RECIPIENT_RESTRICTIONS="reject"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
[ -e "$f" ] || : > "$f"
done
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
RECIPIENT_RESTRICTIONS="reject_unauth_destination,reject_unlisted_recipient"
fi
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
[ -e "$f" ] || : > "$f"
done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
# flags=, user= and argv= are arguments of pipe(8), not -o parameters:
# postconf -P refuses them ("unmatched service_name/type") and leaves a
# bare "pipe", which dies with "fatal: missing user= command-line
# attribute" on every report. They go into the service line itself.
# (Seen in 1.7.0 through 1.9.5, date not recorded. Postfix 3.7.11:
# src/postconf/postconf_master.c, pcf_edit_master_param(), -P writes
# "-o name=value" and nothing else; postconf_edit.c warns "unmatched
# service_name/type" for a service master.cf does not have;
# src/pipe/pipe.c, get_service_attr(): "missing user= command-line
# attribute".)
# ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument
# (man/man8/pipe.8, ${recipient}: as many arguments as recipients).
# One at a time (the 1 before "pipe"): reports are in no hurry, every run
# writes to the database the panel writes to, and a hundred of them in
# parallel - Postfix's default - is what a flood of reports would start.
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - 1 pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
# One recipient per run, so ${recipient} is one argument.
postconf -e "dmarc-ingest_destination_recipient_limit=1"
# pipe(8) starts the command with export_environment only, so what the
# command reads from the container's environment is named there.
# export_environment is a list split at whitespace in which Postfix
# expands "$": a path with either would reach the command as another
# path, and it would quietly open a database of its own. (Postfix 3.7.11,
# man/man8/pipe.8, export_environment; src/global/mail_params.h,
# DEF_EXPORT_ENVIRON "TZ MAIL_CONFIG LANG"; src/global/clean_env.c. The
# parameter is main.cf's, so every Postfix process exports these too.)
case "${SELFPOST_DATA_DIR:-}${SELFPOST_DB_PATH:-}" in
*[[:space:]]* | *'$'*)
echo "postfix-config: SELFPOST_DATA_DIR and SELFPOST_DB_PATH must not contain whitespace or \$ when DMARC_REPORTS_ENABLE=true" >&2
exit 1
;;
esac
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
postconf -e "export_environment=TZ MAIL_CONFIG LANG ${DMARC_INGEST_ENV}"
if [ -n "$RELAY_DOMAINS_SETTING" ]; then
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
else
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
fi
# Comma, not a space, between check_recipient_access and its table: see
# master_cf_set_smtp_inet_o (master.5, NOTE 2: "or use commas instead of
# spaces").
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
fi
postconf -e \
"relay_domains=${RELAY_DOMAINS_SETTING}" \
"transport_maps=${TRANSPORT_SETTING}" \
"smtpd_reject_unlisted_recipient=yes"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
# With the relay on, Postfix refuses every recipient of a relay domain
# that relay_recipient_maps does not list (smtpd_reject_unlisted_recipient
# above) - and it does so after the restriction list, whatever
# check_recipient_access answered. The DMARC report addresses live on a
# relay domain too (the server's own name), so their table is a recipient
# table as well; without it they are "User unknown in relay recipient
# table" and no report is ever accepted while both features are on.
# (Postfix 3.7.11, src/smtpd/smtpd_check.c, smtpd_check_rcpt(): after
# the lists, check_recipient_rcpt_maps() runs unless the status is
# REJECT - an OK from check_recipient_access does not skip it.)
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
fi
# The panel writes the same "[host]:port" into transport and
# tls_policy: the policy lookup key is the verbatim next-hop, port and
# brackets included (Postfix 3.7.11, README_FILES/TLS_README, "TLS
# policy table" note: ":smtp", ":25" and no port are different keys).
postconf -e \
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
else
postconf -e \
"relay_recipient_maps=" \
"smtp_tls_policy_maps="
fi
# Milters on port 25. main.cf's chain (OpenDKIM strict + journal-milter) is
# for the SUBMISSION path only: OpenDKIM must not gate someone else's
# inbound mail (a signing outage would defer it), and the journal-milter
# would file inbound mail into the outbound send log under the *sender's*
# domain — polluting Deliveries and letting a forged From: consume that
# domain's level-2 rate-limit budget. So smtpd_milters is always overridden
# here: the optional antispam milter, then - with the inbound relay on - the
# panel's inbound journal milter, or nothing at all. (Postfix 3.7.11,
# man/man5/master.5: "-o" overrides the main.cf parameter for this service;
# src/smtpd/smtpd.c hands its own milter list to cleanup, milter_send(),
# and src/cleanup/cleanup_milter.c, cleanup_milter_receive(), uses it.)
INBOUND_MILTERS=""
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
# A glob's "*" matches anything, so the shape alone would pass
# "inet:a:1},{unix:/elsewhere": characters outside the set are refused
# first, then the shape is checked.
case "${INBOUND_ANTISPAM_MILTER}" in
*[!A-Za-z0-9._:/-]*)
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
exit 1
;;
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
*)
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
exit 1
;;
esac
case "${INBOUND_ANTISPAM_MILTER_ACTION}" in
accept|tempfail) ;;
*)
echo "FATAL: INBOUND_ANTISPAM_MILTER_ACTION must be accept or tempfail, got: ${INBOUND_ANTISPAM_MILTER_ACTION}" >&2
exit 1
;;
esac
INBOUND_MILTERS="{${INBOUND_ANTISPAM_MILTER},default_action=${INBOUND_ANTISPAM_MILTER_ACTION}}"
fi
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
# Last in the chain: Postfix hands a message to its milters one after
# another at end-of-message, so this one reads the verdict headers the
# filter added and sees only what the filter let through. It watches and
# never refuses, and must never hold up someone else's mail: accept when
# it is down. A comma, not a space, between the two - see
# master_cf_set_smtp_inet_o.
# Postfix 3.7.11: src/milter/milter.c, milter_message(), sends the
# message to each milter in list order; src/milter/milter8.c,
# milter8_event(), applies a header a milter adds (SMFIR_ADDHEADER) to
# the queue file at once, and the next milter8_message() reads that
# file with rec_get(), which follows the added records.
case "${INBOUND_JOURNAL_SOCK}" in
*[!A-Za-z0-9._/-]*)
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
exit 1
;;
/[A-Za-z0-9._/-]*) ;;
*)
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
exit 1
;;
esac
INBOUND_MILTERS="${INBOUND_MILTERS:+${INBOUND_MILTERS},}{unix:${INBOUND_JOURNAL_SOCK},default_action=accept}"
fi
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
PORT25_RATE="${DMARC_RATE_LIMIT_MESSAGES_PER_IP}"
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
fi
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
if [ "${INBOUND_MESSAGE_SIZE_LIMIT}" -gt "${DMARC_MESSAGE_SIZE_LIMIT}" ]; then
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
else
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
fi
fi
# syslog_name: this listener's lines read postfix/inbound/smtpd, the
# submission ports' postfix/smtpd. The log tailer files a refusal on port 25
# into the inbound journal and has to tell the two apart. (Postfix 3.7.11,
# src/global/mail_task.c, mail_task(): the log tag is syslog_name "/"
# program name. Only smtpd carries it: cleanup and the rest are other
# services and log as postfix/.)
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
postconf -P \
"smtp/inet/syslog_name=postfix/inbound" \
"smtp/inet/smtpd_sasl_auth_enable=no" \
"smtp/inet/smtpd_tls_auth_only=no" \
"smtp/inet/smtpd_sender_login_maps=" \
"smtp/inet/smtpd_sender_restrictions=" \
"smtp/inet/smtpd_client_restrictions=" \
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
"smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \
"smtp/inet/message_size_limit=${PORT25_SIZE}"
master_cf_set_smtp_inet_o smtpd_recipient_restrictions "${RECIPIENT_RESTRICTIONS}"
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
# smtpd's -o limit is not the whole of it: cleanup writes the queue file
# under its own message_size_limit, main.cf's, which is never set here and
# so is 10240000 (Postfix 3.7.11, src/cleanup/cleanup_init.c,
# cleanup_post_jail(), set_file_limit(); src/global/mail_params.h,
# DEF_MESSAGE_LIMIT), and a larger message is refused 552 after DATA
# (assumptions C-P31). cleanup gets port 25's limit when it is larger;
# smtpd on 465 and 587 still holds its own, main.cf's (src/smtpd/smtpd.c,
# the DATA and BDAT size checks).
if [ "${PORT25_SIZE}" -gt "$(postconf -h message_size_limit)" ]; then
postconf -P "cleanup/unix/message_size_limit=${PORT25_SIZE}"
fi
else
postconf -MX "smtp/inet" 2>/dev/null || true
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
postconf -e \
"relay_domains=" \
"transport_maps=" \
"relay_recipient_maps=" \
"smtp_tls_policy_maps="
fi
# Disable chroot for every service. Debian ships the smtp delivery agent and
# others chrooted to /var/spool/postfix, where they cannot read /etc/resolv.conf
# — so outbound MX lookups fail with "Host not found" and mail never leaves.
# Inside a container the chroot buys little (the container is the isolation
# boundary) and breaks DNS/TLS trust-store access, so turn it off uniformly. Our
# own smtps/submission services are already n; this covers the delivery agents
# and the rest. (postfix 3.7.11-0+deb12u1: debian/patches/40_chroot_by_default.diff
# sets chroot y; debian/configure-instance.sh copies etc/resolv.conf and the
# like into the chroot, and nothing runs it in this container. chroot(2) also
# needs CAP_SYS_CHROOT, which deploy/docker-compose.yml drops with cap_drop:
# ALL - capabilities(7).)
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
# agent and others chrooted to /var/spool/postfix, where they cannot read
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
# never leaves. Inside a container the chroot buys little (the container is the
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off
# uniformly. Our own smtps/submission services are already n; this covers the
# delivery agents and the rest.
postconf -F "*/*/chroot=n"
# --- Cyrus SASL app config for smtpd -----------------------------------------
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
# passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN
# only — both are safe because TLS is mandatory before auth on every port.
# The file is found by a Debian patch, not by upstream Postfix: postfix
# 3.7.11-0+deb12u1, debian/patches/07_sasl_config.diff, xsasl_getconfpath() in
# src/xsasl/xsasl_cyrus_log.c gives Cyrus "$config_directory/sasl:/usr/lib/sasl",
# and smtpd_sasl_path=smtpd names smtpd.conf there. The keys: Cyrus SASL
# 2.1.28, doc/legacy/options.html (pwcheck_method, auxprop_plugin,
# sasldb_path, mech_list).
mkdir -p /etc/postfix/sasl
cat > /etc/postfix/sasl/smtpd.conf <<EOF
pwcheck_method: auxprop
@@ -710,9 +215,7 @@ mech_list: PLAIN LOGIN
EOF
# Validate the generated configuration; fail loudly if postconf produced
# anything Postfix rejects, before the wrapper tries to start it. (Postfix
# 3.7.11, conf/postfix-script, "check": "check-fatal || exit 1"; check-fatal
# also runs "post-install create-missing", which builds the queue tree.)
# anything Postfix rejects, before the wrapper tries to start it.
set +e
check_out=$(postfix check 2>&1)
ec=$?
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
# The reload the panel asks supervisord for (program postfix-reload,
# build/supervisord.conf; internal/supervisor RunOnce reads its exit status).
#
# Exit 75 says no Postfix master runs - it is stopped, or still in its start
# behind the milter wait (build/postfix-wrapper.sh) - and the master that
# starts next reads the files as they are now, so the panel takes that as
# done. Anything else is "postfix reload" itself: 0, or its failure (Postfix
# 3.7.11, conf/postfix-script, "reload": exit 1 when not running or when
# postsuper fails; src/postfix/postfix.c, a main.cf it cannot read). "master
# -t" exits 0, silently, while no master holds the lock (src/master/master.c,
# test_lock). A master that stops between the two is a failure the panel
# reports - the rarer mistake (review of stage 2c, F5).
d=$(postconf -h daemon_directory) || exit 1
if "$d/master" -t 2>/dev/null; then
echo "postfix-reload: Postfix is not running; its start reads the files" >&2
exit 75
fi
exec /usr/sbin/postfix reload
+11 -74
View File
@@ -1,37 +1,23 @@
#!/bin/sh
# Postfix start wrapper: solves the cold-start race where Postfix would try to
# reach the milter sockets before they are listening.
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
# try to reach the milter sockets before they are listening.
#
# It blocks until the milter sockets — OpenDKIM, the panel's sender check and
# journal-milter and, with the inbound relay on, its inbound journal milter —
# are present, then runs `postfix start-fg` and stays its parent. If they are
# not ready within the timeout it exits non-zero WITHOUT starting Postfix.
# supervisord counts that exit as one that came too quickly - the program's
# startsecs is the timeout plus ten seconds (build/entrypoint.sh) - so after
# startretries it is FATAL and crashexit takes the container down, instead of
# a relay running blind (supervisor 4.2.5, supervisor/process.py,
# Subprocess.finish(): an exit before startsecs is "Exited too quickly" and
# goes to BACKOFF; transition(): BACKOFF past startretries is FATAL;
# assumptions C-V6).
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter
# — are present, then execs `postfix start-fg`. If they are not ready within the
# timeout it exits non-zero WITHOUT starting Postfix, so supervisord/Docker see
# an explicit startup failure instead of a relay running blind.
#
# This handles cold start only. Once Postfix runs, a milter that does not
# answer costs what its default_action says (build/postfix-config.sh): mail is
# deferred for OpenDKIM and the sender check, and passes the journal-milter.
# This handles cold start only. Runtime milter failures after a successful start
# are governed by fail-open (milter_default_action).
set -eu
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
SENDER_SOCK="${SENDER_CHECK_SOCKET:-/run/selfpost/sender.sock}"
TIMEOUT="${MILTER_WAIT_TIMEOUT:-30}"
INTERVAL=1
SOCKS="$OPENDKIM_SOCK $JOURNAL_SOCK $SENDER_SOCK"
if [ "${INBOUND_RELAY_ENABLE:-false}" = "true" ]; then
SOCKS="$SOCKS ${INBOUND_JOURNAL_MILTER_SOCKET:-/run/selfpost/inbound-journal.sock}"
fi
elapsed=0
for sock in $SOCKS; do
for sock in "$OPENDKIM_SOCK" "$JOURNAL_SOCK"; do
while [ ! -S "$sock" ]; do
if [ "$elapsed" -ge "$TIMEOUT" ]; then
echo "postfix-wrapper: timed out after ${TIMEOUT}s waiting for milter socket $sock" >&2
@@ -43,54 +29,5 @@ for sock in $SOCKS; do
echo "postfix-wrapper: milter socket ready: $sock"
done
echo "postfix-wrapper: milter sockets ready, starting postfix"
# Postfix 3.7.11: postfix(1) execs conf/postfix-script (src/postfix/postfix.c),
# whose "start-fg" execs master only as PID 1 and otherwise runs "master -s" as
# its child; master then makes itself a session leader (src/master/master.c,
# setsid()). A signal supervisord sends this program never reaches master
# (supervisor 4.2.5, supervisor/process.py, Subprocess.kill(): to the pid
# alone without stopasgroup, and master is not in its group anyway): before,
# a stop left master running - a restart of the program then found Postfix
# "already running", and a stop of the container cut Postfix off with
# SIGKILL (assumptions C-P42). So this shell stays, and on TERM or INT runs
# "postfix stop" (conf/postfix-script, "stop": TERM to master, then waits up
# to five seconds and kills its group; src/master/master_sig.c,
# master_sigdeath(): "terminating on signal 15").
#
# "postfix start-fg" spends its first seconds in its checks before master
# runs; a stop asked then would find nothing to stop, so it is asked again
# until the child is gone. "master -t" exits 0, silently, while no master
# holds the lock (src/master/master.c, test_lock).
daemon_directory=$(postconf -h daemon_directory)
child=
stop_asked=
stop_postfix() {
if [ -z "$child" ]; then
# Between "&" and "child=$!": stopped right after the pid is known.
stop_asked=1
return
fi
trap '' TERM INT
while kill -0 "$child" 2>/dev/null; do
if "$daemon_directory/master" -t 2>/dev/null; then
sleep 1
else
postfix stop || true
fi
done
exit 0
}
trap stop_postfix TERM INT
postfix start-fg &
child=$!
if [ -n "$stop_asked" ]; then
stop_postfix
fi
# A trapped signal makes wait return at once, the trap is taken right after
# (POSIX, Shell Command Language, 2.11 Signals and Error Handling) and exits.
# Otherwise wait returns when Postfix ended on its own: its status is this
# program's, and supervisord restarts it.
status=0
wait "$child" || status=$?
echo "postfix-wrapper: postfix exited with status $status" >&2
exit "$status"
echo "postfix-wrapper: both milter sockets ready, starting postfix"
exec postfix start-fg
+32 -100
View File
@@ -1,24 +1,12 @@
; SelfPost process supervision.
; SelfPost process supervision (spec 4).
;
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
; the sender check's and the journal-milter's sockets), then the Postfix
; wrapper — which additionally blocks until the milter sockets are ready
; before starting Postfix. (supervisor 4.2.5, docs/configuration.rst,
; [program:x] "priority": "the start and shutdown ordering"; supervisor/
; supervisord.py, runforever(), transitions every group in that order in one
; pass, with no wait for a program to be ready.)
; the journal-milter socket), then the Postfix wrapper — which additionally
; blocks until both milter sockets are ready before starting Postfix.
;
; If a managed process of the mail path exhausts its restart retries (FATAL),
; or exits unexpectedly five times in ten minutes, the crashexit event listener
; brings the whole container down so Docker's restart policy starts it again
; (the same container: docs.docker.com, "Start containers automatically",
; restart policies), rather than lingering with a dead component. A program
; reaches FATAL only through BACKOFF, by exits that come before its startsecs
; (supervisor 4.2.5, supervisor/process.py, Subprocess.finish() and
; transition()): an exit after it is EXITED and simply restarted, for ever -
; which the count of exits covers (audit 2026-10-12, L32). logrotate's,
; postfix-reload's and syslog-relay's failures are left on Server > Health
; (build/crashexit.py).
; If any managed process exhausts its restart retries (FATAL), the crashexit
; event listener brings the whole container down so Docker's restart policy can
; recreate it cleanly, rather than lingering with a dead component.
[supervisord]
nodaemon=true
@@ -28,20 +16,15 @@ logfile_maxbytes=0
pidfile=/run/supervisord.pid
loglevel=info
; The control socket is reachable by the `supervisor` group, whose one member
; is the panel (the unprivileged `panel` user): it asks supervisord to signal
; OpenDKIM after it rewrites the DKIM tables, to run the Postfix reload, and
; for the processes' state. Whoever reaches the socket can stop, start or
; restart any program, so it is not the shared `selfpost` group's: OpenDKIM
; and Postfix are in that one for the files under /data, and a fault in either
; must not reach every other program (audit 2026-10-12, stage 5). Not
; reachable by world. No HTTP auth is configured because access is gated by
; socket permissions, not the network. (supervisor 4.2.5,
; docs/configuration.rst, [unix_http_server]: chmod and chown set the socket's
; mode and owner; without username and password no authentication is asked.)
; The control socket is reachable by the shared `selfpost` group so the panel
; (running as the unprivileged `panel` user) can ask supervisord to send
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is
; still unreachable by world; only root and the two SelfPost services are in the
; group. No HTTP auth is configured because access is gated by socket
; permissions, not the network.
[unix_http_server]
file=/run/supervisor.sock
chown=root:supervisor
chown=root:selfpost
chmod=0770
[rpcinterface:supervisor]
@@ -50,29 +33,8 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/supervisor.sock
; The container's /dev/log (a link to the socket below, build/entrypoint.sh):
; what OpenDKIM - which logs through syslog(3) and nothing else - sends there
; comes out on stdout, into the container's log (internal/syslogrelay; audit
; 2026-10-12, L30). Before OpenDKIM, so its first lines are not lost; a
; message sent while nothing listens is dropped by syslog(3), not queued.
[program:syslog-relay]
command=/usr/local/bin/panel -syslog-relay=/run/syslog/log
user=panel
priority=50
autostart=true
autorestart=true
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:opendkim]
; It waits, five seconds at most, for the syslog relay's socket: supervisord
; starts programs in priority order without waiting for one to be ready, and
; OpenDKIM's start line would go nowhere (review of audit stage 5). exec keeps
; the supervised pid OpenDKIM's.
command=/bin/sh -c 'i=0; while [ ! -S /run/syslog/log ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done; exec /usr/sbin/opendkim -f -x /etc/opendkim.conf'
command=/usr/sbin/opendkim -f -x /etc/opendkim.conf
priority=100
autostart=true
autorestart=true
@@ -82,10 +44,6 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; user=panel also gives the panel its supplementary groups, selfpost and
; postdrop (build/Dockerfile): supervisor 4.2.5, supervisor/options.py,
; drop_privileges(), os.setgroups() from the user's memberships in
; grp.getgrall().
[program:panel]
command=/usr/local/bin/panel
user=panel
@@ -98,45 +56,28 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; startsecs is MILTER_WAIT_TIMEOUT plus ten seconds (build/entrypoint.sh): the
; wrapper's exit when the milter sockets never come counts as too quick, and
; so does a Postfix that will not start. The price: for that long after each
; start the program is STARTING, not RUNNING (supervisor 4.2.5,
; supervisor/options.py, processes_from_section(): startsecs is expanded like
; any value, %(ENV_x)s from supervisord's environment; assumptions C-V6).
; stopwaitsecs covers "postfix stop", which waits five seconds for master
; before it kills (build/postfix-wrapper.sh).
[program:postfix]
command=/usr/local/bin/postfix-wrapper.sh
priority=300
autostart=true
autorestart=true
startsecs=%(ENV_POSTFIX_STARTSECS)s
startretries=3
stopwaitsecs=15
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
; reload` itself, and it cannot signal the Postfix master directly: `postfix
; start-fg` forks a separate master process, so a signal to the supervised
; foreground process never reaches it (unlike OpenDKIM, which runs in the
; foreground as the supervised process itself). Instead the panel asks
; supervisord — over the group-accessible control socket — to run this program,
; which runs the canonical `postfix reload` as root - or exits 75 when no
; master runs (build/postfix-reload.sh). autostart is off (it only runs on
; demand). A program that cannot even be spawned twice in a row ends FATAL
; (startretries=1); crashexit leaves that to the page that asked for the
; reload, which reports it (build/crashexit.py; review of stage 2c, F6). (start-fg: see build/postfix-wrapper.sh. supervisor
; 4.2.5, supervisor/process.py, Subprocess.finish(): with startsecs=0 no exit
; counts as "too quickly", so even a failed run ends EXITED - never BACKOFF,
; never FATAL - with its exit status kept, which the panel reads
; (internal/supervisor, RunOnce). Its resting state is STOPPED until the first
; run, EXITED after it; assumptions C-V10.)
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
; directly: `postfix start-fg` forks a separate master process, so a signal to
; the supervised foreground process never reaches it (unlike OpenDKIM, which
; runs in the foreground as the supervised process itself). Instead the panel
; asks supervisord — over the group-accessible control socket — to run this
; program, which executes the canonical `postfix reload` as root. autostart is
; off (it only runs on demand) and a fast, clean exit is expected, so it never
; trips the crashexit listener.
[program:postfix-reload]
command=/usr/local/bin/postfix-reload.sh
command=/usr/sbin/postfix reload
autostart=false
autorestart=false
startsecs=0
@@ -147,9 +88,8 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; TLS-certificate refresh. Once a day it compares the certificate the
; reverse-proxy renews in the read-only mount with the copy Postfix reads, and
; when they differ copies it again and runs `postfix reload`.
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload`
; so certificates the reverse-proxy renews in the read-only mount are picked up.
; Long-running (it loops), runs as root so it can reload Postfix, and never exits
; non-zero, so it neither trips the crashexit listener nor needs restarting.
[program:cert-reload]
@@ -163,31 +103,23 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; Periodic logrotate for /data/log/mail.log (daily, 14 files kept). Runs as
; root so logrotate can read/rotate the log. Exits non-zero at once when the
; config is group/other-writable (logrotate would ignore it silently): with
; startsecs=1 that exit is too quick; once startretries are spent the program
; is FATAL, and Server > Health shows it. With startsecs=0 it was restarted
; every second for ever, RUNNING and EXITED in turn (supervisor 4.2.5,
; supervisor/process.py, Subprocess.finish(); assumptions C-V5). crashexit
; leaves this FATAL alone: logrotate is not on the mail path.
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
; in the image). Runs as root so logrotate can read/rotate the log. Exits
; non-zero when the config is group/other-writable (logrotate would ignore it
; silently); autorestart surfaces BACKOFF on the Status page.
[program:logrotate]
command=/usr/local/bin/logrotate-loop.sh
priority=400
autostart=true
autorestart=true
startsecs=1
startretries=3
startsecs=0
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; supervisor 4.2.5, supervisor/process.py: a program enters FATAL only from
; BACKOFF, once startretries are spent; a stop asked of supervisord ends in
; STOPPED (Subprocess.finish(), self.killing), which this listener never sees.
[eventlistener:crashexit]
command=/usr/local/bin/crashexit.py
events=PROCESS_STATE_FATAL,PROCESS_STATE_EXITED
events=PROCESS_STATE_FATAL
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
-34
View File
@@ -1,34 +0,0 @@
package main
import "testing"
// The panel reads the inbound filter's two variables with the semantics of
// build/postfix-config.sh: the action is accept unless it says tempfail, and a
// value the script would refuse is not guessed at.
func TestAntispamActionMirrorsTheBuildScript(t *testing.T) {
for raw, want := range map[string]string{
"": "accept", "accept": "accept", "tempfail": "tempfail",
"reject": "", "Accept": "", "TEMPFAIL": "", " accept": "",
} {
if got := antispamAction(raw); got != want {
t.Errorf("antispamAction(%q) = %q, want %q", raw, got, want)
}
}
}
func TestLoadConfigReadsTheInboundFilter(t *testing.T) {
t.Setenv("INBOUND_RELAY_ENABLE", "true")
t.Setenv("INBOUND_ANTISPAM_MILTER", "inet:antispam:11332")
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "tempfail")
cfg := loadConfig()
if !cfg.inboundEnabled || cfg.inboundAntispamMilter != "inet:antispam:11332" || cfg.inboundAntispamAction != "tempfail" {
t.Errorf("config = %+v", cfg)
}
t.Setenv("INBOUND_ANTISPAM_MILTER", "")
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "")
cfg = loadConfig()
if cfg.inboundAntispamMilter != "" || cfg.inboundAntispamAction != "accept" {
t.Errorf("unset filter: milter %q, action %q", cfg.inboundAntispamMilter, cfg.inboundAntispamAction)
}
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
)
func runDMARCIngestMode() error {
cfg := loadConfig()
if !cfg.dmarcEnabled {
return fmt.Errorf("dmarc ingest invoked but DMARC_REPORTS_ENABLE is not true")
}
st, err := store.Open(cfg.dbPath)
if err != nil {
return err
}
defer st.Close()
recipient := ingestRecipient(os.Args[1:])
if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil {
if !errors.Is(err, dmarc.ErrRejected) {
return err
}
// Not a report this relay keeps. It is dropped: deferring would have
// Postfix try it again for days, and a bounce would go to an envelope
// sender anyone can write - the report addresses are public. Postfix
// 3.7.11: exit 0 is PIPE_STAT_OK, logged "status=sent (delivered via
// ... service ...)" (src/global/pipe_command.c, pipe_command();
// src/pipe/pipe.c, eval_command_status()); a 4.x.x status defers until
// maximal_queue_lifetime and a 5.x.x one bounces, either way a notice
// to the sender (src/qmgr/qmgr_active.c, src/global/bounce.c).
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr)
}
logRejected(err)
}
return nil
}
// logRejected leaves the reason in the mail log. Postfix puts the start of
// what a delivery command wrote - about a hundred bytes - into its own line
// for the message ("status=sent (delivered via dmarc-ingest service (...))"),
// so the reason goes out first and bare: no prefix, no date, no recipient,
// all of which that line already has. Postfix 3.7.11, src/pipe/pipe.c,
// eval_command_status(): " (%.100s)" of the output, which
// src/global/pipe_command.c takes from stdout and stderr together. postlog is
// no use here: pipe(8) starts the command without the group that may write to
// Postfix's log socket (src/util/set_ugid.c, set_ugid(): setgroups() to the
// user's one group). Nor does postlog bring the group with it: upstream
// installs it set-gid, Debian's package does not - measured in the image,
// 2026-10-15: /usr/sbin/postlog is -rwxr-xr-x root:root (postfix
// 3.7.11-0+deb12u1), and dpkg-statoverride lists only postdrop and
// postqueue (assumptions A-43).
func logRejected(reason error) {
fmt.Fprintln(os.Stderr, "dropped: "+logSafe(strings.TrimPrefix(reason.Error(), dmarc.ErrRejected.Error()+": ")))
}
// logSafe makes a reason fit for a log line. Parts of it are the sender's own
// bytes - a header line, a file name inside an archive, a piece of XML - so it
// is cut short and anything that is not printable ASCII becomes "?", whatever
// Postfix would have done with it.
func logSafe(s string) string {
const max = 200
if len(s) > max {
s = s[:max]
}
return strings.Map(func(r rune) rune {
if r < 0x20 || r > 0x7e {
return '?'
}
return r
}, s)
}
// ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Postfix 3.7.11, src/pipe/pipe.c, deliver_message(): pipe_command() gets only
// CA_PIPE_CMD_EXPORT(export_environment), no extra environment;
// src/local/command.c sets "RECIPIENT".
// Without it the check that a report came to its own domain's address is
// skipped.
func ingestRecipient(args []string) string {
for _, a := range args {
if a == "-dmarc-ingest" {
continue
}
return strings.ToLower(strings.TrimSpace(a))
}
return ""
}
func isDMARCIngestInvocation() bool {
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
return true
}
return filepath.Base(os.Args[0]) == "dmarc-ingest"
}
-34
View File
@@ -1,34 +0,0 @@
package main
import "testing"
func TestIngestRecipient(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
// As pipe(8) starts it: the symlink name, then ${recipient}.
{[]string{"dmarc-reports+Example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest", "dmarc-reports+example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest"}, ""},
{nil, ""},
} {
if got := ingestRecipient(tc.args); got != tc.want {
t.Errorf("ingestRecipient(%q) = %q, want %q", tc.args, got, tc.want)
}
}
}
func TestLogSafe(t *testing.T) {
got := logSafe("zip entry \"a\nstatus=bounced\tx\" \x1b[31m é")
if want := "zip entry \"a?status=bounced?x\" ?[31m ?"; got != want {
t.Errorf("logSafe = %q, want %q", got, want)
}
long := make([]byte, 1000)
for i := range long {
long[i] = 'a'
}
if got := logSafe(string(long)); len(got) != 200 {
t.Errorf("logSafe kept %d bytes of 1000, want 200", len(got))
}
}
+112 -116
View File
@@ -1,138 +1,134 @@
package main
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
)
// The environment the image reads and the environment the documents describe
// are compared as they are, not as lists kept by hand beside them (audit
// 2026-10-12, stage 5 - the three lists this file held were compared with each
// other and with nothing else). Read: what the panel's code asks of the
// environment (cmd/panel and internal, tests aside) and what the start-up
// scripts take with a default (build/*.sh, "${VAR:-…}"). Documented: the
// operator's table in guide.md § Environment variables, with the paragraph
// under it, and the internal list in architecture.md § Configuration.
var (
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]{2,})`")
)
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
// documentedPublic matches docs/guide.md "Environment variables" table.
var documentedPublic = []string{
"SELFPOST_HOSTNAME",
"SUBMISSION_ENABLE",
"RATE_LIMIT_MESSAGES_PER_IP",
"RATE_LIMIT_WINDOW_SECONDS",
"SEND_LOG_RETENTION_DAYS",
"PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS",
"TRUSTED_PROXY_CIDR",
}
// envRead is every variable the code and the scripts read.
func envRead(t *testing.T) map[string]bool {
t.Helper()
keys := map[string]bool{}
for _, root := range []string{".", filepath.Join("..", "..", "internal")} {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
for _, m := range goEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
keys[m[1]] = true
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
scripts, err := filepath.Glob(filepath.Join("..", "..", "build", "*.sh"))
if err != nil || len(scripts) == 0 {
t.Fatalf("no build scripts: %v", err)
}
for _, path := range scripts {
for _, m := range scriptEnvRead.FindAllStringSubmatch(readFile(t, path), -1) {
keys[m[1]] = true
}
}
return keys
// documentedInternal matches architecture.md § Configuration "Internal env vars".
var documentedInternal = []string{
"SELFPOST_DATA_DIR",
"SELFPOST_DB_PATH",
"SELFPOST_SETUP_TOKEN_FILE",
"PANEL_HTTP_ADDR",
"JOURNAL_MILTER_SOCKET",
"MAIL_LOG",
"PANEL_COOKIE_SECURE",
"OPENDKIM_SOCKET",
"OPENDKIM_DIR",
"DKIM_SELECTOR_DEFAULT",
"SASL_DB_PATH",
"SASL_REALM",
"POSTFIX_DIR",
"POSTFIX_SENDER_LOGIN_MAPS",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
"MILTER_WAIT_TIMEOUT",
"TLS_RELOAD_INTERVAL_SECONDS",
"LOGROTATE_INTERVAL_SECONDS",
}
// section is the text of doc from the first line that starts with start up
// to the next that starts with any of ends.
func section(t *testing.T, doc, start string, ends ...string) string {
t.Helper()
i := strings.Index(doc, "\n"+start)
if i < 0 {
t.Fatalf("no %q", start)
}
rest := doc[i+1+len(start):]
end := len(rest)
for _, e := range ends {
if j := strings.Index(rest, "\n"+e); j >= 0 && j < end {
end = j
}
}
return rest[:end]
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
var documentedComposeFixed = []string{
"TLS_CERT_FILE",
"TLS_KEY_FILE",
}
// envDocumented is every variable the documents name, and where.
func envDocumented(t *testing.T) map[string]string {
t.Helper()
keys := map[string]string{}
guide := section(t, readFile(t, filepath.Join("..", "..", "docs", "guide.md")), "### Environment variables", "### ")
for _, m := range docEnvName.FindAllStringSubmatch(guide, -1) {
keys[m[1]] = "guide.md § Environment variables"
}
arch := section(t, readFile(t, filepath.Join("..", "..", "docs", "architecture.md")), "**Internal env vars.**", "### ", "## ", "**")
for _, m := range docEnvName.FindAllStringSubmatch(arch, -1) {
if _, ok := keys[m[1]]; !ok {
keys[m[1]] = "architecture.md § Configuration"
}
}
return keys
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
// Update together with loadConfig when adding a new key.
var loadConfigKeys = []string{
"SELFPOST_DATA_DIR",
"PANEL_HTTP_ADDR",
"JOURNAL_MILTER_SOCKET",
"MAIL_LOG",
"SEND_LOG_RETENTION_DAYS",
"SELFPOST_DB_PATH",
"SELFPOST_SETUP_TOKEN_FILE",
"SELFPOST_HOSTNAME",
"PANEL_COOKIE_SECURE",
"SUBMISSION_ENABLE",
"TRUSTED_PROXY_CIDR",
"PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS",
"TLS_CERT_FILE",
"OPENDKIM_SOCKET",
"OPENDKIM_DIR",
"DKIM_SELECTOR_DEFAULT",
"SASL_DB_PATH",
"SASL_REALM",
"POSTFIX_DIR",
}
func TestEnvironmentReadIsDocumented(t *testing.T) {
doc := envDocumented(t)
var missing []string
for key := range envRead(t) {
if _, ok := doc[key]; !ok {
missing = append(missing, key)
}
}
slices.Sort(missing)
for _, key := range missing {
t.Errorf("%s is read but documented nowhere: guide.md § Environment variables for an operator's setting, architecture.md § Configuration for an internal one", key)
}
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
var buildScriptKeys = []string{
"SELFPOST_HOSTNAME",
"TLS_CERT_FILE",
"TLS_KEY_FILE",
"RATE_LIMIT_MESSAGES_PER_IP",
"RATE_LIMIT_WINDOW_SECONDS",
"OPENDKIM_SOCKET",
"JOURNAL_MILTER_SOCKET",
"MAIL_LOG",
"POSTFIX_SENDER_LOGIN_MAPS",
"SASL_DB_PATH",
"SUBMISSION_ENABLE",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
"MILTER_WAIT_TIMEOUT",
"TLS_RELOAD_INTERVAL_SECONDS",
"LOGROTATE_INTERVAL_SECONDS",
}
func TestEnvironmentDocumentedIsRead(t *testing.T) {
read := envRead(t)
var stale []string
for key, where := range envDocumented(t) {
if !read[key] {
stale = append(stale, key+" ("+where+")")
}
}
slices.Sort(stale)
for _, key := range stale {
t.Errorf("%s is documented but nothing reads it", key)
}
func documentedKeys() []string {
keys := append([]string{}, documentedPublic...)
keys = append(keys, documentedInternal...)
keys = append(keys, documentedComposeFixed...)
slices.Sort(keys)
return slices.Compact(keys)
}
// The parsers find what they are for: a key of each kind is among the read.
func TestEnvironmentParsersFindKeys(t *testing.T) {
read := envRead(t)
for _, key := range []string{"SELFPOST_HOSTNAME", "PANEL_HTTP_ADDR", "MILTER_WAIT_TIMEOUT", "POSTFIX_QUEUE_DIR"} {
if !read[key] {
t.Errorf("%s not found among the keys read", key)
func TestLoadConfigKeysDocumented(t *testing.T) {
doc := documentedKeys()
for _, key := range loadConfigKeys {
if !slices.Contains(doc, key) {
t.Errorf("loadConfig reads %s but it is not listed in guide.md public, internal, or compose-fixed env docs", key)
}
}
}
func TestBuildScriptKeysDocumented(t *testing.T) {
doc := documentedKeys()
for _, key := range buildScriptKeys {
if !slices.Contains(doc, key) {
t.Errorf("build scripts read %s but it is not listed in guide.md env documentation", key)
}
}
}
func TestDocumentedKeysAreRead(t *testing.T) {
read := append([]string{}, loadConfigKeys...)
read = append(read, buildScriptKeys...)
slices.Sort(read)
read = slices.Compact(read)
for _, key := range documentedKeys() {
if !slices.Contains(read, key) {
t.Errorf("guide.md documents %s but no code in loadConfig or build scripts reads it", key)
}
}
}
+26 -91
View File
@@ -10,9 +10,7 @@ import (
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web"
@@ -21,15 +19,10 @@ import (
// mailStack is the panel's domain and application services plus the on-disk
// mail-path adapters they write through.
type mailStack struct {
Domains *domain.Service
Apps *app.Service
Inbound *inbound.Service
Lists *inbound.Lists
DMARC *dmarc.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool
dmarcEnabled bool
Domains *domain.Service
Apps *app.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
}
func newMailStack(cfg config, st *store.Store) *mailStack {
@@ -37,16 +30,7 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
// The panel's notifications are sent as postmaster@ the server's own name,
// and signed as it.
domains.SetServerName(cfg.hostname)
inb := inbound.NewService(st, pf)
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
return &mailStack{
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
Lists: inbound.NewLists(st, cfg.antispamDir),
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
}
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
}
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
@@ -58,21 +42,6 @@ func (m *mailStack) Resync() error {
if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err)
}
if m.inboundEnabled {
if err := m.Inbound.Resync(); err != nil {
return fmt.Errorf("inbound maps resync: %w", err)
}
// The filter's configuration names all six list files; they exist from
// the first start, empty until an entry is added.
if err := m.Lists.Sync(); err != nil {
return fmt.Errorf("filter lists resync: %w", err)
}
}
if m.dmarcEnabled {
if err := m.DMARC.Resync(); err != nil {
return fmt.Errorf("dmarc maps resync: %w", err)
}
}
return nil
}
@@ -97,63 +66,25 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
// the environment describes it, with nothing bound to a port yet.
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st)
// postfix-config.sh has already run (entrypoint). postconf -h is the
// effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md). Postfix 3.7.11,
// postconf(1): without -d or -n it shows main.cf's value or the default,
// and -h drops the "name = " label; values are not $-expanded without -x.
retryPolicy := postfix.LoadRetryPolicy()
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled,
MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir,
DBPath: cfg.dbPath,
DeployRoot: cfg.deployRoot,
Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile,
TLSServedCertFile: cfg.tlsServedCertFile,
TLSReloadInterval: cfg.tlsReloadInterval,
OpenDKIMSocket: cfg.opendkimSocket,
JournalSocket: cfg.journalSocket,
SenderSocket: cfg.senderSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled,
InboundAntispamMilter: cfg.inboundAntispamMilter,
InboundAntispamAction: cfg.inboundAntispamAction,
DMARCEnabled: cfg.dmarcEnabled,
SendLogRetentionEnvDefault: cfg.retentionDays,
InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays,
return web.New(st, ms.Domains, ms.Apps, web.Config{
Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled,
MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir,
DBPath: cfg.dbPath,
Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile,
OpenDKIMSocket: cfg.opendkimSocket,
JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
}, cfg.setupTokenPath)
}
// newHTTPServer bounds what a client may hold the panel's server for (audit
// 2026-10-12, L14): the request with its body - a form, or a domain export
// of 1 MiB at most - in 30 s; the answer in 60 s, the slowest page being one
// that asks DNS; an idle kept-alive connection 2 min. The full backup moves
// its own write deadline to two hours: it streams for as long as the archive
// takes (handlers.HandleBackup). net/http, Go 1.26: ReadTimeout covers the
// header and the body, WriteTimeout runs from the end of the header's read
// to the end of the response's write, IdleTimeout is the wait for the next
// request on a kept-alive connection.
func newHTTPServer(addr string, h http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
}
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
// the database handle shared by all roles: setup, login and the authenticated
// panel surface (security.md).
@@ -166,7 +97,11 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
return err
}
srv := newHTTPServer(cfg.httpAddr, srvApp.Handler())
srv := &http.Server{
Addr: cfg.httpAddr,
Handler: srvApp.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
// Shut the server down cleanly when the process is asked to stop.
go func() {
-25
View File
@@ -1,25 +0,0 @@
package main
import (
"testing"
"time"
)
// The panel's server bounds every phase a client could hold it in (audit
// 2026-10-12, L14); before, only the header's read was.
func TestHTTPServerHasItsTimeouts(t *testing.T) {
s := newHTTPServer(":0", nil)
for name, d := range map[string]time.Duration{
"ReadHeaderTimeout": s.ReadHeaderTimeout,
"ReadTimeout": s.ReadTimeout,
"WriteTimeout": s.WriteTimeout,
"IdleTimeout": s.IdleTimeout,
} {
if d <= 0 {
t.Errorf("%s is %v", name, d)
}
}
if s.ReadTimeout < s.ReadHeaderTimeout || s.WriteTimeout < 30*time.Second {
t.Errorf("read %v (header %v), write %v: too short for a form or a DNS page", s.ReadTimeout, s.ReadHeaderTimeout, s.WriteTimeout)
}
}
+10 -45
View File
@@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/store"
)
@@ -16,65 +15,31 @@ import (
// serveJournal opens the journal-milter Unix socket and runs the real milter
// (architecture.md § Mail path), recording accepted messages into the send
// log. Socket lifecycle (creation, stale cleanup, group permissions) lives
// in listenMilter; the protocol handler lives in internal/milter.
// here; the protocol handler lives in internal/milter.
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
ln, err := listenMilter(cfg.journalSocket)
if err != nil {
return err
}
log.Printf("journal-milter listening on %s", cfg.journalSocket)
return milter.Serve(ctx, ln, st)
}
// serveInboundJournal runs the inbound journal milter, the last milter on
// port 25 (internal/milter/inbound.go). It exists only while the inbound relay
// is on: build/postfix-config.sh attaches the socket under the same condition.
func serveInboundJournal(ctx context.Context, cfg config, st *store.Store) error {
ln, err := listenMilter(cfg.inboundJournalSocket)
if err != nil {
return err
}
// With a filter attached, the key it signs with. Without the key no
// verdict can be told from a sender's own, so none is read: the journal
// still records the mail, and the log says why it shows no verdicts.
key := ""
if cfg.inboundAntispamMilter != "" {
if key, err = inbound.FilterKey(cfg.antispamDir); err != nil {
log.Printf("inbound-journal-milter: no key for the filter's signature, verdicts will not be recorded: %v", err)
key = ""
}
}
log.Printf("inbound-journal-milter listening on %s", cfg.inboundJournalSocket)
return milter.ServeInbound(ctx, ln, st, key)
}
// listenMilter opens a milter's Unix socket where Postfix can reach it.
func listenMilter(socketPath string) (net.Listener, error) {
socketPath := cfg.journalSocket
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
return nil, err
return err
}
// Clear a stale socket left behind by an unclean shutdown, otherwise the
// listen below fails with "address already in use" (unix(7): the socket
// file "must be deleted by the caller when it is no longer needed"; bind(2)
// EADDRINUSE).
// listen below fails with "address already in use".
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
return err
}
ln, err := net.Listen("unix", socketPath)
if err != nil {
return nil, err
return err
}
// Postfix (user `postfix`) connects to this socket as a milter and needs
// group access. The socket inherits group `selfpost` from the setgid parent
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
// it (connecting to a Unix socket needs write permission on the node).
// unix(7): "connecting to a stream socket object requires write
// permission on that socket"; inode(7): a file made in a set-group-ID
// directory takes the directory's group.
if err := os.Chmod(socketPath, 0o660); err != nil {
ln.Close()
return nil, err
return err
}
return ln, nil
log.Printf("journal-milter listening on %s", socketPath)
return milter.Serve(ctx, ln, st)
}
+23 -204
View File
@@ -19,50 +19,22 @@ import (
"strings"
"sync"
"syscall"
"time"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/syslogrelay"
)
func main() {
if isDMARCIngestInvocation() {
log.SetFlags(log.LstdFlags | log.LUTC)
log.SetPrefix("dmarc-ingest: ")
if err := runDMARCIngestMode(); err != nil {
log.Printf("ingest failed: %v", err)
// EX_TEMPFAIL — ask Postfix to defer. Postfix 3.7.11,
// src/global/pipe_command.c, pipe_command(): a sysexits code maps
// to its DSN (src/global/sys_exits.c: 75 is "4.3.0") and a 4 defers,
// unless the output starts with a "D.S.N text" of its own - the
// log prefix above makes sure it does not.
os.Exit(75)
}
return
}
showVersion := flag.Bool("version", false, "print version and exit")
relay := flag.String("syslog-relay", "", "relay syslog(3) datagrams sent to this socket path to stdout, and do nothing else")
flag.Parse()
if *showVersion {
fmt.Println(buildinfo.Version)
return
}
if *relay != "" {
// The container's /dev/log, for OpenDKIM (internal/syslogrelay;
// build/supervisord.conf, [program:syslog-relay]).
if err := syslogrelay.Run(*relay, os.Stdout); err != nil {
log.Fatal(err)
}
return
}
log.SetFlags(log.LstdFlags | log.LUTC)
log.SetPrefix("panel: ")
@@ -77,8 +49,6 @@ func main() {
type config struct {
httpAddr string
journalSocket string
// The sender check's socket; the default mirrors build/postfix-config.sh.
senderSocket string
mailLog string
retentionDays int
@@ -101,11 +71,6 @@ type config struct {
// was configured with.
tlsCertFile string
opendkimSocket string
// tlsServedCertFile is the certificate Postfix serves (its copy of
// tlsCertFile), read from postconf at the start; tlsReloadInterval is
// how often build/postfix-cert-reload.sh looks for a renewal.
tlsServedCertFile string
tlsReloadInterval time.Duration
opendkimDir string
dkimSelectorDef string
@@ -113,23 +78,6 @@ type config struct {
saslDBPath string
saslRealm string
postfixDir string
deployRoot string
// Where the filter lists are written as map files for the external spam
// filter; the antispam compose fragment mounts it into the sidecar.
antispamDir string
inboundEnabled bool
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and
// what Postfix does when it is down. Read-only: build/postfix-config.sh is
// what attaches it; the panel only says so. See antispamAction.
inboundAntispamMilter string
inboundAntispamAction string
// The inbound journal milter's socket; the default mirrors
// build/postfix-config.sh. Served only while the inbound relay is on.
inboundJournalSocket string
// Retention of the inbound journal until Server > Settings says otherwise.
inboundSpamLogRetentionDays int
dmarcEnabled bool
}
func loadConfig() config {
@@ -137,7 +85,6 @@ func loadConfig() config {
return config{
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
senderSocket: envDefault("SENDER_CHECK_SOCKET", "/run/selfpost/sender.sock"),
// Postfix's delivery log, under /data so the lines that resolve a
// "queued" send-log row outlive the container. The default must match
// maillog_file in build/postfix-config.sh.
@@ -175,9 +122,8 @@ func loadConfig() config {
rateLimitMessagesPerIP: envInt("RATE_LIMIT_MESSAGES_PER_IP", 100),
rateLimitWindowSeconds: envInt("RATE_LIMIT_WINDOW_SECONDS", 3600),
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
tlsReloadInterval: time.Duration(envInt("TLS_RELOAD_INTERVAL_SECONDS", 86400)) * time.Second,
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
// Per-domain DKIM state (architecture.md § OpenDKIM). The directory layout
// matches what entrypoint.sh prepares (setgid, shared `selfpost` group).
@@ -192,45 +138,12 @@ func loadConfig() config {
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
saslRealm: saslRealm(),
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
antispamDir: envDefault("ANTISPAM_DIR", filepath.Join(dataDir, "antispam")),
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"),
inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")),
inboundJournalSocket: envDefault("INBOUND_JOURNAL_MILTER_SOCKET", "/run/selfpost/inbound-journal.sock"),
inboundSpamLogRetentionDays: envInt("INBOUND_SPAM_LOG_RETENTION_DAYS", store.InboundSpamLogRetentionDaysDefault),
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
}
}
// antispamAction reads INBOUND_ANTISPAM_MILTER_ACTION the way
// build/postfix-config.sh does: empty means accept, and accept and tempfail are
// the only other values it lets the container start with. Anything else is
// returned as "" — the panel says nothing about the action rather than guess.
func antispamAction(raw string) string {
switch raw {
case "", "accept":
return "accept"
case "tempfail":
return "tempfail"
}
return ""
}
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
// hostname Postfix's SASL layer uses so a client authenticating with a bare
// login resolves to the right account. Postfix 3.7.11,
// src/xsasl/xsasl_cyrus_server.c, xsasl_cyrus_server_create(): serverFQDN is
// $myhostname (= SELFPOST_HOSTNAME, build/postfix-config.sh) and user_realm
// NULL while smtpd_sasl_local_domain is empty; Cyrus SASL 2.1.28,
// plugins/sasldb.c, sasldb_auxprop_lookup(), and common/plugin_common.c,
// _plug_parseuser(): a login
// without "@" is then looked up in the realm serverFQDN. A SASL_REALM other
// than SELFPOST_HOSTNAME does not follow that - assumptions A-48.
// login resolves to the right account.
func saslRealm() string {
if r := os.Getenv("SASL_REALM"); r != "" {
return r
@@ -275,17 +188,10 @@ func parseTrustedProxies(raw string) []*net.IPNet {
continue
}
if !strings.Contains(cidr, "/") {
// The prefix length goes with how the address is written: an
// IPv4-mapped address written as IPv6 (::ffff:10.0.0.1) is a
// 128-bit address, and "/32" on it is ::/32 - every address
// whose first 32 bits are zero (::1 among them) trusted, and the
// host meant not at all (audit 2026-10-12, L4). Go 1.26
// net.IPNet.Contains compares an IPv4 client with the mapped
// /128 as with 10.0.0.1/32 (net/ip.go, networkNumberAndMask).
if strings.Contains(cidr, ":") {
cidr += "/128"
} else {
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
cidr += "/32"
} else {
cidr += "/128"
}
}
_, n, err := net.ParseCIDR(cidr)
@@ -298,37 +204,6 @@ func parseTrustedProxies(raw string) []*net.IPNet {
return nets
}
// openData opens the database the panel runs on. When a backup was unpacked
// into /data, the restore guard comes first (architecture.md § Persistence):
// the manifest's version must match this binary and the archive must have
// been unpacked whole before the database is touched, so neither schema skew
// nor half an archive can corrupt the restored state. That first start then
// runs resync, and only after it the manifest is consumed: a start that fails
// on the way leaves it, and the next start is a restore again (audit
// 2026-10-12, M1). Without a manifest it is an ordinary start.
func openData(cfg config, resync func(*store.Store) error) (st *store.Store, restored bool, err error) {
restored, err = backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
return nil, false, err
}
st, err = store.Open(cfg.dbPath)
if err != nil {
return nil, false, err
}
if restored {
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
if err := resync(st); err != nil {
_ = st.Close()
return nil, false, err
}
if err := backup.ConsumeRestore(cfg.manifestPath); err != nil {
_ = st.Close()
return nil, false, err
}
}
return st, restored, nil
}
// run starts the panel's three roles and blocks until a shutdown signal or the
// first fatal error from any role. A signal triggers a clean stop of all
// roles; a role error cancels the others and is returned so the process exits
@@ -336,74 +211,40 @@ func openData(cfg config, resync func(*store.Store) error) (st *store.Store, res
// Image and processes).
func run() error {
cfg := loadConfig()
// postfix-config.sh has written main.cf before supervisord started the
// panel (build/entrypoint.sh), and nothing rewrites it after.
cfg.tlsServedCertFile = health.ServedCertPath()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Printf("starting selfpost panel %s", buildinfo.Version)
// Restore version guard (architecture.md § Persistence): if a backup was
// extracted into /data, its manifest version must match this binary before we
// touch the database, so schema/format skew between versions cannot corrupt
// the restored state. A match consumes the manifest; its absence is the
// normal (non-restore) case.
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
return err
}
// One database handle shared by every role. The store serialises writes
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
// can all use it without stepping on each other under WAL.
st, restored, err := openData(cfg, func(st *store.Store) error {
return resyncAfterRestore(cfg, st, false)
})
st, err := store.Open(cfg.dbPath)
if err != nil {
return err
}
defer st.Close()
if !restored && cfg.dmarcEnabled {
ms := newMailStack(cfg, st)
ms.pf.SetReloadHook(func() error { return nil })
if err := ms.DMARC.Resync(); err != nil {
log.Printf("dmarc maps bootstrap: %v", err)
if restored {
log.Printf("restore manifest accepted; regenerating mail-path maps from SQLite")
if err := resyncAfterRestore(cfg, st, false); err != nil {
return err
}
}
// The filter lists as files, on every start: the spam filter's
// configuration names all six, and a data directory from before the lists
// existed has none. Not fatal - the lists are the filter's business, and
// the panel has mail to relay either way.
if cfg.inboundEnabled && !restored {
if err := inbound.NewLists(st, cfg.antispamDir).Sync(); err != nil {
log.Printf("filter lists bootstrap: %v", err)
}
}
// The key the filter signs with has to be in the shared directory before
// the filter reads it, whether or not a filter is attached yet.
if cfg.inboundEnabled {
if _, err := inbound.FilterKey(cfg.antispamDir); err != nil {
log.Printf("filter key bootstrap: %v", err)
}
}
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
return fmt.Errorf("bootstrap send-log retention: %w", err)
}
// With the inbound relay on, the tailer also writes the inbound journal's
// rows for refused mail and prunes that journal.
var inboundTail *logtail.Inbound
if cfg.inboundEnabled {
if err := st.EnsureInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays); err != nil {
return fmt.Errorf("bootstrap inbound journal retention: %w", err)
}
inboundTail = &logtail.Inbound{Store: st, Retention: func() int {
days, err := st.GetInboundSpamLogRetentionDays(cfg.inboundSpamLogRetentionDays)
if err != nil {
log.Printf("inbound journal retention: %v", err)
return cfg.inboundSpamLogRetentionDays
}
return days
}}
}
var wg sync.WaitGroup
// Room for every role to report a failure without blocking: seven at most.
errc := make(chan error, 7)
errc := make(chan error, 3)
roles := []struct {
name string
@@ -411,29 +252,7 @@ func run() error {
}{
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
{"sender-check", func(ctx context.Context) error { return serveSenderCheck(ctx, cfg, st) }},
{"log-tailer", func(ctx context.Context) error {
return logtail.Run(ctx, cfg.mailLog, st, func() int {
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
if err != nil {
log.Printf("send-log retention: %v", err)
return cfg.retentionDays
}
return days
}, inboundTail, &logtail.SenderCheck{Store: st, Socket: cfg.senderSocket})
}},
{"rate-limit-recalc", func(ctx context.Context) error {
runAutoRateLimitRecalc(ctx, cfg, st)
return nil
}},
{"notifier", func(ctx context.Context) error { return runNotifier(ctx, cfg, st) }},
}
if cfg.inboundEnabled {
roles = append(roles, struct {
name string
fn func(context.Context) error
}{"inbound-journal-milter", func(ctx context.Context) error { return serveInboundJournal(ctx, cfg, st) }})
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
}
for _, r := range roles {
-62
View File
@@ -1,62 +0,0 @@
package main
import (
"context"
"log"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/notify"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// runNotifier writes to the panel's users when something needs them
// (architecture.md § Notifications), until ctx is cancelled. It reads what the
// pages read - a domain's published records against the key this server signs
// with, the certificate file, Postfix's queue - with a DNS checker of its own,
// so that its hourly look is never a page's cached one.
func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
ms := newMailStack(cfg, st)
dns := dnscheck.New(cfg.dnsResolvers)
// OpenDKIM's tables are rebuilt when a domain is added or deleted. On the
// first start of a version that signs the server's own name they do not
// have its line yet. A failure here costs the signature, not the message:
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, mlfi_eoh(): with no
// SigningTable match and Mode s (build/opendkim.conf) the message is
// accepted unsigned. A line whose key the KeyTable lacks or that does not
// load is another matter - dkimf_apply_signtable() < 0 there answers
// SMFIS_TEMPFAIL (assumptions A-47).
if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil {
log.Printf("notifier: sign as %s: %v", cfg.hostname, err)
} else if rebuilt {
log.Printf("notifier: OpenDKIM now signs mail of %s", cfg.hostname)
}
n := notify.New(st, notify.Checks{
Domain: func(d store.Domain) (dnscheck.Domain, error) {
rec, err := ms.Domains.DKIMRecord(d)
if err != nil {
return dnscheck.Domain{}, err
}
// The server's own addresses are cached for a minute: one lookup
// for a pass over all the domains.
srv := dns.Server(cfg.hostname, false)
return dns.Domain(dnscheck.Query{
Name: d.Name,
Selector: d.DKIMSelector,
ExpectedDKIM: rec.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
DMARCReportEmail: d.DMARCRua,
}, true), nil
},
Certificate: func() health.Certificate {
return health.CheckServedCertificate(cfg.tlsServedCertFile, cfg.tlsCertFile, cfg.tlsReloadInterval, time.Now())
},
Queue: postfix.QueueMessages,
}, notify.NewMailer(cfg.hostname), cfg.hostname)
return n.Run(ctx)
}
-36
View File
@@ -1,36 +0,0 @@
package main
import (
"net"
"testing"
)
// A bare address is one host however it is written; an IPv4-mapped one in
// IPv6 notation used to become ::/32 (audit 2026-10-12, L4).
func TestParseTrustedProxiesTakesABareAddressAsOneHost(t *testing.T) {
for _, c := range []struct {
entry string
trusts []string
refuses []string
}{
{"10.0.0.1", []string{"10.0.0.1"}, []string{"10.0.0.2"}},
{"::ffff:10.0.0.1", []string{"10.0.0.1", "::ffff:10.0.0.1"}, []string{"10.0.0.2", "::1", "0:0:1::1"}},
{"2001:db8::1", []string{"2001:db8::1"}, []string{"2001:db8::2"}},
{"10.0.0.0/8", []string{"10.1.2.3"}, []string{"11.0.0.1"}},
} {
nets := parseTrustedProxies(c.entry)
if len(nets) != 1 {
t.Fatalf("%q parsed as %v", c.entry, nets)
}
for _, ip := range c.trusts {
if !nets[0].Contains(net.ParseIP(ip)) {
t.Errorf("%q (%v) does not trust %s", c.entry, nets[0], ip)
}
}
for _, ip := range c.refuses {
if nets[0].Contains(net.ParseIP(ip)) {
t.Errorf("%q (%v) trusts %s", c.entry, nets[0], ip)
}
}
}
}
-49
View File
@@ -1,49 +0,0 @@
package main
import (
"context"
"log"
"time"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/store"
)
// runAutoRateLimitRecalc recomputes auto-mode level-2 limits on the same
// interval as send-log retention pruning (6 hours).
func runAutoRateLimitRecalc(ctx context.Context, cfg config, st *store.Store) {
recalc := func() {
retention := cfg.retentionDays
if days, err := st.GetSendLogRetentionDays(cfg.retentionDays); err == nil {
retention = days
}
l1Max := cfg.rateLimitMessagesPerIP
if l1Max <= 0 {
l1Max = 100
}
l1Window := cfg.rateLimitWindowSeconds
if l1Window <= 0 {
l1Window = 3600
}
n, err := st.RecalcAllAutoRateLimits(retention, l1Max, l1Window)
if err != nil {
log.Printf("auto rate-limit recalc: %v", err)
return
}
if n > 0 {
log.Printf("auto rate-limit recalc: updated %d limit(s)", n)
}
}
recalc()
t := time.NewTicker(logtail.RetentionInterval())
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
recalc()
}
}
}
-184
View File
@@ -1,184 +0,0 @@
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/store"
)
// A restore that goes wrong half way must leave the operator where they
// started: the archive is refused before the database is opened, and a start
// that fails after the guard is a restore again the next time (audit
// 2026-10-12, M1 and stage 3).
// A start that fails after the guard - here its Resync - leaves the manifest,
// so the next start is a restore again and runs the Resync the failed one did
// not finish. The failed start had the database open, so its files are no
// longer the archive's; the next start does not compare them again.
func TestARestoreStartThatFailsIsARestoreAgain(t *testing.T) {
target := t.TempDir()
extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target)
broken := errors.New("postfix reload failed")
if _, _, err := openData(cfg, func(*store.Store) error { return broken }); !errors.Is(err, broken) {
t.Fatalf("a start whose Resync failed = %v, want that failure", err)
}
if _, err := os.Stat(cfg.manifestPath); err != nil {
t.Fatalf("the failed start consumed the manifest: %v", err)
}
mapPath := filepath.Join(cfg.dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
t.Fatalf("write stale map: %v", err)
}
st, restored, err := openData(cfg, testResync(cfg))
if err != nil {
t.Fatalf("the start after a failed one: %v", err)
}
st.Close()
if !restored {
t.Fatal("the start after a failed one did not take the data directory for a restore")
}
if got, _ := os.ReadFile(mapPath); string(got) != "@"+restoreDomain+" shop\n" {
t.Errorf("sender map = %q: the second start did not run the Resync", got)
}
for _, name := range []string{backup.ManifestName, backup.ContentsName, "contents.checked.json"} {
if _, err := os.Stat(filepath.Join(cfg.dataDir, name)); !os.IsNotExist(err) {
t.Errorf("%s is still there after the restore start succeeded (stat err = %v)", name, err)
}
}
st, restored, err = openData(cfg, func(*store.Store) error {
t.Error("an ordinary start ran the restore Resync")
return nil
})
if err != nil {
t.Fatalf("the ordinary start after the restore: %v", err)
}
st.Close()
if restored {
t.Error("the start after a finished restore took it for a restore again")
}
}
// An archive unpacked in part - a download cut short, a tar stopped by a
// damaged block - is refused before the database is opened, with a message
// that says so, and nothing in the directory is changed: the database the
// archive left is as it left it, and the manifest is there for the next try.
func TestAnArchiveUnpackedInPartIsRefused(t *testing.T) {
archive := archiveOf(t, seedPanelProject(t))
dbSize := entrySize(t, archive, backup.DataArchivePrefix+"selfpost.db")
for _, c := range []struct {
name string
cut string
at int64
}{
{"in the database", backup.DataArchivePrefix + "selfpost.db", dbSize / 2},
{"before the deploy files", backup.ComposeFileName, 0},
{"in its last entry", backup.DataArchivePrefix + backup.ContentsName, 10},
} {
t.Run(c.name, func(t *testing.T) {
target := t.TempDir()
extractCut(t, archive, target, c.cut, c.at)
cfg := panelConfig(t, target)
before, err := os.ReadFile(cfg.dbPath)
if err != nil {
t.Fatalf("the cut archive left no database: %v", err)
}
_, _, err = openData(cfg, func(*store.Store) error {
t.Error("a refused restore ran its Resync")
return nil
})
if err == nil || !strings.Contains(err.Error(), "not unpacked whole") {
t.Fatalf("the start on an archive cut %s = %v, want it refused as unpacked in part", c.name, err)
}
if after, _ := os.ReadFile(cfg.dbPath); !bytes.Equal(before, after) {
t.Error("the refused start changed the database")
}
for _, side := range []string{"-wal", "-shm", "-journal"} {
if _, err := os.Stat(cfg.dbPath + side); !os.IsNotExist(err) {
t.Errorf("selfpost.db%s exists: the refused start opened the database", side)
}
}
if _, err := os.Stat(cfg.manifestPath); err != nil {
t.Errorf("the refused start removed the manifest: %v", err)
}
})
}
}
// A file the archive holds and the data directory holds otherwise - damaged
// on the way, or unpacked from another archive - is refused by its name.
func TestADamagedRestoredFileIsRefusedByName(t *testing.T) {
target := t.TempDir()
extract(t, archiveOf(t, seedPanelProject(t)), target)
cfg := panelConfig(t, target)
key := filepath.Join(cfg.dataDir, "opendkim", "keys", restoreDomain, "selfpost.private")
f, err := os.OpenFile(key, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
t.Fatalf("open key: %v", err)
}
f.WriteString("x")
f.Close()
_, _, err = openData(cfg, func(*store.Store) error {
t.Error("a refused restore ran its Resync")
return nil
})
want := "data/opendkim/keys/" + restoreDomain + "/selfpost.private"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("the start on a damaged key = %v, want a refusal naming %s", err, want)
}
}
// archiveOf takes a full backup of a seeded project for this binary's version.
func archiveOf(t *testing.T, deployRoot string) []byte {
t.Helper()
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: buildinfo.Version,
DeployRoot: deployRoot,
}); err != nil {
t.Fatalf("create backup: %v", err)
}
return archive.Bytes()
}
// entrySize is the size of the archive's entry name.
func entrySize(t *testing.T, archive []byte, name string) int64 {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("gzip: %v", err)
}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
t.Fatalf("the archive has no %s", name)
}
if err != nil {
t.Fatalf("read the archive: %v", err)
}
if hdr.Name == name {
return hdr.Size
}
}
}
+58 -115
View File
@@ -22,13 +22,12 @@ import (
)
// Restoring a SelfPost backup is not a code path in the panel: the operator
// extracts the archive into a project directory and starts the image, and the
// extracts the archive into the /data bind mount and starts the image, and the
// panel is expected to come up on it (architecture.md § Persistence). Nothing
// below stubs that story out — the archive is downloaded from a running panel
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
// is started on the result through the same startup sequence run() uses:
// openData (CheckRestore, store.Open, Resync, ConsumeRestore), newPanel,
// Start.
// CheckRestore, store.Open, newPanel, Start.
const (
restorePassword = "correct-horse-battery"
@@ -38,10 +37,9 @@ const (
// restored is the outcome of a full backup-and-restore round trip.
type restored struct {
panel http.Handler // panel booted on the restored data directory
deployRoot string // the restored project directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
panel http.Handler // panel booted on the restored data directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
}
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
@@ -52,7 +50,7 @@ type restored struct {
func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper()
live := seedPanelProject(t)
live := seedPanelData(t)
panel := bootPanel(t, live)
session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password)
@@ -60,12 +58,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
target := t.TempDir()
extract(t, archive, target)
return restored{
panel: bootPanel(t, target),
deployRoot: target,
dataDir: filepath.Join(target, "data"),
session: session,
}
return restored{panel: bootPanel(t, target), dataDir: target, session: session}
}
// The panel has to come up on the restored directory and show the state that
@@ -75,7 +68,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "")
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
@@ -98,12 +91,6 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
t.Errorf("the restored project directory has no %s: %v", name, err)
}
}
}
// The one-time setup link is closed by the presence of a panel user, and the
@@ -129,7 +116,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "")
rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
}
@@ -142,7 +129,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password")
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
}
@@ -151,9 +138,8 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
// drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
cfg := panelConfig(t, deployRoot)
dataDir := seedPanelData(t)
cfg := panelConfig(t, dataDir)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
@@ -172,17 +158,22 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
t.Fatalf("write manifest: %v", err)
}
// A record of no files: this test is about the maps, and the files it
// seeded were never in an archive.
writeContents(t, dataDir, backup.Contents{Format: backup.FormatContents})
st, restored, err := openData(cfg, testResync(cfg))
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("open the restored data: %v", err)
t.Fatalf("CheckRestore: %v", err)
}
if !restored {
t.Fatal("CheckRestore did not report a restore")
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
if !restored {
t.Fatal("the start did not take the data directory for a restore")
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
got, err := os.ReadFile(mapPath)
@@ -200,14 +191,12 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
// stays put on a mismatch: the operator's next move is to start the image the
// backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
live := seedPanelData(t)
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: "9.9.9",
DeployRoot: deployRoot,
DataDir: live,
DBPath: filepath.Join(live, "selfpost.db"),
Version: "9.9.9",
}); err != nil {
t.Fatalf("create backup: %v", err)
}
@@ -229,15 +218,13 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
}
}
// seedPanelProject builds an operator project tree: data/ with a panel that has
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
func seedPanelProject(t *testing.T) string {
// seedPanelData builds the /data tree of a panel that has been in use: an
// administrator, a sending domain with an application and one logged message,
// and the daemon state the mail path needs (a DKIM key, the SASL database and
// Postfix's sender map).
func seedPanelData(t *testing.T) string {
t.Helper()
deployRoot := t.TempDir()
dataDir := filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
dataDir := t.TempDir()
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
@@ -281,33 +268,16 @@ func seedPanelProject(t *testing.T) string {
t.Fatalf("write %s: %v", full, err)
}
}
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
return deployRoot
return dataDir
}
func writeDeployFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// panelConfig resolves the panel's own configuration for a project directory, so
// panelConfig resolves the panel's own configuration for a data directory, so
// the test finds the files where the running binary would look for them rather
// than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, deployRoot string) config {
func panelConfig(t *testing.T, dataDir string) config {
t.Helper()
dataDir := filepath.Join(deployRoot, "data")
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data
@@ -318,21 +288,32 @@ func panelConfig(t *testing.T, deployRoot string) config {
// bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler.
func bootPanel(t *testing.T, deployRoot string) http.Handler {
func bootPanel(t *testing.T, dataDir string) http.Handler {
t.Helper()
cfg := panelConfig(t, deployRoot)
cfg := panelConfig(t, dataDir)
st, restored, err := openData(cfg, testResync(cfg))
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
}
t.Cleanup(func() { _ = st.Close() })
if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil {
t.Errorf("the restore manifest was not consumed, so the next start is gated by it too")
}
}
st, err := store.Open(cfg.dbPath)
if err != nil {
t.Fatalf("open the restored database: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if restored {
if err := resyncAfterRestore(cfg, st, true); err != nil {
t.Fatalf("resync after restore: %v", err)
}
}
panel, err := newPanel(cfg, st)
if err != nil {
t.Fatalf("build the panel: %v", err)
@@ -369,7 +350,7 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
form.Set("password", password)
form.Set("password_confirm", password)
}
rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
}
@@ -399,18 +380,9 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
return plain
}
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
// directory before the image is started.
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
// bind mount before the image is started.
func extract(t *testing.T, archive []byte, dir string) {
t.Helper()
extractCut(t, archive, dir, "", 0)
}
// extractCut unpacks the archive as extract does, except that the stream ends
// cutAt bytes into the entry named cut: that entry is written as far as the
// stream went and nothing after it is, which is what tar leaves of a download
// cut short or a damaged block. An empty cut unpacks the whole archive.
func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
@@ -420,9 +392,6 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
for {
hdr, err := tr.Next()
if err == io.EOF {
if cut != "" {
t.Fatalf("the archive has no entry %s to cut", cut)
}
break
}
if err != nil {
@@ -433,10 +402,6 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
t.Fatalf("the archive escapes the directory it is extracted into: %q", hdr.Name)
}
path := filepath.Join(dir, name)
var body io.Reader = tr
if hdr.Name == cut {
body = io.LimitReader(tr, cutAt)
}
switch hdr.Typeflag {
case tar.TypeDir:
@@ -451,7 +416,7 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
if err != nil {
t.Fatalf("create %s: %v", path, err)
}
if _, err := io.Copy(f, body); err != nil {
if _, err := io.Copy(f, tr); err != nil {
f.Close()
t.Fatalf("write %s: %v", path, err)
}
@@ -459,28 +424,6 @@ func extractCut(t *testing.T, archive []byte, dir, cut string, cutAt int64) {
t.Fatalf("close %s: %v", path, err)
}
}
if hdr.Name == cut {
return
}
}
}
// testResync is the Resync a restore start runs, without reloading daemons
// the test does not have.
func testResync(cfg config) func(*store.Store) error {
return func(st *store.Store) error { return resyncAfterRestore(cfg, st, true) }
}
// writeContents writes the record of an archive's files into dataDir, as the
// archive's last entry would have.
func writeContents(t *testing.T, dataDir string, c backup.Contents) {
t.Helper()
body, err := json.Marshal(c)
if err != nil {
t.Fatalf("marshal contents: %v", err)
}
if err := os.WriteFile(filepath.Join(dataDir, backup.ContentsName), body, 0o600); err != nil {
t.Fatalf("write contents: %v", err)
}
}
-196
View File
@@ -1,196 +0,0 @@
package main
import (
"context"
"log"
"os"
"time"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// aliveEvery is how often the sender check marks itself alive. The gap a
// restart finds since the last mark is the check's downtime, so the mark's
// age is the error of that figure.
const aliveEvery = 2 * time.Second
// serveSenderCheck runs the sender check (internal/milter/sender.go) on a
// socket of its own. Postfix attaches that socket strictly
// (default_action=tempfail in build/postfix-config.sh; Postfix 3.7.11,
// src/milter/milter8.c, milter8_comm_error(): "451 4.7.1 Service unavailable -
// try again later"), so while this role is not serving, mail is deferred:
// what that costs is counted here and
// by the log tailer, and shown on Server > Health
// (docs/plans/from-header-binding.md § Statistics).
func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
ln, err := listenMilter(cfg.senderSocket)
if err != nil {
return err
}
log.Printf("sender-check listening on %s", cfg.senderSocket)
// Statistics are the database's business and supervisord's, and the check
// is neither: they are kept beside it, and nothing of them is waited for
// before it serves.
now := time.Now()
mark := cfg.senderSocket + ".alive"
// The mark's age is read before the mark is kept again: asking supervisord
// may take half a minute, and the mark must not go stale meanwhile, or a
// restart in that half minute would count it as downtime.
var markAt time.Time
if fi, err := os.Stat(mark); err == nil {
markAt = fi.ModTime()
}
go keepAlive(ctx, mark, st)
go func() {
if err := st.StartSenderCheckStats(now); err != nil {
log.Printf("sender-check: statistics: %v", err)
}
if down, estimated := senderCheckDowntime(markAt, now, postfixStarted); down > 0 {
if estimated {
log.Printf("sender-check: was not answering for at most %s (supervisord did not say since when Postfix ran)", down.Round(time.Second))
} else {
log.Printf("sender-check: was not answering for %s while Postfix was running", down.Round(time.Second))
}
for _, part := range byDay(now.Add(-down), now) {
if err := st.AddSenderCheckDown(part.day, part.down, estimated); err != nil {
log.Printf("sender-check: statistics: %v", err)
}
}
}
}()
// The two tables the mail path itself goes by: whom Postfix lets send as
// what, and what OpenDKIM signs.
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
return milter.ServeSender(ctx, ln, bind, st)
}
// postfixState is what supervisord said about Postfix: since when it runs, if
// it does; known is false when supervisord did not answer.
type postfixState struct {
started time.Time
running, known bool
}
// senderCheckDowntime is for how long the check was not serving before now
// while Postfix was: the time since the mark was last set (markAt; zero
// when there is none), or since
// Postfix started if that was later. No mark means the container is new, and
// a Postfix that is not running means mail was stopped for another reason -
// a rollout, a reboot - and neither is the check's downtime. When supervisord
// could not say, the time since the mark is taken and estimated is true: an
// upper bound, since Postfix may not have been running all of it (audit
// 2026-10-12, stage 2b - until then such a start counted nothing).
func senderCheckDowntime(markAt, now time.Time, ask func() postfixState) (down time.Duration, estimated bool) {
if markAt.IsZero() {
return 0, false
}
from := markAt
p := ask()
switch {
case !p.known:
estimated = true
case !p.running:
return 0, false
case p.started.After(from):
from = p.started
}
if d := now.Sub(from); d > 0 {
return d, estimated
}
return 0, false
}
// dayPart is the part of a stretch of downtime that fell on one UTC day.
type dayPart struct {
day time.Time
down time.Duration
}
// byDay cuts the time from 'from' to 'to' at the UTC midnights between them,
// so that a night's outage is not all booked on the morning it ended.
func byDay(from, to time.Time) []dayPart {
from, to = from.UTC(), to.UTC()
var parts []dayPart
for from.Before(to) {
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
if next.After(to) {
next = to
}
parts = append(parts, dayPart{day: from, down: next.Sub(from)})
from = next
}
return parts
}
// postfixAskFor is how long postfixStarted keeps asking a supervisord that
// does not answer - at the container's start it may not be listening yet.
var postfixAskFor, postfixAskEvery = 30 * time.Second, 2 * time.Second
// postfixStarted asks supervisord since when Postfix has been running, again
// and again for postfixAskFor while it does not answer. supervisor 4.2.5,
// supervisor/rpcinterface.py, _interpretProcessInfo(): the uptime runs from
// the spawn of program "postfix", which is build/postfix-wrapper.sh; it waits
// for the milter sockets (up to MILTER_WAIT_TIMEOUT) before it execs
// "postfix start-fg", so the time given is up to that much early
// (assumptions A-46). For startsecs - that wait plus ten seconds
// (build/supervisord.conf) - after its start the program is STARTING, not
// RUNNING, and is taken here as not running: a panel restart within that
// time after Postfix's own counts no downtime.
func postfixStarted() postfixState {
deadline := time.Now().Add(postfixAskFor)
for {
procs, err := health.Processes()
if err == nil {
for _, p := range procs {
if p.Name != "postfix" || p.State != "RUNNING" {
continue
}
if up, ok := health.Uptime(p.Detail); ok {
return postfixState{started: time.Now().Add(-up), running: true, known: true}
}
}
return postfixState{known: true}
}
if time.Now().After(deadline) {
// Said aloud: this start's downtime is then an estimate.
log.Printf("sender-check: statistics: when Postfix started is not known: %v", err)
return postfixState{}
}
time.Sleep(postfixAskEvery)
}
}
// keepAlive sets the mark until ctx ends, and once a day drops the days of
// the statistics that are older than a year.
func keepAlive(ctx context.Context, path string, st *store.Store) {
touch := func() {
now := time.Now()
if err := os.Chtimes(path, now, now); err != nil {
if err := os.WriteFile(path, nil, 0o644); err != nil {
log.Printf("sender-check: alive mark: %v", err)
}
}
}
touch()
tick := time.NewTicker(aliveEvery)
defer tick.Stop()
prune := time.NewTicker(24 * time.Hour)
defer prune.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
touch()
case <-prune.C:
if err := st.PruneSenderCheckDays(time.Now()); err != nil {
log.Printf("sender-check: statistics: %v", err)
}
}
}
}
-64
View File
@@ -1,64 +0,0 @@
package main
import (
"testing"
"time"
)
// The check's downtime is the time since its last mark, under a Postfix that
// was running: a new container has no mark, and a Postfix that is not up, or
// came up later than the mark, was not kept waiting by the check for all of
// that time.
func TestSenderCheckDowntime(t *testing.T) {
now := time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC)
var mark time.Time // the alive mark's time; zero is no mark
set := func(at time.Time) { mark = at }
postfix := func(started time.Time, running bool) func() postfixState {
return func() postfixState { return postfixState{started: started, running: running, known: true} }
}
silent := func() postfixState { return postfixState{} }
down := func(ask func() postfixState) (time.Duration, bool) { return senderCheckDowntime(mark, now, ask) }
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
t.Errorf("no mark: %s", d)
}
set(now.Add(-40 * time.Second))
if d, est := down(postfix(now.Add(-time.Hour), true)); d != 40*time.Second || est {
t.Errorf("Postfix up throughout: %s %v, want 40s, not estimated", d, est)
}
if d, _ := down(postfix(now.Add(-10*time.Second), true)); d != 10*time.Second {
t.Errorf("Postfix started within the gap: %s, want 10s", d)
}
if d, _ := down(postfix(time.Time{}, false)); d != 0 {
t.Errorf("Postfix not running: %s", d)
}
// supervisord did not answer: the time since the mark, an estimate.
if d, est := down(silent); d != 40*time.Second || !est {
t.Errorf("supervisord silent: %s %v, want 40s, estimated", d, est)
}
set(now.Add(time.Minute)) // a clock set back
if d, _ := down(postfix(now.Add(-time.Hour), true)); d != 0 {
t.Errorf("a mark in the future: %s", d)
}
}
// Downtime is booked on the days it fell on.
func TestByDay(t *testing.T) {
at := func(d, h, m int) time.Time { return time.Date(2026, 10, d, h, m, 0, 0, time.UTC) }
got := byDay(at(12, 23, 50), at(14, 0, 10))
want := []dayPart{{at(12, 23, 50), 10 * time.Minute}, {at(13, 0, 0), 24 * time.Hour}, {at(14, 0, 0), 10 * time.Minute}}
if len(got) != len(want) {
t.Fatalf("parts = %+v", got)
}
for i := range want {
if !got[i].day.Equal(want[i].day) || got[i].down != want[i].down {
t.Errorf("part %d = %+v, want %+v", i, got[i], want[i])
}
}
if one := byDay(at(12, 10, 0), at(12, 10, 0).Add(40*time.Second)); len(one) != 1 || one[0].down != 40*time.Second {
t.Errorf("within a day: %+v", one)
}
if none := byDay(at(12, 10, 0), at(12, 10, 0)); len(none) != 0 {
t.Errorf("no time: %+v", none)
}
}
+19 -93
View File
@@ -6,9 +6,9 @@
//
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
//
// Use -o to write to a file instead. The resulting archive is self-contained:
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
// secret (architecture.md § Persistence).
// Use -o to write to a file instead. The resulting archive contains DKIM private
// keys, the admin password hash and SASL credentials — treat it as a secret
// (architecture.md § Persistence).
//
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
// argument, which would show up in the process list) the archive is written as
@@ -21,21 +21,16 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/mixeme/selfpost/internal/atomicfile"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/web/validate"
)
// passwordEnv names the environment variable holding the encryption password.
@@ -75,20 +70,13 @@ func run(outPath, password string) error {
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
// The floor the panel holds the same files to: an encrypted backup is
// attacked offline, at leisure (security.md; audit 2026-10-12, M5). An
// empty password is no encryption, by design.
if password != "" && utf8.RuneCountInString(password) < validate.MinSecretFilePasswordLen {
return fmt.Errorf("the encryption password must be at least %d characters", validate.MinSecretFilePasswordLen)
}
w, err := openOutput(outPath)
w, closeOut, err := openOutput(outPath)
if err != nil {
return err
}
defer w.abort()
defer closeOut()
var sink io.Writer = w
sink := w
var env *secretfile.Writer
if password != "" {
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
@@ -99,13 +87,9 @@ func run(outPath, password string) error {
}
if err := backup.Create(sink, backup.Params{
DataDir: dataDir,
DBPath: dbPath,
Version: buildinfo.Version,
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
OnWarn: func(msg string) {
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
},
DataDir: dataDir,
DBPath: dbPath,
Version: buildinfo.Version,
}); err != nil {
return err
}
@@ -114,9 +98,6 @@ func run(outPath, password string) error {
return err
}
}
if err := w.commit(); err != nil {
return err
}
if outPath != "" {
kind := "plain"
if password != "" {
@@ -148,28 +129,17 @@ func runDecrypt(inPath, outPath, password string) error {
if err != nil {
return err
}
// The first chunk is opened before anything is written: a wrong password
// is told before the output is touched, and the envelope's type is
// authenticated only once a chunk opens (secretfile.Reader.Type; audit
// 2026-10-12, L1).
plain := bufio.NewReaderSize(env, 64<<10)
if _, err := plain.Peek(1); err != nil && !errors.Is(err, io.EOF) {
return err
}
if env.Type() != secretfile.TypeFullBackup {
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
}
w, err := openOutput(outPath)
w, closeOut, err := openOutput(outPath)
if err != nil {
return err
}
defer w.abort()
defer closeOut()
if _, err := io.Copy(w, plain); err != nil {
return err
}
if err := w.commit(); err != nil {
if _, err := io.Copy(w, env); err != nil {
return err
}
if outPath != "" {
@@ -178,61 +148,17 @@ func runDecrypt(inPath, outPath, password string) error {
return nil
}
// output is where the CLI writes: stdout, or a file that takes its name only
// once it is whole. A backup or a decryption that fails half way - a wrong
// password, an envelope cut short - leaves whatever had the name before, and
// no half-written file under it (audit 2026-10-12, L1 and stage 3). The file
// is the owner's alone (os.CreateTemp makes it 0600): plain and encrypted
// backups are secret enough to keep off other users' eyes.
type output struct {
io.Writer
tmp *os.File // nil for stdout
path string
}
func openOutput(outPath string) (*output, error) {
// openOutput returns stdout, or a freshly created owner-only file: both plain
// and encrypted backups are secret enough to keep off other users' eyes.
func openOutput(outPath string) (io.Writer, func(), error) {
if outPath == "" {
return &output{Writer: os.Stdout}, nil
return os.Stdout, func() {}, nil
}
f, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*")
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return nil, err
return nil, nil, err
}
return &output{Writer: f, tmp: f, path: outPath}, nil
}
// commit puts the whole file under its name.
func (o *output) commit() error {
if o.tmp == nil {
return nil
}
f := o.tmp
o.tmp = nil
if err := f.Sync(); err != nil {
f.Close()
os.Remove(f.Name())
return err
}
if err := f.Close(); err != nil {
os.Remove(f.Name())
return err
}
if err := os.Rename(f.Name(), o.path); err != nil {
os.Remove(f.Name())
return err
}
_ = atomicfile.SyncDir(filepath.Dir(o.path))
return nil
}
// abort removes a file that was not committed; after commit it does nothing.
func (o *output) abort() {
if o.tmp == nil {
return
}
o.tmp.Close()
os.Remove(o.tmp.Name())
o.tmp = nil
return f, func() { _ = f.Close() }, nil
}
// readPassword takes the password from the given file (first line) or, when no
+7 -105
View File
@@ -9,18 +9,13 @@ import (
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/store"
)
// seedProject builds the minimum operator project tree a backup can be taken from.
func seedProject(t *testing.T) (deployRoot, dataDir string) {
// seedDataDir builds the minimum /data tree a backup can be taken from.
func seedDataDir(t *testing.T) string {
t.Helper()
deployRoot = t.TempDir()
dataDir = filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
dataDir := t.TempDir()
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
t.Fatalf("open store: %v", err)
@@ -31,31 +26,16 @@ func seedProject(t *testing.T) (deployRoot, dataDir string) {
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
return deployRoot, dataDir
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return dataDir
}
// An encrypted backup is only worth having if the container it came from can
// hand it back as an ordinary archive during a restore, so the two halves of
// the CLI are tested as the one round trip an operator actually performs.
func TestEncryptedBackupRoundTrip(t *testing.T) {
seedProject(t)
seedDataDir(t)
dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk")
plain := filepath.Join(dir, "backup.tar.gz")
@@ -101,12 +81,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
}
names[hdr.Name] = true
}
for _, want := range []string{
backup.DataArchivePrefix + backup.ManifestName,
backup.DataArchivePrefix + "selfpost.db",
backup.ComposeFileName,
backup.EnvFileName,
} {
for _, want := range []string{"manifest.json", "selfpost.db"} {
if !names[want] {
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
}
@@ -116,7 +91,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
// Without a password the CLI keeps producing the plain archive that existing
// backup scripts consume.
func TestUnencryptedBackupStaysPlain(t *testing.T) {
seedProject(t)
seedDataDir(t)
out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err != nil {
t.Fatalf("create backup: %v", err)
@@ -162,76 +137,3 @@ func TestReadPassword(t *testing.T) {
t.Error("-decrypt without a password was accepted")
}
}
// An encrypted backup is held to the panel's password floor (audit
// 2026-10-12, M5); a refused one writes nothing.
func TestEncryptionPasswordFloor(t *testing.T) {
seedProject(t)
out := filepath.Join(t.TempDir(), "backup.spbk")
err := run(out, "eleven-char")
if err == nil || !strings.Contains(err.Error(), "at least 12 characters") {
t.Fatalf("an 11-character password = %v, want refused", err)
}
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
t.Errorf("the refused backup wrote %s", out)
}
}
// A wrong SELFPOST_DB_PATH makes no backup and no database (audit
// 2026-10-12, M3).
func TestBackupOfAMissingDatabaseIsRefused(t *testing.T) {
_, dataDir := seedProject(t)
missing := filepath.Join(dataDir, "typo.db")
t.Setenv("SELFPOST_DB_PATH", missing)
out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err == nil {
t.Fatal("a backup of a database that is not there was made")
}
for _, p := range []string{missing, out} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("the refused backup left %s (stat err = %v)", p, err)
}
}
}
// -decrypt -o writes its file whole or not at all: a wrong password or an
// envelope cut short leaves the file that had the name before, and nothing
// else in its directory (audit 2026-10-12, L1 and stage 3).
func TestDecryptLeavesTheOutputAloneOnFailure(t *testing.T) {
seedProject(t)
dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk")
const password = "a long enough password"
if err := run(encrypted, password); err != nil {
t.Fatalf("create encrypted backup: %v", err)
}
sealed, err := os.ReadFile(encrypted)
if err != nil {
t.Fatal(err)
}
cut := filepath.Join(dir, "cut.spbk")
writeFile(t, cut, string(sealed[:len(sealed)-100]))
outDir := t.TempDir()
out := filepath.Join(outDir, "backup.tar.gz")
const before = "the archive that was here"
for _, c := range []struct{ name, in, password, want string }{
{"wrong password", encrypted, "the wrong password", "wrong password"},
{"cut short", cut, password, "corrupted"},
} {
t.Run(c.name, func(t *testing.T) {
writeFile(t, out, before)
err := runDecrypt(c.in, out, c.password)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("runDecrypt = %v, want %q", err, c.want)
}
if got, _ := os.ReadFile(out); string(got) != before {
t.Errorf("the failed decryption changed %s to %d bytes", out, len(got))
}
entries, _ := os.ReadDir(outDir)
if len(entries) != 1 {
t.Errorf("the failed decryption left files beside its output: %v", entries)
}
})
}
}
-28
View File
@@ -11,34 +11,6 @@ SELFPOST_HOSTNAME=mail.example.com
# primary 465/smtps listener.
SUBMISSION_ENABLE=false
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
# mail on port 25 only for domains configured in the panel and forwards them
# to the upstream you set. Off by default — the outbound path is unchanged.
INBOUND_RELAY_ENABLE=false
# Optional DMARC aggregate report ingest. When true, Postfix accepts mail on port
# 25 only for report addresses configured in the panel (global default and/or
# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker.
DMARC_REPORTS_ENABLE=false
# Coarse per-client-IP limit and message size for DMARC ingest on port 25.
# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20
# DMARC_MESSAGE_SIZE_LIMIT=5242880
# Optional milter on the inbound listener only (not 465/587). Empty = off.
# Example with deploy/antispam/docker-compose.antispam.yml:
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
# INBOUND_ANTISPAM_MILTER_ACTION=accept
# Days the inbound journal keeps a row (7-365) until the panel says otherwise;
# it never holds more than 10 000 rows.
# INBOUND_SPAM_LOG_RETENTION_DAYS=90
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
RATE_LIMIT_MESSAGES_PER_IP=100
RATE_LIMIT_WINDOW_SECONDS=3600
@@ -1,42 +0,0 @@
# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md).
#
# SelfPost does not ship or start an anti-spam engine. This fragment brings up
# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it
# only when you want filtering on port 25:
#
# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
#
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
#
# The rspamd image is pinned to one build, 3.11.1-3 - "3.11" and "3.11.1"
# move with each rebuild (Docker Hub rspamd/rspamd tags 3.11.1-0 ... -3) -
# and it is the build the comments in antispam/rspamd/ were read against;
# bump it deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
# defer instead.
services:
selfpost:
environment:
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
depends_on:
- antispam
antispam:
# The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
# (its Dockerfile, USER 11333:11333).
image: rspamd/rspamd:3.11.1-3
restart: unless-stopped
# No host ports: only SelfPost talks to the milter on the compose network.
volumes:
# The verdict headers SelfPost's inbound journal reads.
- ./antispam/rspamd/milter_headers.conf:/etc/rspamd/local.d/milter_headers.conf:ro
# The allow and deny lists kept in the panel: the rules, and the files
# SelfPost writes them to.
- ./antispam/rspamd/multimap.conf:/etc/rspamd/local.d/multimap.conf:ro
- ./data/antispam:/etc/rspamd/selfpost:ro
@@ -1,75 +0,0 @@
# Mounted into the sidecar as /etc/rspamd/local.d/milter_headers.conf.
#
# rspamd writes its verdict into two headers of every message it lets through:
# the action it chose and the score with the symbols behind it. SelfPost's
# inbound journal reads them (Inbound > Log shows them next to the message),
# and the upstream mailbox server receives them with the mail.
#
# rspamd removes headers of these names that a sender wrote himself before it
# adds its own - for every client, because of the two lines below. Without
# them it would do neither for a client it counts as local or one that
# authenticated: rspamd 3.11.1, src/plugins/lua/milter_headers.lua,
# skip_wanted() - skip_local and skip_authenticated are on by default, and
# "local" is loopback, a unix socket, or an address in local_addrs, the
# private ranges (src/lua/lua_ip.c, lua_ip_is_local(); conf/options.inc). The
# sender's own verdict would then pass untouched while the signature below,
# a custom routine that skip_wanted() never consults, signed it (assumptions
# C-R7). A private client address is ordinary here: a LAN sender, or every
# sender when Docker's proxy relays port 25 from the bridge gateway.
# Tried on 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 (rspamc --ucl, a
# message carrying its own X-Spamd-Result and X-Rspamd-Action): without the
# two lines, from 10.0.0.5 or as an authenticated user, the milter reply
# removed neither header, added neither, and added the signature; with
# them, both removed and rspamd's own added, as from a public address.
#
# Without this file the journal still lists accepted mail, with no verdict
# next to it.
#
# The third header is the proof that the other two are rspamd's. The filter is
# attached so that mail still flows when it is down, and then nothing removes
# what a sender wrote: he could send his own verdict. So rspamd signs each
# message - an HMAC-SHA256 of the message's queue id under a key SelfPost
# generates and leaves in the directory it shares with this container - and
# SelfPost reads a verdict only next to a signature it can recompute. The
# signature travels on with the mail and is of no use to whoever sees it: it
# fits that one queue id.
#
# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called
# as function(task, common_meta) and returns error, headers to add, headers to
# remove, metadata; a remove value of 0 removes every header of that name
# (docs.rspamd.com/modules/milter_headers, "Custom routines").
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
# back in src/libserver/protocol.c), which Postfix sends at
# end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
# "i").
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
skip_local = false;
skip_authenticated = false;
custom {
selfpost-signature = <<EOD
return function(task, common_meta)
local remove = { ['X-SelfPost-Filter'] = 0 }
-- The key is read for every message and kept nowhere: SelfPost may make a
-- new one (the file was lost, a backup was restored), and a key remembered
-- here would go on signing what SelfPost no longer accepts until someone
-- restarted this container. The file is one short line.
local key
local f = io.open('/etc/rspamd/selfpost/filter.key', 'r')
if f then
key = f:read('*l')
f:close()
end
local qid = task:get_queue_id()
if not key or key == '' or not qid or qid == '' then
return nil, {}, remove, {}
end
local hash = require "rspamd_cryptobox_hash"
local sig = hash.create_specific_keyed(key, 'sha256', qid):hex()
return nil, { ['X-SelfPost-Filter'] = sig }, remove, {}
end
EOD;
}
-93
View File
@@ -1,93 +0,0 @@
# Mounted into the sidecar as /etc/rspamd/local.d/multimap.conf.
#
# The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost
# writes them as six files under data/antispam/, which the fragment mounts at
# /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart.
# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's
# ev_stat - inotify where the kernel has it, else a stat poll every
# map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc;
# docs.rspamd.com/configuration/maps asks for an atomic rename, which
# internal/inbound writes with.)
#
# Deny wins over allow, whichever entries match. A denied sender is refused
# whatever else is found (the rule's action is a pre-result, below). An
# allowed one is not accepted outright - rspamd would then let whichever of
# the two rules ran first decide - but given a weight no spam score
# outweighs, which a refusal by the deny list still overrides. (rspamd
# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a
# "normal" symbol, one with an action with the "passthrough" augmentation;
# no "prefilter" setting is read. Measured with this file in a throwaway
# rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry
# matching one message - address, domain, IP - came out reject, and a
# changed map file was in use about ten seconds later.)
#
# A sender rule looks at the envelope sender only, extract_from = "smtp":
# by default multimap.lua takes the header From: when the envelope sender is
# empty (the "from" callback, has_from('smtp') then has_from('mime')), so a
# bounce would be judged by an address the panel never counts it under
# (assumptions C-R4). Tried on 2026-10-14 in rspamd/rspamd:3.11.1-3: a
# bounce whose From: domain is on the deny list was refused by default and
# is not with this setting; a sender on the list is refused either way.
#
# A domain entry is that domain, not its subdomains. (rspamd 3.11.1,
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
# case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives
# the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().)
#
# A refusal is rspamd's "pre-result": multimap.lua calls task:set_pre_result()
# with the rule's action and message, and that action stands whatever the score
# (docs.rspamd.com/modules/multimap, "action"). The message reaches the client
# as "554 5.7.1 <message>": src/libserver/protocol.c, rspamd_scan_result_ucl(),
# puts it into messages.smtp_message, src/libserver/milter.c,
# rspamd_milter_send_task_results(), replies with it.
#
# SelfPost counts how often an entry decided a message by what these rules
# leave behind: the deny message below and the names of the allow symbols.
# Change either and Inbound > Filter lists shows "never" for every entry.
SELFPOST_DENY_IP {
type = "ip";
map = "/etc/rspamd/selfpost/deny_ip.map";
action = "reject";
message = "Refused by this server's deny list";
description = "Client address on the deny list";
}
SELFPOST_DENY_FROM {
type = "from";
extract_from = "smtp";
map = "/etc/rspamd/selfpost/deny_from.map";
action = "reject";
message = "Refused by this server's deny list";
description = "Sender address on the deny list";
}
SELFPOST_DENY_DOMAIN {
type = "from";
extract_from = "smtp";
filter = "email:domain";
map = "/etc/rspamd/selfpost/deny_domain.map";
action = "reject";
message = "Refused by this server's deny list";
description = "Sender domain on the deny list";
}
SELFPOST_ALLOW_IP {
type = "ip";
map = "/etc/rspamd/selfpost/allow_ip.map";
score = -100.0;
description = "Client address on the allow list";
}
SELFPOST_ALLOW_FROM {
type = "from";
extract_from = "smtp";
map = "/etc/rspamd/selfpost/allow_from.map";
score = -100.0;
description = "Sender address on the allow list";
}
SELFPOST_ALLOW_DOMAIN {
type = "from";
extract_from = "smtp";
filter = "email:domain";
map = "/etc/rspamd/selfpost/allow_domain.map";
score = -100.0;
description = "Sender domain on the allow list";
}
+16 -17
View File
@@ -1,19 +1,18 @@
# Example host Apache vhost for the SelfPost panel (default scenario). Install
# on the host running Apache — NOT inside the SelfPost container. Requires
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
# Example host Apache vhost for the SelfPost panel (spec 10.3, default
# scenario). Install on the host running Apache — NOT inside the SelfPost
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled
# (`a2enmod ssl proxy proxy_http`).
#
# Certificates: obtain with the Apache certbot plugin against THIS vhost
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
# redirect vhost, and renews in the background via its own timer. The resulting
# PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host,
# as links into ../../archive/. Link certs/fullchain.pem and certs/privkey.pem
# to them and mount /etc/letsencrypt read-only at the same path (see
# ../docker-compose.yml and docs/guide.md, "Reverse proxy") so Postfix can
# present the very same certificate on 465/587. Mounting live/mail.example.com
# alone gives the container links into a directory it does not have. One
# certificate, two consumers — no separate cert just for the panel.
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a
# :80 -> :443 redirect vhost, and renews in the background via its own timer.
# The resulting PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host
# — bind-mount that directory read-only into the SelfPost container (see
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
# can present the very same certificate on 465/587. One certificate, two
# consumers (spec 10 p.4) — no separate cert just for the panel.
<VirtualHost *:443>
ServerName mail.example.com
@@ -27,9 +26,9 @@
ProxyPass "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "http://127.0.0.1:8080/"
# The panel is plain HTTP behind the proxy; it never terminates TLS itself.
# Cookies are still marked Secure by the panel because the browser only ever
# sees this HTTPS vhost.
# The panel is plain HTTP behind the proxy; it never terminates TLS
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
</VirtualHost>
<VirtualHost *:80>
+6 -16
View File
@@ -1,28 +1,20 @@
# Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
# with no separate certbot container. Caddy writes certificates as plain PEM
# under its own data directory, which this fragment bind-mounts to the host so
# SelfPost can read the same files.
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
# automatic ACME with no separate certbot container. Caddy writes certificates
# as plain PEM under its own data directory, which this fragment bind-mounts
# to the host so SelfPost can read the same files.
#
# NOTE: Caddy's on-disk cert path includes
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes
# the ACME CA's name as a path segment, e.g.
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
# This has been stable across recent Caddy releases but is an internal
# storage detail, not a documented public API — VERIFY the exact path against
# the Caddy version you deploy (`docker compose exec caddy find /data/caddy/certificates -name '*.crt'`)
# before wiring it into the volume mount below.
# Read in the source (caddyserver/certmagic, storage.go, KeyBuilder.SiteCert():
# certificates/<issuer key>/<domain>/<domain>.crt; acmeissuer.go, issuerKey():
# the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's
# Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a
# second issuer only when an email is configured, and the Caddyfile below sets
# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag.
#
# Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
# FIRST -f file, i.e. deploy/, which is why paths below are ./caddy/...):
#
# docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d
@@ -31,7 +23,6 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
environment:
# Caddy names certificate files after the hostname, not
# fullchain.pem/privkey.pem like certbot — override the base file's
@@ -40,7 +31,6 @@ services:
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
volumes: !override
- ./data:/data
- .:/selfpost-deploy:ro
# Replace <hostname> with your actual mail/panel hostname, matching
# the Caddyfile below and SELFPOST_HOSTNAME.
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
+35 -64
View File
@@ -1,12 +1,12 @@
# SelfPost — default deployment, reverse-proxy = Apache.
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5).
#
# This file only runs SelfPost itself. Apache is assumed to already be installed
# on the HOST (the target audience for this project typically runs Apache there
# already) and reverse-proxies HTTPS for the panel; it is not containerised
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
# this compose file mounts.
# This file only runs SelfPost itself. Apache is assumed to already be
# installed on the HOST (the target audience for this project typically runs
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel;
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready
# vhost fragment, and docs/guide.md "Reverse proxy" for the
# certbot steps that produce the PEM files this compose file mounts.
#
# Usage:
# 1. Copy this file (and .env.example as .env) next to your own ./data and
@@ -15,42 +15,32 @@
# TLS paths are fixed in this file to match that bind mount, not .env.
# 3. docker compose up -d
#
# The image tag below is FIXED on purpose: backup restore compares the manifest
# version against the running binary's version, so ":latest" would make that
# check meaningless. Bump the tag deliberately when you want to upgrade.
# The 2.x line is not on ghcr.io yet: build it locally under this exact name
# (README, Quick start) and Compose will use it without pulling.
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup
# restore compares the manifest version against the running binary's version,
# so ":latest" would make that check meaningless. Bump the tag deliberately
# when you want to upgrade.
services:
selfpost:
image: ghcr.io/mixeme/selfpost:2.5.4
image: ghcr.io/mixeme/selfpost:1.3.0
restart: unless-stopped
environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
# Path Postfix reads inside the container — matches the certs bind mount
# below. Point these at your reverse-proxy's PEM output.
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2).
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
# Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
# the primary 465/smtps listener.
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
# alongside the primary 465/smtps listener (spec 5).
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
# Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are
# configured later from the panel itself.
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
# limits (level 2) are configured later from the panel itself.
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
# How long the send log keeps rows before the background sweep deletes
# them — the main driver of /data growth over time.
# them (spec 7.3, 9) — the main driver of /data growth over time.
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
INBOUND_SPAM_LOG_RETENTION_DAYS: "${INBOUND_SPAM_LOG_RETENTION_DAYS:-90}"
# Sliding idle timeout for the panel login session, in days.
# Sliding idle timeout for the panel login session, in days (spec 7.6.6).
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they
# report what the internet sees rather than what this host's own stub
@@ -61,42 +51,30 @@ services:
# client spoof its rate-limit key.
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
volumes:
# All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
# sender map, setup token. Back this up (panel button or the
# selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
# here too (./data/log, rotated daily, 14 files kept) so the delivery
# lines that resolve a queued send-log row survive a container recreate;
# it is diagnostics and is left out of backups.
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
# sasldb2, sender map, setup token. Back this up (panel button or the
# selfpost-backup CLI) before you touch it directly.
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
# kept) so the delivery lines that resolve a queued send-log row survive
# a container recreate; it is diagnostics and is left out of backups.
- ./data:/data
# Read-only project root for full backups: docker-compose.yml, .env, and
# certs/ are archived alongside /data (architecture.md § Persistence).
- .:/selfpost-deploy:ro
# Read-only: SelfPost only ever reads certificates, never manages them.
# ./certs holds fullchain.pem and privkey.pem: the files themselves, or
# links to where your reverse proxy's certbot keeps them.
# Read-only: SelfPost only ever reads certificates, never manages them
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
# writes PEM files, e.g. /etc/letsencrypt on the host.
- ./certs:/etc/postfix/tls:ro
# With certbot on the host (docs/guide.md, "Reverse proxy", Apache):
# certs/ holds links into /etc/letsencrypt/live/, and certbot's live/
# files are themselves relative links into archive/ - so the whole
# directory is mounted, at the same path, for the links to lead
# somewhere inside the container. Uncomment it then.
# - /etc/letsencrypt:/etc/letsencrypt:ro
ports:
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
# published directly — mail traffic bypasses Apache entirely, it only ever
# proxies the panel's HTTP(S). The panel itself (8080) is intentionally
# NOT published here: Apache reaches it over the host network at
# 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
# reachable from the internet without TLS.
# published directly — mail traffic bypasses Apache entirely, it only
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
# (8080) is intentionally NOT published here: Apache reaches it over
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
# panel is never directly reachable from the internet without TLS.
- "465:465"
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
# when the variable is true — see docs/guide.md "Published ports".
- "587:587"
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
# when the variable is true.
- "25:25"
- "127.0.0.1:8080:8080"
# Hardening. SelfPost's entrypoint still needs to run as
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
# root very briefly to fix /data ownership and normalise permissions
# under the shared `selfpost` group (see build/entrypoint.sh) before
# supervisord drops the panel to an unprivileged user — so this cannot be
@@ -104,7 +82,7 @@ services:
# self-healing. What IS applied: no privilege escalation past what the
# image already grants, and every Linux capability dropped except the
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data
# dirs/files while still root (owner-check bypass);
@@ -122,13 +100,6 @@ services:
# FOWNER/FSETID are required by build/entrypoint.sh's permission
# self-healing; without them chmod fails with EPERM and the container
# crash-loops on start.
# Each capability above as capabilities(7) defines it (man7.org,
# "Capabilities list"); SETUID/SETGID are also what pipe(8) uses to become
# user=panel for dmarc-ingest. Not listed, so dropped: SYS_CHROOT, which
# chroot(2) needs - build/postfix-config.sh turns Postfix's chroot off.
# no-new-privileges only changes execve() (Linux,
# Documentation/userspace-api/no_new_privs.rst): root may still call
# setuid()/setgid() with these capabilities.
security_opt:
- no-new-privileges:true
cap_drop:
+12 -31
View File
@@ -1,28 +1,21 @@
# Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
# Apache+certbot scenario — nginx and certbot are close cousins here, both
# writing/reading plain PEM under /etc/letsencrypt.
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
# exactly like the Apache+certbot scenario — nginx and certbot are close
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
#
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it
# adds an nginx + certbot pair and removes the panel's host port publish (nginx
# takes over 80/443 and proxies to the panel over the compose network
# instead). Merge it with the base file — run this from the deploy/ directory
# (Compose resolves every relative path in both files against the directory of
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
# the FIRST -f file, i.e. deploy/, which is why paths below are ./nginx/...):
#
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d
#
# First-run certificate issuance, before nginx has a certificate to serve -
# run once, with port 80 free (certbot answers the challenge itself;
# --webroot would need nginx already serving /.well-known). --entrypoint is
# needed: the service's own entrypoint is the renewal loop below, an
# `sh -c` that ignores any arguments given to `run`
# (docs.docker.com/reference/compose-file/services/#entrypoint; assumptions
# D-guide:301):
# First-run certificate issuance (webroot method, before nginx has a cert to
# serve — run once):
# docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
# run --rm -p 80:80 --entrypoint certbot certbot certonly --standalone \
# run --rm certbot certonly --webroot -w /var/www/certbot \
# -d mail.example.com --email you@example.com --agree-tos --no-eff-email
services:
@@ -30,19 +23,14 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
# No host publish for 8080 here: nginx reaches it over the compose
# network at selfpost:8080 instead (see nginx.conf.example).
volumes: !override
- ./data:/data
- .:/selfpost-deploy:ro
# certs/ holds two links, fullchain.pem and privkey.pem, to
# /etc/letsencrypt/live/mail.example.com/ - a path inside the
# container: certbot's directory below is mounted there whole, because
# its live/ files are relative links into archive/ (mounting live/ alone
# left them pointing at nothing). docs/guide.md, "Reverse proxy", nginx.
- ./certs:/etc/postfix/tls:ro
- ./nginx/certbot-etc:/etc/letsencrypt:ro
# Same host directory certbot below writes into — plain bind mount,
# no named volume, so the PEM files are as directly inspectable as in
# the Apache scenario (spec 10.3).
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
nginx:
image: nginx:1.27
@@ -64,11 +52,4 @@ services:
- ./nginx/certbot-www:/var/www/certbot
# Renewal twice a day is certbot's own recommended cadence; it no-ops
# until a certificate is within its renewal window.
# (eff-certbot.readthedocs.io, User Guide, "Automated Renewals": the
# sample cron runs "certbot renew" at 0 and 12 h; renew acts only on a
# certificate near expiry.) The first certificate was issued --standalone
# (see the top of this file), and certbot renews with the plugin it was
# issued with "unless you specify other plugins or options" (same page,
# "Renewing certificates") - by then nginx holds port 80 and serves the
# webroot, so renewal names it; a renewal that succeeds with it saves it.
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done'
entrypoint: sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'
+3 -5
View File
@@ -1,6 +1,6 @@
# nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
# compose network; mail (465/587) is published directly by the selfpost
# container and never touches nginx.
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
# over the compose network; mail (465/587) is published directly by the
# selfpost container and never touches nginx.
server {
listen 80;
@@ -17,8 +17,6 @@ server {
server {
listen 443 ssl;
# nginx.org/en/docs/http/ngx_http_v2_module.html, "http2": "This directive
# appeared in version 1.25.1" (the fragment runs nginx:1.27).
http2 on;
server_name mail.example.com;
+5 -12
View File
@@ -1,14 +1,12 @@
# Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
# everything (cert + key) lives bundled inside acme.json. SelfPost needs
# separate PEM files for Postfix, so an extraction step is required; see
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
# needs separate PEM files for Postfix, so an extraction step is required; see
# extract-cert.sh in this directory and the cron/systemd-timer note below.
#
# Merge with the base file — run this from the deploy/ directory (Compose
# resolves every relative path in both files against the directory of the
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/... -
# docs.docker.com/compose/how-tos/multiple-compose-files/merge/: "Paths are
# evaluated relative to the base file"):
# FIRST -f file, i.e. deploy/, which is why paths below are ./traefik/...):
#
# docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
@@ -17,20 +15,15 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
volumes: !override
- ./data:/data
- .:/selfpost-deploy:ro
# Populated by extract-cert.sh from traefik's acme.json — see below.
- ./traefik/extracted-certs:/etc/postfix/tls:ro
labels:
# Traefik only proxies the panel; it never sees the mail ports.
# These live on the selfpost service (not traefik's) because the
# docker provider reads routing labels off the container being routed
# to, not off traefik itself. (doc.traefik.io/traefik/v3.1/providers/
# docker/, "Routing Configuration": "Traefik uses container labels";
# with exposedByDefault=false only containers labelled
# traefik.enable=true.)
# to, not off traefik itself.
- traefik.enable=true
- traefik.http.routers.selfpost.rule=Host(`mail.example.com`)
- traefik.http.routers.selfpost.entrypoints=websecure
Executable → Regular
+17 -64
View File
@@ -1,75 +1,28 @@
#!/bin/bash
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
# Traefik keeps its certificates there, so a PEM extraction step is required.
# Run this on the host, after Traefik has issued or renewed the certificate, and
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
# by SelfPost/Postfix.
#!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
# step is required"). Run this on the host, after Traefik has issued or
# renewed the certificate, and again on a schedule (cron/systemd timer) since
# acme.json is not itself watched by SelfPost/Postfix.
#
# Requires bash and jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
#
# The layout is not documented (doc.traefik.io/traefik/v3.1/https/acme/ says
# only "JSON file"); read in Traefik v3.1.0: pkg/provider/acme/local_store.go
# keeps map[resolver name]*StoredData, store.go StoredData{Account,
# Certificates}, provider.go Certificate{domain, certificate, key} with the PEM
# as []byte - base64 in JSON (Go encoding/json). "le" is the resolver
# docker-compose.traefik.yml names.
#
# Nothing is replaced unless both new files are PEM: a domain acme.json does
# not hold, an empty field or a broken file stops the script with an error and
# leaves the files that were there (audit 2026-10-12, M21 - under sh without
# pipefail, jq finding nothing still exited 0 and two empty files replaced the
# certificate; Postfix's TLS broke at its next restart). The new files are
# written beside the old ones, the owner's alone until they are whole, then
# renamed into place.
set -euo pipefail
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
set -eu
ACME_JSON="${1:?path to acme.json}"
DOMAIN="${2:?domain name, e.g. mail.example.com}"
OUT_DIR="${3:?output directory, e.g. ./extracted-certs}"
mkdir -p "$OUT_DIR"
umask 077
die() {
echo "extract-cert: $*" >&2
exit 1
}
jq -r --arg domain "$DOMAIN" '
.le.Certificates[]
| select(.domain.main == $domain)
| .certificate' "$ACME_JSON" | base64 -d > "$OUT_DIR/fullchain.pem"
# field prints the base64 of one field of the domain's first certificate, or
# nothing.
field() {
jq -r --arg domain "$DOMAIN" --arg field "$1" '
first(.le.Certificates[]? | select(.domain.main == $domain) | .[$field]) // empty
' "$ACME_JSON"
}
jq -r --arg domain "$DOMAIN" '
.le.Certificates[]
| select(.domain.main == $domain)
| .key' "$ACME_JSON" | base64 -d > "$OUT_DIR/privkey.pem"
cert_b64=$(field certificate)
key_b64=$(field key)
[ -n "$cert_b64" ] || die "$ACME_JSON holds no certificate for $DOMAIN"
[ -n "$key_b64" ] || die "$ACME_JSON holds no key for $DOMAIN"
tmp_cert="" tmp_key=""
trap 'rm -f "$tmp_cert" "$tmp_key"' EXIT
tmp_cert=$(mktemp "$OUT_DIR/.fullchain.pem.XXXXXX")
tmp_key=$(mktemp "$OUT_DIR/.privkey.pem.XXXXXX")
printf '%s' "$cert_b64" | base64 -d >"$tmp_cert" || die "the certificate of $DOMAIN is not base64"
printf '%s' "$key_b64" | base64 -d >"$tmp_key" || die "the key of $DOMAIN is not base64"
grep -q -- '-----BEGIN CERTIFICATE-----' "$tmp_cert" || die "the certificate of $DOMAIN is not PEM"
grep -q -- '-----BEGIN .*PRIVATE KEY-----' "$tmp_key" || die "the key of $DOMAIN is not PEM"
chmod 0640 "$tmp_cert" "$tmp_key"
# The group the files had stays theirs: the guide gives the key to group 998
# so the panel can read it for a full backup, and a run from root's cron would
# otherwise hand it to root's group (review of audit stage 5).
for f in fullchain privkey; do
if [ -e "$OUT_DIR/$f.pem" ]; then
tmp=$tmp_cert
[ "$f" = privkey ] && tmp=$tmp_key
chgrp --reference="$OUT_DIR/$f.pem" "$tmp" || die "cannot give the new $f.pem the group of the old one"
fi
done
mv -f "$tmp_cert" "$OUT_DIR/fullchain.pem"
mv -f "$tmp_key" "$OUT_DIR/privkey.pem"
trap - EXIT
chmod 0640 "$OUT_DIR/fullchain.pem" "$OUT_DIR/privkey.pem"
echo "extracted $DOMAIN to $OUT_DIR/{fullchain,privkey}.pem"
+108 -878
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

-164
View File
@@ -1,164 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<header class="sp-bar">
<div class="sp-wrap">
<nav>
<ul>
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
</ul>
<ul class="sp-sections">
<li><a href="status.html">Overview</a></li>
<li><a href="domain.html" aria-current="true">Outbound</a></li>
<li><a href="#">Inbound</a></li>
<li><a href="#">Server</a></li>
</ul>
<ul class="sp-user">
<li>
<details class="dropdown">
<summary>admin</summary>
<ul dir="rtl">
<li><a href="#">Account</a></li>
<li><a href="#">Help</a></li>
<li><a href="login.html">Sign out</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</div>
</header>
<div class="sp-pages">
<div class="sp-wrap">
<a href="#" aria-current="true">Domains</a>
<a href="#">Log</a>
<a href="#">Queue</a>
<a href="#">DMARC reports</a>
</div>
</div>
<main class="sp-wrap">
<div class="sp-head">
<div>
<p class="sp-kicker"><a href="#">Outbound domains</a> /</p>
<h1>example.org</h1>
</div>
<small>1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h</small>
</div>
<section class="sp-sheet">
<header><span class="sp-no">01</span><h2>Connection</h2><small>The same for every domain</small></header>
<div class="sp-body sp-cols">
<dl class="sp-kv">
<dt>Server</dt>
<dd><span class="sp-value">mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
<dl class="sp-kv">
<dt>Ports</dt>
<dd><span class="sp-value">465 — SSL/TLS (implicit)<br>587 — STARTTLS (submission)</span></dd>
</dl>
</div>
</section>
<section class="sp-sheet">
<header>
<span class="sp-no">02</span><h2>DNS records</h2>
<small>Checked 3 min ago</small>
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check</button></form>
</header>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="sp-tag ok">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">sp2026._domainkey.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="sp-tag fail">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=spf1 ip4:203.0.113.25 ~all</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>In DNS now</dt><dd><span class="sp-value">v=spf1 include:_spf.google.com ~all</span></dd>
</dl>
<p class="sp-note fail">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="sp-tag ok">published</span><small>Report address from <a href="#">Settings</a></small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">_dmarc.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
<p class="sp-note"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="#">reports</a> look clean.</p>
</div>
</section>
<section class="sp-sheet">
<header>
<span class="sp-no">03</span><h2>Applications</h2>
<small>SASL logins that may send as this domain</small>
<a href="#" role="button" class="sp-btn-sm">Add application</a>
</header>
<table class="sp-apps">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
<tr>
<td class="sp-login">prod-server</td>
<td>*@example.org</td>
<td>1 102 msg · peak 96/h</td>
<td><span class="sp-tag ok">200 / h</span></td>
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
</tr>
<tr>
<td class="sp-login">alerts</td>
<td>alerts@example.org, noc@example.org</td>
<td>182 msg · peak 14/h</td>
<td><span class="sp-tag">domain</span> <span class="sp-tag ok">2 IPs</span></td>
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
</tr>
</tbody>
</table>
</section>
<section class="sp-sheet sp-ledger">
<header><span class="sp-no">04</span><h2>Domain settings</h2><small>Rarely changed</small></header>
<details>
<summary><strong>DMARC reports</strong><span class="sp-muted">Same as Settings — dmarc@mail.example.org</span><span class="sp-tag">default</span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Rate limit</strong><span class="sp-muted">Auto ×3 — 288 messages / hour, recalculated yesterday</span><span class="sp-tag ok">active</span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Export</strong><span class="sp-muted">DKIM key and applications as one file, optionally encrypted</span><span></span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Delete domain</strong><span class="sp-muted">Removes the DKIM key and both applications</span><span></span></summary>
<div class="sp-detail"><p class="sp-muted">The confirmation opens here.</p></div>
</details>
</section>
<footer class="sp-foot">
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</main>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="sp-signin">
<div class="sp-signin-card">
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<section class="sp-sheet">
<header><span class="sp-no">→</span><h2>Sign in</h2></header>
<div class="sp-body">
<form method="post" action="status.html">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
</div>
</section>
<p class="sp-tagline">YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<footer class="sp-wrap sp-foot">
© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</body>
</html>
-136
View File
@@ -1,136 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<header class="sp-bar">
<div class="sp-wrap">
<nav>
<ul>
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
</ul>
<ul class="sp-sections">
<li><a href="status.html" aria-current="page">Overview</a></li>
<li><a href="domain.html">Outbound</a></li>
<li><a href="#">Inbound</a></li>
<li><a href="#">Server</a></li>
</ul>
<ul class="sp-user">
<li>
<details class="dropdown">
<summary>admin</summary>
<ul dir="rtl">
<li><a href="#">Account</a></li>
<li><a href="#">Help</a></li>
<li><a href="login.html">Sign out</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</div>
</header>
<main class="sp-wrap">
<div class="sp-head">
<div>
<p class="sp-kicker">mail.example.org</p>
<h1>Overview</h1>
</div>
<small>Updated 14:02:31 UTC · refreshes every 10 s</small>
</div>
<section class="sp-sheet sp-verdict">
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div>
<h2>Mail is flowing. One thing needs attention.</h2>
<p>The TLS certificate expires in 12 days and has not renewed yet. Everything else is healthy.</p>
</div>
</section>
<section class="sp-sheet sp-ledger">
<header><span class="sp-no">01</span><h2>Checks</h2><small>Open a row for its detail</small></header>
<details>
<summary><strong>Processes</strong><span class="sp-muted">5 of 5 running</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
</tbody>
</table>
</div>
</details>
<details open>
<summary><strong>TLS certificate</strong><span class="sp-muted">Expires in 12 days — renewal has not run</span><span class="sp-tag warn">warn</span></summary>
<div class="sp-detail">
<dl class="sp-kv">
<dt>Expires</dt><dd><span class="sp-value">2026-10-03 09:14 UTC</span></dd>
<dt>Names</dt><dd><span class="sp-value">mail.example.org</span></dd>
</dl>
<p class="sp-note">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="#">system log</a>.</p>
</div>
</details>
<details>
<summary><strong>Machine</strong><span class="sp-muted">CPU 12 % · memory 41 % · ↓ 18 kB/s ↑ 44 kB/s</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><meter value="12" min="0" max="100" low="70" high="90" optimum="10"></meter>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><meter value="41" min="0" max="100" low="70" high="90" optimum="10"></meter>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table>
</div>
</details>
<details>
<summary><strong>Queues</strong><span class="sp-muted">Outbound empty · inbound empty</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail"><p>No messages are waiting. Open the <a href="#">outbound queue</a> or the <a href="#">inbound queue</a>.</p></div>
</details>
<details>
<summary><strong>Milter sockets</strong><span class="sp-muted">2 of 2 answering</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>opendkim</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">inet:127.0.0.1:8891</td></tr>
<tr><td>journal-milter</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">unix:/run/selfpost/journal.sock</td></tr>
</tbody>
</table>
</div>
</details>
<details>
<summary><strong>Hostname and reverse DNS</strong><span class="sp-muted">mail.example.org ⇄ 203.0.113.25</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<dl class="sp-kv">
<dt>Hostname</dt><dd><span class="sp-value">mail.example.org</span></dd>
<dt>Lookup</dt><dd><span class="sp-value">mail.example.org → 203.0.113.25<br>203.0.113.25 → mail.example.org</span></dd>
</dl>
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check DNS</button></form>
</div>
</details>
</section>
<footer class="sp-foot">
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</main>
</body>
</html>
-206
View File
@@ -1,206 +0,0 @@
/* Direction A — "Blank" (the postal form). Pico CSS v2 carries every generic
control (type, buttons, inputs, tables, details, dropdown); this file maps
the brand onto Pico's variables and adds only what is SelfPost's own:
the two-row header, the sheet, numbered form sections, the postmark and
the DNS record block. */
/* :root[...] rather than [data-theme=light] alone: Pico declares its light
palette on :root:not([data-theme=dark]), which a bare attribute selector
loses to. */
:root[data-theme=light] {
--pico-font-family-sans-serif: var(--sp-sans);
--pico-font-family-monospace: var(--sp-mono);
--pico-font-family: var(--sp-sans);
--pico-border-radius: 2px;
--pico-spacing: .9rem;
--pico-typography-spacing-vertical: .9rem;
--pico-form-element-spacing-vertical: .5rem;
--pico-form-element-spacing-horizontal: .75rem;
--pico-background-color: var(--sp-paper);
--pico-color: var(--sp-ink);
--pico-muted-color: var(--sp-muted);
--pico-muted-border-color: var(--sp-rule);
--pico-h1-color: var(--sp-ink); --pico-h2-color: var(--sp-ink);
--pico-h3-color: var(--sp-ink); --pico-h4-color: var(--sp-ink);
--pico-primary: var(--sp-brick);
--pico-primary-background: var(--sp-brick);
--pico-primary-border: var(--sp-brick);
--pico-primary-underline: rgb(122 59 46 / .35);
--pico-primary-hover: var(--sp-brick-dark);
--pico-primary-hover-background: var(--sp-brick-dark);
--pico-primary-hover-border: var(--sp-brick-dark);
--pico-primary-hover-underline: var(--sp-brick-dark);
--pico-primary-focus: rgb(122 59 46 / .25);
--pico-primary-inverse: #fff;
--pico-secondary: var(--sp-ink-2);
--pico-secondary-background: var(--sp-ink-2);
--pico-secondary-border: var(--sp-ink-2);
--pico-secondary-hover: var(--sp-ink);
--pico-secondary-hover-background: var(--sp-ink);
--pico-secondary-hover-border: var(--sp-ink);
--pico-secondary-focus: rgb(18 22 28 / .2);
--pico-secondary-inverse: #fff;
--pico-form-element-background-color: #fff;
--pico-form-element-border-color: var(--sp-rule-strong);
--pico-form-element-active-border-color: var(--sp-brick);
--pico-form-element-focus-color: rgb(122 59 46 / .25);
--pico-table-border-color: var(--sp-rule);
--pico-code-background-color: var(--sp-well);
--pico-code-color: var(--sp-ink);
--pico-dropdown-background-color: #fff;
--pico-dropdown-border-color: var(--sp-rule);
--pico-dropdown-hover-background-color: var(--sp-brick-tint);
}
/* Pico grows the root font with the viewport (up to 131%). An ops panel wants
one size everywhere. */
:root { font-size: 15px; }
body { min-height: 100vh; display: flex; flex-direction: column; }
h1 { font-size: 1.7rem; font-weight: 600; margin: 0; }
small, .sp-muted { color: var(--sp-muted); }
code, .sp-mono { font-family: var(--sp-mono); font-size: .88em; }
/* ---- header: sections on the first row, the section's pages on the second */
.sp-wrap { width: 100%; max-width: 72rem; margin-inline: auto; padding-inline: 1.5rem; }
.sp-bar { background: var(--sp-sheet); border-bottom: 1px solid var(--sp-rule); }
.sp-bar nav { align-items: center; }
.sp-bar nav li { padding-block: .45rem; }
.sp-brand img { height: 46px; width: auto; display: block; }
.sp-sections a {
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600;
letter-spacing: .09em; text-transform: uppercase; text-decoration: none;
color: var(--sp-muted); padding: .5rem .8rem; margin: 0; border-radius: 0;
}
.sp-sections a:hover { color: var(--sp-ink); }
.sp-sections a[aria-current] { color: var(--sp-brick); box-shadow: inset 0 -3px 0 var(--sp-brick); }
.sp-user summary { font-size: .9rem; }
.sp-pages { background: var(--sp-paper); border-bottom: 1px solid var(--sp-rule); }
.sp-pages .sp-wrap { display: flex; gap: 1.6rem; }
.sp-pages a { color: var(--sp-muted); text-decoration: none; padding: .6rem 0; font-size: .95rem; }
.sp-pages a:hover { color: var(--sp-ink); }
.sp-pages a[aria-current] { color: var(--sp-ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sp-ink); }
/* ---- the sheet: one column, one width, every page */
main.sp-wrap { flex: 1; padding-block: 1.8rem 2.5rem; }
.sp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
.sp-head .sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); margin: 0 0 .15rem; }
.sp-head .sp-kicker a { color: inherit; }
.sp-head-actions { display: flex; gap: .5rem; align-items: center; }
.sp-head-actions > * { margin: 0; }
.sp-sheet { background: var(--sp-sheet); border: 1px solid var(--sp-rule); margin-bottom: 1.4rem; }
.sp-sheet > header {
display: flex; align-items: baseline; gap: .8rem; padding: .7rem 1.2rem;
border-bottom: 1px solid var(--sp-rule);
}
.sp-sheet > header .sp-no { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-brick); font-weight: 600; }
.sp-sheet > header h2 {
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em;
text-transform: uppercase; margin: 0; flex: 1;
}
.sp-sheet > header small { font-size: .85rem; }
.sp-sheet > .sp-body { padding: 1.1rem 1.2rem; }
.sp-sheet > .sp-body > :last-child { margin-bottom: 0; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; border-top: 1px solid var(--sp-rule); padding-block: 1rem; }
.sp-foot a { color: inherit; }
/* ---- status tag and the postmark */
.sp-tag {
display: inline-block; font-family: var(--sp-mono); font-size: .72rem; font-weight: 600;
letter-spacing: .08em; text-transform: uppercase; padding: .1rem .5rem;
border: 1px solid var(--sp-idle-rule); background: var(--sp-idle-bg); color: var(--sp-idle);
border-radius: 2px; white-space: nowrap; text-decoration: none;
}
.sp-tag.ok { color: var(--sp-ok); background: var(--sp-ok-bg); border-color: var(--sp-ok-rule); }
.sp-tag.warn { color: var(--sp-warn); background: var(--sp-warn-bg); border-color: var(--sp-warn-rule); }
.sp-tag.fail { color: var(--sp-fail); background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
.sp-verdict { display: flex; align-items: center; gap: 1.5rem; padding: 1.3rem 1.4rem; }
.sp-verdict h2 { margin: 0 0 .2rem; font-size: 1.35rem; font-weight: 600; }
.sp-verdict p { margin: 0; color: var(--sp-muted); }
.sp-postmark {
flex: none; width: 5.6rem; height: 5.6rem; border-radius: 50%;
border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px;
display: grid; place-content: center; text-align: center; transform: rotate(-9deg);
font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15;
color: var(--sp-ok);
}
.sp-postmark.warn { color: var(--sp-warn); }
.sp-postmark.fail { color: var(--sp-fail); }
.sp-postmark b { font-size: 1.25rem; display: block; }
.sp-postmark span { font-size: .55rem; display: block; }
/* ---- ledger: one row per check, the row opens into its detail */
.sp-ledger details { margin: 0; padding: 0; border-bottom: 1px solid var(--sp-rule); }
.sp-ledger details:last-child { border-bottom: 0; }
.sp-ledger summary {
display: grid; grid-template-columns: 13rem 1fr auto 1.2rem; gap: 1rem; align-items: center;
padding: .75rem 1.2rem; margin: 0; color: var(--sp-ink);
}
.sp-ledger summary::after { grid-column: 4; margin: 0; float: none; }
.sp-ledger summary:hover { background: var(--sp-paper); }
.sp-ledger summary strong { font-weight: 600; }
.sp-ledger summary span.sp-muted { font-size: .93rem; }
.sp-ledger .sp-detail { padding: .2rem 1.2rem 1.1rem 15.2rem; }
.sp-ledger .sp-detail table { margin: 0; font-size: .93rem; }
.sp-ledger .sp-detail > :last-child { margin-bottom: 0; }
.sp-ledger meter { width: 9rem; height: .7rem; vertical-align: middle; margin-right: .5rem; }
/* ---- label / value / copy: the DNS record block and connection settings */
.sp-record { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--sp-rule); }
.sp-record:last-child { border-bottom: 0; }
.sp-record-head { display: flex; align-items: center; gap: .7rem; margin-bottom: .7rem; }
.sp-record-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
.sp-record-head small { margin-left: auto; }
.sp-kv { display: grid; grid-template-columns: 6.5rem 1fr; gap: .45rem 1rem; align-items: start; margin: 0; }
.sp-kv dt {
font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--sp-muted); padding-top: .5rem; margin: 0;
}
.sp-kv dd { margin: 0; display: flex; gap: .4rem; align-items: stretch; min-width: 0; }
.sp-value {
flex: 1; min-width: 0; font-family: var(--sp-mono); font-size: .86rem; line-height: 1.5;
background: var(--sp-well); border: 1px solid var(--sp-rule); padding: .4rem .65rem;
overflow-wrap: anywhere; border-radius: 2px;
}
.sp-value.plain { background: none; border-color: transparent; padding-left: 0; font-family: var(--sp-sans); font-size: .95rem; }
.sp-copy {
flex: none; align-self: flex-start; height: 2.15rem; margin: 0; padding: 0 .7rem; font-size: .8rem;
--pico-background-color: #fff; --pico-border-color: var(--sp-rule-strong); --pico-color: var(--sp-ink-2);
}
.sp-copy:hover { --pico-background-color: var(--sp-paper); --pico-border-color: var(--sp-ink-2); --pico-color: var(--sp-ink); }
.sp-note { margin: .7rem 0 0 7.5rem; font-size: .9rem; color: var(--sp-muted); }
.sp-note.fail { color: var(--sp-fail); }
.sp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0 2.4rem; }
/* ---- applications */
.sp-apps { margin: 0; font-size: .95rem; }
.sp-apps td, .sp-apps th { padding-inline: 1.2rem .4rem; vertical-align: middle; }
.sp-apps th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
.sp-apps td:last-child { text-align: right; padding-right: 1.2rem; white-space: nowrap; }
.sp-apps .sp-login { font-family: var(--sp-mono); font-weight: 600; }
.sp-link-btn { font-size: .9rem; margin-left: .9rem; }
.sp-link-btn.danger { color: var(--sp-fail); --pico-underline: rgb(180 35 24 / .35); text-decoration-color: rgb(180 35 24 / .35); }
.sp-btn-sm { padding: .35rem .8rem; font-size: .9rem; margin: 0; width: auto; }
/* ---- sign-in */
.sp-signin { flex: 1; display: grid; place-content: center; padding: 2rem 1.5rem; }
.sp-signin-card { width: 23rem; max-width: 100%; }
.sp-signin-card img { display: block; width: 15rem; height: auto; margin: 0 auto 1.6rem; }
.sp-signin-card .sp-body { padding: 1.4rem 1.4rem 1.2rem; }
.sp-signin-card button { margin-bottom: 0; }
.sp-tagline { text-align: center; font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .16em; color: var(--sp-muted); margin-top: 1.2rem; }
@media (max-width: 48rem) {
.sp-ledger summary { grid-template-columns: 1fr auto 1.2rem; }
.sp-ledger summary span.sp-muted { grid-column: 1 / -1; grid-row: 2; }
.sp-ledger summary::after { grid-column: 3; }
.sp-ledger .sp-detail { padding-left: 1.2rem; }
.sp-kv { grid-template-columns: 1fr; gap: .15rem; }
.sp-kv dt { padding-top: .5rem; }
.sp-note { margin-left: 0; }
.sp-pages .sp-wrap { overflow-x: auto; white-space: nowrap; }
}
-212
View File
@@ -1,212 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
<div class="navbar-collapse collapse show">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
<li class="sp-nav-label">Outbound</li>
<li class="nav-item active"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
<li class="sp-nav-label">Inbound</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="sp-nav-label">Server</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
</ul>
<ul class="navbar-nav sp-nav-foot">
<li class="sp-nav-user">Signed in as admin</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
</ul>
</div>
</div>
</aside>
<div class="page-wrapper">
<div class="page-header">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle"><a href="#" class="text-reset">Outbound domains</a> /</div>
<h2 class="page-title">example.org <span class="badge bg-red-lt ms-2">DNS: 1 mismatch</span></h2>
</div>
<div class="col-auto">
<div class="btn-list">
<form method="post" action="#"><button type="submit" class="btn"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form>
<a href="#" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Add application</a>
</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="row row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">DNS records</h3><div class="card-actions text-secondary small">Checked 3 min ago</div></div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">DKIM</h4><span class="badge bg-green-lt">published</span>
<span class="ms-auto text-secondary small">Not a secret · selector <code>sp2026</code></span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="sp2026._domainkey.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><textarea class="form-control" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
</div>
</div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">SPF</h4><span class="badge bg-red-lt">mismatch</span>
<span class="ms-auto text-secondary small">Merge into the existing record — never publish a second one</span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><input class="form-control" readonly value="v=spf1 ip4:203.0.113.25 ~all"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-12">
<label class="form-label">In DNS now</label>
<input class="form-control" readonly value="v=spf1 include:_spf.google.com ~all">
<div class="text-danger small mt-2">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</div>
</div>
</div>
</div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">DMARC</h4><span class="badge bg-green-lt">published</span>
<span class="ms-auto text-secondary small">Report address from <a href="#">Settings</a></span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="_dmarc.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><input class="form-control" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Connection</h3></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Server</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
<div class="datagrid-item"><div class="datagrid-title">Ports</div><div class="datagrid-content font-monospace">465 SSL/TLS<br>587 STARTTLS</div></div>
</div>
<p class="text-secondary small mt-3 mb-0">The same for every domain. Authenticate with an application login.</p>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Sent, 30 days</h3></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Messages</div><div class="datagrid-content h2 m-0">1 284</div></div>
<div class="datagrid-item"><div class="datagrid-title">Peak</div><div class="datagrid-content h2 m-0">96 <span class="text-secondary fs-5">/h</span></div></div>
<div class="datagrid-item"><div class="datagrid-title">Average</div><div class="datagrid-content h2 m-0">1.8 <span class="text-secondary fs-5">/h</span></div></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Domain settings</h3></div>
<div class="list-group list-group-flush">
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>DMARC reports</div><div class="text-secondary small">Same as Settings</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Rate limit <span class="badge bg-green-lt ms-1">active</span></div><div class="text-secondary small">Auto ×3 — 288 messages / hour</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Export</div><div class="text-secondary small">Key and applications as one file</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center text-danger"><div>Delete domain</div><i class="ti ti-chevron-right ms-auto"></i></a>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header"><h3 class="card-title">Applications</h3><div class="card-actions text-secondary small">SASL logins that may send as this domain</div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th class="w-1"></th></tr></thead>
<tbody>
<tr>
<td class="font-monospace fw-semibold">prod-server</td><td>*@example.org</td><td class="text-secondary">1 102 msg · peak 96/h</td>
<td><span class="badge bg-green-lt">200 / h</span></td>
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
</tr>
<tr>
<td class="font-monospace fw-semibold">alerts</td><td>alerts@example.org, noc@example.org</td><td class="text-secondary">182 msg · peak 14/h</td>
<td><span class="badge bg-secondary-lt">domain</span> <span class="badge bg-green-lt">2 IPs</span></td>
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer footer-transparent">
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</footer>
</div>
</div>
</body>
</html>
-36
View File
@@ -1,36 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4"><img class="sp-signin-mark" src="../../selfpost-stamp.svg" alt="SelfPost"></div>
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-4">Sign in</h2>
<form method="post" action="status.html">
<div class="mb-3">
<label class="form-label" for="username">Username</label>
<input class="form-control" id="username" name="username" autocomplete="username" required>
</div>
<div class="mb-2">
<label class="form-label" for="password">Password</label>
<input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="form-footer"><button type="submit" class="btn btn-primary w-100">Sign in</button></div>
</form>
</div>
</div>
<div class="text-center sp-tagline mt-4">YOUR SMTP. YOUR RULES.</div>
<div class="text-center sp-foot mt-3">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</div>
</div>
</body>
</html>
-168
View File
@@ -1,168 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
<div class="navbar-collapse collapse show">
<ul class="navbar-nav">
<li class="nav-item active"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
<li class="sp-nav-label">Outbound</li>
<li class="nav-item"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
<li class="sp-nav-label">Inbound</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="sp-nav-label">Server</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
</ul>
<ul class="navbar-nav sp-nav-foot">
<li class="sp-nav-user">Signed in as admin</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
</ul>
</div>
</div>
</aside>
<div class="page-wrapper">
<div class="page-header">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">mail.example.org</div>
<h2 class="page-title">Overview</h2>
</div>
<div class="col-auto text-secondary small">Updated 14:02:31 UTC · refreshes every 10 s</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="alert alert-warning bg-white" role="alert">
<div class="d-flex">
<div><i class="ti ti-alert-triangle alert-icon"></i></div>
<div>
<h4 class="alert-title">Mail is flowing. One thing needs attention.</h4>
<div class="text-secondary">The TLS certificate expires in 12 days and has not renewed yet.</div>
</div>
</div>
</div>
<div class="row row-cards mb-3">
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-cpu sp-tile-icon"></i><div><div class="fw-semibold">Machine</div><div class="text-secondary small">CPU 12 % · mem 41 %</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-server-cog sp-tile-icon"></i><div><div class="fw-semibold">Processes</div><div class="text-secondary small">5 of 5 running</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-stack-2 sp-tile-icon"></i><div><div class="fw-semibold">Queues</div><div class="text-secondary small">Both empty</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-certificate sp-tile-icon"></i><div><div class="fw-semibold">TLS certificate</div><div class="text-secondary small">Expires in 12 days</div></div><span class="status status-yellow"><span class="status-dot"></span>Warn</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-plug-connected sp-tile-icon"></i><div><div class="fw-semibold">Milter sockets</div><div class="text-secondary small">2 of 2 answering</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-arrows-exchange sp-tile-icon"></i><div><div class="fw-semibold">Reverse DNS</div><div class="text-secondary small">Forward = reverse</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
</div>
<div class="row row-deck row-cards">
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Machine</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><progress class="sp-bar" value="12" max="100"></progress>12 %</td><td class="text-secondary">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><progress class="sp-bar" value="41" max="100"></progress>41 %</td><td class="text-secondary">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="text-secondary">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Processes</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 39, uptime 18 days</td></tr>
<tr><td>journal-milter</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 40, uptime 18 days</td></tr>
<tr><td>panel</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 44, uptime 18 days</td></tr>
<tr><td>dmarc-ingest</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 47, uptime 18 days</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">TLS certificate</h3><div class="card-actions"><span class="badge bg-yellow-lt">warn</span></div></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Expires</div><div class="datagrid-content font-monospace">2026-10-03 09:14 UTC</div></div>
<div class="datagrid-item"><div class="datagrid-title">Names</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
</div>
<p class="text-secondary mt-3 mb-0">Renewal normally runs 30 days before expiry. Check port 80 and the <a href="#">system log</a>.</p>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Milter sockets</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<table class="table table-vcenter card-table">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>
<tr><td>opendkim<div class="text-secondary small font-monospace">inet:127.0.0.1:8891</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
<tr><td>journal-milter<div class="text-secondary small font-monospace">unix:/run/selfpost/journal.sock</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Hostname and reverse DNS</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Hostname</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
<div class="datagrid-item"><div class="datagrid-title">Lookup</div><div class="datagrid-content font-monospace">→ 203.0.113.25<br>← mail.example.org</div></div>
</div>
</div>
<div class="card-footer"><form method="post" action="#"><button type="submit" class="btn btn-sm"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form></div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer footer-transparent">
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</footer>
</div>
</div>
</body>
</html>
-80
View File
@@ -1,80 +0,0 @@
/* Direction B — "Console". Tabler (Bootstrap 5 admin kit) carries the shell,
cards, tables, badges, input groups and the sign-in page as shipped; this
file only re-points Tabler's variables at the brand and adds the two things
Tabler has no word for: a sidebar group label and the DNS record block. */
:root, [data-bs-theme=light] {
--tblr-font-sans-serif: var(--sp-sans);
--tblr-font-monospace: var(--sp-mono);
--tblr-body-bg: var(--sp-paper);
--tblr-body-color: var(--sp-ink);
--tblr-secondary: var(--sp-muted);
--tblr-border-color: var(--sp-rule);
--tblr-border-radius: 3px;
--tblr-border-radius-lg: 4px;
--tblr-primary: #7a3b2e;
--tblr-primary-rgb: 122, 59, 46;
--tblr-link-color: #7a3b2e;
--tblr-link-color-rgb: 122, 59, 46;
--tblr-link-hover-color: #632f25;
--tblr-link-hover-color-rgb: 99, 47, 37;
--tblr-green: #2c6b43; --tblr-green-rgb: 44, 107, 67;
--tblr-yellow: #b5700f; --tblr-yellow-rgb: 181, 112, 15;
--tblr-red: #b42318; --tblr-red-rgb: 180, 35, 24;
--tblr-success: #2c6b43; --tblr-success-rgb: 44, 107, 67;
--tblr-warning: #b5700f; --tblr-warning-rgb: 181, 112, 15;
--tblr-danger: #b42318; --tblr-danger-rgb: 180, 35, 24;
}
body { font-feature-settings: normal; }
.btn-primary { --tblr-btn-hover-bg: #632f25; --tblr-btn-active-bg: #632f25; }
code, .font-monospace { font-family: var(--sp-mono); }
/* ---- sidebar: ink, with the stamp on its own paper */
.navbar-vertical {
--tblr-navbar-bg: var(--sp-ink); background: var(--sp-ink);
--tblr-navbar-color: #aab2bd; --tblr-navbar-hover-color: #fff; --tblr-navbar-active-color: #fff;
--tblr-navbar-border-color: transparent;
}
.navbar-vertical .navbar-brand { padding: 1.1rem 0 .9rem; justify-content: flex-start; }
.navbar-vertical .navbar-brand img { height: 52px; width: auto; }
.navbar-vertical .nav-link { padding-block: .42rem; }
.navbar-vertical .nav-link-icon { font-size: 1.15rem; }
.navbar-vertical .nav-item.active > .nav-link {
background: rgb(255 255 255 / .07); box-shadow: inset 3px 0 0 #c4705e; color: #fff;
}
.sp-nav-label {
font-family: var(--sp-mono); font-size: .66rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; color: #6f7a88; padding: 1.1rem 1.5rem .3rem;
}
.sp-nav-foot { margin-top: auto; padding-bottom: .8rem; border-top: 1px solid rgb(255 255 255 / .08); padding-top: .6rem; }
.sp-nav-user { color: #6f7a88; font-size: .8rem; padding: .3rem 1.5rem; }
/* ---- page */
.page-pretitle { font-family: var(--sp-mono); letter-spacing: .1em; }
.card-title { font-weight: 600; }
.table thead th { font-family: var(--sp-mono); letter-spacing: .08em; }
.sp-tile .card-body { display: flex; align-items: center; gap: .8rem; padding: .85rem 1rem; }
.sp-tile .sp-tile-icon { font-size: 1.5rem; color: var(--sp-muted); }
.sp-tile .fw-semibold { line-height: 1.2; }
.sp-tile .status { margin-left: auto; }
progress.sp-bar {
appearance: none; -webkit-appearance: none; width: 8rem; height: .4rem; border: 0;
border-radius: 1rem; overflow: hidden; vertical-align: middle; margin-right: .6rem; background: var(--sp-well);
}
progress.sp-bar::-webkit-progress-bar { background: var(--sp-well); }
progress.sp-bar::-webkit-progress-value { background: var(--tblr-green); }
progress.sp-bar::-moz-progress-bar { background: var(--tblr-green); }
/* ---- the DNS record block: Tabler's read-only input group, plus a status */
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); }
.sp-record .form-control[readonly], .sp-record textarea[readonly] {
font-family: var(--sp-mono); font-size: .8rem; background: var(--sp-well);
}
.sp-record textarea { resize: none; }
.sp-record .form-label { font-size: .75rem; color: var(--sp-muted); margin-bottom: .25rem; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; }
.sp-signin-mark { width: 15rem; height: auto; }
.sp-tagline { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .16em; color: var(--sp-muted); }
-200
View File
@@ -1,200 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="status.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="domain.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="domain.html">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
<a class="navbar-item" href="#">DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">System log</a>
<a class="navbar-item" href="#">Backup</a>
<a class="navbar-item" href="#">Users</a>
<a class="navbar-item" href="#">Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="#">Account</a>
<a class="navbar-item" href="#">Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html">Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<main>
<div class="container sp-page">
<div class="level mb-5">
<div class="level-left">
<div>
<p class="sp-kicker"><a href="status.html">Outbound domains</a> /</p>
<h1 class="title is-3 mb-0">example.org</h1>
</div>
</div>
<div class="level-right">
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button">Re-check DNS</button></form>
<a class="button is-primary" href="#">Add application</a>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">This domain</p>
<ul class="menu-list">
<li><a class="is-active" href="#dns">DNS records <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#apps">Applications <span class="tag is-light">2</span></a></li>
<li><a href="#connection">Connection</a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a href="#">DMARC reports</a></li>
<li><a href="#">Rate limit <span class="tag is-success is-light">active</span></a></li>
<li><a href="#">Export</a></li>
<li><a class="has-text-danger" href="#">Delete domain</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field">
<label class="label">In DNS now</label>
<div class="control"><input class="input" readonly value="v=spf1 include:_spf.google.com ~all"></div>
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
</div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address from <a href="#">Settings</a></small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
</div>
</div>
<div class="box" id="apps">
<div class="sp-box-head"><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
<tr>
<td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td>
<td><span class="tag is-success is-light">200 / h</span></td>
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
</tr>
<tr>
<td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td>
<td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
</tr>
</tbody>
</table>
</div>
<div class="columns">
<div class="column">
<div class="box" id="connection">
<div class="sp-box-head"><h2>Connection</h2><div class="sp-end">Same for every domain</div></div>
<p class="sp-mono">mail.example.org</p>
<p class="sp-mono sp-muted">465 SSL/TLS · 587 STARTTLS</p>
</div>
</div>
<div class="column">
<div class="box">
<div class="sp-box-head"><h2>Sent, 30 days</h2></div>
<div class="sp-stats">
<div><b>1 284</b><span class="sp-muted">messages</span></div>
<div><b>96</b><span class="sp-muted">peak / h</span></div>
<div><b>1.8</b><span class="sp-muted">average / h</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
-38
View File
@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form method="post" action="status.html">
<h1 class="title is-3">Sign in</h1>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control"><input class="input" id="username" name="username" autocomplete="username" required></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
-117
View File
@@ -1,117 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item is-active" href="status.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="domain.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="domain.html">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
<a class="navbar-item" href="#">DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">System log</a>
<a class="navbar-item" href="#">Backup</a>
<a class="navbar-item" href="#">Users</a>
<a class="navbar-item" href="#">Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="#">Account</a>
<a class="navbar-item" href="#">Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html">Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<main>
<div class="container sp-page">
<div class="level mb-5">
<div class="level-left">
<div>
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3 mb-0">Overview</h1>
</div>
</div>
<div class="level-right sp-muted is-size-7">Updated 14:02:31 UTC · refreshes every 10 s</div>
</div>
<div class="box sp-health">
<a href="#"><div class="sp-h-name">Machine</div><div class="sp-h-value">12 % CPU</div><div class="sp-h-sub">41 % memory</div></a>
<a href="#"><div class="sp-h-name">Processes</div><div class="sp-h-value">5 of 5</div><div class="sp-h-sub">running</div></a>
<a href="#"><div class="sp-h-name">Queues</div><div class="sp-h-value">Empty</div><div class="sp-h-sub">outbound and inbound</div></a>
<a href="#" class="warn"><div class="sp-h-name">TLS certificate</div><div class="sp-h-value">12 days</div><div class="sp-h-sub">renewal has not run</div></a>
<a href="#"><div class="sp-h-name">Milter sockets</div><div class="sp-h-value">2 of 2</div><div class="sp-h-sub">answering</div></a>
<a href="#"><div class="sp-h-name">Reverse DNS</div><div class="sp-h-value">Match</div><div class="sp-h-sub">203.0.113.25</div></a>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><h2>Outbound domains</h2><div class="sp-end"><a class="button is-small is-primary" href="#">Add domain</a></div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>30 days</th></tr></thead>
<tbody>
<tr><td><a href="domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>2</td><td class="sp-muted">1 284 msg</td></tr>
<tr><td><a href="#"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>1</td><td class="sp-muted">18 920 msg</td></tr>
<tr><td><a href="#"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td>3</td><td class="sp-muted">402 msg</td></tr>
</tbody>
</table>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><h2>Inbound domains</h2><div class="sp-end"><a class="button is-small" href="#">Add domain</a></div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
<tbody>
<tr><td><a href="#"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">mx.internal:25</td></tr>
<tr><td><a href="#"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">10.0.4.12:2525</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
-98
View File
@@ -1,98 +0,0 @@
/* Direction C — "Counter". Bulma v1 (pure CSS, no JavaScript, no data: URIs)
carries the navbar with hover dropdowns, columns, boxes, tables, tags,
the side menu and input-with-addon fields. This file points Bulma's HSL
variables at the brand and adds the health strip and the record block. */
:root {
--bulma-family-primary: var(--sp-sans);
--bulma-family-secondary: var(--sp-sans);
--bulma-family-code: var(--sp-mono);
--bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%;
--bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%;
--bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%;
--bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%;
--bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%;
--bulma-radius: 6px; --bulma-radius-large: 10px;
--bulma-body-size: 15px;
/* Bulma derives link text and the on-primary colour from lightness steps;
at the brick's 33 % both land off-brand, so they are pinned. */
--bulma-link-text: var(--sp-brick);
--bulma-link-text-hover: var(--sp-brick-dark);
}
.button.is-primary, .button.is-primary:hover { color: #fff; }
a strong { color: inherit; }
html { background: var(--sp-paper); }
body { background: var(--sp-paper); color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; }
main { flex: 1; }
code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .85em; }
/* ---- navbar: brick, the stamp on it, hover dropdowns */
.navbar.is-primary { --bulma-navbar-height: 4rem; }
.navbar-brand img { max-height: 3rem; height: 3rem; }
.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; }
.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); }
.navbar.is-primary .navbar-start > .navbar-item.is-active,
.navbar.is-primary .navbar-start > .has-dropdown.sp-current > .navbar-link { box-shadow: inset 0 -3px 0 var(--sp-on-brick); }
.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); }
.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; }
.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
/* ---- page */
.sp-page { padding-block: 2rem 2.5rem; }
.sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); }
.sp-kicker a { color: inherit; }
.title { color: var(--sp-ink); font-weight: 600; }
.box { box-shadow: none; border: 1px solid var(--sp-rule); }
.box > .sp-box-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
.box > .sp-box-head h2 { font-size: 1.1rem; font-weight: 600; margin: 0; }
.box > .sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; }
.table { background: transparent; }
.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.table td { vertical-align: middle; }
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
.sp-muted { color: var(--sp-muted); }
.sp-mono { font-family: var(--sp-mono); }
/* ---- health strip */
.sp-health { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; padding: 0; overflow: hidden; }
.sp-health > a {
display: block; padding: 1rem 1.2rem; color: var(--sp-ink); border-right: 1px solid var(--sp-rule);
box-shadow: inset 0 3px 0 var(--sp-ok);
}
.sp-health > a:last-child { border-right: 0; }
.sp-health > a:hover { background: var(--sp-paper); }
.sp-health > a.warn { box-shadow: inset 0 3px 0 #d9962b; background: var(--sp-warn-bg); }
.sp-health > a.fail { box-shadow: inset 0 3px 0 var(--sp-fail); background: var(--sp-fail-bg); }
.sp-health .sp-h-name { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.sp-health .sp-h-value { font-size: 1.15rem; font-weight: 600; line-height: 1.3; }
.sp-health .sp-h-sub { font-size: .8rem; color: var(--sp-muted); }
/* ---- domain page */
.sp-side { position: sticky; top: 1.5rem; }
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
.menu-list a { display: flex; align-items: center; gap: .5rem; }
.menu-list a .tag { margin-left: auto; }
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); margin-top: 1.25rem; padding-top: 1.25rem; }
.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; }
.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; }
.sp-record-head small { margin-left: auto; color: var(--sp-muted); }
.sp-record .label { font-size: .75rem; font-weight: 400; color: var(--sp-muted); margin-bottom: .25rem; }
.sp-record .input[readonly], .sp-record .textarea[readonly] {
font-family: var(--sp-mono); font-size: .82rem; background: var(--sp-well); box-shadow: none;
}
.sp-record .textarea { resize: none; min-height: 0; }
.sp-record .field:not(:last-child) { margin-bottom: .6rem; }
.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; }
.sp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.sp-stats b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
/* ---- sign-in */
.sp-signin { min-height: 100vh; margin: 0; }
.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; }
.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; }
.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; }
.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; }
.sp-signin-form form { width: 21rem; max-width: 100%; margin-inline: auto; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.2rem; }
.sp-foot a { color: inherit; text-decoration: underline; }
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

-22
View File
@@ -1,22 +0,0 @@
/* Cover sheet for the three directions. No library on purpose: it must not
look like any one of them. */
body { margin: 0; background: var(--sp-paper); color: var(--sp-ink); font: 16px/1.55 var(--sp-sans); }
main { max-width: 68rem; margin: 0 auto; padding: 3rem 1.5rem 4rem; }
h1 { font-size: 2rem; font-weight: 600; margin: .2rem 0 1rem; text-wrap: balance; }
h2 { font-size: 1.15rem; font-weight: 600; margin: 2.6rem 0 .8rem; }
h3 { font-size: 1.4rem; font-weight: 600; margin: .1rem 0 .6rem; }
.kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-brick); margin: 0; }
.lead { max-width: 46rem; color: var(--sp-ink-2); }
ul { padding-left: 1.2rem; max-width: 52rem; }
li { margin-bottom: .5rem; }
a { color: var(--sp-brick); }
code { font-family: var(--sp-mono); font-size: .85em; background: var(--sp-well); padding: .1em .35em; border-radius: 3px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1.2rem; }
.cards section { background: var(--sp-sheet); border: 1px solid var(--sp-rule); border-top: 3px solid var(--sp-brick); padding: 1.3rem 1.4rem; display: flex; flex-direction: column; }
.cards p { margin: 0 0 1rem; }
.links { margin-top: auto !important; margin-bottom: 0 !important; display: flex; gap: 1.1rem; font-weight: 600; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; background: var(--sp-sheet); border: 1px solid var(--sp-rule); font-size: .93rem; }
th, td { text-align: left; vertical-align: top; padding: .6rem .9rem; border-bottom: 1px solid var(--sp-rule); }
thead th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
tbody th { font-weight: 600; white-space: nowrap; }
-138
View File
@@ -1,138 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel redesign · three directions</title>
<link rel="stylesheet" href="shared/brand.css">
<link rel="stylesheet" href="index.css">
</head>
<body>
<main>
<p class="kicker">SelfPost · panel redesign · stage 2</p>
<h1>The accepted direction, every screen</h1>
<p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one
or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section
heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-four screens and a components page live in
<a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own
fonts and mark by relative path.</p>
<h2>Screen map</h2>
<div class="scroll">
<table>
<tbody>
<tr><th>Signed out</th><td><a href="panel/login.html">Sign in</a> · <a href="panel/login-no-admin.html">sign in before an administrator exists</a> · <a href="panel/setup.html">Create administrator</a></td></tr>
<tr><th>Overview</th><td><a href="panel/overview.html">Overview</a> — postmark verdict, six health cards, both domain lists</td></tr>
<tr><th>Outbound</th><td><a href="panel/out-domains.html">Domains</a> · <a href="panel/out-domain.html">domain</a> ·
<a href="panel/out-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> ·
<a href="panel/out-app-created.html">password shown once</a> · <a href="panel/out-domain-delete.html">delete domain</a><br>
<a href="panel/out-log.html">Log</a> · <a href="panel/out-message.html">message</a> ·
<a href="panel/out-queue.html">Queue</a> · <a href="panel/dmarc.html">DMARC reports</a> ·
<a href="panel/dmarc-domain.html">by domain</a> · <a href="panel/dmarc-report.html">one report</a></td></tr>
<tr><th>Inbound</th><td><a href="panel/in-domains.html">Domains</a> · <a href="panel/in-domain.html">domain</a> ·
<a href="panel/in-domain-delete.html">delete domain</a> · <a href="panel/in-log.html">Log</a> ·
<a href="panel/in-queue.html">Queue</a> (empty state)<br>
<a href="panel/in-quarantine.html">Quarantine</a> · <a href="panel/in-quarantine-message.html">held message</a> ·
<a href="panel/in-filter-lists.html">Filter lists</a> — roadmap items, drawn as their target look</td></tr>
<tr><th>Server</th><td><a href="panel/health.html">Health</a> · <a href="panel/preflight.html">Preflight</a> (roadmap) · <a href="panel/system-log.html">System log</a> ·
<a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> ·
<a href="panel/user-delete.html">delete user</a> · <a href="panel/settings.html">Settings</a></td></tr>
<tr><th>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr>
<tr><th>Contract</th><td><a href="panel/components.html">Components</a> — every building block in every state ·
<a href="../../plans/panel-redesign.md">the plan</a> · <code>panel/check.py</code> lints the mockups and writes <code>panel/outlines.json</code></td></tr>
</tbody>
</table>
</div>
<h2>What changed against today's panel</h2>
<ul>
<li><strong>Overview shows verdicts, Health shows tables.</strong> Process lists, socket paths and interface counters moved to
<em>Server › Health</em>, one click from each card.</li>
<li><strong>Every page is one of three layouts</strong>: a list (form on top, table below), a detail page (sticky side menu +
numbered boxes), or a form page (boxes in equal columns). No page picks its own width.</li>
<li><strong>Own password is Account</strong>, instance defaults are Settings. Export sits with the domain, import with Backup,
and each links to the other.</li>
<li><strong>Queues are tables</strong>, not a <code>postqueue -p</code> dump — this needs <code>postqueue -j</code> parsing.
The inbound queue is the same queue filtered by recipient domain.</li>
<li><strong>Confirm pages say what will be lost</strong> as a list, beside a hint for the usual alternative (export before delete).</li>
<li>Own components on top of Bulma: postmark, perforated edge, numbered box head, health card, DNS record block, facts grid,
timeline, log pane. The mockup's <code>theme.css</code> is about 200 lines.</li>
<li><code>panel/build.py</code> wraps the fragments in <code>panel/src/</code> with the shared navbar; the built pages are committed,
so nothing has to be run to view them.</li>
</ul>
<h2>Stage 1 · three directions on three key screens</h2>
<p class="lead">Kept for the record: Overview, an outbound domain and sign-in, drawn three ways.</p>
<h2>Shared by all three</h2>
<ul>
<li><strong>Navigation by mail direction.</strong> Overview · <em>Outbound</em> (Domains, Log, Queue, DMARC reports) ·
<em>Inbound</em> (Domains, Log, Queue) · <em>Server</em> (System log, Backup, Users, Settings). Account, Help and
Sign out live with the user name. Eleven flat entries become four groups.</li>
<li><strong>One block per DNS record.</strong> Today "DNS status" and "records to publish" show every host twice.
Here a record is one block: its status, what to publish, and — only on a mismatch — what DNS holds now.</li>
<li><strong>The domain page stops being nine cards.</strong> DNS and applications are the page; limits, report address,
export and delete are "rarely changed" and sit behind one click.</li>
<li><strong>Applications are a table</strong>; editing one is its own form page instead of a three-column panel inside a list row.</li>
<li>Brand carriers stay: the stamp, brick, IBM Plex. No inline <code>style</code> or script in any mockup.</li>
</ul>
<h2>The directions</h2>
<div class="cards">
<section>
<p class="kicker">A · Pico CSS 2.1</p>
<h3>Blank</h3>
<p>The panel as a postal form: one centred sheet, numbered sections, mono field labels, a postmark for the
verdict. Overview is a ledger — one row per check, the row opens into its detail. Sections on the first
header row, the section's pages on the second.</p>
<p class="links"><a href="a/status.html">Overview</a><a href="a/domain.html">Domain</a><a href="a/login.html">Sign in</a></p>
</section>
<section>
<p class="kicker">B · Tabler 1.5 (Bootstrap 5)</p>
<h3>Console</h3>
<p>The classic admin kit: ink sidebar with every page one click away, status tiles, cards and tables in a
12-column grid, read-only input groups with Copy. Almost nothing is ours; it also looks the most like
every other admin panel.</p>
<p class="links"><a href="b/status.html">Overview</a><a href="b/domain.html">Domain</a><a href="b/login.html">Sign in</a></p>
</section>
<section>
<p class="kicker">C · Bulma 1.0</p>
<h3>Counter</h3>
<p>Brick top bar with CSS-only hover menus. Overview is the whole service on one screen: a health strip over
the outbound and inbound domain lists. The domain page gets a sticky side menu. Softer, rounder, the
boldest use of the brand colour.</p>
<p class="links"><a href="c/status.html">Overview</a><a href="c/domain.html">Domain</a><a href="c/login.html">Sign in</a></p>
</section>
</div>
<h2>What each one costs</h2>
<div class="scroll">
<table>
<thead><tr><th></th><th>A · Pico</th><th>B · Tabler</th><th>C · Bulma</th></tr></thead>
<tbody>
<tr><th>Licence</th><td>MIT</td><td>MIT</td><td>MIT</td></tr>
<tr><th>Vendored CSS (minified)</th><td>83 KB</td><td>694 KB</td><td>678 KB</td></tr>
<tr><th>JavaScript</th><td>none</td><td>none for these screens; Bootstrap bundle only if dropdowns / modals are wanted</td><td>none</td></tr>
<tr><th>Build step</th><td>none</td><td>none</td><td>none (a Sass build could trim it; not needed)</td></tr>
<tr><th>CSP <code>default-src 'self'</code></th>
<td>needs <code>img-src 'self' data:</code> — 14 <code>data:</code> SVGs (select chevron, checkbox tick)</td>
<td>needs <code>img-src 'self' data:</code> — 28 <code>data:</code> SVGs</td>
<td>works as is — no <code>data:</code> URIs</td></tr>
<tr><th>Our own CSS in the mockup</th><td>≈ 200 lines</td><td>≈ 80 lines</td><td>≈ 100 lines</td></tr>
<tr><th>Markup style</th><td>semantic HTML, few classes — closest to today's templates</td><td>class-heavy Bootstrap markup</td><td>class-based, moderate</td></tr>
<tr><th>Own components</th><td>sheet, ledger row, postmark, record block</td><td>sidebar group label, record block</td><td>health strip, record block</td></tr>
<tr><th>Recognisable</th><td>most</td><td>least</td><td>middle</td></tr>
</tbody>
</table>
</div>
<h2>Open points</h2>
<ul>
<li>Icons in B come from Tabler Icons (MIT) through its webfont for the mockup; the panel would vendor only the used glyphs as an SVG sprite.</li>
<li><em>Inbound log</em> rides on the agreed <code>inbound-antispam-panel</code> journal; <em>Inbound queue</em> is a new filter over the one Postfix queue and needs its own roadmap row.</li>
<li>Dark scheme and the narrow-screen shell are not drawn at this stage; all three libraries ship both.</li>
<li>The earlier <a href="../panel-ui/index.html">panel-ui</a> experiment and <a href="../../plans/panel-redesign.md">panel-redesign.md</a> are superseded by whichever direction is accepted.</li>
</ul>
</main>
</body>
</html>
@@ -1,115 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Account · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="cur">Current password</label><div class="control"><input class="input" id="cur" name="current_password" type="password" autocomplete="current-password" required></div></div>
<div class="sp-pair">
<div class="field"><label class="label" for="new">New password</label><div class="control"><input class="input" id="new" name="new_password" type="password" autocomplete="new-password" required></div></div>
<div class="field"><label class="label" for="new2">Repeat</label><div class="control"><input class="input" id="new2" name="new_password_confirm" type="password" autocomplete="new-password" required></div></div>
</div>
<p class="help mb-3">Changing it signs out every other session of this account; this one stays signed in.</p>
<button type="submit" class="button">Change password</button>
</form>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
-55
View File
@@ -1,55 +0,0 @@
/* Rendered-page audit for the panel: what check.py cannot see because it needs
a browser. Open any page of the mockups (or of the running panel), paste this
file into the console, then:
await panelAudit(["overview.html", "out-domain.html", ...]) // same-origin URLs
await panelAudit() // the page you are on
It loads each page in a 1440px and a 1280px frame and reports
- text under the 13px floor (the postmark's imprint excepted),
- text under 4.5:1 against the ground it actually sits on,
- anything wider than its box, and tables that have to scroll,
- health-card values that got clipped.
An empty `problems` list is the pass. Attach the output to the step's evidence
(docs/plans/panel-redesign.md, Enforcement). */
async function panelAudit(urls = [location.href], widths = [1440, 1280]) {
const lum = (c) => {
const [r, g, b] = c.map((v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const rgba = (s) => { const m = s.match(/[\d.]+/g).map(Number); return { rgb: m.slice(0, 3), a: m.length > 3 ? m[3] : 1 }; };
const ground = (el, win) => {
for (let e = el; e; e = e.parentElement) { const b = rgba(win.getComputedStyle(e).backgroundColor); if (b.a > 0.5) return b.rgb; }
return [255, 255, 255];
};
const problems = new Set();
let texts = 0;
for (const url of urls) for (const width of widths) {
const f = document.createElement("iframe");
f.width = width; f.height = 900; f.src = url;
document.body.appendChild(f);
await new Promise((r) => (f.onload = r));
await f.contentDocument.fonts.ready;
await new Promise((r) => setTimeout(r, 150));
const d = f.contentDocument, win = f.contentWindow, at = `${width} ${url.split("/").pop()}`;
const walker = d.createTreeWalker(d.body, NodeFilter.SHOW_TEXT);
for (let n; (n = walker.nextNode()); ) {
const text = n.textContent.trim(), el = n.parentElement;
if (!text || el.closest(".sp-log")) continue; // the log pane has its own dark ground and palette
texts++;
const cs = win.getComputedStyle(el), what = `${el.tagName.toLowerCase()}.${[...el.classList].join(".")} "${text.slice(0, 24)}"`;
if (parseFloat(cs.fontSize) < 12.9 && !el.closest(".sp-postmark")) problems.add(`${at}: ${parseFloat(cs.fontSize)}px ${what}`);
const fg = rgba(cs.color).rgb, bg = ground(el, win), a = lum(fg), b = lum(bg);
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const large = parseFloat(cs.fontSize) >= 24 || (parseFloat(cs.fontSize) >= 18.6 && +cs.fontWeight >= 600);
if (ratio < (large ? 3 : 4.5)) problems.add(`${at}: contrast ${ratio.toFixed(2)} ${what}`);
}
if (d.documentElement.scrollWidth > win.innerWidth) problems.add(`${at}: the page scrolls sideways`);
for (const e of d.querySelectorAll(".box, .sp-head, .sp-box-head")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: overflow in .${e.className}`);
for (const e of d.querySelectorAll(".table-container")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: a table scrolls by ${e.scrollWidth - e.clientWidth}px`);
for (const e of d.querySelectorAll(".sp-h-name, .sp-h-value, .sp-h-sub")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: clipped "${e.textContent}"`);
for (const i of d.querySelectorAll("i.ti")) if (win.getComputedStyle(i, "::before").content === "none") problems.add(`${at}: missing icon ${i.className}`);
f.remove();
}
return { pages: urls.length, widths, textsChecked: texts, problems: [...problems] };
}
@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item is-active" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Backup</h1>
<p class="sp-lead">Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets.</p>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. The Postfix queue is left out: it cannot be copied reliably while the server is running. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="#">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
<div class="sp-pair">
<div class="field"><label class="label" for="pw">Password</label><div class="control"><input class="input" id="pw" type="password" placeholder="at least 12 characters"></div></div>
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div>
</div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
<div class="field"><label class="label">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
<div class="field"><label class="label" for="ipw">Password</label><div class="control"><input class="input" id="ipw" name="import_password" type="password" autocomplete="off"></div>
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
<button type="submit" class="button is-primary"><i class="ti ti-file-import"></i>Import domain</button>
</form>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
"""Wrap every fragment in src/ with the panel shell and write it next to this file.
The mockups are static HTML, but thirty pages share one navbar; keeping that
navbar in one place is the only reason this script exists. Run it from
anywhere: python docs/assets/panel-redesign/panel/build.py
A fragment starts with one meta comment:
<!-- title: Outbound domains | section: outbound | page: domains -->
section/page pick the highlighted entries; "shell: bare" skips the navbar
(sign-in, setup).
"""
import pathlib
import re
HERE = pathlib.Path(__file__).parent
NAV = [
("overview", "Overview", "overview.html", "ti-activity-heartbeat", []),
("outbound", "Outbound", "out-domains.html", "ti-send", [
("domains", "Domains", "out-domains.html", "ti-world-upload"),
("log", "Log", "out-log.html", "ti-list-details"),
("queue", "Queue", "out-queue.html", "ti-stack-2"),
("dmarc", "DMARC reports", "dmarc.html", "ti-report-analytics"),
]),
("inbound", "Inbound", "in-domains.html", "ti-inbox", [
("domains", "Domains", "in-domains.html", "ti-world-download"),
("log", "Log", "in-log.html", "ti-list-details"),
("queue", "Queue", "in-queue.html", "ti-stack-2"),
("quarantine", "Quarantine", "in-quarantine.html", "ti-mail-pause"),
("filter-lists", "Filter lists", "in-filter-lists.html", "ti-filter"),
]),
("server", "Server", "health.html", "ti-server-2", [
("health", "Health", "health.html", "ti-heartbeat"),
("preflight", "Preflight", "preflight.html", "ti-checklist"),
("system-log", "System log", "system-log.html", "ti-file-text"),
("backup", "Backup", "backup.html", "ti-archive"),
("users", "Users", "users.html", "ti-users"),
("settings", "Settings", "settings.html", "ti-settings"),
]),
]
HEAD = """<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
"""
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
' · <a href="#">Source</a> · No warranty</footer>')
def navbar(section, page):
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>',
' <div class="navbar-menu is-active">', ' <div class="navbar-start">']
for key, label, href, _icon, pages in NAV:
cur = " sp-current" if key == section else ""
if not pages:
out.append(f' <a class="navbar-item{cur}" href="{href}">{label}</a>')
continue
out.append(f' <div class="navbar-item has-dropdown is-hoverable{cur}">')
out.append(f' <a class="navbar-link" href="{href}">{label}</a>')
out.append(' <div class="navbar-dropdown">')
for pkey, plabel, phref, picon in pages:
act = " is-active" if key == section and pkey == page else ""
out.append(f' <a class="navbar-item{act}" href="{phref}"><i class="ti {picon}"></i>{plabel}</a>')
out.append(' </div>')
out.append(' </div>')
out += [' </div>', ' <div class="navbar-end">',
' <div class="navbar-item has-dropdown is-hoverable%s">' % (" sp-current" if section == "user" else ""),
' <a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>',
' <div class="navbar-dropdown is-right">',
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>',
' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
' <hr class="navbar-divider">',
# Signing out changes state, so it is a POST: a form with a button, not a link.
' <form method="post" action="login.html"><button type="submit" class="navbar-item">'
'<i class="ti ti-logout"></i>Sign out</button></form>',
' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
'<div class="sp-perf" aria-hidden="true"></div>']
return "\n".join(out)
def subnav(section, page):
for key, _label, _href, _icon, pages in NAV:
if key == section and pages:
cur = ' aria-current="page"'
links = "".join(
f'<a href="{phref}"{cur if pkey == page else ""}><i class="ti {picon}"></i>{plabel}</a>'
for pkey, plabel, phref, picon in pages)
return f'<div class="sp-subnav"><div class="container">{links}</div></div>'
return ""
def build(path):
text = path.read_text(encoding="utf-8")
m = re.match(r"\s*<!--(.*?)-->\s*", text, re.S)
meta = dict(part.split(":", 1) for part in m.group(1).split("|"))
meta = {k.strip(): v.strip() for k, v in meta.items()}
body = text[m.end():]
html = HEAD.format(title=meta["title"])
if meta.get("shell") == "bare":
html += f"<body>\n{body}\n</body>\n</html>\n"
else:
section, page = meta.get("section", ""), meta.get("page", "")
html += (f"<body>\n{navbar(section, page)}\n{subnav(section, page)}\n<main>\n"
f' <div class="container sp-page">\n{body}\n {FOOT}\n </div>\n</main>\n</body>\n</html>\n')
(HERE / path.name).write_text(html, encoding="utf-8", newline="\n")
if __name__ == "__main__":
pages = sorted((HERE / "src").glob("*.html"))
for p in pages:
build(p)
print(f"built {len(pages)} pages")

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