39 Commits
Author SHA1 Message Date
mixandCursor af9867bc2f docs: record panel-redesign givens
test / test (push) Canceled after 0s
Capture the layout and component decisions for a future panel restyle so
mockups start from a frozen vocabulary instead of per-page widths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:52:04 +03:00
mixemeandCursor 10f5b4e8e1 docs: remove unused legacy icon.png asset
test / test (push) Canceled after 0s
Drop docs/assets/icon.png — superseded by brand SVGs and favicon.png,
with no references anywhere in the tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 14:55:14 +03:00
mix 50f1e4b298 docs: adopt YOUR SMTP. YOUR RULES. tagline and hero line
test / test (push) Has been cancelled
Record canonical positioning in product.md and refresh the stamp, README,
and brand proof page now that inbound relay is in scope.

docs: keep SELF-HOSTED SMTP RELAY on the stamp

Revert the mark subtitle; tagline and hero line stay in product.md and README only.
2026-08-22 12:06:29 +03:00
mixandClaude Opus 5 b29f4845ba docs: retire the code-review plan, its content now lives where it belongs
test / test (push) Has been cancelled
The review document was the last place several findings existed, so it could
not just be deleted. Everything still useful moved out first:

- roadmap items csrf-tokens, template-data-typing, structured-logging and
  review-2026-08-followups are now self-contained — each row names the file,
  the symptom and what "done" means, instead of pointing at a paragraph number
  in a plan file.
- security.md's CSRF accepted-risk bullet now names the csrf-tokens item, so
  the two records of that decision cannot drift apart unnoticed; the item says
  the same in the other direction.
- CHANGELOG [Unreleased] records the review itself in its intro rather than as
  an added file: the document was created and removed inside one unreleased
  cycle, so it never was part of a release.
- the declined finding (//go:build linux for the /proc reader) keeps its
  rationale in the roadmap so it is not raised again.

Also repaired three links to inbound-relay.md, deleted back when that feature's
plan was cleaned up: the two inbound plans now point at architecture.md § Mail
path for the as-built behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:48:13 +03:00
mixandClaude Opus 5 1e4746e52f docs: track every unimplemented review finding in the roadmap
test / test (push) Has been cancelled
Nothing from the 2026-08-19 code review now lives only in its plan file. Four
new roadmap items, all `candidate`:

- csrf-tokens — §4.4. The review recorded a decision to add per-session tokens
  but never turned it into a task, and security.md still argues the header
  checks suffice. The item says to settle which of the two stands first.
- template-data-typing — §7.1, map[string]any template payloads.
- structured-logging — §7.2 and §5.5, log/slog plus the per-package logf
  aliases.
- review-2026-08-followups — the small remainder: backup re-auth, the missing
  DMARC-service / handler / error-page / corrupted-backup tests, and the nine
  minor findings (§6.1, §6.2, §6.4, §7.4, §8.6, §9.3, §10.3-10.5) that were
  never carried into the phase tables.

§7.3 (build tags for the /proc reader) is deliberately not carried over: it was
declined during the pass, and both documents now say so, so it does not get
re-raised at the next review.

The plan's status table links each open row to its roadmap item, and the
roadmap's recommended-order note points back at the plan. Also unlinked two
CHANGELOG references to plan files deleted after those releases shipped; the
text stays, the dead links do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 00:09:43 +03:00
mixandClaude Opus 5 ab2cd885c4 review follow-up: close the gaps left by the phase1-6 pass
test / test (push) Has been cancelled
Verified the tree against docs/plans/code-review-2026-08.md and finished the
tasks that were only partly done, plus two defects the pass introduced.

- bcrypt: the setup account was still hashed at DefaultCost (10). The work
  factor now has one definition, auth.BcryptCost = 12, used everywhere.
- rate limiter: protecting blocked buckets from eviction was unbounded — an
  attacker with many source addresses could grow the map by making each one
  fail its way to blocked. Blocked buckets are now evicted past
  maxBuckets * 4, nearest expiry first, logged once per window. Tests cover
  both halves.
- postqueue: the 5s timeout left the output size unbounded; reads are now
  capped at 4 MiB and report truncation.
- spec references: the ~76 `spec N` citations CHANGELOG [0.5.0] claimed were
  gone are removed from build/, deploy/, the first two migrations and the
  release workflow, with the affected comment paragraphs re-wrapped. Comments
  only; shell/YAML/SQL syntax re-checked.
- schema-migrations.md: schema section was headed v9 with the chain at v10,
  and three links pointed at deleted plan files.
- internal/configsafe: the shared empty/forbidden-character check behind the
  OpenDKIM and Postfix config writers; each writer keeps its own forbidden set.
- dropped the parseDomainRateLimitForm/parseAppRateLimitForm pass-throughs
  left behind by the parser merge (only their own tests still called them).
- plan doc now carries a status table: what shipped, what was already in
  place, what was declined (//go:build linux for the /proc reader) and the
  open CSRF-token question.

Not done, each its own phase: typed template data structs, log/slog, DMARC
service and handler tests, error-page/corrupted-backup tests, backup re-auth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:03:05 +03:00
4204ee279c phase4-6: harden help/docs links and auth defaults
test / test (push) Has been cancelled
Close remaining low-priority review follow-ups by removing hardcoded help URLs, improving help drawer accessibility, raising explicit bcrypt cost, and adding validation/injection coverage; also bound postqueue reads with a timeout to avoid hangs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:15:30 +03:00
mixandCursor 81190a7aad phase1-3: apply review fixes and docs alignment
test / test (push) Has been cancelled
Implement phase 1 and 2 review actions (DMARC UI CSS fixes, bounded DMARC ingest stdin,
rate-limit eviction hardening, settings/user handler cleanup, shared atomic/supervisor helpers)
and phase 3 documentation/changelog updates including roadmap and accepted-risk notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 19:06:33 +03:00
mixandCursor 5a41d34cac docs: record implementation decisions in code review plan
test / test (push) Has been cancelled
Resolved open questions: fail-open+log for auth IP, add CSRF tokens,
typed template structs, slog migration, rate limiter eviction policy
(nearest-expiry, blocked buckets protected), guide URL as full path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:23:21 +03:00
0aa7318a41 docs: add full codebase review plan
test / test (push) Has been cancelled
38 findings across architecture, code quality, security, GUI, tests, documentation, and AGPL compliance. Prioritised 6-phase implementation plan with model recommendations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 15:33:43 +03:00
mixandClaude Fable 5 1b13487356 docs: record 2026-08-19 security review of the 1.3.0..1.9.1 diff
test / test (push) Has been cancelled
No exploitable findings across inbound relay, DMARC ingestion, auto rate
limiting, auth-IP allow-lists, and the new panel surface. Two deliberate
behaviours the pass examined are now recorded as accepted risks:
unauthenticated DMARC report ingest and fail-open auth-IP enforcement.
This closes the note that the inbound-relay path awaited its own review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 15:32:07 +03:00
mixandCursor fb26f7656a docs: remove plan documents for already-shipped features
test / test (push) Has been cancelled
Plans for domain-stats-auto-ratelimit, dmarc-reports, inbound-relay, panel-docs, queue-retries, and send-log-retention are deleted — all shipped between 1.3.1 and 1.7.0. Only unimplemented plans remain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 14:58:28 +03:00
mixandCursor d88a8a6cba docs: add preflight installation-check page plan and roadmap entry
test / test (push) Has been cancelled
Captures the design for a /preflight page that verifies instance-level infrastructure (rDNS, TLS, ports, HELO banner, proxy headers, DKIM socket, test email). Status: candidate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 14:57:03 +03:00
mixandClaude Opus 5 aa9a95ce4a fix: e2e port-25 check misparsed wrapped master.cf continuation lines
test / test (push) Has been cancelled
Found running the new checkPort25ConfigCombinations on selfpost.mixfed.ru: it
compared against raw, unnormalized master.cf text. postconf -F "*/*/chroot=n"
(unconditional, runs after our smtp/inet edits) rewraps the whole file using
master.cf's own line-continuation convention, splitting a single -o value
across lines and re-flowing what were separate -o lines into one paragraph.
Valid Postfix syntax -- postfix check passes -- but plain substring matching
against the raw text broke on it.

parseSMTPInetOptions now joins continuation lines into one token stream and
reconstructs each "-o param=value" assignment before comparing, verified
against a real captured stanza in port25_config_check_test.go. Full make e2e
(13 scenarios plus the two new parser unit tests) is green on the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:40:48 +03:00
mixandClaude Opus 5 23d52bab7c fix: port-25 milter chain, DMARC report validation, auto rate-limit window
test / test (push) Has been cancelled
Review pass over the 1.4.0-1.9.1 work.

Port 25 no longer inherits main.cf's submission milter chain: smtpd_milters
is always overridden on smtp/inet, to the antispam milter or to nothing.
Without that, OpenDKIM (strict, tempfail) gated relayed mail during a signing
outage, and the journal-milter filed inbound mail into the outbound send log
under the sender's domain -- so Deliveries showed other people's mail and a
forged From: consumed that domain's level-2 budget. architecture.md already
described the intended behaviour; the code now matches.

DMARC reports are checked before they are stored: the policy domain must be a
configured sending domain, and must match the +tag of a per-domain hosted
address. The rua address form is predictable, so anyone able to reach it could
file reports under any domain and evict genuine ones through the retention cap.
Zip attachments are now decompressed as well as gzip.

The auto level-2 ceiling is scaled to the level-1 window before the multiplier
is applied; any window other than 3600s shifted the allowance by window/3600.

Migration 0010 indexes send_log(app_login, created_at) -- both the per-MAIL
FROM rate-limit count and the per-application statistics were scanning the
created_at range.

e2e now regenerates the Postfix config in-container for all four port-25 flag
combinations and requires postfix check to pass; the 1.9.1 crash-loop shipped
in 1.4.0 and survived three releases because only the flags-off path was
exercised.

Migration 0009 stays as shipped -- its conversion branch only fires for a
database carrying application-scope allowed_ips from before 1.9.0. The schema
reference notes that the old list was permissive and the new column is
restrictive; the guide documents the allow-list as a sending control on a
fail-open path, not an authentication boundary.

Also: gofmt across cmd/ and internal/, unused duplicate validators dropped from
internal/web/validate, export no longer swallows store errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:17:39 +03:00
mixandCursor b81a261d01 docs: verify CHANGELOG against full git history since repo creation
test / test (push) Has been cancelled
Correct historical gaps: compose cap_add (CAP_FOWNER/FSETID/KILL) in [0.2.0], nav Backup/Sign-out icons in [0.3.0], Export/Danger layout in [1.2.5], and move GHCR per-arch tag cleanup from [1.3.0] to [1.3.1] where the fix landed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 01:11:31 +03:00
mixandCursor 72126b63af docs: align operator and as-built docs with code after 1.9.1.
test / test (push) Has been cancelled
Port 25, DMARC routes, restore Resync vs reload, image pin, and roadmap
index were stale relative to the running panel and Postfix config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:54:11 +03:00
mixandCursor 4a51db277b release: 1.9.1
test / test (push) Has been cancelled
Fix port-25 Postfix startup when inbound relay, DMARC ingest, or antispam milter are enabled; consolidate schema-migrations and inbound antispam/quarantine roadmap docs in the changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:47:45 +03:00
mixandCursor 6adbb41459 fix: port-25 Postfix config when inbound, DMARC, or antispam are enabled
postconf -P cannot set master.cf -o values with whitespace; patch smtp/inet
directly and route DMARC ingest through a dmarc-ingest symlink.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:43:59 +03:00
mixandCursor 1e4623b6e1 docs: agree inbound antispam panel plan for 1.10.0.
test / test (push) Has been cancelled
Add journal and allow/deny list design, quarantine candidate, roadmap entries, and Composer to Opus to Fable routing note.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:32:03 +03:00
mixandCursor bb26e5022e docs: add schema-migrations reference for SQLite chain and legacy.
test / test (push) Has been cancelled
Centralises migration history, 1.x rules, and the 2.x squash gate so
roadmap and architecture no longer carry a stale v5 snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:12:37 +03:00
mixandCursor b4a9b93cf2 release: 1.9.0
test / test (push) Has been cancelled
Application client IP allow-list restricts which addresses may submit as a SASL login; level-2 rate limits override the domain ceiling per application (higher or lower, capped at L1). Migration 0009, authips form, milter enforcement, export/import, and operator docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:51:41 +03:00
mixandCursor a8ded7ecc8 Document all full-backup capture methods in the operator guide.
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:13:26 +03:00
mixandCursor 33ee08f7b6 release: 1.8.0
test / test (push) Has been cancelled
Add in-panel Help: CSS-only drawer with seeded Status and domain topics,
«?» entry points on cards, and guide.md boundary for what stays in-repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:00:08 +03:00
mixandCursor 27aeadc71d release: 1.7.0
test / test (push) Has been cancelled
Receive DMARC aggregate reports on port 25 and show parsed summaries in the panel. Close Unreleased; pin compose and docs to 1.7.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:53:36 +03:00
mixandCursor c1ec4fbd79 release: 1.6.0
test / test (push) Has been cancelled
Add 30-day send statistics and auto level-2 rate limits on the domain page. Close Unreleased; pin compose and docs to 1.6.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:26:51 +03:00
mixandCursor 8538b8d5f6 release: 1.5.0
test / test (push) Has been cancelled
Close Unreleased for send-log retention in Settings; pin compose and docs to 1.5.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:01:26 +03:00
mixandCursor 909145e5c8 Add send-log retention days to panel Settings.
Global administrators can set delivery journal retention (7-365 days) in SQLite;
SEND_LOG_RETENTION_DAYS seeds the initial default and the log-tailer re-reads
the value every prune cycle without a restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 21:57:48 +03:00
mixandCursor d0bf4a4226 release: 1.4.0
test / test (push) Has been cancelled
Close Unreleased for inbound relay; pin compose and docs to 1.4.0.

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

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

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

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

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

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

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

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

Co-Authored-By: Cursor <cursoragent@cursor.com>
2026-08-15 23:47:55 +03:00
187 changed files with 13988 additions and 1605 deletions
+22
View File
@@ -0,0 +1,22 @@
---
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
globs: docs/assets/panel-ui/**
alwaysApply: false
---
# Panel UI system
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
- Two peer jobs → `pair` (one child shrinks to `measure`).
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
- Confirm/login/user form → `measure`.
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
- Phone: `pair` stacks; `field-row` does not; tables become lists.
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
+10 -10
View File
@@ -1,19 +1,19 @@
name: release name: release
# Publishes an immutable, version-tagged image on ghcr.io (spec 10.1). # Publishes an immutable, version-tagged image on ghcr.io.
# Ordinary commits and bare git tag pushes do not publish anything. A published # 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). # 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. # 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: # Native per-architecture builds (see docs/development.md), not qemu: running
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate # the full Postfix/OpenDKIM stack under emulation for the e2e gate below is
# below is impractically slow. Each arch builds, e2e-gates and pushes its own # impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
# tag on its own native runner; a merge job then combines them into the one # own native runner; a merge job then combines them into the one manifest tag
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not # documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
# push-by-digest then test) is deliberate: it means the bytes that get tagged # then test) is deliberate: it means the bytes that get tagged are exactly the
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so # bytes that passed e2e. Per-arch tags are pushed only so imagetools can
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR # assemble the multi-arch manifest; merge removes them from GHCR so operators
# so operators see a single version tag (spec 10.1). # see a single version tag.
on: on:
release: release:
types: [published] types: [published]
+436 -20
View File
@@ -5,6 +5,421 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission
milter chain, DMARC reports are validated before they are stored, and the auto
rate limit is correct for a non-default level-1 window. Schema migration
`0010` (index only). Upgrading is a tag bump.
A full-tree code review on 2026-08-19 (architecture, code quality, security,
GUI, tests, documentation, AGPL compliance; 38 findings) produced most of what
follows. Its four open items live in [roadmap.md](docs/roadmap.md); its working
document is not part of the release.
### Changed
- README hero line and a **Positioning** block in `docs/product.md` record the
tagline (`YOUR SMTP. YOUR RULES.`) and hero copy; the stamp subtitle stays
`SELF-HOSTED SMTP RELAY`. `docs/assets/selfpost-proof.html` updated to match.
### Added
- Roadmap item and givens for a panel visual redesign
([docs/plans/panel-redesign.md](docs/plans/panel-redesign.md)) — layout
grammar (`measure` / `fill` plus Web Awesome layout CSS), no JS component
library, new mockups rather than `docs/assets/panel-ui/`. Status: candidate.
- Roadmap item and plan for `/preflight` installation check page
([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level
infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers,
DKIM socket, test email). Status: candidate.
- SQLite migration `0010_send_log_app_login_index.sql` —
`idx_send_log_app_login_created_at`. The journal-milter counts an
application's messages on every `MAIL FROM`, and the domain page computes
per-application statistics on every render; both were scanning the
`created_at` range because `app_login` was unindexed.
- DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
(several reporters ship a single-entry zip; those reports previously counted
as parse failures).
- e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
inside the container for all four combinations of `INBOUND_RELAY_ENABLE` /
`DMARC_REPORTS_ENABLE` / `INBOUND_ANTISPAM_MILTER` and requires
`postfix check` to pass. The `1.9.1` crash-loop shipped in `1.4.0` and
survived three releases because the suite only ever exercised the flags-off
path.
### Fixed
- The setup account's password is hashed at bcrypt cost `12` like every other
panel password; it was still being written at `bcrypt.DefaultCost` (10). The
work factor now has one definition, `auth.BcryptCost`. bcrypt records the
cost in the hash, so existing passwords keep verifying unchanged.
- Login/setup rate limiter: protecting blocked buckets from eviction is now
bounded. Blocked buckets are still never evicted to make room, but past
`maxBuckets × 4` they are evicted too (nearest expiry first) — otherwise an
attacker with many source addresses could grow the bucket map without limit
by making each address fail its way to blocked. The overflow is logged at
most once per window.
- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
the listing, and therefore the panel's allocation, up to the queue).
- [schema-migrations.md](docs/schema-migrations.md): the schema section was
still headed `v9` while the chain head is `v10`, and three links pointed at
plan files deleted when those features shipped.
- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
context timeout so a stuck queue command cannot hang the queue panel path
indefinitely.
- DMARC panel layout classes now exist in
`internal/web/view/static/panel.css` (`.pair`, `.attn`, `.desk-only`,
`.phone-only`, `.phone-list`, `.check-row`), so desktop/mobile DMARC views and
attention highlighting render as intended.
- `dmarc-ingest` now bounds stdin with `io.LimitReader` (10 MiB) before parsing.
- User-role update no longer contains a duplicate "last global admin" demotion
guard; one check now returns a context-specific message for self-demotion.
- Settings flash text now reflects what actually changed and only mentions
session sign-out when the password changed.
- Login/setup rate-limiter bucket eviction now prefers the nearest-expiry bucket
and does not evict currently blocked buckets.
- Help page/drawer links now use `SourceURL` (`{{.SourceURL}}/blob/main/docs/...`)
instead of a hardcoded repository URL.
- Help drawer close controls include accessibility attributes (`role="button"`,
`aria-label`) for the scrim and close action.
- Password hashing now uses explicit bcrypt cost `12` in settings/user password
updates, instead of `bcrypt.DefaultCost`.
- Test coverage expanded for DMARC map token injection rejection and email
validation edge cases (ASCII/local-part/domain/freemail cases).
- **Port 25 no longer runs the submission milter chain.** `smtpd_milters` is
now always overridden on `smtp/inet` — to the optional antispam milter, or
to nothing. Previously, with `INBOUND_ANTISPAM_MILTER` unset, inbound mail
inherited main.cf's chain: OpenDKIM (strict, `tempfail`) could defer relayed
mail during a signing outage, and the journal-milter filed inbound mail into
the outbound send log under the **sender's** domain — so Deliveries showed
other people's mail, and a forged `From:` on an inbound message consumed that
sending domain's level-2 rate-limit budget. [architecture.md](docs/architecture.md)
already described the intended behaviour; the code now matches it.
- **DMARC reports are validated before they are stored.** A report is accepted
only when the domain in `policy_published` is a sending domain configured
here and, for a per-domain hosted address, matches that address's `+tag`.
The rua address form is predictable, so anyone able to reach it could
previously file reports under any domain, skew the panel's alignment figures
and the tighten-`p=` hint, and evict genuine reports through the 500-report
retention cap.
- Auto level-2 rate limit: the ceiling is now scaled to the level-1 window
before the multiplier is applied. `ceil(avg msg/h × multiplier)` was stored
as the ceiling for a window of `RATE_LIMIT_WINDOW_SECONDS`, so any window
other than the default `3600` shifted the effective allowance by
`window/3600` (a 1800s window allowed twice the intended rate). A domain
that has sent anything now also gets a floor of 1.
- Single-domain export no longer drops rate limits silently when the store
errors — the error is returned instead of being swallowed by `err == nil && ok`.
### Changed
- [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
review left unimplemented, so no finding lives only in a plan file:
`csrf-tokens` (the recorded §4.4 decision that never became a task),
`template-data-typing`, `structured-logging`, and
`review-2026-08-followups` for the small remainder (backup re-auth, the
missing service/handler/error-page tests, and nine minor findings that were
never carried into the review's own phase tables). Each roadmap row stands on
its own — file, symptom, and what "done" means — and the declined finding
(`//go:build linux` for the `/proc` reader) is recorded as declined so it is
not raised again. The review's working document is gone from `docs/plans/`
now that nothing depends on it; git history keeps it.
- Extracted `internal/configsafe` — the empty-value and forbidden-character
check every generated config file shares. Each writer keeps its own forbidden
set next to the file it protects (an OpenDKIM table and a Postfix map break on
different characters); only the check itself is shared.
- Build, deploy and migration comments no longer cite the archived
specification. The `spec N` references CHANGELOG `[0.5.0]` said were gone
survived in `build/`, `deploy/`, the first two migrations and the release
workflow; they are removed and the affected comment paragraphs re-wrapped.
Comments only — no build or configuration behaviour changes.
- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
wrappers left behind when the two parsers were merged: nothing but their own
tests still called them.
- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
`internal/supervisor` (supervisorctl start/signal wrappers), and reused them in
domain/postfix code paths.
- `docs/roadmap.md`: shipped `panel-docs` now references CHANGELOG history
instead of a removed plan file.
- [schema-migrations.md](docs/schema-migrations.md) notes that migration `0009`
changed what the IP list *means* — permissive (those IPs got the application
ceiling) before 1.9.0, restrictive (every other IP refused) after. Its
conversion branch only fires for a database that carried application-scope
`allowed_ips` from before 1.9.0.
- Panel help, [guide.md](docs/guide.md) and [architecture.md](docs/architecture.md):
the client IP allow-list is a **sending** control, not an authentication
boundary — the SASL login still succeeds, the check runs in the
journal-milter at `MAIL FROM`, and it is fail-open like the rate limits.
architecture.md's rate-limit description still documented the pre-1.9.0
trusted-IP semantics.
- Removed unused duplicates of the inbound validators (`Host`, `Port`,
`TLSMode`, `RecipientMode`, `MailboxInDomain`, `NormalizeHost`) from
`internal/web/validate` — the live path uses `internal/inbound/validate.go`;
the copies were reachable only from their own tests.
- `gofmt` across `cmd/` and `internal/` (10 files had drifted); dead branch
removed from `postfix.inboundNexthop`.
- The planned inbound-antispam migration is renumbered `0011_inbound_spam_log.sql`.
- Operator and as-built docs aligned with the code after a full pass —
[architecture.md](docs/architecture.md) (port 25 opens for inbound relay
and/or DMARC ingest; antispam requires inbound relay; DMARC pipe transport
and map paths; `/help` and `/dmarc*` routes; four panel roles including
rate-limit recalc; restore Resync vs manual reload); [guide.md](docs/guide.md)
and [README.md](README.md) (image pin `1.9.1`, port 25 conditions, combined
listener limits, reload vs Resync); [roadmap.md](docs/roadmap.md) (`panel-docs`
closed at `[1.8.0]`, next item inbound-antispam-panel); [development.md](docs/development.md)
version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md)
last-updated note. No behaviour change.
### Removed
- Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
and `internal/web/static/favicon.png`; nothing referenced it).
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
`dmarc-reports`, `inbound-relay`, `panel-docs`, `queue-retries`,
`send-log-retention`. Only unimplemented plans remain in `docs/plans/`.
## [1.9.1] - 2026-08-19
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam
milter are enabled. Schema-migration reference and roadmap plans for inbound
antispam and quarantine. Upgrading from 1.9.0 is a tag bump; no migration.
### Added
- [docs/plans/inbound-antispam-panel.md](docs/plans/inbound-antispam-panel.md) —
agreed plan for inbound antispam journal (inbound-journal milter + mail.log
rejects) and instance-wide allow/deny lists synced to rspamd maps; target
`1.10.0`; Composer → Opus → Fable workflow.
- [docs/plans/inbound-quarantine.md](docs/plans/inbound-quarantine.md) — candidate
plan for held-mail review/release (design TBD).
- [docs/schema-migrations.md](docs/schema-migrations.md) — living reference for the
SQLite migration chain (`user_version` head, per-file history, legacy artefacts,
1.x rules, and planned 2.x squash gate).
### Changed
- [docs/roadmap.md](docs/roadmap.md) — `inbound-antispam-panel` (agreed, 1/12)
and `inbound-quarantine` (candidate) in the index.
- [docs/development.md](docs/development.md) — plan checklists may override
model routing with Composer → Opus → Fable.
- [docs/development.md](docs/development.md), [docs/roadmap.md](docs/roadmap.md),
and [docs/architecture.md](docs/architecture.md) link to the new file;
`schema-squash` defers gate thresholds to it instead of a stale v5 snapshot.
### Fixed
- `build/postfix-config.sh`: enabling inbound relay, DMARC ingest, and/or the
inbound antispam milter no longer crash-loops on start — `postconf -P` cannot
set `master.cf` `-o` values that contain whitespace, so port-25
`smtpd_recipient_restrictions` and `smtpd_milters` are patched directly; DMARC
pipe `argv` uses a `dmarc-ingest` symlink instead of `panel -dmarc-ingest`.
## [1.9.0] - 2026-08-18
Application sending controls: client IP allow-list for authorization, and
level-2 rate limits that override the domain ceiling per application.
### Added
- Per-application **client IP allow-list** on the domain page: when enabled,
only listed addresses may authenticate and submit as that application; when
off, any client IP is allowed. Form `POST /applications/{id}/authips`.
- SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs
from `rate_limits` into `applications.auth_allowed_ips`).
### Changed
- Application level-2 rate limit **overrides** the domain limit for that login
— the ceiling may be higher or lower than the domain setting (still ≤ level
1). Rate limits are no longer tied to client IPs; enforcement is in the
journal-milter at `MAIL FROM`.
- Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy
`allowed_ips` on an application `rate_limit` in export JSON migrate on import.
- Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics
and the IP allow-list boundary.
- [guide.md](docs/guide.md): full-backup section documents all four capture
methods (panel, `selfpost-backup`, stopped `tar` of the project directory,
stopped `tar` of `./data` only), warns against `tar` on a live container, and
compares downtime, archive contents, and restore behaviour in a table.
## [1.8.0] - 2026-08-18
Built-in operator documentation in the panel: a CSS-only help drawer and Help
page so Status checks and domain controls are explained without leaving the
panel.
### Added
- **Help** navigation entry and `/help` index page.
- CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine,
TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records,
DMARC, connection, applications, settings, export).
- «?» entry points on Status and domain card heads open the matching drawer
topic.
### Changed
- [guide.md](docs/guide.md) documents the boundary: in-panel help for card
meaning; the repository guide for installation, env vars, and operations.
## [1.7.0] - 2026-08-18
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
gzip/XML into SQLite, and show summaries in the panel.
### Added
- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25
accepts only configured `rua=` addresses, gzip/XML is parsed into SQLite, and
the panel shows per-domain roll-ups and individual reports under *DMARC*.
- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled
(`dmarc-reports+<domain>@<hostname>`).
## [1.6.0] - 2026-08-18
30-day sending statistics per domain and application, plus optional auto
level-2 rate limits derived from average send rate.
### Added
- Domain page **Sending statistics** card: total messages, peak and average
msg/h over the last 30 days (or shorter when send-log retention is below
30 days). Per-application stats in the application list.
- Level-2 rate limit **Manual** / **Auto** mode on domain and application
forms. Auto sets `max_messages` from `ceil(avg × multiplier)` over the
level-1 window, capped at level 1; background recalc every six hours and
**Recalculate now** on the domain page.
- Domain export JSON now includes rate limits (`mode`, `auto_multiplier`,
ceilings, trusted IPs).
### Changed
- Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual
mode behaviour is unchanged.
## [1.5.0] - 2026-08-18
Send-log retention is configurable from panel **Settings** (global
administrator); `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
### Added
- Panel **Settings** (global administrator): **Send log retention (days)** —
how long delivery journal rows on `/deliveries` are kept (7–365). Stored in
SQLite `settings`; `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
The log-tailer re-reads the value every prune cycle (no restart).
### Changed
- Deliveries list and delivery detail pages show the configured retention
instead of a hardcoded ninety-day message.
## [1.4.0] - 2026-08-17
Optional inbound relay (backup-MX / forwarder) on port 25, off by default.
Mail is accepted only for domains configured in the panel and forwarded to
an upstream; there are still no mailboxes. A dedicated security review of
the inbound path is still open on the plan (Fable).
### Added
- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
port 25 without SASL, only for `relay_domains` + known recipients, and
forwards via `transport_maps`. Empty upstream host is omitted from the
maps so mail is never accepted with nowhere to send it.
- Panel **Inbound** pages (global administrator): domains, upstream host/port
and TLS, recipient list or any-at-domain, MX check against
`SELFPOST_HOSTNAME`.
- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
[deploy/antispam/docker-compose.antispam.yml](deploy/antispam/docker-compose.antispam.yml).
Default milter action is fail-open (`accept`).
- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
`INBOUND_MESSAGE_SIZE_LIMIT` (default 25 MiB) on smtpd port 25.
### Changed
- Compose publishes host port 25 even when the flag is off (nothing listens
until it is on), the same pattern as 587 / `SUBMISSION_ENABLE`.
- Full-backup restore Resync also rebuilds inbound maps when the flag is on.
Single-domain export/import is still sending domains only.
## [1.3.1] - 2026-08-17
Retry policy in the panel, persistent Postfix queue, and self-contained
full backups. Mail queue and delivery history show this Postfix's first
retry, backoff cap, and queue lifetime. Deferred mail survives container
recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`;
the archive layout breaks 1.3.0 flat backups (unpack those with
`tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a
tag bump; no schema migration.
### Added
- Mail queue and a delivery's history show this Postfix's retry policy
(first delay, backoff cap, queue lifetime), read from `postconf -h` once
at panel start. A manual `postconf -e` override is visible after the next
panel restart. There is no attempt counter — Postfix retries on time.
- docs: **Plan checklists** in [development.md](docs/development.md) — format,
`Progress` column in [roadmap.md](docs/roadmap.md), per-step commit +
CHANGELOG, version cuts `1.3.1`…`1.8.0` per roadmap stage.
- docs: **Implementation checklists** with model routing in
[docs/plans/](docs/plans/) (queue-retries, inbound-relay, send-log-retention,
domain-stats-auto-ratelimit, dmarc-reports, panel-docs).
- docs: roadmap candidates **send-log-retention** (panel Settings for delivery
journal retention) and **domain-stats-auto-ratelimit** (30-day send stats per
domain/application, auto level-2 rate limit from avg × multiplier) — plans in
[docs/plans/](docs/plans/).
- Postfix queue under `/data/postfix/queue` — deferred and active mail survive
container recreate and are included in full backups.
- Full backup archives the whole operator project: `data/`, `docker-compose.yml`,
`.env`, and `certs/` (requires `.:/selfpost-deploy:ro` in compose). Restore
by unpacking into an empty project directory.
### Changed
- **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
deploy files sit at the archive root. Old flat archives restore with
`tar xzf backup.tar.gz -C ./data` as before.
- docs: operator guide, architecture, security, and backup UI updated for
self-contained backups and persistent queue.
- docs: HTML mockups for a full panel UI refresh
([docs/assets/panel-ui/](docs/assets/panel-ui/index.html)) — current screens,
agreed and candidate roadmap surfaces (queue-retries, inbound-relay,
dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms
keep a reading measure), and a 390px emergency layout. The stamp and brick
palette stay; extra nav icons are proposals only. Design artefact; the
running panel is unchanged.
### Changed
- docs: panel UI mockups after review — Status restored to live readings
(queue line, PTR, machine/process tables, Configuration); inbound MX DNS
check, recipient mode (list or any), and Danger zone beside recipients;
Backup and Settings as two-column ops pages; Save and Delete user on one
row; help «?» on domain cards; Host/name ‖ Type height matched to the live
panel (`b0ebe06`).
- docs: panel UI design system and mockups rebuilt as separate pages
(`system.html`, `status.html`, `domain.html`, …), not one hash sheet.
Regions are `stack` / `pair` / `measure` / `fill`; Host ‖ Type is
`field-row`; Save + Delete is `actions-row`. Shared chrome is `shell.js`.
- docs: panel UI mockups — add-domain sits in the list card (Domains and
Inbound); DMARC candidate screens drill into a domain roll-up and a
parsed aggregate report (aligned vs third-party fail), not a hub-only
summary.
### Fixed
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
after the manifest merge via the GitHub Packages API. The `[1.3.0]` merge job
called `docker buildx imagetools rm`, which is not a valid subcommand — cleanup
failed with a warning and the side-effect tags stayed in the registry until
this fix.
## [1.3.0] - 2026-08-14 ## [1.3.0] - 2026-08-14
Security and quality after 1.2.5: domain-admin send-log authorization, Security and quality after 1.2.5: domain-admin send-log authorization,
@@ -22,7 +437,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
- docs: agreed roadmap item **queue-retries** — show this Postfix's retry - docs: agreed roadmap item **queue-retries** — show this Postfix's retry
policy (first delay, backoff cap, queue lifetime) on Mail queue and on a policy (first delay, backoff cap, queue lifetime) on Mail queue and on a
delivery's history, reading `postconf -h` once at panel start so a manual delivery's history, reading `postconf -h` once at panel start so a manual
override is visible. Plan: [docs/plans/queue-retries.md](docs/plans/queue-retries.md). override is visible. Plan: `docs/plans/queue-retries.md` (removed once shipped).
Explanation only; no attempt counter and no panel knobs. Not yet Explanation only; no attempt counter and no panel knobs. Not yet
implemented. implemented.
@@ -89,11 +504,6 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
the same Resync on demand. The `internal/backup` package comment now matches the same Resync on demand. The `internal/backup` package comment now matches
this behaviour. this behaviour.
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
after the manifest merge via the GitHub Packages API. The merge job had called
`docker buildx imagetools rm`, which is not a valid subcommand — cleanup failed
with a warning and the side-effect tags stayed in the registry.
### Changed ### Changed
- docs: operator and as-built docs aligned with the code after a full - docs: operator and as-built docs aligned with the code after a full
@@ -143,15 +553,13 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
(`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a (`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a
bare git tag push no longer starts the build. `release.yml` listens for bare git tag push no longer starts the build. `release.yml` listens for
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each `release: published`, checks out that tag (not `main` HEAD), e2e-gates each
native arch build, merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one manifest, native arch build, and merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one
then removes the per-arch tags from GHCR via the GitHub Packages API so manifest. Reliable removal of the per-arch side-effect tags from GHCR landed
operators see only `ghcr.io/mixeme/selfpost:X.Y.Z` (what in `[1.3.1]`. A dispatch whose version input is missing or not `X.Y.Z` fails
`deploy/docker-compose.yml` pins). A dispatch whose version input is missing in `prepare`. [development.md](docs/development.md) documents draft vs
or not `X.Y.Z` fails in `prepare`. [development.md](docs/development.md) published releases, why deleting a release tag converts it back to draft, and
documents draft vs published releases, why deleting a release tag converts Gitea → GitHub tag-mirror pitfalls (do not prune release tags on GitHub; a
it back to draft, and Gitea → GitHub tag-mirror pitfalls (do not prune release mirrored `v1.0.0` still runs that tag's `on: push: tags` workflow).
tags on GitHub; a mirrored `v1.0.0` still runs that tag's `on: push: tags`
workflow).
- test: the authorization and sign-in surfaces that had no tests now have them. - test: the authorization and sign-in surfaces that had no tests now have them.
The login limiter is covered for its ceiling, its per-address scope, the reset The login limiter is covered for its ceiling, its per-address scope, the reset
@@ -221,7 +629,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
RBAC in the doc-alignment pass above). Corrected stale RBAC in the doc-alignment pass above). Corrected stale
`admin.dmarc_report_email` references in `admin.dmarc_report_email` references in
[roadmap.md](docs/roadmap.md) and [roadmap.md](docs/roadmap.md) and
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's `docs/plans/dmarc-reports.md` (removed once shipped) to the setting's
actual home after migration `0005`. No behaviour change. actual home after migration `0005`. No behaviour change.
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is - panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
@@ -248,7 +656,9 @@ Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
carry muted leads and matched control height; trusted-IP help sits under the carry muted leads and matched control height; trusted-IP help sits under the
IP field. Domain settings pairs DMARC reports with the level-2 rate limit IP field. Domain settings pairs DMARC reports with the level-2 rate limit
using CSS subgrid so titles, fields, and Save / Remove buttons line up across using CSS subgrid so titles, fields, and Save / Remove buttons line up across
columns. columns. Domain page Export and Danger cards stack naturally again — a
shared-baseline flex pin on `.split` cards made mismatched body lengths look
worse than a plain stack.
## [1.2.4] - 2026-08-12 ## [1.2.4] - 2026-08-12
@@ -1055,9 +1465,10 @@ database, or the on-disk layout. Upgrading is a tag bump.
Bookmarks to the old paths stop working. Bookmarks to the old paths stop working.
- panel: each entry in the navigation bar now carries an icon beside its label, - panel: each entry in the navigation bar now carries an icon beside its label,
so the bar is scannable at a glance instead of a row of similar-length words. so the bar is scannable at a glance instead of a row of similar-length words —
The icons are inline SVG drawn in the entry's own colour — no extra request, Backup uses a filing-cabinet mark; Sign out has its own icon too. The icons
no exemption from the panel's Content-Security-Policy — and are hidden from are inline SVG drawn in the entry's own colour — no extra request, no
exemption from the panel's Content-Security-Policy — and are hidden from
screen readers, which still announce the label alone. screen readers, which still announce the label alone.
- panel: the navigation bar is laid out as two rows on purpose — the signed-in - panel: the navigation bar is laid out as two rows on purpose — the signed-in
@@ -1197,6 +1608,11 @@ database, or the on-disk layout. Upgrading is a tag bump.
login/password and on the sending server name. login/password and on the sending server name.
- panel: the *Addresses* field is hidden while an application's address mode is - panel: the *Addresses* field is hidden while an application's address mode is
*Any address of the domain*, where the server ignores it. *Any address of the domain*, where the server ignores it.
- deploy: `cap_add` gains `CAP_FOWNER` and `CAP_FSETID` so the entrypoint can
chmod and set the setgid bit on `/data` directories it has just chowned under
`cap_drop: ALL`, and `CAP_KILL` so supervisord can signal OpenDKIM across uids
when domains are added or removed. Production boot under the hardened compose
file exposed both gaps after Phase 10.
- ci: disable provenance attestation on release image push, so the ghcr.io - ci: disable provenance attestation on release image push, so the ghcr.io
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`). manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
- ci: run `go vet` and `go test ./...` on every push to `main` and every pull - ci: run `go vet` and `go test ./...` on every push to `main` and every pull
+3 -2
View File
@@ -1,12 +1,13 @@
# SelfPost build. # SelfPost build.
# #
# The version is stamped into both binaries and MUST match the Docker image tag # The version is stamped into both binaries and MUST match the Docker image tag
# (spec 7.5.A: backup/restore compatibility check). Override on the command line: # (architecture.md § Persistence: backup/restore compatibility check). Override
# on the command line:
# #
# make build VERSION=1.3.0 # make build VERSION=1.3.0
# #
# CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure # CGO is disabled to keep the binaries fully static (modernc.org/sqlite is pure
# Go, so no cgo is required) — see spec 7.1. # Go, so no cgo is required).
VERSION ?= dev VERSION ?= dev
MODULE := github.com/mixeme/selfpost MODULE := github.com/mixeme/selfpost
+27 -9
View File
@@ -4,13 +4,16 @@
# SelfPost # SelfPost
Self-hosted outbound SMTP relay with a web control panel, shipped as a single Self-hosted SMTP relay with a web panel — one Docker image, no provider.
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.
SelfPost sends mail straight to the internet from **your own IP**, with per-domain Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail. configure domains, DKIM keys, and SASL applications once, then point your apps
at the SMTP endpoint.
SelfPost sends mail straight to the internet from **your own IP**, with
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
(backup-MX / forwarder on port 25) forwards to an upstream; it does not store
mail.
**For:** operators who run their own VPS or home server and want a simple relay **For:** operators who run their own VPS or home server and want a simple relay
they control, without a third-party SMTP provider. they control, without a third-party SMTP provider.
@@ -21,6 +24,10 @@ send log and DNS checks in the panel, encrypted backups.
## Features ## Features
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing - Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help drawer and `/help` page for Status and domain controls
- Per-application client IP allow-list for SMTP AUTH (optional)
- Web panel — domains, applications, deliveries, mail queue, system log, backup - Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain - Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
@@ -78,6 +85,15 @@ For every domain you add in the panel:
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide. See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
### Per inbound domain (optional)
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
if this host is backup-MX)
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
## Quick start ## Quick start
> **First boot — create the admin account.** On a fresh container SelfPost prints > **First boot — create the admin account.** On a fresh container SelfPost prints
@@ -96,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.3.0 ghcr.io/mixeme/selfpost:1.9.1
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
@@ -136,8 +152,10 @@ DNS — lives in the operator guide's [Full
deployment](docs/guide.md#full-deployment) section, with proxy-specific deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory). commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file always publishes **465** and **587**; Postfix listens on 587 The compose file always publishes **465**, **587**, and **25**; Postfix listens
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
`INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_ENABLE=true` (see
[Ports](docs/guide.md#ports)). Bump the
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables). variables](docs/guide.md#environment-variables).
+21 -21
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# #
# SelfPost — single Debian-slim image running postfix + opendkim + panel under # SelfPost — single Debian-slim image running postfix + opendkim + panel under
# supervisord (spec 4). Build from the repository root: # supervisord. Build from the repository root:
# #
# docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev . # 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 WORKDIR /src
# Version stamped into both binaries; MUST match the image tag (spec 7.5.A). # Version stamped into both binaries; MUST match the image tag.
ARG VERSION=dev ARG VERSION=dev
# Module metadata first for layer caching. # Module metadata first for layer caching.
@@ -52,13 +52,13 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Unprivileged user for the panel process (spec 7.6.8). # Unprivileged user for the panel process.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel RUN useradd --system --no-create-home --shell /usr/sbin/nologin panel
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel # Shared group bridging the unprivileged services: the panel generates
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the # per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
# Postfix sender map, while OpenDKIM and Postfix (different users) must read # sender map, while OpenDKIM and Postfix (different users) must read them.
# them. Membership in this group — plus setgid dirs under /data (set up in # 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 # entrypoint.sh) — lets OpenDKIM read the panel-owned keys and lets Postfix read
# the sasldb2/sender map, and lets the panel reach the supervisor control socket # the sasldb2/sender map, and lets the panel reach the supervisor control socket
# to signal OpenDKIM/Postfix reloads without any process running as root. # to signal OpenDKIM/Postfix reloads without any process running as root.
@@ -67,15 +67,15 @@ RUN groupadd --system selfpost \
&& usermod -aG selfpost opendkim \ && usermod -aG selfpost opendkim \
&& usermod -aG selfpost postfix && usermod -aG selfpost postfix
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the # The panel reads the mail queue with `postqueue -p` (and the status page's
# status page's queue card). postqueue is setgid postdrop, which normally gives # queue card). postqueue is setgid postdrop, which normally gives it the group
# it the group needed to reach Postfix's showq socket — but the documented # needed to reach Postfix's showq socket — but the documented deployment runs
# deployment runs with `no-new-privileges`, which disables setgid transitions, # with `no-new-privileges`, which disables setgid transitions, so the panel
# so the panel would always see "Permission denied". Making `panel` a real # would always see "Permission denied". Making `panel` a real member of postdrop
# member of postdrop grants the same access without relying on a setgid # grants the same access without relying on a setgid escalation the hardening
# escalation the hardening deliberately forbids. postdrop membership is # deliberately forbids. postdrop membership is read-side only: it does not let
# read-side only: it does not let the panel bypass any Postfix restriction that # the panel bypass any Postfix restriction that a local user does not already
# a local user does not already have through the world-executable sendmail. # have through the world-executable sendmail.
RUN usermod -aG postdrop panel RUN usermod -aG postdrop panel
# Runtime directories: milter sockets and the consolidated persistent root. # Runtime directories: milter sockets and the consolidated persistent root.
@@ -107,10 +107,10 @@ COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional) # Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client, # inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
# which needs no inbound listener or EXPOSE. # Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 EXPOSE 8080 465 587 25
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix). # Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations. # Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
@@ -118,5 +118,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1 CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
# The entrypoint fixes /data ownership (bind mount) as root, then execs # The entrypoint fixes /data ownership (bind mount) as root, then execs
# supervisord, which becomes PID 1 and owns process supervision (spec 4). # supervisord, which becomes PID 1 and owns process supervision.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+1 -1
View File
@@ -4,7 +4,7 @@
If any managed program exhausts its restart retries and enters FATAL, this If any managed program exhausts its restart retries and enters FATAL, this
listener signals supervisord (its parent) to terminate, so the whole container listener signals supervisord (its parent) to terminate, so the whole container
exits and Docker's `restart` policy can recreate it cleanly — rather than exits and Docker's `restart` policy can recreate it cleanly — rather than
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4). lingering "alive" with a dead Postfix/OpenDKIM/panel.
Communication with supervisord uses the event listener protocol over stdin and Communication with supervisord uses the event listener protocol over stdin and
stdout, so stdout must carry only protocol tokens. stdout, so stdout must carry only protocol tokens.
+43 -29
View File
@@ -9,14 +9,14 @@ set -e
# SELFPOST_HOSTNAME is an identity, not a setting with a safe default: it must # 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 # simultaneously match the PTR/rDNS record, the certificate CN/SAN, and the
# Cyrus SASL realm (spec 5.2 p.3, 8). The panel (main.go saslRealm()) and # Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
# postfix-config.sh each fall back independently when it's unset — to # fall back independently when it's unset — to `localhost` and to the container
# `localhost` and to the container hostname respectively — so accounts get # hostname respectively — so accounts get written under one realm and looked up
# written under one realm and looked up under another and authentication # under another and authentication silently fails for every application, while
# silently fails for every application, while HELO also stops matching the # HELO also stops matching the PTR record and mail that does go out lands in
# PTR record and mail that does go out lands in spam. No fallback can be # spam. No fallback can be correct, so fail loudly here, before either side of
# correct, so fail loudly here, before either side of that split has a chance # that split has a chance to run, rather than leave a green panel with broken
# to run, rather than leave a green panel with broken mail. # mail.
if [ -z "$SELFPOST_HOSTNAME" ]; then if [ -z "$SELFPOST_HOSTNAME" ]; then
cat >&2 <<'EOF' cat >&2 <<'EOF'
FATAL: SELFPOST_HOSTNAME is not set. FATAL: SELFPOST_HOSTNAME is not set.
@@ -52,11 +52,11 @@ case "$SELFPOST_HOSTNAME" in
;; ;;
esac esac
# The persistent root /data is a host bind mount (spec 9), so it arrives owned # The persistent root /data is a host bind mount, so it arrives owned by the
# by the host user (typically root), not by the unprivileged panel user that # host user (typically root), not by the unprivileged panel user that actually
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8). # writes the SQLite database, setup token and DKIM keys. Fix its ownership here
# Fix its ownership here — the one place still running as root — before handing # — the one place still running as root — before handing off to supervisord,
# off to supervisord, which starts the panel as the panel user. # which starts the panel as the panel user.
# #
# Mode must stay world-traversable (0755): OpenDKIM and Postfix reach their # 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 # trees under /data as other users. Go's testing.TempDir is 0700, and a bare
@@ -69,9 +69,9 @@ chmod 755 /data
# later phase deliberately hands to another service. /data/log is exempt: it is # later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is # deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below. # normalised on its own below.
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} + find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes # DKIM key tree. The panel (user `panel`) generates keys and writes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the # the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
# tree on every start so it is correct whether /data is fresh, restarted, or # tree on every start so it is correct whether /data is fresh, restarted, or
# just restored from a backup: # just restored from a backup:
@@ -89,7 +89,7 @@ find /data/opendkim -type d -exec chmod 2750 {} +
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} + find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
# Application SASL accounts (spec 5.1, 9). The panel (user `panel`) writes the # Application SASL accounts. The panel (user `panel`) writes the
# sasldb2 via saslpasswd2; Postfix (user `postfix`) reads it to authenticate SMTP # 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: # 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 # setgid directory so new files inherit the group, and the database itself
@@ -99,13 +99,20 @@ chown -R panel:selfpost /data/sasl
chmod 2750 /data/sasl chmod 2750 /data/sasl
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2 [ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it. # Postfix state under /data (architecture.md § Persistence). The panel writes
# Ensure the file exists (empty is fine) before Postfix starts so a reload that # sender_login_maps; Postfix owns the on-disk queue tree under queue/.
# references it never fails on a missing file, and keep it group-readable. mkdir -p /data/postfix/queue
mkdir -p /data/postfix
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps [ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
chown -R panel:selfpost /data/postfix # Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
# Empty files keep postfix check happy if the flag is on before any domain exists.
for f in relay_domains transport relay_recipients tls_policy; do
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
chown panel:selfpost "/data/postfix/$f"
chmod 0640 "/data/postfix/$f"
done
chown panel:selfpost /data/postfix
chmod 2750 /data/postfix chmod 2750 /data/postfix
chown panel:selfpost /data/postfix/sender_login_maps
chmod 0640 /data/postfix/sender_login_maps chmod 0640 /data/postfix/sender_login_maps
# Delivery log (architecture.md § Log tailer). postlogd writes it as user # Delivery log (architecture.md § Log tailer). postlogd writes it as user
@@ -125,14 +132,14 @@ chown -R postfix:selfpost /data/log
chmod 2750 /data/log chmod 2750 /data/log
find /data/log -type f -exec chmod 0640 {} + find /data/log -type f -exec chmod 0640 {} +
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must # Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's # both milter sockets — OpenDKIM's and the panel's journal-milter — not just
# journal-milter — not just probe them at start-up. The sockets are # probe them at start-up. The sockets are created by the opendkim and panel
# created by the opendkim and panel users respectively, so bridge them to # users respectively, so bridge them to `postfix` through the shared `selfpost`
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean # group: group-owned + setgid dirs mean each socket created inside inherits
# each socket created inside inherits group `selfpost`, and group-traversable # group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and, # this, smtpd cannot talk to OpenDKIM and, because signing is strict
# because signing is strict (default_action=tempfail), rejects all mail. # (default_action=tempfail), rejects all mail.
mkdir -p /run/opendkim /run/selfpost mkdir -p /run/opendkim /run/selfpost
chown opendkim:selfpost /run/opendkim chown opendkim:selfpost /run/opendkim
chown panel:selfpost /run/selfpost chown panel:selfpost /run/selfpost
@@ -144,4 +151,11 @@ chmod 2750 /run/opendkim /run/selfpost
# start the same way the /data normalisation above is. # start the same way the /data normalisation above is.
/usr/local/bin/postfix-config.sh /usr/local/bin/postfix-config.sh
# Initialise the persistent queue tree on first start or after restore. postfix
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
if [ ! -d /data/postfix/queue/active ]; then
postfix set-permissions
fi
chown -R postfix:postfix /data/postfix/queue
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+7 -7
View File
@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the # Periodic logrotate for /data/log/mail.log. Rotation renames the file,
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container # recreates it (`create 0640 postfix selfpost`, matching a cold container
# start), then runs `postfix reload` (the same mechanism `postfix logrotate` # start), then runs `postfix reload` (the same mechanism `postfix logrotate`
# uses): postlogd keeps writing to the renamed inode until reload, and the # 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 # panel's log-tailer holds its own descriptor on that inode, so nothing written
# written before the reload is lost. `create` (rather than `nocreate`) matters # before the reload is lost. `create` (rather than `nocreate`) matters here
# here beyond timing: a postlogd-triggered recreate lands the file at 0600 # beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
# owned by postfix, which the unprivileged panel process cannot read — # postfix, which the unprivileged panel process cannot read — confirmed on a
# confirmed on a live container — so logrotate must be the one to create it. # live container — so logrotate must be the one to create it.
# #
# logrotate itself only rotates once the configured "daily" period has elapsed # logrotate itself only rotates once the configured "daily" period has elapsed
# (tracked in /var/lib/logrotate/status), so it is safe to invoke this more # (tracked in /var/lib/logrotate/status), so it is safe to invoke this more
+2 -2
View File
@@ -1,9 +1,9 @@
# OpenDKIM — per-domain signing (spec 6). # OpenDKIM — per-domain signing.
# #
# Signing is strictly per-domain: each sending domain has its own key pair and # 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 # selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks # keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
# supervisord to send this process SIGUSR1 to reload the tables (spec 6.2, 6.5). # supervisord to send this process SIGUSR1 to reload the tables.
# #
# Both table files are created (empty) by entrypoint.sh before OpenDKIM starts, # 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 # so the daemon comes up cleanly on a fresh /data with no domains yet and simply
+221 -51
View File
@@ -1,28 +1,59 @@
#!/bin/sh #!/bin/sh
# Generate the outbound-relay Postfix configuration (spec 5, 5.1, 5.2). # Generate the outbound-relay Postfix configuration.
# #
# Run as root from entrypoint.sh on every container start, BEFORE supervisord # 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 # 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 # 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 # 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 # (the only panel-edited Postfix file, sender_login_maps, lives under /data and
# is generated separately by the panel — spec 5.1). # is generated separately by the panel).
# #
# main.cf is written with `postconf -e`, the master.cf submission services with # 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 # `postconf -M`/`-P`. No user input is interpolated: every value here comes from
# a fixed literal or a container environment variable (spec 7.6.3). # a fixed literal or a container environment variable.
set -eu set -eu
# --- environment knobs (spec 8) ---------------------------------------------- # postconf -P refuses master.cf -o values that contain whitespace; inbound port
# 25 needs restriction lists and milter specs that include spaces. Patch the
# smtp/inet service stanza directly instead.
master_cf_set_smtp_inet_o() {
param="$1"
value="$2"
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 -------------------------------------------------------
# Server hostname: used as HELO name AND, crucially, as the Cyrus SASL realm the # 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 # sasldb2 accounts are looked up under. The panel creates accounts under realm
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication # $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
# fails. Fall back to the container hostname only outside a real deployment. # fails. Fall back to the container hostname only outside a real deployment.
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
# TLS material supplied by the reverse-proxy through a read-only bind mount # TLS material supplied by the reverse-proxy through a read-only bind mount. The
# (spec 5.2). The relay requires TLS on 465; if these files are absent the # relay requires TLS on 465; if these files are absent the master still starts
# master still starts but TLS handshakes on 465 fail until they appear. # but TLS handshakes on 465 fail until they appear.
# #
# Postfix insists the private key is root-owned and mode 0600. The bind mount # 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 # is often :ro and owned by the host user (e2e TempDir / CI runner UID), so
@@ -44,8 +75,8 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem" TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
fi fi
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative # Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
# defaults, sensible during IP warm-up (spec 10). # during IP warm-up.
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}" RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
@@ -54,15 +85,41 @@ RATE_WINDOW="${RATE_LIMIT_WINDOW_SECONDS:-3600}"
OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}" OPENDKIM_SOCK="${OPENDKIM_SOCKET:-/run/opendkim/opendkim.sock}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}" JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so # Persistent panel-managed sender map; texthash needs no postmap, so the
# the unprivileged panel can rewrite it and just ask for a reload. # unprivileged panel can rewrite it and just ask for a reload.
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}" SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
# Transit mail queue under /data so deferred/active messages survive container
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}" SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only # Optional submission service on 587 (off by default, enabled only when a client
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465). # library needs STARTTLS on 587 instead of implicit TLS on 465).
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}" SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
# absent. When true, smtp inet on 25 accepts only relay_domains + known
# recipients (docs/plans/inbound-relay.md).
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
# 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 # Delivery log, written by postlogd and read by the panel's log-tailer. It lives
# under the persistent /data (not the ephemeral /var/log) so the delivery lines # under the persistent /data (not the ephemeral /var/log) so the delivery lines
# for messages still marked "queued" survive a container recreate — without # for messages still marked "queued" survive a container recreate — without
@@ -96,13 +153,13 @@ postconf -e \
"alias_maps=" \ "alias_maps=" \
"alias_database=" "alias_database="
# Outbound delivery: straight to the recipient MX, opportunistic TLS (spec 5 p.2). # Outbound delivery: straight to the recipient MX, opportunistic TLS.
postconf -e \ postconf -e \
"smtp_tls_security_level=may" \ "smtp_tls_security_level=may" \
"smtp_tls_note_starttls_offer=yes" "smtp_tls_note_starttls_offer=yes"
# TLS server material shared by every inbound service (spec 5.2). auth_only # TLS server material shared by every inbound service. auth_only guarantees
# guarantees credentials are never accepted before TLS is up on any port. # credentials are never accepted before TLS is up on any port.
postconf -e \ postconf -e \
"smtpd_tls_cert_file=${TLS_CERT}" \ "smtpd_tls_cert_file=${TLS_CERT}" \
"smtpd_tls_key_file=${TLS_KEY}" \ "smtpd_tls_key_file=${TLS_KEY}" \
@@ -110,11 +167,11 @@ postconf -e \
"smtpd_tls_auth_only=yes" \ "smtpd_tls_auth_only=yes" \
"smtpd_tls_loglevel=1" "smtpd_tls_loglevel=1"
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm # SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name # implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
# Postfix uses for sender_login_maps is the BARE login the panel writes into the # uses for sender_login_maps is the BARE login the panel writes into the map;
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname # the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
# as the server realm, which equals the realm the accounts were created under. # the server realm, which equals the realm the accounts were created under.
postconf -e \ postconf -e \
"smtpd_sasl_auth_enable=yes" \ "smtpd_sasl_auth_enable=yes" \
"smtpd_sasl_type=cyrus" \ "smtpd_sasl_type=cyrus" \
@@ -124,42 +181,42 @@ postconf -e \
"smtpd_sasl_tls_security_options=noanonymous" \ "smtpd_sasl_tls_security_options=noanonymous" \
"broken_sasl_auth_clients=yes" "broken_sasl_auth_clients=yes"
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash # Sender binding (the critical anti-spoofing control). texthash resolves the
# resolves the full address first, then the "@domain" wildcard, so both address # full address first, then the "@domain" wildcard, so both address modes work
# modes work from the same map. # from the same map.
postconf -e \ postconf -e \
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" "smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
"queue_directory=${QUEUE_DIR}"
# Restrictions: authenticated clients only, no relay to foreign destinations, # Restrictions: authenticated clients only, no relay to foreign destinations,
# and every authenticated sender address must be owned by its login. NO # and every authenticated sender address must be owned by its login. NO
# permit_mynetworks anywhere — authorisation is by credentials, never by network # permit_mynetworks anywhere — authorisation is by credentials, never by
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible. # network. This is what makes an open relay impossible.
postconf -e \ postconf -e \
"smtpd_helo_required=yes" \ "smtpd_helo_required=yes" \
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \ "smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit" "smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even # Level-1 rate limit by client IP. Backstop that keeps working even if the
# if the journal-milter (level 2) is down. # journal-milter (level 2) is down.
postconf -e \ postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \ "smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s" "anvil_rate_time_unit=${RATE_WINDOW}s"
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly # Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
# (default_action=tempfail: if it is unreachable, defer rather than send # if it is unreachable, defer rather than send unsigned). The journal-milter is
# unsigned). The journal-milter is monitoring only and is fail-open # monitoring only and is fail-open (default_action=accept): its failure must
# (default_action=accept): its failure must never block the relay. Per-milter # never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
# settings use Postfix 3.0+ brace syntax.
postconf -e \ postconf -e \
"milter_protocol=6" \ "milter_protocol=6" \
"milter_default_action=tempfail" \ "milter_default_action=tempfail" \
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \ "smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
"non_smtpd_milters=" "non_smtpd_milters="
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never # Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
# replies) must fail open just like a crash, not stall mail acceptance until the # must fail open just like a crash, not stall mail acceptance until the Postfix
# Postfix defaults (300s content) elapse. With default_action per milter, a # defaults (300s content) elapse. With default_action per milter, a
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail, # 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 # 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 # response time (signing/DB insert are sub-second), so they never fire in normal
@@ -170,8 +227,8 @@ postconf -e \
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}" "milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
# --- master.cf: inbound submission services ---------------------------------- # --- master.cf: inbound submission services ----------------------------------
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service # smtps (465, implicit/wrapper TLS) — the primary, always-on submission
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data # service. chroot=n so smtpd can read the sasldb2 and sender map under /data
# and the Cyrus config outside any chroot. # and the Cyrus config outside any chroot.
postconf -M "smtps/inet=smtps inet n - n - - smtpd" postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P \ postconf -P \
@@ -179,10 +236,10 @@ postconf -P \
"smtps/inet/smtpd_sasl_auth_enable=yes" \ "smtps/inet/smtpd_sasl_auth_enable=yes" \
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits; # submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
# the only difference is TLS is negotiated via STARTTLS, so require encryption # difference is TLS is negotiated via STARTTLS, so require encryption before
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a # auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
# restart after disabling it does not leave the port listening. # after disabling it does not leave the port listening.
if [ "${SUBMISSION_ENABLE}" = "true" ]; then if [ "${SUBMISSION_ENABLE}" = "true" ]; then
postconf -M "submission/inet=submission inet n - n - - smtpd" postconf -M "submission/inet=submission inet n - n - - smtpd"
postconf -P \ postconf -P \
@@ -193,18 +250,131 @@ else
postconf -MX "submission/inet" 2>/dev/null || true postconf -MX "submission/inet" 2>/dev/null || true
fi fi
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery # --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
# agent and others chrooted to /var/spool/postfix, where they cannot read # Debian's stock master.cf enables smtp/inet. When both flags are off, remove
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail # that listener so port 25 is not an inbound smtpd (outbound delivery uses
# never leaves. Inside a container the chroot buys little (the container is the # smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off # configured domains/recipients; DMARC accepts only allow-listed report addresses.
# uniformly. Our own smtps/submission services are already n; this covers the if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
# delivery agents and the rest. 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
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe"
postconf -P \
"dmarc-ingest/unix-pipe/flags=Rq" \
"dmarc-ingest/unix-pipe/user=panel" \
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/dmarc-ingest"
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
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
fi
postconf -e \
"relay_domains=${RELAY_DOMAINS_SETTING}" \
"transport_maps=${TRANSPORT_SETTING}" \
"smtpd_reject_unlisted_recipient=yes"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
postconf -e \
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
"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: either the optional antispam milter, or nothing at all.
INBOUND_MILTERS=""
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
case "${INBOUND_ANTISPAM_MILTER}" in
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
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
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
postconf -P \
"smtp/inet/smtpd_sasl_auth_enable=no" \
"smtp/inet/smtpd_tls_auth_only=no" \
"smtp/inet/smtpd_sender_login_maps=" \
"smtp/inet/smtpd_sender_restrictions=" \
"smtp/inet/smtpd_client_restrictions=" \
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
"smtp/inet/smtpd_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}"
else
postconf -MX "smtp/inet" 2>/dev/null || true
postconf -MX "dmarc-ingest/unix-pipe" 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.
postconf -F "*/*/chroot=n" postconf -F "*/*/chroot=n"
# --- Cyrus SASL app config for smtpd ----------------------------------------- # --- Cyrus SASL app config for smtpd -----------------------------------------
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify # Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
# passwords straight from the panel-maintained sasldb2 (spec 5.1). PLAIN/LOGIN # passwords straight from the panel-maintained sasldb2. PLAIN/LOGIN
# only — both are safe because TLS is mandatory before auth on every port. # only — both are safe because TLS is mandatory before auth on every port.
mkdir -p /etc/postfix/sasl mkdir -p /etc/postfix/sasl
cat > /etc/postfix/sasl/smtpd.conf <<EOF cat > /etc/postfix/sasl/smtpd.conf <<EOF
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would # Postfix start wrapper: solves the cold-start race where Postfix would try to
# try to reach the milter sockets before they are listening. # reach the milter sockets before they are listening.
# #
# It blocks until BOTH milter sockets — OpenDKIM and the panel's journal-milter # 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 # — are present, then execs `postfix start-fg`. If they are not ready within the
+17 -17
View File
@@ -1,4 +1,4 @@
; SelfPost process supervision (spec 4). ; SelfPost process supervision.
; ;
; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens ; Start ORDER is enforced by priority=: OpenDKIM, then the panel (which opens
; the journal-milter socket), then the Postfix wrapper — which additionally ; the journal-milter socket), then the Postfix wrapper — which additionally
@@ -18,8 +18,8 @@ loglevel=info
; The control socket is reachable by the shared `selfpost` group so the panel ; The control socket is reachable by the shared `selfpost` group so the panel
; (running as the unprivileged `panel` user) can ask supervisord to send ; (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 ; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
; still unreachable by world; only root and the two SelfPost services are in the ; 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 ; group. No HTTP auth is configured because access is gated by socket
; permissions, not the network. ; permissions, not the network.
[unix_http_server] [unix_http_server]
@@ -67,15 +67,15 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and ; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
; cannot run `postfix reload` itself, and it cannot signal the Postfix master ; reload` itself, and it cannot signal the Postfix master directly: `postfix
; directly: `postfix start-fg` forks a separate master process, so a signal to ; start-fg` forks a separate master process, so a signal to the supervised
; the supervised foreground process never reaches it (unlike OpenDKIM, which ; foreground process never reaches it (unlike OpenDKIM, which runs in the
; runs in the foreground as the supervised process itself). Instead the panel ; foreground as the supervised process itself). Instead the panel asks
; asks supervisord — over the group-accessible control socket — to run this ; supervisord — over the group-accessible control socket — to run this program,
; program, which executes the canonical `postfix reload` as root. autostart is ; which executes the canonical `postfix reload` as root. autostart is off (it
; off (it only runs on demand) and a fast, clean exit is expected, so it never ; only runs on demand) and a fast, clean exit is expected, so it never trips the
; trips the crashexit listener. ; crashexit listener.
[program:postfix-reload] [program:postfix-reload]
command=/usr/sbin/postfix reload command=/usr/sbin/postfix reload
autostart=false autostart=false
@@ -88,7 +88,7 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; Periodic TLS-certificate refresh (spec 5.2 p.4). Runs a daily `postfix reload` ; Periodic TLS-certificate refresh. Runs a daily `postfix reload`
; so certificates the reverse-proxy renews in the read-only mount are picked up. ; 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 ; 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. ; non-zero, so it neither trips the crashexit listener nor needs restarting.
@@ -103,10 +103,10 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept ; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
; in the image). Runs as root so logrotate can read/rotate the log. Exits ; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
; non-zero when the config is group/other-writable (logrotate would ignore it ; the config is group/other-writable (logrotate would ignore it silently);
; silently); autorestart surfaces BACKOFF on the Status page. ; autorestart surfaces BACKOFF on the Status page.
[program:logrotate] [program:logrotate]
command=/usr/local/bin/logrotate-loop.sh command=/usr/local/bin/logrotate-loop.sh
priority=400 priority=400
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
)
const maxIngestStdin = 10 << 20 // 10 MiB
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 := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT")))
if recipient == "" {
recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT")))
}
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr)
}
return err
}
return nil
}
func isDMARCIngestInvocation() bool {
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
return true
}
return filepath.Base(os.Args[0]) == "dmarc-ingest"
}
+36
View File
@@ -15,6 +15,14 @@ var documentedPublic = []string{
"PANEL_SESSION_IDLE_DAYS", "PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS", "SELFPOST_DNS_RESOLVERS",
"TRUSTED_PROXY_CIDR", "TRUSTED_PROXY_CIDR",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT",
"DMARC_REPORTS_ENABLE",
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
"DMARC_MESSAGE_SIZE_LIMIT",
} }
// documentedInternal matches architecture.md § Configuration "Internal env vars". // documentedInternal matches architecture.md § Configuration "Internal env vars".
@@ -33,6 +41,15 @@ var documentedInternal = []string{
"SASL_REALM", "SASL_REALM",
"POSTFIX_DIR", "POSTFIX_DIR",
"POSTFIX_SENDER_LOGIN_MAPS", "POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"POSTFIX_DMARC_RECIPIENTS",
"POSTFIX_DMARC_TRANSPORT",
"POSTFIX_DMARC_RELAY_DOMAINS",
"SELFPOST_DEPLOY_ROOT",
"MILTER_CONNECT_TIMEOUT", "MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT", "MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT", "MILTER_CONTENT_TIMEOUT",
@@ -70,6 +87,9 @@ var loadConfigKeys = []string{
"SASL_DB_PATH", "SASL_DB_PATH",
"SASL_REALM", "SASL_REALM",
"POSTFIX_DIR", "POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE",
"DMARC_REPORTS_ENABLE",
} }
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh // buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
@@ -84,8 +104,24 @@ var buildScriptKeys = []string{
"JOURNAL_MILTER_SOCKET", "JOURNAL_MILTER_SOCKET",
"MAIL_LOG", "MAIL_LOG",
"POSTFIX_SENDER_LOGIN_MAPS", "POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"SASL_DB_PATH", "SASL_DB_PATH",
"SUBMISSION_ENABLE", "SUBMISSION_ENABLE",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT",
"DMARC_REPORTS_ENABLE",
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
"DMARC_MESSAGE_SIZE_LIMIT",
"POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"POSTFIX_DMARC_RECIPIENTS",
"POSTFIX_DMARC_TRANSPORT",
"POSTFIX_DMARC_RELAY_DOMAINS",
"MILTER_CONNECT_TIMEOUT", "MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT", "MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT", "MILTER_CONTENT_TIMEOUT",
+32 -2
View File
@@ -10,7 +10,9 @@ import (
"github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/buildinfo" "github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web" "github.com/mixeme/selfpost/internal/web"
@@ -21,8 +23,12 @@ import (
type mailStack struct { type mailStack struct {
Domains *domain.Service Domains *domain.Service
Apps *app.Service Apps *app.Service
Inbound *inbound.Service
DMARC *dmarc.Service
pf *postfix.Postfix pf *postfix.Postfix
odk *domain.OpenDKIM odk *domain.OpenDKIM
inboundEnabled bool
dmarcEnabled bool
} }
func newMailStack(cfg config, st *store.Store) *mailStack { func newMailStack(cfg config, st *store.Store) *mailStack {
@@ -30,7 +36,12 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
odk := domain.NewOpenDKIM(cfg.opendkimDir) odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf) apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef) domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk} inb := inbound.NewService(st, pf)
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
return &mailStack{
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
}
} }
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and // Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
@@ -42,6 +53,16 @@ func (m *mailStack) Resync() error {
if err := m.Apps.Resync(); err != nil { if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err) return fmt.Errorf("postfix resync: %w", err)
} }
if m.inboundEnabled {
if err := m.Inbound.Resync(); err != nil {
return fmt.Errorf("inbound maps resync: %w", err)
}
}
if m.dmarcEnabled {
if err := m.DMARC.Resync(); err != nil {
return fmt.Errorf("dmarc maps resync: %w", err)
}
}
return nil return nil
} }
@@ -66,13 +87,18 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
// the environment describes it, with nothing bound to a port yet. // the environment describes it, with nothing bound to a port yet.
func newPanel(cfg config, st *store.Store) (*web.Server, error) { func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st) ms := newMailStack(cfg, st)
return web.New(st, ms.Domains, ms.Apps, web.Config{ // postfix-config.sh has already run (entrypoint). postconf -h is the
// effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md).
retryPolicy := postfix.LoadRetryPolicy()
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
Hostname: cfg.hostname, Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure, CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled, SubmissionEnabled: cfg.submissionEnabled,
MailLogPath: cfg.mailLog, MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir, DataDir: cfg.dataDir,
DBPath: cfg.dbPath, DBPath: cfg.dbPath,
DeployRoot: cfg.deployRoot,
Version: buildinfo.Version, Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies, TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile, TLSCertFile: cfg.tlsCertFile,
@@ -82,6 +108,10 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
DNSResolvers: cfg.dnsResolvers, DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled,
DMARCEnabled: cfg.dmarcEnabled,
SendLogRetentionEnvDefault: cfg.retentionDays,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
} }
+44 -2
View File
@@ -28,6 +28,16 @@ import (
) )
func main() { 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)
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
}
return
}
showVersion := flag.Bool("version", false, "print version and exit") showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse() flag.Parse()
@@ -78,6 +88,10 @@ type config struct {
saslDBPath string saslDBPath string
saslRealm string saslRealm string
postfixDir string postfixDir string
deployRoot string
inboundEnabled bool
dmarcEnabled bool
} }
func loadConfig() config { func loadConfig() config {
@@ -138,6 +152,11 @@ func loadConfig() config {
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")), saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
saslRealm: saslRealm(), saslRealm: saslRealm(),
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
} }
} }
@@ -241,10 +260,20 @@ func run() error {
if err := resyncAfterRestore(cfg, st, false); err != nil { if err := resyncAfterRestore(cfg, st, false); err != nil {
return err return err
} }
} else if 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 err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
return fmt.Errorf("bootstrap send-log retention: %w", err)
} }
var wg sync.WaitGroup var wg sync.WaitGroup
errc := make(chan error, 3) errc := make(chan error, 4)
roles := []struct { roles := []struct {
name string name string
@@ -252,7 +281,20 @@ func run() error {
}{ }{
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }}, {"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }}, {"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }}, {"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
})
}},
{"rate-limit-recalc", func(ctx context.Context) error {
runAutoRateLimitRecalc(ctx, cfg, st)
return nil
}},
} }
for _, r := range roles { for _, r := range roles {
+49
View File
@@ -0,0 +1,49 @@
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()
}
}
}
+56 -22
View File
@@ -22,7 +22,7 @@ import (
) )
// Restoring a SelfPost backup is not a code path in the panel: the operator // Restoring a SelfPost backup is not a code path in the panel: the operator
// extracts the archive into the /data bind mount and starts the image, and the // extracts the archive into a project directory and starts the image, and the
// panel is expected to come up on it (architecture.md § Persistence). Nothing // panel is expected to come up on it (architecture.md § Persistence). Nothing
// below stubs that story out — the archive is downloaded from a running panel // below stubs that story out — the archive is downloaded from a running panel
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel // through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
@@ -38,6 +38,7 @@ const (
// restored is the outcome of a full backup-and-restore round trip. // restored is the outcome of a full backup-and-restore round trip.
type restored struct { type restored struct {
panel http.Handler // panel booted on the restored data directory panel http.Handler // panel booted on the restored data directory
deployRoot string // the restored project directory
dataDir string // the restored /data dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken session *http.Cookie // a session opened before the backup was taken
} }
@@ -50,7 +51,7 @@ type restored struct {
func restoreFromOwnBackup(t *testing.T, password string) restored { func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper() t.Helper()
live := seedPanelData(t) live := seedPanelProject(t)
panel := bootPanel(t, live) panel := bootPanel(t, live)
session := signIn(t, panel) session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password) archive := downloadBackup(t, panel, session, password)
@@ -58,7 +59,12 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
target := t.TempDir() target := t.TempDir()
extract(t, archive, target) extract(t, archive, target)
return restored{panel: bootPanel(t, target), dataDir: target, session: session} return restored{
panel: bootPanel(t, target),
deployRoot: target,
dataDir: filepath.Join(target, "data"),
session: session,
}
} }
// The panel has to come up on the restored directory and show the state that // The panel has to come up on the restored directory and show the state that
@@ -91,6 +97,12 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
t.Errorf("%s = %q, want %q", path, got, want) t.Errorf("%s = %q, want %q", path, got, want)
} }
} }
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
t.Errorf("the restored project directory has no %s: %v", name, err)
}
}
} }
// The one-time setup link is closed by the presence of a panel user, and the // The one-time setup link is closed by the presence of a panel user, and the
@@ -138,8 +150,9 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
// A restore boot runs one Resync from SQLite. If the archive's Postfix map // A restore boot runs one Resync from SQLite. If the archive's Postfix map
// drifted from the database, that step puts it back before mail flows. // drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) { func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
dataDir := seedPanelData(t) deployRoot := seedPanelProject(t)
cfg := panelConfig(t, dataDir) dataDir := filepath.Join(deployRoot, "data")
cfg := panelConfig(t, deployRoot)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps") mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil { if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
@@ -191,12 +204,14 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
// stays put on a mismatch: the operator's next move is to start the image the // stays put on a mismatch: the operator's next move is to start the image the
// backup names, and it has to be there when they do. // backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) { func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
live := seedPanelData(t) deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{ if err := backup.Create(&archive, backup.Params{
DataDir: live, DataDir: dataDir,
DBPath: filepath.Join(live, "selfpost.db"), DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: "9.9.9", Version: "9.9.9",
DeployRoot: deployRoot,
}); err != nil { }); err != nil {
t.Fatalf("create backup: %v", err) t.Fatalf("create backup: %v", err)
} }
@@ -218,13 +233,15 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
} }
} }
// seedPanelData builds the /data tree of a panel that has been in use: an // seedPanelProject builds an operator project tree: data/ with a panel that has
// administrator, a sending domain with an application and one logged message, // been in use, plus docker-compose.yml, .env, and certs/ for full backups.
// and the daemon state the mail path needs (a DKIM key, the SASL database and func seedPanelProject(t *testing.T) string {
// Postfix's sender map).
func seedPanelData(t *testing.T) string {
t.Helper() t.Helper()
dataDir := t.TempDir() deployRoot := t.TempDir()
dataDir := filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
st, err := store.Open(filepath.Join(dataDir, "selfpost.db")) st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil { if err != nil {
@@ -268,16 +285,33 @@ func seedPanelData(t *testing.T) string {
t.Fatalf("write %s: %v", full, err) t.Fatalf("write %s: %v", full, err)
} }
} }
return dataDir
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
return deployRoot
} }
// panelConfig resolves the panel's own configuration for a data directory, so func writeDeployFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// panelConfig resolves the panel's own configuration for a project directory, so
// the test finds the files where the running binary would look for them rather // the test finds the files where the running binary would look for them rather
// than where it put them. Cookies are marked insecure for the same reason the // than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP. // e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, dataDir string) config { func panelConfig(t *testing.T, deployRoot string) config {
t.Helper() t.Helper()
dataDir := filepath.Join(deployRoot, "data")
t.Setenv("SELFPOST_DATA_DIR", dataDir) t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
t.Setenv("PANEL_COOKIE_SECURE", "false") t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru") t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data // MAIL_LOG's default is an absolute path, not one derived from the data
@@ -288,13 +322,13 @@ func panelConfig(t *testing.T, dataDir string) config {
// bootPanel performs the startup sequence run() performs, in the same order, // bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler. // and returns the panel's HTTP handler.
func bootPanel(t *testing.T, dataDir string) http.Handler { func bootPanel(t *testing.T, deployRoot string) http.Handler {
t.Helper() t.Helper()
cfg := panelConfig(t, dataDir) cfg := panelConfig(t, deployRoot)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version) restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil { if err != nil {
t.Fatalf("the panel refused to start on %s: %v", dataDir, err) t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
} }
if restored { if restored {
if _, err := os.Stat(cfg.manifestPath); err == nil { if _, err := os.Stat(cfg.manifestPath); err == nil {
@@ -380,8 +414,8 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
return plain return plain
} }
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data // extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
// bind mount before the image is started. // directory before the image is started.
func extract(t *testing.T, archive []byte, dir string) { func extract(t *testing.T, archive []byte, dir string) {
t.Helper() t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive)) gz, err := gzip.NewReader(bytes.NewReader(archive))
+7 -3
View File
@@ -6,9 +6,9 @@
// //
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz // docker exec <container> selfpost-backup > selfpost-backup.tar.gz
// //
// Use -o to write to a file instead. The resulting archive contains DKIM private // Use -o to write to a file instead. The resulting archive is self-contained:
// keys, the admin password hash and SASL credentials — treat it as a secret // data/, docker-compose.yml, .env, and certs/ when present — treat it as a
// (architecture.md § Persistence). // secret (architecture.md § Persistence).
// //
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an // Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
// argument, which would show up in the process list) the archive is written as // argument, which would show up in the process list) the archive is written as
@@ -90,6 +90,10 @@ func run(outPath, password string) error {
DataDir: dataDir, DataDir: dataDir,
DBPath: dbPath, DBPath: dbPath,
Version: buildinfo.Version, Version: buildinfo.Version,
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
OnWarn: func(msg string) {
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
},
}); err != nil { }); err != nil {
return err return err
} }
+32 -7
View File
@@ -9,13 +9,18 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
// seedDataDir builds the minimum /data tree a backup can be taken from. // seedProject builds the minimum operator project tree a backup can be taken from.
func seedDataDir(t *testing.T) string { func seedProject(t *testing.T) (deployRoot, dataDir string) {
t.Helper() t.Helper()
dataDir := t.TempDir() deployRoot = t.TempDir()
dataDir = filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
st, err := store.Open(filepath.Join(dataDir, "selfpost.db")) st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil { if err != nil {
t.Fatalf("open store: %v", err) t.Fatalf("open store: %v", err)
@@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string {
if err := st.Close(); err != nil { if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err) t.Fatalf("close store: %v", err)
} }
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
t.Setenv("SELFPOST_DATA_DIR", dataDir) t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")) t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
return dataDir t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
return deployRoot, dataDir
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
} }
// An encrypted backup is only worth having if the container it came from can // An encrypted backup is only worth having if the container it came from can
// hand it back as an ordinary archive during a restore, so the two halves of // hand it back as an ordinary archive during a restore, so the two halves of
// the CLI are tested as the one round trip an operator actually performs. // the CLI are tested as the one round trip an operator actually performs.
func TestEncryptedBackupRoundTrip(t *testing.T) { func TestEncryptedBackupRoundTrip(t *testing.T) {
seedDataDir(t) seedProject(t)
dir := t.TempDir() dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk") encrypted := filepath.Join(dir, "backup.spbk")
plain := filepath.Join(dir, "backup.tar.gz") plain := filepath.Join(dir, "backup.tar.gz")
@@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
} }
names[hdr.Name] = true names[hdr.Name] = true
} }
for _, want := range []string{"manifest.json", "selfpost.db"} { for _, want := range []string{
backup.DataArchivePrefix + backup.ManifestName,
backup.DataArchivePrefix + "selfpost.db",
backup.ComposeFileName,
backup.EnvFileName,
} {
if !names[want] { if !names[want] {
t.Errorf("decrypted archive has no %s (entries: %v)", want, names) t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
} }
@@ -91,7 +116,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
// Without a password the CLI keeps producing the plain archive that existing // Without a password the CLI keeps producing the plain archive that existing
// backup scripts consume. // backup scripts consume.
func TestUnencryptedBackupStaysPlain(t *testing.T) { func TestUnencryptedBackupStaysPlain(t *testing.T) {
seedDataDir(t) seedProject(t)
out := filepath.Join(t.TempDir(), "backup.tar.gz") out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err != nil { if err := run(out, ""); err != nil {
t.Fatalf("create backup: %v", err) t.Fatalf("create backup: %v", err)
+24
View File
@@ -11,6 +11,30 @@ SELFPOST_HOSTNAME=mail.example.com
# primary 465/smtps listener. # primary 465/smtps listener.
SUBMISSION_ENABLE=false SUBMISSION_ENABLE=false
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
# mail on port 25 only for domains configured in the panel and forwards them
# to the upstream you set. Off by default — the outbound path is unchanged.
INBOUND_RELAY_ENABLE=false
# Optional 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
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting". # Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
RATE_LIMIT_MESSAGES_PER_IP=100 RATE_LIMIT_MESSAGES_PER_IP=100
RATE_LIMIT_WINDOW_SECONDS=3600 RATE_LIMIT_WINDOW_SECONDS=3600
@@ -0,0 +1,29 @@
# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md).
#
# SelfPost does not ship or start an anti-spam engine. This fragment brings up
# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it
# only when you want filtering on port 25:
#
# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
#
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
#
# Pin the rspamd tag the same way the SelfPost image is pinned; bump it
# deliberately. Default milter action is fail-open (accept) so a down sidecar
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
# defer instead.
services:
selfpost:
environment:
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
depends_on:
- antispam
antispam:
image: rspamd/rspamd:3.11
restart: unless-stopped
# No host ports: only SelfPost talks to the milter on the compose network.
+13 -14
View File
@@ -1,18 +1,17 @@
# Example host Apache vhost for the SelfPost panel (spec 10.3, default # Example host Apache vhost for the SelfPost panel (default scenario). Install
# scenario). Install on the host running Apache — NOT inside the SelfPost # on the host running Apache — NOT inside the SelfPost container. Requires
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled # mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
# (`a2enmod ssl proxy proxy_http`).
# #
# Certificates: obtain with the Apache certbot plugin against THIS vhost # Certificates: obtain with the Apache certbot plugin against THIS vhost
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place # (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a # add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
# :80 -> :443 redirect vhost, and renews in the background via its own timer. # redirect vhost, and renews in the background via its own timer. The resulting
# The resulting PEM files land at # PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host # /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
# — bind-mount that directory read-only into the SelfPost container (see # bind-mount that directory read-only into the SelfPost container (see
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix # ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
# can present the very same certificate on 465/587. One certificate, two # can present the very same certificate on 465/587. One certificate, two
# consumers (spec 10 p.4) — no separate cert just for the panel. # consumers — no separate cert just for the panel.
<VirtualHost *:443> <VirtualHost *:443>
ServerName mail.example.com ServerName mail.example.com
@@ -26,9 +25,9 @@
ProxyPass "/" "http://127.0.0.1:8080/" ProxyPass "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "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 # The panel is plain HTTP behind the proxy; it never terminates TLS itself.
# itself (spec 10 p.2). Cookies are still marked Secure by the panel # Cookies are still marked Secure by the panel because the browser only ever
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost. # sees this HTTPS vhost.
</VirtualHost> </VirtualHost>
<VirtualHost *:80> <VirtualHost *:80>
+7 -5
View File
@@ -1,10 +1,10 @@
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully # Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
# automatic ACME with no separate certbot container. Caddy writes certificates # with no separate certbot container. Caddy writes certificates as plain PEM
# as plain PEM under its own data directory, which this fragment bind-mounts # under its own data directory, which this fragment bind-mounts to the host so
# to the host so SelfPost can read the same files. # SelfPost can read the same files.
# #
# NOTE (spec 10.3 explicitly flags this): Caddy's on-disk cert path includes # NOTE: Caddy's on-disk cert path includes
# the ACME CA's name as a path segment, e.g. # the ACME CA's name as a path segment, e.g.
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt # <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
# This has been stable across recent Caddy releases but is an internal # This has been stable across recent Caddy releases but is an internal
@@ -23,6 +23,7 @@ services:
ports: !override ports: !override
- "465:465" - "465:465"
- "587:587" - "587:587"
- "25:25"
environment: environment:
# Caddy names certificate files after the hostname, not # Caddy names certificate files after the hostname, not
# fullchain.pem/privkey.pem like certbot — override the base file's # fullchain.pem/privkey.pem like certbot — override the base file's
@@ -31,6 +32,7 @@ services:
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro
# Replace <hostname> with your actual mail/panel hostname, matching # Replace <hostname> with your actual mail/panel hostname, matching
# the Caddyfile below and SELFPOST_HOSTNAME. # the Caddyfile below and SELFPOST_HOSTNAME.
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro - ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
+48 -35
View File
@@ -1,12 +1,12 @@
# SelfPost — default deployment, reverse-proxy = Apache (spec 10, 10.5). # SelfPost — default deployment, reverse-proxy = Apache.
# #
# This file only runs SelfPost itself. Apache is assumed to already be # This file only runs SelfPost itself. Apache is assumed to already be installed
# installed on the HOST (the target audience for this project typically runs # on the HOST (the target audience for this project typically runs Apache there
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel; # already) and reverse-proxies HTTPS for the panel; it is not containerised
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready # here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
# vhost fragment, and docs/guide.md "Reverse proxy" for the # docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
# certbot steps that produce the PEM files this compose file mounts. # this compose file mounts.
# #
# Usage: # Usage:
# 1. Copy this file (and .env.example as .env) next to your own ./data and # 1. Copy this file (and .env.example as .env) next to your own ./data and
@@ -15,32 +15,39 @@
# TLS paths are fixed in this file to match that bind mount, not .env. # TLS paths are fixed in this file to match that bind mount, not .env.
# 3. docker compose up -d # 3. docker compose up -d
# #
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup # The image tag below is FIXED on purpose: backup restore compares the manifest
# restore compares the manifest version against the running binary's version, # version against the running binary's version, so ":latest" would make that
# so ":latest" would make that check meaningless. Bump the tag deliberately # check meaningless. Bump the tag deliberately when you want to upgrade.
# when you want to upgrade.
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.3.0 image: ghcr.io/mixeme/selfpost:1.9.1
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
# Path Postfix reads inside the container — matches the certs bind mount # Path Postfix reads inside the container — matches the certs bind mount
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2). # below. Point these at your reverse-proxy's PEM output.
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
# Set to true to also publish RFC 6409 submission (587/STARTTLS) # Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
# alongside the primary 465/smtps listener (spec 5). # the primary 465/smtps listener.
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}" SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
# limits (level 2) are configured later from the panel itself. 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.
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}" RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}" RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
# How long the send log keeps rows before the background sweep deletes # How long the send log keeps rows before the background sweep deletes
# them (spec 7.3, 9) — the main driver of /data growth over time. # them — the main driver of /data growth over time.
SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}" SEND_LOG_RETENTION_DAYS: "${SEND_LOG_RETENTION_DAYS:-90}"
# Sliding idle timeout for the panel login session, in days (spec 7.6.6). # Sliding idle timeout for the panel login session, in days.
PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}" PANEL_SESSION_IDLE_DAYS: "${PANEL_SESSION_IDLE_DAYS:-7}"
# Resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly, so they # 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 # report what the internet sees rather than what this host's own stub
@@ -51,30 +58,36 @@ services:
# client spoof its rate-limit key. # client spoof its rate-limit key.
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}" TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
volumes: volumes:
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys, # All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
# sasldb2, sender map, setup token. Back this up (panel button or the # sender map, setup token. Back this up (panel button or the
# selfpost-backup CLI) before you touch it directly. # selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files # here too (./data/log, rotated daily, 14 files kept) so the delivery
# kept) so the delivery lines that resolve a queued send-log row survive # lines that resolve a queued send-log row survive a container recreate;
# a container recreate; it is diagnostics and is left out of backups. # it is diagnostics and is left out of backups.
- ./data:/data - ./data:/data
# Read-only: SelfPost only ever reads certificates, never manages them # Read-only project root for full backups: docker-compose.yml, .env, and
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot # certs/ are archived alongside /data (architecture.md § Persistence).
# writes PEM files, e.g. /etc/letsencrypt on the host. - .:/selfpost-deploy:ro
# Read-only: SelfPost only ever reads certificates, never manages them.
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g.
# /etc/letsencrypt on the host.
- ./certs:/etc/postfix/tls:ro - ./certs:/etc/postfix/tls:ro
ports: ports:
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are # 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
# published directly — mail traffic bypasses Apache entirely, it only # published directly — mail traffic bypasses Apache entirely, it only ever
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself # proxies the panel's HTTP(S). The panel itself (8080) is intentionally
# (8080) is intentionally NOT published here: Apache reaches it over # NOT published here: Apache reaches it over the host network at
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the # 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
# panel is never directly reachable from the internet without TLS. # reachable from the internet without TLS.
- "465:465" - "465:465"
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only # 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
# when the variable is true — see docs/guide.md "Published ports". # when the variable is true — see docs/guide.md "Published ports".
- "587:587" - "587:587"
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
# when the variable is true.
- "25:25"
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as # Hardening. SelfPost's entrypoint still needs to run as
# root very briefly to fix /data ownership and normalise permissions # root very briefly to fix /data ownership and normalise permissions
# under the shared `selfpost` group (see build/entrypoint.sh) before # under the shared `selfpost` group (see build/entrypoint.sh) before
# supervisord drops the panel to an unprivileged user — so this cannot be # supervisord drops the panel to an unprivileged user — so this cannot be
@@ -82,7 +95,7 @@ services:
# self-healing. What IS applied: no privilege escalation past what the # self-healing. What IS applied: no privilege escalation past what the
# image already grants, and every Linux capability dropped except the # image already grants, and every Linux capability dropped except the
# small set the root startup phase and Postfix/OpenDKIM genuinely need: # small set the root startup phase and Postfix/OpenDKIM genuinely need:
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024; # - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`; # - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data # - FOWNER — entrypoint then chmods those now panel-owned /data
# dirs/files while still root (owner-check bypass); # dirs/files while still root (owner-check bypass);
+8 -6
View File
@@ -1,7 +1,7 @@
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host # Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
# exactly like the Apache+certbot scenario — nginx and certbot are close # Apache+certbot scenario — nginx and certbot are close cousins here, both
# cousins here, both writing/reading plain PEM under /etc/letsencrypt. # writing/reading plain PEM under /etc/letsencrypt.
# #
# This is a FRAGMENT, not a full replacement for ../docker-compose.yml: it # 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 # adds an nginx + certbot pair and removes the panel's host port publish (nginx
@@ -23,13 +23,15 @@ services:
ports: !override ports: !override
- "465:465" - "465:465"
- "587:587" - "587:587"
- "25:25"
# No host publish for 8080 here: nginx reaches it over the compose # No host publish for 8080 here: nginx reaches it over the compose
# network at selfpost:8080 instead (see nginx.conf.example). # network at selfpost:8080 instead (see nginx.conf.example).
volumes: !override volumes: !override
- ./data:/data - ./data:/data
# Same host directory certbot below writes into — plain bind mount, - .:/selfpost-deploy:ro
# no named volume, so the PEM files are as directly inspectable as in # Same host directory certbot below writes into — plain bind mount, no
# the Apache scenario (spec 10.3). # named volume, so the PEM files are as directly inspectable as in the
# Apache scenario.
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro - ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
nginx: nginx:
+3 -3
View File
@@ -1,6 +1,6 @@
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel # nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
# over the compose network; mail (465/587) is published directly by the # compose network; mail (465/587) is published directly by the selfpost
# selfpost container and never touches nginx. # container and never touches nginx.
server { server {
listen 80; listen 80;
+5 -3
View File
@@ -1,7 +1,7 @@
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain # Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost # everything (cert + key) lives bundled inside acme.json. SelfPost needs
# needs separate PEM files for Postfix, so an extraction step is required; see # 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. # 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 # Merge with the base file — run this from the deploy/ directory (Compose
@@ -15,8 +15,10 @@ services:
ports: !override ports: !override
- "465:465" - "465:465"
- "587:587" - "587:587"
- "25:25"
volumes: !override volumes: !override
- ./data:/data - ./data:/data
- .:/selfpost-deploy:ro
# Populated by extract-cert.sh from traefik's acme.json — see below. # Populated by extract-cert.sh from traefik's acme.json — see below.
- ./traefik/extracted-certs:/etc/postfix/tls:ro - ./traefik/extracted-certs:/etc/postfix/tls:ro
labels: labels:
+5 -5
View File
@@ -1,9 +1,9 @@
#!/bin/sh #!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json # 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 # Traefik keeps its certificates there, so a PEM extraction step is required.
# step is required"). Run this on the host, after Traefik has issued or # Run this on the host, after Traefik has issued or renewed the certificate, and
# renewed the certificate, and again on a schedule (cron/systemd timer) since # again on a schedule (cron/systemd timer) since acme.json is not itself watched
# acme.json is not itself watched by SelfPost/Postfix. # by SelfPost/Postfix.
# #
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir> # Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
set -eu set -eu
+106 -22
View File
@@ -27,7 +27,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
| Program | User | Priority | Role | | Program | User | Priority | Role |
|---|---|---|---| |---|---|---|---|
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter | | `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer goroutine | | `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc |
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist | | `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) | | `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs | | `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
@@ -53,13 +53,45 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
├─► OpenDKIM milter (sign, tempfail on failure) ├─► OpenDKIM milter (sign, tempfail on failure)
├─► journal-milter (send log + L2 rate limits, fail-open) ├─► journal-milter (send log + L2 rate limits, fail-open)
└─► outbound MX delivery (port 25 client) └─► outbound MX delivery (port 25 client)
Internet ──► Postfix smtp inet :25 (when INBOUND_RELAY_ENABLE=true
│ and/or DMARC_REPORTS_ENABLE=true)
│
├─► optional antispam milter (inbound relay only)
├─► DMARC aggregate ingest (pipe → dmarc-ingest)
└─► smtp:[upstream]:port (inbound relay only;
transport_maps; no local delivery)
``` ```
The smtp/inet listener is **absent** when both flags are off (`postconf -MX
smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
`smtp unix` client; it is not the same service.
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh)) ### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
- **465/smtps** — implicit TLS, SASL required; primary listener. - **465/smtps** — implicit TLS, SASL required; primary listener.
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with - **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`smtpd_tls_security_level=encrypt`. `smtpd_tls_security_level=encrypt`.
- **25/smtp inet** — when `INBOUND_RELAY_ENABLE=true` and/or
`DMARC_REPORTS_ENABLE=true`. No SASL, no OpenDKIM, no journal-milter —
`smtpd_milters` is always overridden on this service (to the antispam milter
or to nothing), because main.cf's submission chain would otherwise let a
signing outage defer relayed mail and would file inbound mail into the
outbound send log under the sender's domain.
**Inbound relay** (`INBOUND_RELAY_ENABLE=true`): accepts only
`relay_domains` + `relay_recipient_maps` (`reject_unauth_destination`,
`reject_unlisted_recipient`). Maps under `/data/postfix/` (`relay_domains`,
`transport`, `relay_recipients`, `tls_policy`), written atomically by
[internal/postfix/inbound.go](../internal/postfix/inbound.go). Domains with
an empty upstream host are omitted from the maps. Optional
`INBOUND_ANTISPAM_MILTER` only when inbound relay is enabled (not on
DMARC-only port 25); default `milter_default_action` is fail-open
(`accept`). **DMARC ingest** (`DMARC_REPORTS_ENABLE=true`): allow-listed
report addresses via `check_recipient_access` on `postfix/dmarc_recipients`;
messages pipe to `dmarc-ingest` (symlink to `panel`). When both flags are
on, one listener serves both paths; per-IP rate uses
`INBOUND_RATE_LIMIT_MESSAGES_PER_IP` and message size uses the greater of
`INBOUND_MESSAGE_SIZE_LIMIT` and `DMARC_MESSAGE_SIZE_LIMIT`.
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`; - **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. `smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` / - **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
@@ -75,22 +107,34 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
### Panel binary ([cmd/panel](../cmd/panel)) ### Panel binary ([cmd/panel](../cmd/panel))
One process, three roles: One process, four roles:
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse 1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only. proxy only. On start it runs `postconf -h` once for the deferred-mail retry
parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`delay_warning_time`) and caches the snapshot on the handlers config. The
Mail queue card and a delivery's `deferred` / `bounced` history print those
numbers; they never call `postconf` per request. If `postconf` is missing,
the panel logs a warning and uses Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain (`default_action=accept`) so milter failure does not stop mail. Domain
ceilings apply to every client IP; an application ceiling with trusted IPs ceilings apply to every client IP; an application ceiling **overrides** the
raises the limit for those IPs only and skips the domain check (guide § Rate domain one for that login, higher or lower (guide § Rate limiting). Since
limiting). The level-2 count is the stored send-log rows plus the messages 1.9.0 the client IP allow-list is a separate, restrictive control on the
application (`applications.auth_ip_restrict`), checked here at `MAIL FROM`
and fail-open like the rest of this milter — not an authentication
boundary. The level-2 count is the stored send-log rows plus the messages
this process has admitted but not yet written (`internal/milter/inflight.go`), this process has admitted but not yet written (`internal/milter/inflight.go`),
so concurrent sessions cannot each spend the same last slot; a reservation so concurrent sessions cannot each spend the same last slot; a reservation
is released at end-of-message, on ABORT, or after a 10-minute TTL. is released at end-of-message, on ABORT, or after a 10-minute TTL.
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
4. **rate-limit recalc** — every six hours (and on demand from the domain
page), recomputes level-2 **Auto** rate limits from send-log statistics.
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
@@ -173,18 +217,23 @@ below is a summary — HTMX fragment endpoints
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** | | `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) | | `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view | | `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail | | `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps | | `/reload` | **Global.** `POST` — reload OpenDKIM tables, Postfix sender map, and inbound relay maps when enabled (not DMARC maps — see § Persistence restore) |
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) | | `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/help` | In-panel operator help (any authenticated user) |
| `/settings` | Username/password for any user; DMARC report default is **global** only | | `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD | | `/users`, `/users/*` | **Global.** Panel user CRUD |
| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. |
| `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}` | **Global** list and report detail; domain roll-up scoped like deliveries. Registered only when `DMARC_REPORTS_ENABLE=true`; otherwise 404. |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend `panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
session idle timeout (only non-`HX-Request` GET and mutating requests count as session idle timeout (only non-`HX-Request` GET and mutating requests count as
activity). activity). The Mail queue retry-policy card is outside that fragment: it is the
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
not a live re-read.
### Sessions ### Sessions
@@ -242,6 +291,8 @@ flowchart TB
subgraph services ["Services — multi-store operations + rollback"] subgraph services ["Services — multi-store operations + rollback"]
domainSvc["internal/domain"] domainSvc["internal/domain"]
appSvc["internal/app"] appSvc["internal/app"]
inboundSvc["internal/inbound"]
dmarcSvc["internal/dmarc"]
end end
subgraph persistence ["Persistence"] subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"] store["internal/store — SQLite, embedded migrations"]
@@ -263,20 +314,27 @@ flowchart TB
web --> store web --> store
web --> domainSvc web --> domainSvc
web --> appSvc web --> appSvc
web --> inboundSvc
web --> dmarcSvc
web --> backupPkg web --> backupPkg
web --> dnscheck web --> dnscheck
web --> health web --> health
web --> secretfile web --> secretfile
domainSvc --> store domainSvc --> store
appSvc --> store appSvc --> store
inboundSvc --> store
dmarcSvc --> store
milterPkg --> store milterPkg --> store
logtail --> store logtail --> store
domainSvc --> postfix domainSvc --> postfix
appSvc --> postfix appSvc --> postfix
inboundSvc --> postfix
dmarcSvc --> postfix
``` ```
The three roles inside the `panel` process (HTTP server, journal-milter, The four roles inside the `panel` process (HTTP server, journal-milter,
log-tailer goroutine) share one binary and one SQLite handle on purpose — see log-tailer, rate-limit recalc) share one binary and one SQLite handle on
purpose — see
[Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the [Panel binary](#panel-binary-cmdpanel) for why, and *Persistence* below for the
single-connection trade-off that follows from it. single-connection trade-off that follows from it.
@@ -286,28 +344,42 @@ single-connection trade-off that follows from it.
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset | | `selfpost.db` | SQLite panel state (domains, apps, users, sessions, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [schema-migrations.md](schema-migrations.md) |
| `setup-token` | First-run setup token file | | `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables | | `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials | | `sasl/sasldb2` | Application SASL credentials |
| `postfix/sender_login_maps` | Login → From binding | | `postfix/sender_login_maps` | Login → From binding |
| `postfix/relay_domains` | Inbound domains accepted on port 25 |
| `postfix/transport` | Inbound next-hop `smtp:[host]:port` |
| `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all |
| `postfix/tls_policy` | TLS policy for inbound next hops |
| `postfix/dmarc_recipients` | Allow-listed DMARC aggregate report addresses |
| `postfix/dmarc_transport` | Pipe transport for report ingest |
| `postfix/dmarc_relay_domains` | Domains accepted for DMARC report delivery |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) | | `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `manifest.json` | Backup version stamp (consumed on restore) | | `manifest.json` | Backup version stamp (consumed on restore) |
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
(transit mail not migrated by design). full backups also archive the operator's `./certs` PEM files when present.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 rotated files, check every 6h, rename + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of **Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on `data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
restore. On the first successful boot after restore, the panel runs one under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from version check on restore. Requires the project directory mounted read-only at
SQLite and both daemons are reloaded, so drift between the extracted archive `SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
and the database is healed before mail flows (same step as `POST /reload` on first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
demand). Stopped-container `tar` of `./data` is safe (see guide). tables, Postfix's sender map, inbound relay maps (when
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`)
are re-derived from SQLite and both daemons are reloaded, so drift between the
extracted archive and the database is healed before mail flows. Manual
`POST /reload` on the Status page resyncs OpenDKIM, the sender map, and
inbound maps only — not DMARC maps. Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads **Optional encryption** of the two secret-bearing downloads
([internal/secretfile](../internal/secretfile/secretfile.go)): password → ([internal/secretfile](../internal/secretfile/secretfile.go)): password →
@@ -350,7 +422,19 @@ unsupported rather than as a missing doc:
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS` `POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel (`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains). desyncs the map Postfix reads from the file the panel maintains),
`POSTFIX_RELAY_DOMAINS` (`/data/postfix/relay_domains`),
`POSTFIX_TRANSPORT_MAPS` (`/data/postfix/transport`),
`POSTFIX_RELAY_RECIPIENTS` (`/data/postfix/relay_recipients`),
`POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if
overridden without matching the panel writer in
[internal/postfix/inbound.go](../internal/postfix/inbound.go),
`POSTFIX_DMARC_RECIPIENTS` (`/data/postfix/dmarc_recipients`),
`POSTFIX_DMARC_TRANSPORT` (`/data/postfix/dmarc_transport`),
`POSTFIX_DMARC_RELAY_DOMAINS` (`/data/postfix/dmarc_relay_domains`),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
full backups; mount `.:/selfpost-deploy:ro` in compose).
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`), - **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`), `MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds). `MILTER_WAIT_TIMEOUT` (`30` seconds).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — panel mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<script>
(function () {
var map = {
login: "login.html",
setup: "setup.html",
status: "status.html",
domains: "domains.html",
domain: "domain.html",
"domain-delete": "domain-delete.html",
deliveries: "deliveries.html",
delivery: "delivery.html",
"mail-queue": "mail-queue.html",
"system-log": "system-log.html",
backup: "backup.html",
users: "users.html",
"user-form": "user-form.html",
"user-delete": "user-delete.html",
settings: "settings.html",
inbound: "inbound.html",
"inbound-domain": "inbound-domain.html",
"inbound-backup": "inbound-backup.html",
"inbound-delete": "inbound-delete.html",
dmarc: "dmarc.html",
"dmarc-domain": "dmarc-domain.html",
"dmarc-report": "dmarc-report.html",
"dmarc-report-fail": "dmarc-report-fail.html",
help: "help.html"
};
var h = location.hash.replace(/^#/, "");
location.replace((map[h] || "status.html") + location.search);
})();
</script>
</head>
<body>
<p>Макеты собраны отдельными страницами. Откройте <a href="status.html">Status</a> или <a href="index.html">оглавление</a>.</p>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup &amp; migration — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-backup" data-page="backup" data-nav="backup" data-title="Backup" data-global-only="1">
<main class="stack">
<h1>Backup &amp; migration</h1>
<div class="pair">
<div class="card">
<h2>Full backup</h2>
<p class="muted">Download a self-contained backup — <code>data/</code> (database, DKIM keys, credentials, Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and <code>certs/</code>. Extract into an empty project directory on a new machine, adjust hostname if needed, and start the <strong>same SelfPost version</strong> before first boot. The reverse-proxy vhost is not included.</p>
<p class="muted"><strong>The backup file is a secret.</strong> Encrypting it is the simplest way to store it: the download is then a <code>.spbk</code> that only the password opens.</p>
<div class="encrypt">
<label class="check"><input type="checkbox" checked> <span>Encrypt with a password</span></label>
<div class="encrypt-fields">
<label>Password</label><input type="password" placeholder="at least 8 characters">
<label>Repeat password</label><input type="password">
<p class="muted">Keep this password: without it the file cannot be opened.</p>
</div>
</div>
<div class="actions-row"><button type="button">Download full backup</button></div>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.</p>
<label>Domain export file</label>
<input type="file">
<label>Password</label>
<input type="password" autocomplete="off">
<p class="muted">Needed for a <code>.spde</code> file. Leave empty for plain <code>.json</code>.</p>
<div class="actions-row"><button type="button">Import domain</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deliveries — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-deliveries" data-page="deliveries" data-nav="deliveries" data-title="Deliveries">
<main class="stack">
<h1>Deliveries</h1>
<form class="toolbar fill" action="#" onsubmit="return false">
<div class="field"><label>Domain</label>
<select><option>All domains</option><option>example.com</option><option>alerts.example.com</option></select>
</div>
<div class="field"><label>Application</label>
<select><option>All applications</option><option>newsletter</option><option>billing</option></select>
</div>
<button type="button">Filter</button>
</form>
<div class="fill">
<div class="card">
<table class="desk-only">
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr>
<td class="time muted">2026-08-15 20:14:02</td>
<td>billing@example.com</td>
<td>ada@example.net</td>
<td class="subject"><span>Invoice #4412</span></td>
<td class="status"><span class="st st-warn">deferred</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 20:11:40</td>
<td>news@example.com</td>
<td>list-bounces@example.net</td>
<td class="subject"><span>August digest</span></td>
<td class="status"><span class="st st-ok">delivered</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 19:02:11</td>
<td>alerts@alerts.example.com</td>
<td>noreply@blocked.example</td>
<td class="subject"><span>Disk 92% on web-3</span></td>
<td class="status"><span class="st st-error">bounced</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 18:44:09</td>
<td>news@example.com</td>
<td>sam@example.org</td>
<td class="subject"><span>August digest</span></td>
<td class="status"><span class="st st-ok">delivered</span></td>
<td class="actions"><a href="delivery.html">Details</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<a href="delivery.html">
<span class="st st-warn">deferred</span><span class="when">20:14</span>
<span class="meta">billing@example.com → ada@example.net</span>
<span class="subj">Invoice #4412</span>
</a>
</li>
<li>
<a href="delivery.html">
<span class="st st-ok">delivered</span><span class="when">20:11</span>
<span class="meta">news@example.com → list-bounces@example.net</span>
<span class="subj">August digest</span>
</a>
</li>
<li>
<a href="delivery.html">
<span class="st st-error">bounced</span><span class="when">19:02</span>
<span class="meta">alerts@alerts.example.com → noreply@blocked.example</span>
<span class="subj">Disk 92% on web-3</span>
</a>
</li>
</ul>
<p class="muted">Page 1 of 4 · <a href="deliveries.html">Older →</a></p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invoice #4412 — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-delivery" data-page="delivery" data-nav="deliveries" data-title="Message">
<main class="stack">
<div>
<h1 class="subject">Invoice #4412</h1>
<p class="route">
<span class="addr">billing@example.com</span>
<span class="arrow">→</span>
<span class="addr">ada@example.net</span>
<span class="st st-warn">deferred</span>
</p>
<a class="back" href="deliveries.html">&larr; Back to deliveries</a>
</div>
<div class="pair">
<div class="card">
<h2>Message</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
<div class="fact"><span class="fact-label">Application</span><span class="fact-value">billing</span></div>
<div class="fact"><span class="fact-label">Accepted</span><span class="fact-value">2026-08-15 20:14:02 UTC</span></div>
<div class="fact"><span class="fact-label">Status reported</span><span class="fact-value">2026-08-15 20:14:08 UTC</span></div>
<div class="fact"><span class="fact-label">Queue id</span><span class="fact-value mono">4C3A1E2F1A</span></div>
<div class="fact"><span class="fact-label">Journal id</span><span class="fact-value mono">1842</span></div>
</div>
</div>
<div class="card">
<h2>History</h2>
<ol class="timeline">
<li class="event lvl-ok">
<p class="event-time">2026-08-15 20:14:02 UTC</p>
<p class="event-title"><span class="st st-ok">accepted</span> Received by the relay</p>
<p class="event-detail muted">SASL login billing, queued as 4C3A1E2F1A.</p>
</li>
<li class="event lvl-warn">
<p class="event-time">2026-08-15 20:14:08 UTC</p>
<p class="event-title"><span class="st st-warn">deferred</span> Receiving MX asked to try later</p>
<p class="event-detail muted">Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.</p>
</li>
<li class="event pending">
<p class="event-time">not yet</p>
<p class="event-title"><span class="st st-unknown">delivery</span> Waiting on the next retry</p>
</li>
</ol>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Delivery log</h2>
<table class="log desk-only">
<thead><tr><th>Time</th><th>Message</th></tr></thead>
<tbody>
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing</td></tr>
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/cleanup[224]: 4C3A1E2F1A: message-id=&lt;4412@example.com&gt;</td></tr>
<tr><td class="time muted">20:14:08</td><td class="log-text">postfix/smtp[230]: 4C3A1E2F1A: to=&lt;ada@example.net&gt;, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)</td></tr>
</tbody>
</table>
<div class="phone-only">
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:08</p>
<span class="code">status=deferred (450 4.2.1 mailbox busy)</span>
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:02</p>
<span class="code">client=203.0.113.40, sasl_username=billing</span>
</div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.com — DMARC — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc-domain" data-page="dmarc-domain" data-nav="dmarc" data-title="example.com DMARC">
<main class="stack">
<div>
<h1>example.com <span class="tag future">candidate</span></h1>
<a class="back g-only" href="dmarc.html">&larr; All DMARC reports</a>
<a class="back" href="domain.html">&larr; Back to example.com</a>
</div>
<div class="pair">
<div class="card">
<h2>Last 7 days <span class="st st-ok">pass</span></h2>
<p>98% aligned. Tightening <code>p=</code> looks reasonable.</p>
<p class="muted"><a href="domain.html">Domain DNS and rua=</a></p>
</div>
<div class="card">
<h2>Third-party senders</h2>
<p>google.com / 66.102.0.0/20 failed SPF and DKIM 6 times. Everyone else is this relay.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Reports</h2>
<table class="desk-only">
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr>
<td class="time muted">2026-08-15 06:12</td>
<td class="muted">google.com</td>
<td>14 Aug</td>
<td>412</td>
<td>6</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
<tr>
<td class="time muted">2026-08-14 06:08</td>
<td class="muted">google.com</td>
<td>13 Aug</td>
<td>390</td>
<td>2</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<a href="dmarc-report.html">
<span class="st st-ok">412 pass</span><span class="when">14 Aug</span>
<span class="meta">google.com · 6 fail</span>
</a>
</li>
<li>
<a href="dmarc-report.html">
<span class="st st-ok">390 pass</span><span class="when">13 Aug</span>
<span class="meta">google.com · 2 fail</span>
</a>
</li>
</ul>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Sources · last 7 days</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>
<tr><td class="muted">203.0.113.10 (this relay)</td><td>802</td><td>0</td><td><span class="st st-ok">none</span></td></tr>
<tr><td class="muted">google.com / 66.102.0.0/20</td><td>0</td><td>8</td><td><span class="st st-warn">none</span></td></tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="dmarc-report.html"><span>this relay</span><span class="st st-ok">802 pass</span></a></li>
<li><a href="dmarc-report.html"><span>google.com / 66.102.0.0/20</span><span class="st st-warn">8 fail</span></a></li>
</ul>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>google.com report — alerts.example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc-report-fail" data-page="dmarc-report-fail" data-nav="dmarc" data-title="DMARC report">
<main class="stack">
<div>
<h1 class="subject">google.com · 14 Aug</h1>
<p class="route">
<span class="addr">alerts.example.com</span>
<span class="st st-ok">88 pass</span>
<span class="st st-error">19 fail</span>
</p>
<a class="back" href="dmarc.html">&larr; All DMARC reports</a>
</div>
<div class="pair">
<div class="card">
<h2>Report</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944098765432109</span></div>
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 04:40 UTC</span></div>
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
</div>
</div>
<div class="card attn">
<h2>Published policy</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">alerts.example.com</span></div>
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
</div>
<p class="muted">A third-party source is not in SPF or DKIM. Do not tighten <code>p=</code> until that sender is gone or aligned.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Records</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
<tbody>
<tr>
<td class="muted">203.0.113.10 (this relay)</td>
<td>88</td>
<td><span class="st st-ok">none</span></td>
<td><span class="st st-ok">pass</span></td>
<td><span class="st st-ok">pass</span></td>
<td>alerts.example.com</td>
</tr>
<tr>
<td class="muted">198.51.100.80 (unknown)</td>
<td>19</td>
<td><span class="st st-error">none</span></td>
<td><span class="st st-error">fail</span></td>
<td><span class="st st-error">fail</span></td>
<td>alerts.example.com</td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<div class="item">
<span>this relay · 88</span><span class="st st-ok">pass</span>
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
</div>
</li>
<li>
<div class="item">
<span>unknown · 19</span><span class="st st-error">fail</span>
<span class="meta">198.51.100.80 · SPF fail · DKIM fail</span>
</div>
</li>
</ul>
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>google.com report — example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc-report" data-page="dmarc-report" data-nav="dmarc" data-title="DMARC report">
<main class="stack">
<div>
<h1 class="subject">google.com · 14 Aug</h1>
<p class="route">
<span class="addr">example.com</span>
<span class="st st-ok">412 pass</span>
<span class="st st-warn">6 fail</span>
</p>
<a class="back" href="dmarc-domain.html">&larr; Back to example.com</a>
</div>
<div class="pair">
<div class="card">
<h2>Report</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944012345678901</span></div>
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 06:12 UTC</span></div>
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
</div>
</div>
<div class="card">
<h2>Published policy</h2>
<div class="facts">
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
</div>
<p class="muted"><code>p=none</code> does not affect delivery. Failures here are forwarding through Google, not this relay.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Records</h2>
<table class="desk-only">
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
<tbody>
<tr>
<td class="muted">203.0.113.10 (this relay)</td>
<td>412</td>
<td><span class="st st-ok">none</span></td>
<td><span class="st st-ok">pass</span></td>
<td><span class="st st-ok">pass</span></td>
<td>example.com</td>
</tr>
<tr>
<td class="muted">66.102.1.44 (google.com)</td>
<td>6</td>
<td><span class="st st-warn">none</span></td>
<td><span class="st st-error">fail</span></td>
<td><span class="st st-error">fail</span></td>
<td>example.com</td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<div class="item">
<span>this relay · 412</span><span class="st st-ok">pass</span>
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
</div>
</li>
<li>
<div class="item">
<span>google.com · 6</span><span class="st st-error">fail</span>
<span class="meta">66.102.1.44 · SPF fail · DKIM fail</span>
</div>
</li>
</ul>
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DMARC reports — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-dmarc" data-page="dmarc" data-nav="dmarc" data-title="DMARC" data-global-only="1">
<main class="stack">
<div class="page-head">
<h1>DMARC reports <span class="tag future">candidate</span></h1>
</div>
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>. Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed XML — not a dashboard.</p>
<div class="pair">
<div class="card">
<h2>Ingest <span class="st st-ok">ok</span></h2>
<p>Last report 6 hours ago. Port 25 accepts only the configured report address.</p>
</div>
<div class="card">
<h2>This week</h2>
<p>14 kept, 0 parse failures. Older summaries are pruned.</p>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Recent reports</h2>
<table class="desk-only">
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr>
<td class="time muted">2026-08-15 06:12</td>
<td><a href="dmarc-domain.html">example.com</a></td>
<td class="muted">google.com</td>
<td>14 Aug</td>
<td>412</td>
<td>6</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
<tr>
<td class="time muted">2026-08-15 04:40</td>
<td>alerts.example.com</td>
<td class="muted">google.com</td>
<td>14 Aug</td>
<td>88</td>
<td>19</td>
<td class="actions"><a href="dmarc-report-fail.html">View</a></td>
</tr>
<tr>
<td class="time muted">2026-08-14 06:08</td>
<td><a href="dmarc-domain.html">example.com</a></td>
<td class="muted">google.com</td>
<td>13 Aug</td>
<td>390</td>
<td>2</td>
<td class="actions"><a href="dmarc-report.html">View</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li>
<a href="dmarc-report.html">
<span class="st st-ok">412 pass</span><span class="when">06:12</span>
<span class="meta">example.com · google.com · 14 Aug</span>
<span class="subj">6 fail</span>
</a>
</li>
<li>
<a href="dmarc-report-fail.html">
<span class="st st-error">19 fail</span><span class="when">04:40</span>
<span class="meta">alerts.example.com · google.com · 14 Aug</span>
<span class="subj">88 pass</span>
</a>
</li>
<li>
<a href="dmarc-report.html">
<span class="st st-ok">390 pass</span><span class="when">14 Aug</span>
<span class="meta">example.com · google.com · 13 Aug</span>
<span class="subj">2 fail</span>
</a>
</li>
</ul>
<p class="muted">Each row is one aggregate XML SelfPost parsed. Domain names open the roll-up for that sending domain.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-domain-delete" data-page="domain-delete" data-nav="domains" data-title="Delete domain">
<main class="stack">
<div>
<h1>Delete example.com</h1>
<a class="back" href="domain.html">&larr; Back to example.com</a>
</div>
<div class="measure">
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete <strong>example.com</strong>. This will:</p>
<ul>
<li>permanently delete its DKIM signing key;</li>
<li>delete <strong>all 2 bound applications</strong>, including their SASL credentials;</li>
<li>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<p class="muted">This cannot be undone.</p>
<div class="actions-row"><button type="button" class="danger">Delete example.com</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+260
View File
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-domain" data-page="domain" data-nav="domains" data-title="example.com">
<main class="stack">
<div>
<h1>example.com</h1>
<a class="back" href="domains.html">&larr; All domains</a>
</div>
<div class="measure">
<div class="card credential" id="new-credential">
<div class="card-head">
<h2>New application password</h2>
<label class="help-link" for="help-password" title="Why this is shown once">?</label>
</div>
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now.</p>
<label>Login</label>
<div class="code-row"><span class="code">newsletter</span><button type="button" class="copy">Copy</button></div>
<label>Password</label>
<div class="code-row"><span class="code">xK.9fQ2m-pL7wR</span><button type="button" class="copy">Copy</button></div>
</div>
</div>
<div class="fill">
<div class="card" id="dns-status">
<div class="card-head">
<h2>DNS status <span class="st st-ok">ok</span></h2>
<label class="help-link" for="help-dns" title="What these checks mean">?</label>
</div>
<div class="stack">
<div class="pair">
<div>
<label>DKIM <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">mail._domainkey.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
</div>
<div>
<label>SPF <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
</div>
</div>
<div class="pair">
<div>
<label>DMARC <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">_dmarc.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<p class="muted">p=none; rua points at SelfPost ingest.</p>
</div>
<div>
<label>Report authorization</label>
<p class="muted">Not required (rua= is on a domain SelfPost accepts).</p>
</div>
</div>
</div>
<div class="actions-row"><button type="button">Re-check</button></div>
</div>
</div>
<div class="pair">
<div class="card" id="dkim-spf">
<div class="card-head">
<h2>DKIM and SPF records</h2>
<label class="help-link" for="help-records" title="What to publish">?</label>
</div>
<p class="check-col-title">DKIM</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">mail._domainkey.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">v=DKIM1; k=rsa; p=MIIBIjANBgkqh…</span><button type="button" class="copy">Copy</button></div>
<p class="check-col-title">SPF</p>
<div class="code-row"><span class="code">v=spf1 ip4:203.0.113.10 -all</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="card" id="dmarc-record">
<div class="card-head">
<h2>DMARC record</h2>
<label class="help-link" for="help-dmarc" title="What this record does">?</label>
</div>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">_dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value <span class="muted">(from Settings)</span></label>
<div class="code-row"><span class="code">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="copy">Copy</button></div>
<p class="muted"><a href="dmarc-domain.html">Open DMARC reports</a> for this domain.</p>
</div>
</div>
<div class="pair">
<div class="card" id="connection">
<div class="card-head">
<h2>Connection settings</h2>
<label class="help-link" for="help-connection" title="How to connect">?</label>
</div>
<label>Server</label>
<div class="code-row"><span class="code">mail.example.org</span><button type="button" class="copy">Copy</button></div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit)
587 — STARTTLS (submission)</span>
</div>
<div class="card" id="add-application">
<div class="card-head">
<h2>Add an application</h2>
<label class="help-link" for="help-apps" title="What an application is">?</label>
</div>
<form action="#" onsubmit="return false">
<label>Login</label>
<input placeholder="prod-server" autocomplete="off">
<label>Address mode</label>
<select><option>Any address of the domain</option><option>Specific addresses (list)</option></select>
<div class="actions-row"><button type="button">Create application</button></div>
</form>
</div>
</div>
<div class="fill">
<div class="card" id="applications">
<div class="card-head">
<h2>Applications</h2>
<label class="help-link" for="help-apps" title="Address mode and rate limits">?</label>
</div>
<ul class="apps">
<li class="app-item">
<p class="app-login">newsletter</p>
<p class="app-addr muted">Any address of the domain — *@example.com</p>
<div class="actions">
<input class="panel-toggle t-edit" id="edit-1" type="checkbox" checked>
<label class="toggle for-edit" for="edit-1">Edit <span class="st st-ok">limit</span></label>
<button type="button">New password</button>
<button type="button" class="danger">Delete</button>
<div class="panel panel-edit">
<div class="pair">
<div>
<p class="check-col-title">Address mode</p>
<select><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select>
</div>
<div>
<p class="check-col-title">Trusted-IP override <span class="st st-ok">active</span></p>
<textarea rows="1">203.0.113.40</textarea>
<div class="field-row equal">
<div class="field">
<label>Message limit</label>
<input value="80">
</div>
<div class="field">
<label>Window (seconds)</label>
<input value="60">
</div>
</div>
</div>
</div>
</div>
</div>
</li>
<li class="app-item">
<p class="app-login">billing</p>
<p class="app-addr muted">Fixed list — invoices@example.com</p>
<div class="actions">
<button type="button">Edit</button>
<button type="button">New password</button>
<button type="button" class="danger">Delete</button>
</div>
</li>
</ul>
</div>
</div>
<div class="pair">
<div class="card" id="domain-settings-dmarc">
<div class="card-head">
<h2>DMARC reports</h2>
<label class="help-link" for="help-domain-settings" title="Reports and rate limits">?</label>
</div>
<label>Aggregate reports (rua=)</label>
<select><option selected>Same as Settings (dmarc@mail.example.org)</option><option>No aggregate reports</option><option>Custom address</option></select>
<div class="actions-row"><button type="button">Save DMARC report settings</button></div>
</div>
<div class="card" id="domain-settings-limit">
<div class="card-head">
<h2>Level-2 rate limit <span class="st st-ok">active</span></h2>
</div>
<div class="field-row equal">
<div class="field">
<label>Message limit</label>
<input value="40">
</div>
<div class="field">
<label>Window (seconds)</label>
<input value="60">
</div>
</div>
<div class="actions-row"><button type="button">Save limit</button></div>
</div>
</div>
<div class="pair">
<div class="card" id="export-domain">
<div class="card-head">
<h2>Export domain</h2>
<label class="help-link" for="help-export" title="What the file contains">?</label>
</div>
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt as <code>.spde</code>.</p>
<div class="encrypt">
<label class="check"><input type="checkbox"> <span>Encrypt with a password</span></label>
<div class="encrypt-fields">
<label>Password</label><input type="password">
<label>Repeat password</label><input type="password">
</div>
</div>
<div class="actions-row"><button type="button">Export domain</button></div>
</div>
<div class="card g-only">
<h2>Danger zone</h2>
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
<div class="actions-row"><a class="danger" href="domain-delete.html">Delete domain</a></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Domains — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-domains" data-page="domains" data-nav="domains" data-title="Domains">
<main class="stack">
<div class="page-head"><h1>Domains</h1></div>
<div class="fill">
<div class="card">
<h2>Domains</h2>
<form class="measure g-only" action="#" onsubmit="return false;">
<label for="dname">Add a sending domain</label>
<div class="input-row">
<input id="dname" placeholder="example.com" autocomplete="off">
<button type="button">Add domain</button>
</div>
</form>
<table class="desk-only">
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr></thead>
<tbody>
<tr>
<td><a href="domain.html">example.com</a></td>
<td><a class="st st-ok" href="domain.html">ok</a></td>
<td class="muted">mail</td>
<td>2</td>
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
</tr>
<tr>
<td><a href="domain.html">alerts.example.com</a></td>
<td><a class="st st-warn" href="domain.html">warn</a></td>
<td class="muted">mail</td>
<td>1</td>
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="domain.html"><span>example.com</span><span class="st st-ok">ok</span><span class="meta">2 apps · selector mail</span></a></li>
<li><a href="domain.html"><span>alerts.example.com</span><span class="st st-warn">warn</span><span class="meta">1 app · selector mail</span></a></li>
</ul>
<p class="muted">The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Help — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-help" data-page="help" data-nav="help" data-title="Help">
<main class="stack">
<div class="page-head">
<h1>Help <span class="tag future">candidate</span></h1>
</div>
<p class="muted">Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status explanations that do not belong on the cards (what a kernel counter is, why PTR is set at the host, what Reload does not touch). The cards themselves keep their readings, Detail columns, and the Configuration control.</p>
<div class="measure stack">
<div class="card">
<h2>On this panel</h2>
<ul>
<li><a href="help.html">Machine</a> — kernel counters and the rate window</li>
<li><a href="help.html">TLS certificate</a> — port 465, reverse-proxy mount</li>
<li><a href="help.html">Hostname / reverse DNS</a> — forward-confirmed PTR at the hosting provider</li>
<li><a href="help.html">Mail queue retries</a> — time-based, no attempt budget</li>
<li><a href="help.html">Inbound</a> — not mailboxes; listed recipients or any address at the domain</li>
<li>Domain page — DNS, records, connection, applications, export (drawer from each card’s «?»)</li>
</ul>
<p class="muted">The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.</p>
</div>
<div class="card">
<h2>Machine</h2>
<p>CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.</p>
</div>
<div class="card">
<h2>TLS certificate</h2>
<p>Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.</p>
</div>
<div class="card">
<h2>Hostname / reverse DNS</h2>
<p>Forward-confirmed reverse DNS: the A/AAAA for <code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>backup.example.net — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound-backup" data-page="inbound-backup" data-nav="inbound" data-title="backup.example.net" data-global-only="1">
<main class="stack">
<div>
<h1>backup.example.net</h1>
<a class="back" href="inbound.html">&larr; All inbound domains</a>
</div>
<div class="fill">
<div class="card">
<h2>DNS status <span class="st st-error">error</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
<label>MX <span class="st st-error">error</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">backup.example.net</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<span class="code">10 mail.primary.example.net.</span>
<p class="error">No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.</p>
<div class="actions-row"><button type="button">Re-check</button></div>
</div>
</div>
<div class="pair">
<div class="card">
<h2>Upstream</h2>
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
<label>Host</label><input value="192.0.2.20">
<label>Port</label><input value="25">
<label>TLS to upstream</label>
<select><option>Opportunistic</option><option>Required</option><option selected>Off</option></select>
<div class="actions-row"><button type="button">Save upstream</button></div>
</div>
<div class="card">
<h2>MX record to publish</h2>
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">backup.example.net</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">20 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
</div>
</div>
<div class="pair">
<div class="card rcpt-mode">
<h2>Valid recipients</h2>
<p class="muted">Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.</p>
<label>Who to accept</label>
<select>
<option value="list">Listed addresses only</option>
<option value="any" selected>Any recipient at this domain</option>
</select>
<div class="rcpt-list">
<label>Addresses (one per line or comma-separated)</label>
<textarea rows="6">postmaster@backup.example.net
abuse@backup.example.net</textarea>
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
</div>
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
<div class="actions-row"><button type="button">Save recipients</button></div>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete lists.example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound-delete" data-page="inbound-delete" data-nav="inbound" data-title="Delete inbound" data-global-only="1">
<main class="stack">
<div>
<h1>Delete lists.example.com</h1>
<a class="back" href="inbound-domain.html">&larr; Back to lists.example.com</a>
</div>
<div class="measure">
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to stop accepting inbound mail for <strong>lists.example.com</strong>. This will:</p>
<ul>
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
<li>stop forwarding to <strong>10.0.0.8:25</strong>;</li>
<li>leave outbound sending domains untouched.</li>
</ul>
<p class="muted">This cannot be undone from a backup of inbound maps alone unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
<div class="actions-row"><button type="button" class="danger">Delete lists.example.com</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lists.example.com — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound-domain" data-page="inbound-domain" data-nav="inbound" data-title="lists.example.com" data-global-only="1">
<main class="stack">
<div>
<h1>lists.example.com</h1>
<a class="back" href="inbound.html">&larr; All inbound domains</a>
</div>
<div class="fill">
<div class="card">
<h2>DNS status <span class="st st-ok">ok</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing. Unlike outbound, inbound needs an MX pointing at this server.</p>
<label>MX <span class="st st-ok">ok</span></label>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">lists.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<span class="code">10 mail.example.org.
20 mail.primary.example.net.</span>
<p class="muted">An MX points at mail.example.org (this server). Other MX values are the domain’s own primaries — they are not an error.</p>
<div class="actions-row"><button type="button">Re-check</button></div>
</div>
</div>
<div class="pair">
<div class="card">
<h2>Upstream</h2>
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
<label>Host</label><input value="10.0.0.8">
<label>Port</label><input value="25">
<label>TLS to upstream</label>
<select><option>Opportunistic</option><option selected>Required</option><option>Off</option></select>
<div class="actions-row"><button type="button">Save upstream</button></div>
</div>
<div class="card">
<h2>MX record to publish</h2>
<p class="muted">Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">lists.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">10 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
</div>
</div>
<div class="pair">
<div class="card rcpt-mode">
<h2>Valid recipients</h2>
<p class="muted">Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.</p>
<label>Who to accept</label>
<select>
<option value="list" selected>Listed addresses only</option>
<option value="any">Any recipient at this domain</option>
</select>
<div class="rcpt-list">
<label>Addresses (one per line or comma-separated)</label>
<textarea rows="6">staff@lists.example.com
postmaster@lists.example.com
abuse@lists.example.com</textarea>
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
</div>
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
<div class="actions-row"><button type="button">Save recipients</button></div>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inbound — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-inbound" data-page="inbound" data-nav="inbound" data-title="Inbound" data-global-only="1">
<main class="stack">
<div class="page-head">
<h1>Inbound <span class="tag">1.x</span></h1>
</div>
<p class="muted">Backup-MX / forwarder. Accepts on port 25 only for listed domains. Recipients are either an allow-list or any address at that domain. Off by default in Compose.</p>
<div class="fill">
<div class="card">
<h2>Forwarding</h2>
<form class="measure" action="#" onsubmit="return false">
<label for="iname">Add inbound domain</label>
<div class="input-row">
<input id="iname" placeholder="lists.example.com">
<button type="button">Add domain</button>
</div>
</form>
<table class="desk-only">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr></thead>
<tbody>
<tr>
<td><a href="inbound-domain.html">lists.example.com</a></td>
<td><a class="st st-ok" href="inbound-domain.html">ok</a></td>
<td class="muted">10.0.0.8:25</td>
<td>12 listed</td>
<td><span class="st st-ok">required</span></td>
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
</tr>
<tr>
<td><a href="inbound-backup.html">backup.example.net</a></td>
<td><a class="st st-error" href="inbound-backup.html">error</a></td>
<td class="muted">192.0.2.20:25</td>
<td>any</td>
<td><span class="st st-unknown">off</span></td>
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
</tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="inbound-domain.html"><span>lists.example.com</span><span class="st st-ok">ok</span><span class="meta">12 listed · 10.0.0.8:25</span></a></li>
<li><a href="inbound-backup.html"><span>backup.example.net</span><span class="st st-error">error</span><span class="meta">any recipient · 192.0.2.20:25</span></a></li>
</ul>
<p class="muted">The <em>DNS</em> badge is the MX check: at least one MX must point at this server. Results are cached for a few minutes; open a domain for the lookup and a <em>Re-check</em> button.</p>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — макеты панели</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
</head>
<body class="doc">
<div class="wrap">
<header>
<p class="eyebrow">макеты · не панель</p>
<h1>Self<b>Post</b> — полное обновление интерфейса</h1>
<p>Проект системы — как собирать экраны, чтобы не чинить пустые колонки, высоту полей и ряды кнопок по одному. Каждый экран — отдельная страница, не простыня с якорями. Знак и палитра из листа утверждения не меняются. HTML в <code>docs/assets/panel-ui/</code>, не вёрстка <code>internal/web</code>.</p>
<div class="cta-row">
<a class="cta" href="status.html">Открыть Status</a>
<a class="cta quiet" href="system.html">Система интерфейса</a>
<a class="cta quiet" href="status.html?view=phone">Аварийный телефон</a>
<a class="cta quiet" href="status.html?theme=dark">Status, тёмная тема</a>
</div>
</header>
<section>
<h2>Система, не рескин</h2>
<p>Первый click-through повторил сетку живой панели, поэтому те же сбои чинились по одному. <a href="system.html">Грамматика</a> задаёт регионы (<code>measure</code> / <code>pair</code> / <code>fill</code>), карточку со слотом справки, <code>field-row</code> (Host и Type одной высоты) и <code>actions-row</code>. Макеты собраны только из этого: один экран — один HTML-файл.</p>
</section>
<section>
<h2>Ширина окна</h2>
<p>Сейчас оболочка центрируется, колонка контента упирается в <code>64rem</code>, формы ещё уже — <code>48rem</code>. На широком мониторе поля пустые. Растянуть поля на 100% окна нельзя — они становятся нечитаемыми. Две независимые карточки рядом — можно.</p>
<h3>Гибрид</h3>
<p>Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до <strong>90rem (~1440px)</strong>. Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Таблица Users — <code>fill</code>. Одиночные формы (подтверждения, форма пользователя) остаются <strong>42rem и выровнены влево</strong>. Login и setup — по-прежнему узкий центрированный блок.</p>
<div class="compare">
<div>
<div class="row">сейчас, окно ~1600px</div>
<div class="bar">
<i class="navc">nav</i>
<i class="col" style="width:42%">64rem</i>
<i class="empty">пусто</i>
</div>
</div>
<div>
<div class="row">макет, ops-страница</div>
<div class="bar">
<i class="navc">nav</i>
<i class="fill">контент до 90rem</i>
</div>
</div>
<div>
<div class="row">макет, форма</div>
<div class="bar">
<i class="navc">nav</i>
<i class="col" style="width:28%">42rem</i>
<i class="empty">поле не растягивает инпуты</i>
</div>
</div>
</div>
<div class="note">На телефоне это не «узкий десктоп». Таблицы из шести колонок складываются в список. Навигация — выезжающая колонка на CSS-checkbox, без обязательного JavaScript (как панели Edit у приложений уже сейчас).</div>
</section>
<section>
<h2>Информационная архитектура</h2>
<div class="ia">
<div>
<h3>Global administrator</h3>
<ul>
<li>Status</li>
<li>Domains → domain → delete</li>
<li>Deliveries → message</li>
<li>Mail queue</li>
<li>System log</li>
<li>Inbound <span class="tag">1.x</span> — скрыт, пока <code>INBOUND_RELAY_ENABLE</code> выключен</li>
<li>DMARC reports <span class="tag future">candidate</span></li>
<li>Backup, Users, Settings</li>
<li>Help <span class="tag future">candidate</span> — пункт навбара и drawer с карточек Status и страницы домена</li>
</ul>
</div>
<div>
<h3>Domain-admin</h3>
<ul>
<li>Domains (только назначенные) → domain → DMARC reports этого домена</li>
<li>Deliveries → message, включая пояснение retry</li>
<li>Settings — только учётные данные панели</li>
<li>Help drawer</li>
<li>Нет Status, очереди, system log, Backup, Users, inbound, глобального DMARC hub</li>
</ul>
</div>
</div>
<p class="muted" style="margin-top:1rem">В прототипе роль, inbound-флаг, ширина и тема переключаются в шапке. <code>contributing</code> и <code>schema-squash</code> экранов не имеют.</p>
</section>
<section>
<h2>Знак и новые иконки</h2>
<p>Утверждённый stamp не заменяется. Для узкой шапки телефона предлагается уже существующий SP-значок (фавикон), а не компактный wordmark: на 390px высота важнее, чем прочитать «SelfPost» второй раз.</p>
<div class="mark-row">
<figure>
<img src="../selfpost-stamp.svg" width="220" height="100" alt="Полный знак">
<figcaption>эталон · login / setup</figcaption>
</figure>
<figure>
<img src="../selfpost-stamp-compact.svg" width="160" height="73" alt="Компактный знак">
<figcaption>колонка навбара</figcaption>
</figure>
<figure>
<img src="../selfpost-icon.svg" width="64" height="64" alt="SP">
<figcaption>телефон · шапка drawer</figcaption>
</figure>
</div>
<h3>Иконки новых пунктов</h3>
<p class="muted">Тот же штрих 1.5, что у Status / Domains. Не пиктограммы-эмодзи и не заливка.</p>
<div class="icons-row">
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>
<span>Inbound</span>
</div>
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></svg>
<span>DMARC</span>
</div>
<div class="icon-card">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>
<span>Help</span>
</div>
</div>
</section>
<section>
<h2>Экраны</h2>
<h3>Система</h3>
<div class="screen-index">
<a href="system.html">Грамматика и живые образцы</a>
</div>
<h3>Вход</h3>
<div class="screen-index">
<a href="login.html">Sign in</a>
<a href="setup.html">Create administrator</a>
</div>
<h3>Текущая панель</h3>
<div class="screen-index">
<a href="status.html">Status</a>
<a href="domains.html">Domains</a>
<a href="domain.html">Domain detail</a>
<a href="domain-delete.html">Delete domain</a>
<a href="deliveries.html">Deliveries</a>
<a href="delivery.html">Delivery (deferred + retries)</a>
<a href="mail-queue.html">Mail queue + retry policy</a>
<a href="system-log.html">System log</a>
<a href="backup.html">Backup</a>
<a href="users.html">Users</a>
<a href="user-form.html">Edit user</a>
<a href="user-delete.html">Delete user</a>
<a href="settings.html">Settings</a>
</div>
<h3>Roadmap</h3>
<div class="screen-index">
<a href="mail-queue.html">queue-retries — карточка на Mail queue</a>
<a href="delivery.html">queue-retries — history deferred</a>
<a href="inbound.html">Inbound list (колонка DNS = MX)</a>
<a href="inbound-domain.html">Inbound domain — DNS ok</a>
<a href="inbound-backup.html">Inbound domain — DNS error</a>
<a href="inbound-delete.html">Delete inbound domain</a>
<a href="status.html?inbound=0">Inbound выключен (пункта нет)</a>
<a href="dmarc.html">DMARC reports hub <span class="tag future">candidate</span></a>
<a href="dmarc-domain.html">DMARC — domain roll-up</a>
<a href="dmarc-report.html">DMARC report — aligned</a>
<a href="dmarc-report-fail.html">DMARC report — third-party fail</a>
<a href="help.html">Help <span class="tag future">candidate</span></a>
</div>
<h3>Аварийный телефон</h3>
<div class="screen-index">
<a href="status.html?view=phone">Status 390px — Overall, then queue/cert, then the rest</a>
<a href="mail-queue.html?view=phone">Mail queue 390px</a>
<a href="deliveries.html?view=phone">Deliveries 390px — список, не таблица</a>
<a href="delivery.html?view=phone">Delivery 390px</a>
</div>
</section>
<section style="border-bottom:none">
<h2>Как смотреть</h2>
<p>Откройте любой экран из списка — это отдельная страница, не якорь в одном файле. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.</p>
<p class="muted">После утверждения макетов вёрстка <code>panel.css</code> и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.</p>
</section>
</div>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-login" data-page="login" data-nav="login" data-title="Sign in" data-auth="1">
<main class="auth">
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
<h1>Sign in</h1>
<div class="card">
<form action="status.html">
<label for="u">Username</label>
<input id="u" name="username" autocomplete="username">
<label for="p">Password</label>
<input id="p" name="password" type="password" autocomplete="current-password">
<div class="actions-row"><button type="submit">Sign in</button></div>
</form>
</div>
<p class="version" style="text-align:center">© Mixeme · <a href="#">License (AGPL-3.0)</a></p>
</main>
<script src="shell.js"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mail queue — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-mail-queue" data-page="mail-queue" data-nav="queue" data-title="Mail queue" data-global-only="1">
<main class="stack">
<h1>Mail queue</h1>
<div class="fill">
<div class="card">
<h2>How delivery retries work</h2>
<p class="muted">This Postfix’s policy, read once at panel start. There is no maximum attempt count — only time.</p>
<div class="facts retry-facts">
<div class="fact"><span class="fact-label">First retry</span><span class="fact-value">5 minutes</span></div>
<div class="fact"><span class="fact-label">Later retries</span><span class="fact-value">doubling, cap 1 h 7 min</span></div>
<div class="fact"><span class="fact-label">Kept in queue</span><span class="fact-value">5 days</span></div>
<div class="fact"><span class="fact-label">Then</span><span class="fact-value">bounced</span></div>
</div>
</div>
</div>
<div class="fill">
<div class="card">
<h2>Pending messages</h2>
<table class="desk-only">
<thead><tr><th>Queue id</th><th>Age</th><th>From</th><th>To</th><th>Size</th></tr></thead>
<tbody>
<tr><td class="mono">4C3A1E2F1A</td><td class="time muted">18 min</td><td>billing@example.com</td><td>ada@example.net</td><td>12 KiB</td></tr>
<tr><td class="mono">4C3A1E3010</td><td class="time muted">11 min</td><td>news@example.com</td><td>pat@slow.example</td><td>48 KiB</td></tr>
<tr><td class="mono">4C3A1E3102</td><td class="time muted">4 min</td><td>billing@example.com</td><td>ada@example.net</td><td>9 KiB</td></tr>
</tbody>
</table>
<ul class="phone-list phone-only">
<li><a href="delivery.html"><span>4C3A1E2F1A</span><span class="when">18 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
<li><a href="delivery.html"><span>4C3A1E3010</span><span class="when">11 min</span><span class="meta">news@example.com → pat@slow.example</span></a></li>
<li><a href="delivery.html"><span>4C3A1E3102</span><span class="when">4 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
</ul>
<label>postqueue -p</label>
<span class="code">-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com
ada@example.net
4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com
pat@slow.example
-- 3 Kbytes in 3 Requests.</span>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+715
View File
@@ -0,0 +1,715 @@
/* SelfPost panel UI mockups. Tokens match internal/web/view/static/panel.css.
This file is a design artifact, not the panel stylesheet. */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap");
:root {
color-scheme: light dark;
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--bg: #f4f2ed; --fg: #12161c;
--muted: #6b7684;
--card-bg: #fff;
--border: #dedcd7;
--control-border: #cbc8c1;
--input-bg: #fff;
--code-bg: #efede9;
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
--nav-active-bg: #ede4de;
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
--on-accent: #fff;
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
--credential-bg: #fcf6e4; --credential-border: #e0c874;
--danger-fill: #b42318; --danger-fill-hover: #912018;
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
--nav-w: 14rem;
--ops-max: 90rem;
--form-max: 42rem;
--auth-max: 24rem;
--gallery-h: 3.1rem;
}
.dark-tokens {
--bg: #16181b; --fg: #e9e6e0;
--muted: #9aa1a9;
--card-bg: #1d2024;
--border: #2c2f34;
--control-border: #3a3e44;
--input-bg: #14161a;
--code-bg: #14161a;
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
--nav-active-bg: #2a1f1b;
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
--credential-bg: #26210d; --credential-border: #5e5013;
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
}
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; }
html:not(.force-light) { }
}
* { box-sizing: border-box; }
html { scrollbar-gutter: stable; }
body {
font: 400 15px/1.5 var(--font-sans);
margin: 0; background: var(--bg); color: var(--fg);
}
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
/* —— Gallery chrome (index + prototype toolbar) —— */
.gallery {
position: sticky; top: 0; z-index: 40;
display: flex; flex-wrap: wrap; align-items: center; gap: 0.55rem 1rem;
min-height: var(--gallery-h);
padding: 0.45rem 1rem;
background: var(--surface-bg);
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
}
.gallery a { color: var(--accent-text); }
.gallery .brand-mini {
font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-text);
text-decoration: none;
}
.gallery .seg {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.2rem 0.35rem;
}
.gallery .seg > span { color: var(--muted); margin-right: 0.15rem; }
.gallery .seg label {
margin: 0; font-weight: 500; cursor: pointer;
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
border-radius: 5px; background: var(--card-bg); color: var(--fg);
}
#role-global:checked ~ .gallery label[for="role-global"],
#role-domain:checked ~ .gallery label[for="role-domain"],
#vp-desktop:checked ~ .gallery label[for="vp-desktop"],
#vp-phone:checked ~ .gallery label[for="vp-phone"],
#theme-light:checked ~ .gallery label[for="theme-light"],
#theme-dark:checked ~ .gallery label[for="theme-dark"] {
background: var(--nav-active-bg); color: var(--accent-text);
border-color: var(--accent-fill); font-weight: 600;
}
.gallery .check-lab {
display: flex; align-items: center; gap: 0.35rem; margin: 0; font-weight: 500; cursor: pointer;
}
.gallery .check-lab input { width: auto; margin: 0; }
.tag {
display: inline-block; font-family: var(--font-mono); font-size: 0.68rem;
font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.12rem 0.4rem; border-radius: 4px;
background: var(--surface-bg); color: var(--muted); border: 1px solid var(--border);
}
.tag.future { color: var(--st-warn-fg); border-color: var(--st-warn-border); background: var(--st-warn-bg); }
body:has(#theme-dark:checked) { background: #16181b; color: #e9e6e0; }
#theme-dark:checked ~ .gallery,
#theme-dark:checked ~ .app,
#theme-dark:checked ~ .help-drawer {
--bg: #16181b; --fg: #e9e6e0;
--muted: #9aa1a9;
--card-bg: #1d2024;
--border: #2c2f34;
--control-border: #3a3e44;
--input-bg: #14161a;
--code-bg: #14161a;
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
--nav-active-bg: #2a1f1b;
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
--credential-bg: #26210d; --credential-border: #5e5013;
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
background: var(--bg); color: var(--fg);
}
/* —— Prototype shell: nav docked left, content uses remaining width —— */
.app {
display: flex; align-items: flex-start; min-height: calc(100vh - var(--gallery-h));
background: var(--bg);
}
.stage { flex: 1 1 auto; min-width: 0; }
.nav {
position: sticky; top: var(--gallery-h); align-self: stretch;
flex: none; width: var(--nav-w);
display: flex; flex-direction: column; gap: 0.75rem;
padding: 1.1rem 0.85rem 1.4rem;
border-right: 1px solid var(--border);
background: var(--bg);
max-height: calc(100vh - var(--gallery-h)); overflow-y: auto;
}
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
.nav .session { margin-top: auto; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
.nav .session-user { display: flex; align-items: center; gap: 0.5rem; }
.nav .brand { padding: 0; display: block; }
.nav .brand img { display: block; width: 100%; height: auto; }
.nav a, .nav [aria-current], .nav .current {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
color: var(--fg);
}
.nav a:hover { background: var(--surface-bg); }
.nav [aria-current], .nav .current {
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
box-shadow: inset 2px 0 0 var(--accent-fill);
}
body.page-status .n-status,
body.page-domains .n-domains,
body.page-domain .n-domains,
body.page-domain-delete .n-domains,
body.page-deliveries .n-deliveries,
body.page-delivery .n-deliveries,
body.page-mail-queue .n-queue,
body.page-system-log .n-log,
body.page-inbound .n-inbound,
body.page-inbound-domain .n-inbound,
body.page-inbound-backup .n-inbound,
body.page-inbound-delete .n-inbound,
body.page-dmarc .n-dmarc,
body.page-dmarc-domain .n-dmarc,
body.page-dmarc-report .n-dmarc,
body.page-dmarc-report-fail .n-dmarc,
body.page-backup .n-backup,
body.page-users .n-users,
body.page-user-form .n-users,
body.page-user-delete .n-users,
body.page-help .n-help,
body.page-settings .n-settings {
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
box-shadow: inset 2px 0 0 var(--accent-fill);
}
.nav .icon { width: 1rem; height: 1rem; flex: none; }
.nav button, .nav .btn-ghost {
display: flex; align-items: center; gap: 0.5rem;
margin: 0; padding: 0.35rem 0.6rem; font: inherit; font-size: 0.95rem; font-weight: 400;
color: var(--danger-fg); background: var(--danger-bg); border: 1px solid var(--danger-border);
border-radius: 5px; cursor: pointer; text-decoration: none; width: 100%;
}
.nav button:hover, .nav .btn-ghost:hover { background: var(--danger-bg-hover); }
.phone-bar { display: none; }
.phone-bar .icon { width: 1.1rem; height: 1.1rem; }
.mono { font-family: var(--font-mono); }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
#feat-inbound:checked ~ .gallery label[for="feat-inbound"] {
background: var(--nav-active-bg); color: var(--accent-text);
border-color: var(--accent-fill);
}
.gallery label[for="feat-inbound"] {
margin: 0; font-weight: 500; cursor: pointer;
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
border-radius: 5px; background: var(--card-bg); color: var(--fg);
}
#role-domain:checked ~ .gallery .g-only { display: none; }
main {
flex: 1 1 auto; min-width: 0; width: 100%;
padding: 1.5rem 1.5rem 2.5rem;
}
main.auth {
max-width: none; display: flex; flex-direction: column; align-items: center;
padding-top: 3rem;
}
main.auth > * { width: 100%; max-width: var(--auth-max); margin-left: auto; margin-right: auto; }
main.stack > .page-head,
main.stack > h1 { margin-bottom: 0; }
main.stack > .back { margin: 0; }
main.stack > .route { margin-top: 0; margin-bottom: 0; }
main.stack > p.muted { margin-top: 0; margin-bottom: 0; }
main.stack > .toolbar { margin-bottom: 0; max-width: var(--ops-max); }
body.page-login .nav,
body.page-setup .nav,
body.page-login .phone-bar,
body.page-setup .phone-bar { display: none !important; }
body.page-login .app,
body.page-setup .app { display: block; }
#role-domain:checked ~ .app .g-only { display: none !important; }
#role-domain:checked ~ .app .pair:has(> .g-only) {
display: block;
max-width: var(--form-max);
}
#feat-inbound:not(:checked) ~ .app .in-only { display: none !important; }
#feat-inbound:checked ~ .app .in-off { display: none !important; }
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin: 0 0 1rem; }
h1.subject { overflow-wrap: anywhere; }
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
.card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 1.25rem 1.4rem; margin: 0;
}
.card.narrow { max-width: var(--auth-max); }
.card + .card { margin-top: 1rem; }
.card.credential { border-color: var(--credential-border); background: var(--credential-bg); }
.muted { color: var(--muted); }
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
a { color: var(--accent-text); }
.back { display: block; margin: -0.4rem 0 1rem; }
.flash {
background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg);
padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1rem;
}
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); }
.version a { color: inherit; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
label.check {
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600; cursor: pointer;
}
label.check input { width: auto; margin: 0; padding: 0; border: 0; background: none; }
input, select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
}
textarea { resize: vertical; }
fieldset {
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
border: 1px solid var(--control-border); border-radius: 5px;
}
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
button, a.btn, a.danger {
display: inline-block; margin-top: 1.1rem; padding: 0.6rem 1.1rem;
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
}
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
button.danger, a.danger { background: var(--danger-fill); color: var(--on-accent); }
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
form.inline { display: inline; margin: 0; }
.form-actions {
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
margin-top: 1.1rem;
}
.form-actions > button, .form-actions > a.btn, .form-actions > a.danger { margin-top: 0; }
.st {
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
letter-spacing: 0.02em; vertical-align: middle; border: 1px solid transparent;
white-space: nowrap;
}
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
a.st, a.st:hover { color: inherit; text-decoration: none; }
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left; padding: 0.5rem 0.45rem; border-bottom: 1px solid var(--border);
white-space: nowrap;
}
th {
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
}
td.actions { text-align: right; }
.metric { white-space: nowrap; }
.card:has(table) { overflow-x: auto; }
td.subject span {
display: block; max-width: 22rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.code {
display: block; white-space: pre; overflow-x: auto; overflow-wrap: normal;
word-break: normal; font-family: var(--font-mono);
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border);
border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0;
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; margin-top: 0; padding-top: 0.45rem; padding-bottom: 0.45rem; }
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
.input-row input { flex: 1; min-width: 0; }
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
.split {
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1rem; margin-top: 1rem;
}
h1 + .split, h1 + form > .split { margin-top: 0; }
.split > .card { min-width: 0; }
.split > .card + .card { margin-top: 0; }
.split + .card, .card + .split, form:has(.split) + .card { margin-top: 1rem; }
#settings form { width: 100%; max-width: none; }
#settings .split,
#backup > .split {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.check-cols {
display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: 1rem 1.2rem; margin-top: 1rem;
}
.check-col { min-width: 0; }
.check-col-title { margin: 0.85rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; }
.field-pair {
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0 1rem; margin-top: 0.45rem;
}
.field-pair > div { min-width: 0; }
.field-pair label { margin-top: 0.45rem; }
/* Host / name beside Type — size the type column to that token rather than
giving it half the row. Vertical padding matches .code-row .code so Type is
the same height as Host when Copy sits beside it. */
.field-pair.host-type {
grid-template-columns: minmax(0, 1fr) auto;
}
.field-pair.host-type .code {
padding-top: 0.45rem; padding-bottom: 0.45rem;
}
.field-type { width: max-content; }
.field-type .code {
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
}
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
.fact-label {
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
}
.fact-value { display: block; margin-top: 0.1rem; white-space: nowrap; overflow-x: auto; }
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
.timeline {
list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border);
}
.event { position: relative; min-width: 0; }
.event::before {
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
width: 0.65rem; height: 0.65rem; border-radius: 50%;
background: var(--card-bg); border: 2px solid var(--control-border);
}
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
.event.pending { opacity: 0.7; }
.event.pending::before { border-style: dashed; }
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
.route { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: -0.5rem; margin-bottom: 1rem; }
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; white-space: nowrap; overflow-x: auto; max-width: 100%; }
.route .arrow { color: var(--muted); }
table.log { margin-top: 1rem; }
table.log th:first-child, table.log td.time { width: 1%; }
table.log td.log-text {
font-family: var(--font-mono);
font-size: 0.8rem; white-space: pre;
}
button.copy, .actions button, .actions > label.toggle, .actions a.danger {
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
border-radius: 5px; white-space: nowrap;
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
}
button.copy:hover, .actions button:hover, .actions > label.toggle:hover { background: var(--surface-bg-hover); }
.actions button.danger, .actions a.danger { color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border); }
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.apps { list-style: none; margin: 1rem 0 0; padding: 0; }
.app-item { padding: 0.9rem 0; border-top: 1px solid var(--border); }
.app-item:last-child { padding-bottom: 0; }
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
.app-addr { margin: 0.15rem 0 0; white-space: nowrap; overflow-x: auto; }
.app-item .actions { margin-top: 0.7rem; }
.app-item .actions > .panel-toggle {
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
}
.app-item .actions > .panel { display: none; flex: 1 0 100%; }
.app-item .actions > .t-edit:checked ~ .panel-edit { display: block; }
.app-item .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
.app-item .actions > .panel button { margin-top: 0.9rem; }
.encrypt { margin-top: 1.2rem; }
.encrypt-fields {
display: none; margin-top: 0.8rem; margin-left: 1.6rem; padding-left: 0.9rem;
border-left: 2px solid var(--border);
}
.encrypt:has(input[type="checkbox"]:checked) .encrypt-fields { display: block; }
.encrypt-fields label { margin-top: 0.7rem; }
.rcpt-list, .rcpt-any { display: none; }
.rcpt-mode:has(option[value="list"]:checked) .rcpt-list { display: block; }
.rcpt-mode:has(option[value="any"]:checked) .rcpt-any { display: block; }
.toolbar {
display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: end;
padding: 0.85rem 1rem; margin-bottom: 1rem;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
}
.toolbar .field { flex: 1 1 10rem; min-width: 8rem; }
.toolbar label { margin-top: 0; font-size: 0.8rem; }
.toolbar button { margin-top: 0; }
/* Status: hero + dense grid */
.status-hero {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.75rem 1.2rem;
padding: 1rem 1.25rem; margin-bottom: 1rem;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
}
.status-hero.attn,
.card.attn { border-color: var(--st-warn-border); }
.status-hero h1 { margin: 0; }
.status-hero .lead { margin: 0; color: var(--fg); }
.status-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(17.5rem, 1fr));
gap: 1rem;
}
.status-grid .card { margin: 0; }
.status-grid .card.attn { border-color: var(--st-warn-border); }
.metric-row {
display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline;
padding: 0.35rem 0; border-bottom: 1px solid var(--border); font-size: 0.92rem;
}
.metric-row:last-child { border-bottom: 0; }
.metric-row .k { font-weight: 600; }
.metric-row .v { font-family: var(--font-mono); font-size: 0.85rem; }
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem;
}
.card-head h2 { margin: 0; }
.help-link {
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center; justify-content: center;
border-radius: 50%; border: 1px solid var(--control-border); color: var(--muted);
text-decoration: none; font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600;
background: var(--surface-bg); cursor: pointer; margin: 0;
}
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
.page-head {
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.5rem 1rem;
margin-bottom: 1rem;
}
.page-head h1 { margin: 0; }
/* Compact retry policy */
.retry-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.5rem; margin-top: 0.7rem; }
.phone-list { display: none; list-style: none; margin: 0; padding: 0; }
.phone-list li { border-bottom: 1px solid var(--border); }
.phone-list a,
.phone-list .item {
display: grid; grid-template-columns: 1fr auto; gap: 0.15rem 0.7rem;
padding: 0.75rem 0.1rem; text-decoration: none; color: var(--fg);
}
.phone-list .when { font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
.phone-list .meta { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; }
.phone-list .subj { grid-column: 1 / -1; margin: 0; }
/* Help drawer — CSS checkbox, no script required */
.help-drawer {
display: none; position: fixed; top: var(--gallery-h); right: 0; bottom: 0;
width: min(26rem, 100vw); z-index: 30;
background: var(--card-bg); border-left: 1px solid var(--border);
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
}
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer,
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; }
.help-pane { display: none; }
html:has(#help-index:checked) .help-pane-index,
html:has(#help-status:checked) .help-pane-status,
html:has(#help-password:checked) .help-pane-password,
html:has(#help-dns:checked) .help-pane-dns,
html:has(#help-records:checked) .help-pane-records,
html:has(#help-dmarc:checked) .help-pane-dmarc,
html:has(#help-connection:checked) .help-pane-connection,
html:has(#help-apps:checked) .help-pane-apps,
html:has(#help-domain-settings:checked) .help-pane-domain-settings,
html:has(#help-export:checked) .help-pane-export { display: block; }
.help-scrim {
display: none; position: fixed; inset: var(--gallery-h) 0 0 0; z-index: 25;
background: rgba(18, 22, 28, 0.28);
}
.help-drawer h2 { margin-top: 1.2rem; }
.help-drawer h2:first-of-type,
.help-pane h2 { margin-top: 0; }
.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; }
.help-drawer .toc li { margin: 0.35rem 0 0; }
.help-drawer .toc label {
margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
}
.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; }
.help-drawer .more label {
display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
}
.help-close {
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
}
/* Phone preview (gallery toggle) and real narrow windows */
.nav-burger { display: none; }
#vp-phone:checked ~ .app {
position: relative;
width: 390px; margin: 0.75rem auto 2rem; min-height: 760px;
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
max-height: none;
}
#vp-phone:checked ~ .app .nav {
position: absolute; top: 0; left: 0; bottom: 0;
height: auto; max-height: none;
width: min(16rem, 82%); z-index: 20;
transform: translateX(-110%);
border-right: 1px solid var(--border); background: var(--card-bg);
}
#nav-open:checked ~ .app .nav { transform: translateX(0); }
#vp-phone:checked ~ .app .phone-bar {
display: flex; align-items: center; gap: 0.55rem;
margin: 0; padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border); background: var(--card-bg);
}
#vp-phone:checked ~ .app .nav-burger {
display: inline-flex; align-items: center; justify-content: center;
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
border: 1px solid var(--control-border); border-radius: 5px;
background: var(--surface-bg); cursor: pointer;
}
#vp-phone:checked ~ .app .phone-mark { width: 1.7rem; height: 1.7rem; }
#vp-phone:checked ~ .app .phone-bar .grow { flex: 1; font-weight: 600; }
#vp-phone:checked ~ .app main { padding: 1.5rem 0.9rem 2rem; }
#vp-phone:checked ~ .app .desk-only { display: none !important; }
#vp-phone:checked ~ .app .phone-only { display: block; }
#vp-phone:checked ~ .app .phone-list { display: block; }
#vp-phone:checked ~ .app .status-grid { display: flex; flex-direction: column; }
#vp-phone:checked ~ .app .status-grid .attn { order: -1; }
#vp-phone:checked ~ .app .pair { grid-template-columns: 1fr; }
#vp-phone:checked ~ .app .split,
#vp-phone:checked ~ .app .check-cols,
#vp-phone:checked ~ .app .field-pair:not(.host-type),
#vp-phone:checked ~ .app .facts,
#vp-phone:checked ~ .app .retry-facts {
grid-template-columns: 1fr;
}
#vp-phone:checked ~ .app td.subject span { max-width: none; }
#vp-phone:checked ~ .help-drawer { width: 100vw; }
.phone-only { display: none; }
@media (max-width: 52rem) {
.app { min-height: calc(100vh - var(--gallery-h)); }
.nav {
position: fixed; top: var(--gallery-h); left: 0; bottom: 0;
z-index: 20; width: min(16rem, 84vw); max-height: none;
transform: translateX(-110%);
border-right: 1px solid var(--border); background: var(--card-bg);
}
#nav-open:checked ~ .app .nav { transform: translateX(0); }
.phone-bar {
display: flex; align-items: center; gap: 0.55rem;
margin: 0; padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border); background: var(--card-bg);
}
.nav-burger {
display: inline-flex; align-items: center; justify-content: center;
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
border: 1px solid var(--control-border); border-radius: 5px;
background: var(--surface-bg); cursor: pointer;
}
.phone-mark { width: 1.7rem; height: 1.7rem; }
.phone-bar .grow { flex: 1; font-weight: 600; }
main { padding: 1.5rem 0.9rem 2rem; }
.desk-only { display: none !important; }
.phone-only, .phone-list { display: block; }
.status-grid { display: flex; flex-direction: column; }
.status-grid .attn { order: -1; }
.split, .pair, #settings .split, #backup > .split,
.check-cols, .field-pair:not(.host-type), .facts, .retry-facts { grid-template-columns: 1fr; }
.help-drawer { width: 100vw; }
}
/* Index page */
.wrap { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
.wrap > header { border-bottom: 2px solid var(--accent-fill); padding-bottom: 1.5rem; margin-bottom: 0.5rem; }
.eyebrow {
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--accent-text); margin: 0 0 0.7rem;
}
.wrap h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); font-weight: 200; }
.wrap h1 b { font-weight: 600; }
.wrap > header p { margin: 0; max-width: 62ch; color: var(--muted); }
.wrap section { border-bottom: 1px solid var(--border); padding: 2.2rem 0; }
.wrap h2 {
font-size: 0.8rem; font-weight: 500; margin: 0 0 1.1rem;
letter-spacing: 0.1em; text-transform: uppercase; font-family: var(--font-mono); color: var(--muted);
}
.wrap h3 { font-size: 1.05rem; font-weight: 600; margin: 1.2rem 0 0.4rem; }
.note {
background: #f1eae4; border-left: 2px solid var(--accent-fill);
padding: 0.9rem 1rem; font-size: 0.92rem; line-height: 1.6; max-width: 64ch; margin-top: 1rem;
}
#theme-dark:checked ~ .doc .note { background: #2a1f1b; }
.compare {
display: grid; grid-template-columns: 1fr; gap: 0.75rem; margin-top: 0.8rem;
}
.compare .row {
font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted);
}
.bar {
display: flex; height: 2.1rem; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--card-bg);
}
.bar i { display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 0.7rem; }
.bar .navc { width: 14%; background: var(--surface-bg); color: var(--fg); border-right: 1px solid var(--border); }
.bar .col { background: var(--nav-active-bg); color: var(--accent-text); }
.bar .empty { flex: 1; background: var(--code-bg); color: var(--muted); }
.bar .fill { flex: 1; background: var(--st-ok-bg); color: var(--st-ok-fg); }
.ia { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 40rem) { .ia { grid-template-columns: 1fr; } }
.ia ul { margin: 0.3rem 0 0; padding-left: 1.1rem; }
.ia li { margin: 0.2rem 0; }
.screen-index { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.4rem 1.5rem; }
.screen-index a { display: block; padding: 0.25rem 0; }
.icons-row { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: end; margin-top: 0.8rem; }
.icon-card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 1rem 1.1rem 0.85rem; text-align: center; min-width: 7.5rem;
}
.icon-card svg { width: 1.5rem; height: 1.5rem; display: block; margin: 0 auto 0.45rem; }
.icon-card span { display: block; font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); }
.mark-row { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; }
.mark-row figure { margin: 0; }
.mark-row figcaption { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
.cta {
display: inline-block; margin-top: 1rem; padding: 0.65rem 1.2rem;
background: var(--accent-fill); color: var(--on-accent); text-decoration: none;
font-weight: 600; border-radius: 5px;
}
.cta:hover { background: var(--accent-fill-hover); color: var(--on-accent); }
.cta-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
.cta.quiet {
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
}
.cta.quiet:hover { background: var(--surface-bg-hover); color: var(--accent-text); }
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-settings" data-page="settings" data-nav="settings" data-title="Settings">
<main class="stack">
<h1>Settings</h1>
<form class="stack" action="#" onsubmit="return false">
<div class="pair">
<div class="card">
<h2>Panel credentials</h2>
<p class="muted">These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.</p>
<label>Username</label><input value="admin" autocomplete="username">
<label>Current password</label><input type="password" autocomplete="current-password">
<label>New password</label><input type="password" autocomplete="new-password">
<label>Confirm new password</label><input type="password">
<div class="actions-row"><button type="button">Save changes</button></div>
<p class="muted">Leave both new-password fields empty to change the username or DMARC address only. Changing the password signs out every other session; this one stays signed in.</p>
</div>
<div class="card g-only">
<h2>DMARC aggregate reports</h2>
<p class="muted">Default <code>rua=</code> for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.</p>
<label>Default report address</label>
<input type="email" value="dmarc@mail.example.org">
<p class="muted">When <code>rua=</code> points at another domain, that hub must publish a report-authorisation record. <a href="dmarc.html">DMARC reports</a> in the panel.</p>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row"><span class="code">mail.example.org._report._dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
<label>Value</label>
<div class="code-row"><span class="code">v=DMARC1;</span><button type="button" class="copy">Copy</button></div>
<label>Report authorization DNS <span class="st st-ok">ok</span></label>
<p class="muted">Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.</p>
</div>
</div>
</form>
<div class="fill g-only">
<div class="card" id="rate-limits">
<h2>Sending rate limits</h2>
<p class="muted">Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domain’s page.</p>
<div class="check-cols">
<div class="check-col">
<p class="check-col-title">Level 1 — per client IP</p>
<span class="code">100 messages / 60 seconds</span>
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> / <code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.</p>
</div>
<div class="check-col">
<p class="check-col-title">Level 2 — domain</p>
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.</p>
</div>
<div class="check-col">
<p class="check-col-title">Level 2 — application</p>
<p class="muted">Optional override for trusted IPs: a ceiling strictly above the domain limit (still ≤ level 1). Those IPs skip the domain check; everyone else stays under the domain (or level 1).</p>
</div>
</div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create administrator — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-setup" data-page="setup" data-nav="setup" data-title="Create administrator" data-auth="1">
<main class="auth">
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
<h1>Create administrator</h1>
<div class="card">
<p class="muted">This one-time link creates the single panel administrator. After you submit, the link stops working for good.</p>
<form action="status.html">
<label>Username</label>
<input autocomplete="username">
<label>Password</label>
<input type="password" autocomplete="new-password">
<label>Confirm password</label>
<input type="password" autocomplete="new-password">
<div class="actions-row"><button type="submit">Create administrator</button></div>
</form>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+276
View File
@@ -0,0 +1,276 @@
/* Shared chrome for panel UI page mockups. Each screen is its own HTML file;
this script injects radios, gallery, nav, sprite, and the help drawer so
file:// viewing does not need a module fetch. */
(function () {
if (document.body.dataset.shell === "1") return;
document.body.dataset.shell = "1";
var main = document.querySelector("main");
if (!main) return;
var page = document.body.getAttribute("data-page") || "";
var navKey = document.body.getAttribute("data-nav") || page;
var title = document.body.getAttribute("data-title") || document.title;
var globalOnly = document.body.getAttribute("data-global-only") === "1";
document.body.insertAdjacentHTML("afterbegin",
'<input class="ctrl" type="radio" name="role" id="role-global" checked>' +
'<input class="ctrl" type="radio" name="role" id="role-domain">' +
'<input class="ctrl" type="radio" name="vp" id="vp-desktop" checked>' +
'<input class="ctrl" type="radio" name="vp" id="vp-phone">' +
'<input class="ctrl" type="radio" name="theme" id="theme-light" checked>' +
'<input class="ctrl" type="radio" name="theme" id="theme-dark">' +
'<input class="ctrl" type="checkbox" id="feat-inbound" checked>' +
'<input class="ctrl" type="checkbox" id="nav-open">' +
'<input class="ctrl" type="radio" name="help" id="help-off" checked>' +
'<input class="ctrl" type="radio" name="help" id="help-index">' +
'<input class="ctrl" type="radio" name="help" id="help-status">' +
'<input class="ctrl" type="radio" name="help" id="help-password">' +
'<input class="ctrl" type="radio" name="help" id="help-dns">' +
'<input class="ctrl" type="radio" name="help" id="help-records">' +
'<input class="ctrl" type="radio" name="help" id="help-dmarc">' +
'<input class="ctrl" type="radio" name="help" id="help-connection">' +
'<input class="ctrl" type="radio" name="help" id="help-apps">' +
'<input class="ctrl" type="radio" name="help" id="help-domain-settings">' +
'<input class="ctrl" type="radio" name="help" id="help-export">'
);
var q = new URLSearchParams(location.search);
if (q.get("view") === "phone") document.getElementById("vp-phone").checked = true;
if (q.get("role") === "domain") document.getElementById("role-domain").checked = true;
if (q.get("theme") === "dark") document.getElementById("theme-dark").checked = true;
if (q.get("inbound") === "0") document.getElementById("feat-inbound").checked = false;
var gallery =
'<header class="gallery">' +
'<a class="brand-mini" href="index.html">Макеты</a>' +
'<div class="seg"><span>Роль</span>' +
'<label for="role-global">Global</label>' +
'<label for="role-domain">Domain-admin</label></div>' +
'<div class="seg"><span>Ширина</span>' +
'<label for="vp-desktop">Desktop</label>' +
'<label for="vp-phone">Phone</label></div>' +
'<div class="seg"><span>Тема</span>' +
'<label for="theme-light">Light</label>' +
'<label for="theme-dark">Dark</label></div>' +
'<label class="g-only" for="feat-inbound">Inbound</label>' +
'<a href="index.html">Оглавление</a>' +
'<a href="system.html">Система</a>' +
"</header>";
var sprite =
'<svg class="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<symbol id="i-status" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></symbol>' +
'<symbol id="i-domains" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></symbol>' +
'<symbol id="i-deliveries" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></symbol>' +
'<symbol id="i-queue" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></symbol>' +
'<symbol id="i-log" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></symbol>' +
'<symbol id="i-inbound" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></symbol>' +
'<symbol id="i-dmarc" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></symbol>' +
'<symbol id="i-backup" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></symbol>' +
'<symbol id="i-users" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></symbol>' +
'<symbol id="i-help" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></symbol>' +
'<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>' +
'<symbol id="i-account" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></symbol>' +
'<symbol id="i-out" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></symbol>' +
'<symbol id="i-menu" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></symbol>' +
"</svg>";
function icon(id) {
return '<svg class="icon"><use href="#' + id + '"/></svg>';
}
var navHtml =
'<nav class="nav">' +
'<a class="brand" href="status.html"><img src="../selfpost-stamp-compact.svg" width="220" height="100" alt="SelfPost"></a>' +
'<div class="links">' +
'<a class="n-status g-only" href="status.html">' + icon("i-status") + "Status</a>" +
'<a class="n-domains" href="domains.html">' + icon("i-domains") + "Domains</a>" +
'<a class="n-deliveries" href="deliveries.html">' + icon("i-deliveries") + "Deliveries</a>" +
'<a class="n-queue g-only" href="mail-queue.html">' + icon("i-queue") + "Mail queue</a>" +
'<a class="n-log g-only" href="system-log.html">' + icon("i-log") + "System log</a>" +
'<a class="n-inbound g-only in-only" href="inbound.html">' + icon("i-inbound") + "Inbound</a>" +
'<a class="n-dmarc g-only" href="dmarc.html">' + icon("i-dmarc") + 'DMARC <span class="tag future">1.x</span></a>' +
'<a class="n-backup g-only" href="backup.html">' + icon("i-backup") + "Backup</a>" +
'<a class="n-users g-only" href="users.html">' + icon("i-users") + "Users</a>" +
'<a class="n-help" href="help.html">' + icon("i-help") + 'Help <span class="tag future">1.x</span></a>' +
"</div>" +
'<div class="session">' +
'<span class="session-user muted">' + icon("i-account") + "User: admin</span>" +
'<a class="n-settings" href="settings.html">' + icon("i-settings") + "Settings</a>" +
'<a class="btn-ghost" href="login.html">' + icon("i-out") + "Sign out</a>" +
"</div>" +
"</nav>";
var phone =
'<div class="phone-bar">' +
'<label class="nav-burger" for="nav-open" title="Menu">' + icon("i-menu") + "</label>" +
'<img class="phone-mark" src="../selfpost-icon.svg" width="28" height="28" alt="">' +
'<span class="grow phone-title"></span>' +
'<label class="help-link" for="help-index" title="Help">?</label>' +
"</div>";
var help =
'<label class="help-scrim" for="help-off"></label>' +
'<aside class="help-drawer">' +
'<label class="help-close" for="help-off">Close</label>' +
'<article class="help-pane help-pane-index">' +
"<h2>Help</h2>" +
"<p>Short notes for the card you opened — not a second copy of the guide.</p>" +
'<p class="muted">Status</p>' +
'<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>' +
'<p class="muted">Domain</p>' +
'<ul class="toc">' +
'<li><label for="help-password">New application password</label></li>' +
'<li><label for="help-dns">DNS status</label></li>' +
'<li><label for="help-records">DKIM and SPF records</label></li>' +
'<li><label for="help-dmarc">DMARC record</label></li>' +
'<li><label for="help-connection">Connection settings</label></li>' +
'<li><label for="help-apps">Applications</label></li>' +
'<li><label for="help-domain-settings">Domain settings</label></li>' +
'<li><label for="help-export">Export domain</label></li>' +
"</ul></article>" +
'<article class="help-pane help-pane-status">' +
"<h2>Status checks</h2>" +
"<p>The cards keep the readings. This drawer is what used to sit under them as paragraphs.</p>" +
"<h2>Machine</h2>" +
"<p>CPU and memory are the container’s readings. Network is a short rate window. These numbers explain load; they do not replace the queue.</p>" +
"<h2>TLS certificate</h2>" +
"<p>Presented on port 465. Issued and renewed outside SelfPost. Warn = expires soon; error = missing, and submission will fail.</p>" +
"<h2>Hostname / reverse DNS</h2>" +
"<p>The hostname must forward to this IP and the PTR must come back to the same name. Set PTR at the provider.</p>" +
"<h2>Mail queue</h2>" +
"<p>Deferred mail is retried on a time schedule (first delay, doubling cap, queue lifetime). There is no “attempt 3 of N”.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-password">' +
"<h2>New application password</h2>" +
"<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is lost, regenerate a new one. The previous password stops working immediately.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-dns">' +
"<h2>DNS status</h2>" +
"<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few minutes — use <em>Re-check</em> after publishing.</p>" +
"<p>SPF is a shallow check: the literal address only, no <code>include:</code> or <code>redirect=</code>. Report authorization is required only when <code>rua=</code> points at a domain this server does not accept.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-records">' +
"<h2>DKIM and SPF records</h2>" +
"<p>DKIM is not a secret. The selector on this page is the one this server signs with. Merge the SPF example into an existing record if the domain already has one — do not publish a second TXT.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-dmarc">' +
"<h2>DMARC record</h2>" +
"<p><code>p=none</code> does not affect delivery. Tighten to <code>p=quarantine</code> then <code>p=reject</code> once reports look clean. The report address is set under Domain settings (or the Settings default).</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-connection">' +
"<h2>Connection settings</h2>" +
"<p>Same host for every domain. Authenticate with an application login from this page. Auth is required on every port. The password is shown once at create or regenerate.</p>" +
"<p>465 is implicit TLS; 587 is STARTTLS submission when that port is enabled.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-apps">' +
"<h2>Applications</h2>" +
"<p>SASL logins for this domain. Login is unique across domains; letters, digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is shown once.</p>" +
"<p>Address mode is which From addresses this application may use: any address of the domain, or a fixed list. A trusted-IP override gives those clients a higher ceiling than the domain (still ≤ level 1) and skips the domain check; everyone else uses the domain limit if set, otherwise level 1.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-domain-settings">' +
"<h2>Domain settings</h2>" +
"<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or you override them per domain. Level 2 is an optional ceiling for all senders on this domain; it must be ≤ level 1. Application overrides live on each application.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
'<article class="help-pane help-pane-export">' +
"<h2>Export domain</h2>" +
"<p>The file is a secret: it carries the DKIM key and application passwords, so published DNS does not have to change on the other instance. Transfer it securely, or encrypt it as <code>.spde</code>.</p>" +
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
"</aside>";
var app = document.createElement("div");
app.className = "app";
app.innerHTML = navHtml + '<div class="stage">' + phone + "</div>";
main.parentNode.insertBefore(app, main);
app.insertAdjacentHTML("beforebegin", gallery + sprite);
app.querySelector(".stage").appendChild(main);
app.insertAdjacentHTML("afterend", help);
var cur = app.querySelector(".n-" + navKey);
if (cur) cur.setAttribute("aria-current", "page");
var grow = app.querySelector(".phone-title");
if (grow) grow.textContent = title;
var skip = { "index.html": 1, "system.html": 1, "app.html": 1 };
function qs() {
var p = new URLSearchParams();
if (document.getElementById("vp-phone").checked) p.set("view", "phone");
if (document.getElementById("role-domain").checked) p.set("role", "domain");
if (document.getElementById("theme-dark").checked) p.set("theme", "dark");
if (!document.getElementById("feat-inbound").checked) p.set("inbound", "0");
var s = p.toString();
return s ? "?" + s : "";
}
function withQuery(href) {
if (!href) return href;
if (href.charAt(0) === "#" || /^(https?:|mailto:|javascript:)/i.test(href)) return href;
var hash = "";
var path = href;
var hashAt = href.indexOf("#");
if (hashAt >= 0) {
hash = href.slice(hashAt);
path = href.slice(0, hashAt);
}
var qAt = path.indexOf("?");
var file = qAt >= 0 ? path.slice(0, qAt) : path;
var extra = qAt >= 0 ? path.slice(qAt + 1) : "";
var base = file.split("/").pop();
if (!base || !/\.html$/i.test(base) || skip[base]) return href;
var p = new URLSearchParams(extra);
var curQs = new URLSearchParams(qs().replace(/^\?/, ""));
["view", "role", "theme", "inbound"].forEach(function (k) {
if (curQs.has(k)) p.set(k, curQs.get(k));
else p.delete(k);
});
var s = p.toString();
return base + (s ? "?" + s : "") + hash;
}
function rewriteLinks() {
document.querySelectorAll("a[href]").forEach(function (a) {
var raw = a.getAttribute("href");
if (!raw) return;
a.setAttribute("href", withQuery(raw));
});
}
function brandHref() {
var brand = document.querySelector(".nav .brand");
if (!brand) return;
brand.setAttribute("href", withQuery(
document.getElementById("role-domain").checked ? "domains.html" : "status.html"
));
}
function gate() {
brandHref();
if (!document.getElementById("role-domain").checked) return;
if (globalOnly) location.replace(withQuery("domains.html"));
}
function syncUrl() {
if (history.replaceState) {
history.replaceState(null, "", location.pathname.split("/").pop() + qs() + location.hash);
}
rewriteLinks();
brandHref();
gate();
}
["role-global", "role-domain", "vp-desktop", "vp-phone", "theme-light", "theme-dark", "feat-inbound"].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener("change", syncUrl);
});
document.querySelectorAll(".nav a").forEach(function (a) {
a.addEventListener("click", function () {
var open = document.getElementById("nav-open");
if (open) open.checked = false;
});
});
rewriteLinks();
gate();
})();
+125
View File
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Status — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-status" data-page="status" data-nav="status" data-title="Status" data-global-only="1">
<main class="stack">
<div class="page-head">
<h1>Status</h1>
<label class="help-link" for="help-status" title="What these checks mean">?</label>
</div>
<div class="fill">
<div class="card" id="overall">
<h2>Overall <span class="st st-warn">warn</span></h2>
<p class="muted">Running, with warnings below.</p>
</div>
</div>
<div class="pair">
<div class="card attn" id="queue">
<h2>Mail queue <span class="st st-warn">warn</span></h2>
<p>3 Kbytes in 3 Requests.</p>
<div class="actions-row"><a class="btn" href="mail-queue.html">View queue</a></div>
</div>
<div class="card" id="certificate">
<h2>TLS certificate <span class="st st-ok">ok</span></h2>
<label>Expires</label>
<span class="code">2026-11-02 12:00 UTC</span>
<p class="muted">Valid for another 78 day(s).</p>
</div>
</div>
<div class="pair">
<div class="card" id="sockets">
<h2>Milter sockets <span class="st st-ok">ok</span></h2>
<table>
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>OpenDKIM</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
<tr><td>send-log</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
</tbody>
</table>
</div>
<div class="card" id="hostname">
<h2>Hostname and reverse DNS <span class="st st-ok">ok</span></h2>
<label>Server hostname</label>
<span class="code">mail.example.org</span>
<label>Forward and reverse lookup</label>
<span class="code">203.0.113.10 → mail.example.org</span>
<p class="muted">mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.</p>
<div class="actions-row"><button type="button">Re-check DNS</button></div>
</div>
</div>
<div class="fill in-only" id="inbound-status">
<div class="card">
<h2>Inbound <span class="st st-warn">warn</span></h2>
<p><code>INBOUND_RELAY_ENABLE</code> is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.</p>
<p class="muted">One domain has no MX pointing at this server. Recipients are a list or any address at the domain. Open Inbound for the list, MX checks, upstream, and recipient maps.</p>
<p><a href="inbound.html">Inbound domains</a></p>
</div>
</div>
<div class="pair">
<div class="card" id="machine">
<h2>Machine <span class="st st-ok">ok</span></h2>
<table>
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr>
<td class="metric">CPU</td>
<td class="metric"><meter value="12" min="0" max="100" low="70" high="90" optimum="10">12%</meter> 12%</td>
<td class="muted">4 cores · 4 threads</td>
</tr>
<tr>
<td class="metric">Memory</td>
<td class="metric"><meter value="41" min="0" max="100" low="70" high="90" optimum="10">41%</meter> 41%</td>
<td class="muted">1.6 GiB used of 4.0 GiB.</td>
</tr>
<tr>
<td class="metric">Network</td>
<td class="metric">↓ 2.0 KiB/s<br>↑ 1.0 KiB/s</td>
<td class="muted"><div>eth0: 1.0 MiB in, 512.0 KiB out</div></td>
</tr>
</tbody>
</table>
</div>
<div class="card" id="processes">
<h2>Processes <span class="st st-ok">ok</span></h2>
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>opendkim</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 21, uptime 3 days, 4:12:01</td></tr>
<tr><td>panel</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 18, uptime 3 days, 4:12:03</td></tr>
<tr><td>postfix</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 42, uptime 3 days, 4:11:58</td></tr>
<tr><td>postfix-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
<tr><td>cert-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
<tr><td>logrotate</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
</tbody>
</table>
</div>
</div>
<div class="fill">
<div class="card" id="configuration">
<h2>Configuration</h2>
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
database and reloads both daemons. Use it if you edited the files by hand,
restored a backup, or the running configuration looks out of step with the
domain and application lists. It does not touch the mail queue or the TLS
certificate, and it is safe to run at any time.</p>
<div class="actions-row"><button type="button">Reload configuration</button></div>
</div>
</div>
<p class="version">SelfPost 1.2.3 · © Mixeme · <a href="#">License (AGPL-3.0)</a></p>
</main>
<script src="shell.js"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>System log — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-system-log" data-page="system-log" data-nav="log" data-title="System log" data-global-only="1">
<main class="stack">
<h1>System log</h1>
<div class="fill">
<div class="card">
<h2>Recent log entries</h2>
<span class="code">Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=&lt;ada@example.net&gt;, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy)
Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=&lt;billing@example.com&gt;, size=12288, nrcpt=1 (queue active)
Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing
Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=&lt;list-bounces@example.net&gt;, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok)
Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=&lt;noreply@blocked.example&gt;, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected)</span>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
/* Panel UI system primitives.
Source of truth: system.html. Screens are separate HTML files composed from
these classes. Do not “fix” empty columns, Host/Type height, or split
actions by one-off rules — compose with these instead. */
.stack {
display: flex; flex-direction: column; gap: 1rem;
width: 100%; min-width: 0;
}
.stack > .card,
.stack > .pair,
.stack > .measure,
.stack > .fill { margin-top: 0; }
.stack > .card + .card { margin-top: 0; }
.measure { width: 100%; max-width: var(--form-max); min-width: 0; }
.fill { width: 100%; max-width: var(--ops-max); min-width: 0; }
.fill > .card { margin-top: 0; }
.fill .card:has(table) { overflow-x: auto; }
.card > .measure { margin-bottom: 1rem; }
/* Two peer jobs. One child → reading measure (domain-admin Settings).
Never a full-width lonely card on an ops page. */
.pair {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem;
width: 100%; max-width: var(--ops-max); min-width: 0;
}
.pair > * { min-width: 0; }
.card > .stack { min-width: 0; }
.pair > .card { margin-top: 0; }
.pair > .card + .card { margin-top: 0; }
.pair:has(> :only-child) {
display: block;
max-width: var(--form-max);
}
.actions-row {
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
margin-top: 1.1rem;
}
.actions-row > button,
.actions-row > a.btn,
.actions-row > a.danger { margin-top: 0; }
/* Two labelled controls on one row. Labels share row 1, values share row 2
so they are the same height by construction — not by matching padding. */
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto minmax(2.5rem, auto);
column-gap: 1rem;
align-items: stretch;
margin-top: 0.45rem;
}
.field-row.equal { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.field-row > .field { display: contents; }
.field-row > .field:first-child > * { grid-column: 1; }
.field-row > .field:last-child > * { grid-column: 2; }
.field-row > .field > label { grid-row: 1; margin-top: 0.45rem; }
.field-row > .field > :not(label) {
grid-row: 2; align-self: stretch; min-width: 0;
margin-top: 0.3rem; box-sizing: border-box;
}
.field-row > .field > .code,
.field-row > .field > .code-row {
height: 100%;
}
.field-row > .field > .code {
display: flex; align-items: center;
padding-top: 0.45rem; padding-bottom: 0.45rem;
}
.field-row > .field:last-child > .code {
width: fit-content; min-width: 2.75rem;
justify-content: center; text-align: center;
}
.field-row .code-row {
display: flex; align-items: stretch; gap: 0.5rem; height: 100%;
}
.field-row .code-row .code {
flex: 1; min-width: 0; margin-top: 0; height: auto;
display: flex; align-items: center;
padding-top: 0.45rem; padding-bottom: 0.45rem;
}
.field-row .code-row .copy { margin-top: 0; align-self: stretch; }
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
margin-bottom: 0.5rem;
}
.card-head h2 { margin: 0; }
@media (max-width: 52rem) {
.pair { grid-template-columns: 1fr; }
/* field-row stays two columns: Type is a token, not a second form. */
}
/* Specimens on the system page */
.sys-wrap { max-width: 70rem; }
.sys-wrap > header p,
.sys-wrap section > p,
.sys-wrap li { max-width: 68ch; }
.sys-toc {
display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem;
margin: 1rem 0 0; padding: 0; list-style: none;
}
.sys-toc a { font-size: 0.92rem; }
.specimen {
margin: 1rem 0 0; padding: 0.9rem 1rem 1.1rem;
border: 1px dashed var(--border); border-radius: 6px; background: var(--code-bg);
}
.specimen > figcaption {
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--muted); margin: 0 0 0.7rem;
}
.specimen.bad {
border-color: var(--danger-border); background: var(--danger-bg);
}
.specimen.bad > figcaption { color: var(--danger-fg); }
.recipes {
width: 100%; margin-top: 0.8rem; font-size: 0.92rem;
}
.recipes th, .recipes td { white-space: normal; vertical-align: top; }
.recipes code { font-size: 0.82rem; }
.forbid { margin: 0.4rem 0 0; padding-left: 1.1rem; }
.forbid li { margin: 0.35rem 0; }
+298
View File
@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — система панели</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="doc">
<div class="wrap sys-wrap">
<header>
<p class="eyebrow">система · не рескин</p>
<h1>Self<b>Post</b> — грамматика интерфейса</h1>
<p>Это проект системы. Экраны складываются из регионов и контролов. Запрещённые состояния нельзя «починить паддингом» — их не из чего собрать. Знак, кирпич и IBM Plex не меняются. Живая панель (<code>internal/web</code>) пока не трогается.</p>
<div class="cta-row">
<a class="cta" href="#regions">Регионы</a>
<a class="cta quiet" href="#controls">Контролы</a>
<a class="cta quiet" href="#recipes">Экраны</a>
<a class="cta quiet" href="status.html">Макеты экранов</a>
</div>
<ul class="sys-toc">
<li><a href="#why">Зачем</a></li>
<li><a href="#regions">Регионы</a></li>
<li><a href="#card">Карточка</a></li>
<li><a href="#controls">Контролы</a></li>
<li><a href="#actions">Действия</a></li>
<li><a href="#forbid">Нельзя</a></li>
<li><a href="#recipes">Рецепты экранов</a></li>
<li><a href="#phone">Телефон</a></li>
<li><a href="#apply">Как внедрять</a></li>
</ul>
</header>
<section id="why">
<h2>Зачем</h2>
<p>Первый макет скопировал грамматику живой панели: страница помечается <code>ops</code> или <code>form</code>, карточки стопкой, пара полей — два независимых <code>.code</code>, Save внутри <code>&lt;form&gt;</code>, Delete снаружи. Дальше каждая мелочь чинилась отдельно — как в <code>panel.css</code> годами. Новый интерфейс должен <strong>не давать собрать</strong> пустую колонку, разные высоты Host/Type и кнопки на двух строках.</p>
<p>Три оси, которые больше не выбираются «на глаз» у каждой страницы:</p>
<ol>
<li><strong>Регион</strong> — как блок занимает ширину окна.</li>
<li><strong>Карточка</strong> — заголовок, справка, тело, действия.</li>
<li><strong>Контрол</strong> — одно поле или пара полей одной высоты.</li>
</ol>
</section>
<section id="regions">
<h2>Регионы</h2>
<p>Страница — это <code>stack</code> регионов, не класс на <code>&lt;main&gt;</code>. Оболочка одна: навбар прижат влево, контент забирает остаток. Навбар не прыгает, когда меняется содержимое.</p>
<table class="recipes">
<thead><tr><th>Регион</th><th>Ширина</th><th>Когда</th></tr></thead>
<tbody>
<tr>
<td><code>measure</code></td>
<td>до 42rem, влево</td>
<td>Одна читаемая форма: login, setup, подтверждение удаления, форма пользователя. Не «вся страница Settings».</td>
</tr>
<tr>
<td><code>pair</code></td>
<td>две колонки до 90rem</td>
<td>Две равноправные задачи на одном экране. Один ребёнок — сам сжимается в <code>measure</code> (domain-admin без DMARC).</td>
</tr>
<tr>
<td><code>fill</code></td>
<td>до 90rem</td>
<td>Таблица, лог, DNS-статус, список приложений. Ячейки не переносят однострочные значения; карточка скроллится по X.</td>
</tr>
<tr>
<td><code>stack</code></td>
<td>колонка с зазором</td>
<td>Вертикальный порядок регионов. Зазор даёт <code>gap</code>, не <code>.card + .card</code>.</td>
</tr>
</tbody>
</table>
<figure class="specimen bad">
<figcaption>Нельзя — страница-форма, две задачи стопкой</figcaption>
<div class="measure">
<div class="card">
<h2>Full backup</h2>
<p class="muted">Карточка узкая. Справа пустое поле на 1600px.</p>
<button type="button">Download</button>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Вторая задача под первой — та же пустота.</p>
<button type="button">Import</button>
</div>
</div>
</figure>
<figure class="specimen">
<figcaption>Надо — pair</figcaption>
<div class="pair">
<div class="card">
<h2>Full backup</h2>
<p class="muted">Секрет. Шифрование — внутри карточки, поля не растягиваются на 1440px.</p>
<div class="actions-row"><button type="button">Download full backup</button></div>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Тот же экран, вторая задача. Не «ещё одна форма ниже».</p>
<div class="actions-row"><button type="button">Import domain</button></div>
</div>
</div>
</figure>
</section>
<section id="card">
<h2>Карточка</h2>
<p>Один хром: <code>card-head</code> (заголовок + слот «?»), тело, при необходимости <code>actions-row</code>. Справка — часть хрома, её не расставляют после того, как карточки уже собраны. Нет «?» — слот пустой, заголовки соседних карточек всё равно на одной линии.</p>
<p>Показания (очередь, PTR, CPU) остаются на карточке. В drawer уходит только то, чего на карточке быть не должно: что такое kernel counter, зачем PTR у провайдера, почему пароль показывают один раз.</p>
<figure class="specimen">
<figcaption>Хром карточки</figcaption>
<div class="pair">
<div class="card">
<div class="card-head">
<h2>DNS status <span class="st st-ok">ok</span></h2>
<span class="help-link" title="What these checks mean">?</span>
</div>
<p class="muted">Чтение и Re-check здесь. Абзац «зачем MX» — в справке.</p>
</div>
<div class="card">
<div class="card-head">
<h2>Danger zone</h2>
</div>
<p class="muted">Без «?»: действие очевидное. Карточка всё равно пара к форме, не на всю ширину.</p>
<div class="actions-row"><a class="danger" href="#forbid">Delete inbound domain</a></div>
</div>
</div>
</figure>
</section>
<section id="controls">
<h2>Контролы</h2>
<p>Одно поле — <code>field</code> (подпись + контроль). Два поля в ряд — <code>field-row</code>: подписи в первой сетке-строке, значения во второй. Высота значений общая, потому что это одна строка грида, а не два блока с подобранным padding. Баг «Type ниже Host» (<code>b0ebe06</code>) из этой разметки не собирается.</p>
<p><code>field-row</code> с узкой второй колонкой — Host / name ‖ Type. <code>field-row equal</code> — два равноправных инпута (лимит и окно).</p>
<figure class="specimen">
<figcaption>field-row — Host ‖ Type, с Copy и без</figcaption>
<div class="pair">
<div class="card">
<h2>With Copy</h2>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<div class="code-row">
<span class="code">mail._domainkey.example.com</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
</div>
<div class="card">
<h2>Lookup, no Copy</h2>
<div class="field-row">
<div class="field">
<label>Host / name</label>
<span class="code">lists.example.com</span>
</div>
<div class="field">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
</div>
</div>
</figure>
<figure class="specimen">
<figcaption>field-row equal — лимит ‖ окно</figcaption>
<div class="measure">
<div class="card">
<h2>Level-2 rate limit</h2>
<div class="field-row equal">
<div class="field">
<label>Message limit</label>
<input value="40">
</div>
<div class="field">
<label>Window (seconds)</label>
<input value="60">
</div>
</div>
</div>
</div>
</figure>
</section>
<section id="actions">
<h2>Действия</h2>
<p>Primary, secondary и danger — всегда <code>actions-row</code>. Для вёрстки неважно, POST это или переход на confirm: ряд один. Форма либо оборачивает всю карточку, либо кнопки несут <code>form=</code>. Нельзя оставить Submit внутри блочной формы, а Delete — следующим соседом: блок формы занимает строку целиком.</p>
<figure class="specimen">
<figcaption>Save и Delete в одном ряду</figcaption>
<div class="measure">
<div class="card">
<h2>Edit user</h2>
<label>Username</label>
<input value="ops-alerts">
<div class="actions-row">
<button type="button">Save</button>
<a class="danger" href="#forbid">Delete user</a>
</div>
</div>
</div>
</figure>
</section>
<section id="forbid">
<h2>Нельзя</h2>
<p>Если хочется добавить правило «только на этой странице» — сначала проверить, какого региона не хватило.</p>
<ul class="forbid">
<li>Класс на <code>main</code> (<code>ops</code> / <code>form</code>) как способ выбрать ширину. Ширину выбирает регион.</li>
<li>Одиночная карточка на 90rem с двумя полями ввода. Это <code>measure</code> или <code>pair</code>.</li>
<li>Danger zone отдельным <code>fill</code>. Она вторая колонка последней пары (получатели ‖ удалить, экспорт ‖ удалить).</li>
<li>Два <code>.code</code> рядом с разным padding, чтобы «почти совпало». Только <code>field-row</code>.</li>
<li>Submit внутри <code>&lt;form&gt;</code>, danger-ссылка после <code>&lt;/form&gt;</code>.</li>
<li>Перенос однострочного поля, адреса, статуса, hostname. <code>nowrap</code> + горизонтальный скролл карточки.</li>
<li>Status из одних бейджей без Detail / без строки очереди / без PTR.</li>
<li>Телефон как уменьшенный десктоп с шестиколоночной таблицей. Таблица → список; <code>pair</code> → одна колонка; <code>field-row</code> остаётся парой.</li>
<li>Копировать разметку <code>internal/web/view/templates</code> «как есть» в макет. Рецепт экрана — ниже, не шаблон Go.</li>
</ul>
</section>
<section id="recipes">
<h2>Рецепты экранов</h2>
<p>Экраны — отдельные HTML-файлы рядом с этой спецификацией. Собираются только так:</p>
<table class="recipes">
<thead><tr><th>Экран</th><th>Стек регионов</th></tr></thead>
<tbody>
<tr>
<td><a href="login.html">Login</a> / <a href="setup.html">setup</a></td>
<td>центр оболочки, <code>measure</code> 24rem, полный stamp</td>
</tr>
<tr>
<td><a href="status.html">Status</a></td>
<td><code>stack</code>: overall → <code>pair</code> очередь‖TLS → <code>pair</code> milters‖PTR → inbound если есть → <code>pair</code> machine‖processes → configuration. На карточках остаются числа и Detail.</td>
</tr>
<tr>
<td><a href="domains.html">Domains</a> / <a href="inbound.html">Inbound</a> / <a href="deliveries.html">Deliveries</a> / <a href="system-log.html">log</a> / <a href="mail-queue.html">queue</a></td>
<td><code>fill</code> таблица. Добавление домена — поле в той же карточке списка (<code>measure</code> внутри, не вторая карточка и не инпут на 90rem).</td>
</tr>
<tr>
<td><a href="domain.html">Domain</a></td>
<td><code>fill</code> DNS status → <code>pair</code> DKIM/SPF ‖ DMARC → <code>pair</code> connection ‖ add app → <code>fill</code> applications → <code>pair</code> domain settings (две половины) → <code>pair</code> export ‖ danger. «?» на рабочих карточках.</td>
</tr>
<tr>
<td><a href="inbound-domain.html">Inbound domain</a></td>
<td><code>fill</code> MX DNS → <code>pair</code> upstream ‖ MX to publish → <code>pair</code> recipients ‖ danger</td>
</tr>
<tr>
<td><a href="dmarc.html">DMARC</a></td>
<td>hub: <code>pair</code> ingest ‖ this week, затем <code>fill</code> список отчётов. Домен: <a href="dmarc-domain.html">roll-up</a> (<code>pair</code> 7 days ‖ third-party, <code>fill</code> reports + sources). Один XML: <a href="dmarc-report.html">просмотр</a> — <code>pair</code> report ‖ policy, <code>fill</code> records. Не дашборд, не <code>ruf=</code>.</td>
</tr>
<tr>
<td><a href="backup.html">Backup</a></td>
<td><code>pair</code> full backup ‖ import</td>
</tr>
<tr>
<td><a href="settings.html">Settings</a></td>
<td>global: <code>pair</code> credentials ‖ DMARC, затем <code>fill</code> rate limits. Domain-admin: один ребёнок в <code>pair</code> → сам <code>measure</code></td>
</tr>
<tr>
<td><a href="users.html">Users</a></td>
<td><code>fill</code> таблица. <a href="user-form.html">Create/Edit</a> — <code>measure</code> + <code>actions-row</code></td>
</tr>
<tr>
<td><a href="domain-delete.html">Confirm delete</a></td>
<td><code>measure</code> одна карточка</td>
</tr>
</tbody>
</table>
</section>
<section id="phone">
<h2>Телефон</h2>
<p>Аварийный доступ, не продукт. <code>pair</code> складывается в одну колонку. Таблица заменяется списком (как сейчас в прототипе). <code>field-row</code> не складывается: Type — токен. Навбар — выезжающая колонка на checkbox, без обязательного JS. Знак в шапке — SP-иконка, не второй wordmark.</p>
</section>
<section id="apply" style="border-bottom:none">
<h2>Как внедрять</h2>
<ol>
<li>Новый экран или правка макета — только классы из <code>system.css</code> (<code>stack</code>, <code>pair</code>, <code>measure</code>, <code>fill</code>, <code>field-row</code>, <code>actions-row</code>).</li>
<li>Не добавлять исключения в <code>mock.css</code> «для этой страницы», если это ширина, высота пары полей или ряд кнопок.</li>
<li>Click-through — отдельные страницы (<code>status.html</code>, <code>domain.html</code>, …), не простыня с якорями. Оболочка общая: <code>shell.js</code>.</li>
<li>Вёрстка <code>internal/web</code> — отдельная задача после утверждения системы, не параллельный рескин шаблонов.</li>
</ol>
<p class="muted">Классы живут в <code>docs/assets/panel-ui/system.css</code>. Этот файл — спецификация. Экраны — <a href="status.html">status.html</a> и соседние страницы; оглавление — <a href="index.html">index.html</a>.</p>
</section>
</div>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete ops-alerts — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-user-delete" data-page="user-delete" data-nav="users" data-title="Delete user" data-global-only="1">
<main class="stack">
<div>
<h1>Delete ops-alerts</h1>
<a class="back" href="user-form.html">&larr; Back to ops-alerts</a>
</div>
<div class="measure">
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete the panel user <strong>ops-alerts</strong>. A signed-in session for this user stops working immediately.</p>
<div class="actions-row"><button type="button" class="danger">Delete ops-alerts</button></div>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Edit user — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-user-form" data-page="user-form" data-nav="users" data-title="Edit user" data-global-only="1">
<main class="stack">
<div>
<h1>Edit user</h1>
<a class="back" href="users.html">&larr; Back to users</a>
</div>
<div class="measure">
<div class="card">
<form action="#" onsubmit="return false">
<label>Username</label>
<input value="ops-alerts">
<label>Password (leave empty to keep)</label>
<input type="password">
<label>Role</label>
<select><option selected>Domain administrator</option><option>Global administrator</option></select>
<fieldset>
<legend>Assigned domains</legend>
<p class="muted">Required for domain administrators.</p>
<label class="check"><input type="checkbox"> example.com</label>
<label class="check"><input type="checkbox" checked> alerts.example.com</label>
</fieldset>
<div class="actions-row">
<button type="button">Save</button>
<a class="danger" href="user-delete.html">Delete user</a>
</div>
</form>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Users — SelfPost mockups</title>
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
<link rel="stylesheet" href="mock.css">
<link rel="stylesheet" href="system.css">
</head>
<body class="page-users" data-page="users" data-nav="users" data-title="Users" data-global-only="1">
<main class="stack">
<h1>Users</h1>
<div class="fill">
<div class="card">
<p><a href="user-form.html">Create user</a></p>
<table>
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
<tbody>
<tr><td>admin</td><td>Global</td><td class="muted">All</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
<tr><td>ops-alerts</td><td>Domain admin</td><td class="muted">alerts.example.com</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
</tbody>
</table>
</div>
</div>
</main>
<script src="shell.js"></script>
</body>
</html>
+10 -2
View File
@@ -86,6 +86,14 @@
<p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p> <p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p>
</header> </header>
<section>
<h2>Positioning</h2>
<div class="note"><b>Stamp subtitle:</b> SELF-HOSTED SMTP RELAY (unchanged on the mark).<br>
<b>Tagline:</b> YOUR SMTP. YOUR RULES.<br>
<b>Hero line (README, GitHub):</b> Self-hosted SMTP relay with a web panel — one Docker image, no provider.<br>
Canonical copy also lives in <code>docs/product.md</code> § Positioning.</div>
</section>
<section> <section>
<h2>Эталон</h2> <h2>Эталон</h2>
<div class="hero"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay"> <div class="hero"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
@@ -121,7 +129,7 @@
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text> <text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
</svg> </svg>
<h3>SelfPost</h3> <h3>SelfPost</h3>
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div> <p>Self-hosted SMTP relay with a web panel — one Docker image, no provider.</p></div>
</div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div> </div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div>
<div><div class="gh dk"> <div><div class="gh dk">
<div class="bc">mixeme / <b>selfpost</b></div> <div class="bc">mixeme / <b>selfpost</b></div>
@@ -135,7 +143,7 @@
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text> <text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="11.5" letter-spacing=".15" fill="#F6EDE2">SELF-HOSTED SMTP RELAY</text>
</svg> </svg>
<h3>SelfPost</h3> <h3>SelfPost</h3>
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div> <p>Self-hosted SMTP relay with a web panel — one Docker image, no provider.</p></div>
</div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div> </div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div>
</div> </div>
</section> </section>
+63
View File
@@ -64,6 +64,66 @@ obligation — not because the convention around AI authorship shifts.
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
mechanics → Haiku. Reviewers must not be the author of the code under review. mechanics → Haiku. Reviewers must not be the author of the code under review.
**Composer → Opus → Fable** (when a plan specifies it): **Composer** writes all
implementation for that item (code, tests, docs); **Opus** does the technical
review of the result; **Fable** does the security review. A plan's checklist
overrides the default routing table for that item only.
---
## Plan checklists
Open [roadmap.md](roadmap.md) items with a linked plan file carry an
**Implementation checklist** in [plans/](plans/). After a context reset, work
the **active** plan — not the roadmap prose — from the first unchecked line.
**Checklist format**
- `- [ ] Step description — **Model**` (model from the routing table above).
- Mark `[x]` only in the commit that actually finishes the step.
- `candidate` items: checklist may exist, but no code until the roadmap status
is agreed.
**Progress column** in the roadmap index (`0/N`) counts every checklist line in
that plan (including docs and `go vet` / `go test` steps). Update `N` when the
checklist changes; update the numerator when steps are checked off.
### Roadmap track: commit and version
For the current 1.x+ roadmap queue ([roadmap.md](roadmap.md) index), stricter
than the default «meaningful step» rule in [§ Commits and release build](#commits-and-release-build):
**After each checklist step**
1. Check off the step in the plan file; bump `Progress` in the roadmap index.
2. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]` for that step.
3. Commit on `main` (push / tag / Release only on explicit request).
**After each roadmap stage** (all checklist steps done; «Done when» satisfied)
1. Version cut in one commit: rename `[Unreleased]` to `[X.Y.Z] - date`, open a
fresh `[Unreleased]`, bump the image pin in
[deploy/docker-compose.yml](../deploy/docker-compose.yml).
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)).
**Planned version cuts** (from pin `1.9.1`; adjust if semver changes mid-track):
| Stage | ID | Cut |
|---|---|---|
| 1 | queue-retries | `1.3.1` (PATCH) |
| 2 | inbound-relay | `1.4.0` |
| 3 | send-log-retention | `1.5.0` |
| 4 | domain-stats-auto-ratelimit | `1.6.0` |
| 5 | dmarc-reports | `1.7.0` |
| 6 | panel-docs | `1.8.0` |
| 7 | application auth IPs | `1.9.0` |
| 8 | port-25 Postfix startup fix | `1.9.1` (PATCH) |
| 9 | inbound-antispam-panel | `1.10.0` (planned) |
Docs-only prep (checklists in plans, no product code) uses the same per-step
commit rule but **no** version cut until the next product stage ships.
--- ---
## Technology stack and tools ## Technology stack and tools
@@ -372,6 +432,7 @@ There is no `docs/archive/` directory.
| As-built design | [architecture.md](architecture.md) | | As-built design | [architecture.md](architecture.md) |
| Development process (this file) | [development.md](development.md) | | Development process (this file) | [development.md](development.md) |
| Security requirements and accepted risks | [security.md](security.md) | | Security requirements and accepted risks | [security.md](security.md) |
| SQLite schema and migrations | [schema-migrations.md](schema-migrations.md) |
| Roadmap (1.x+) | [roadmap.md](roadmap.md) | | Roadmap (1.x+) | [roadmap.md](roadmap.md) |
| Active design plans | [plans/](plans/) | | Active design plans | [plans/](plans/) |
| Release history | [CHANGELOG.md](../CHANGELOG.md) | | Release history | [CHANGELOG.md](../CHANGELOG.md) |
@@ -401,6 +462,8 @@ Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
fails on an undocumented `loadConfig` or build-script key. fails on an undocumented `loadConfig` or build-script key.
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not 3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
silent drive-by edits. silent drive-by edits.
4. **Schema changes:** update [schema-migrations.md](schema-migrations.md) in the
same change as a new `internal/store/migrations/*.sql` file.
### Verifying docs against code ### Verifying docs against code
+337 -84
View File
@@ -31,7 +31,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns) - [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop) - [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
- [Full backup and restore](#full-backup-and-restore) - [Full backup and restore](#full-backup-and-restore)
- [Taking a full backup](#taking-a-full-backup)
- [Restore](#restore)
- [Encrypting a backup or export](#encrypting-a-backup-or-export) - [Encrypting a backup or export](#encrypting-a-backup-or-export)
- [Inbound relay](#inbound-relay)
- [DMARC reports](#dmarc-reports)
- [Domain administration](#domain-administration) - [Domain administration](#domain-administration)
- [Domains page](#domains-page) - [Domains page](#domains-page)
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc) - [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
@@ -44,10 +48,14 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
### Ports ### Ports
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465 `deploy/docker-compose.yml` maps **465**, **587**, and **25** to the host. Port
(smtps) is always active. Port **587** is published even when 465 (smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless, `SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port
but it can look like an open port in external scans. **25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
listen on port 25 until you set **`INBOUND_RELAY_ENABLE=true`** and/or
**`DMARC_REPORTS_ENABLE=true`** (see [Inbound relay](#inbound-relay) and
[DMARC reports](#dmarc-reports)). Harmless extra publishes can look like open
ports in external scans.
### Local trial ### Local trial
@@ -165,7 +173,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.3.0`. Intermediate deliberately never `:latest`. The current pin is `1.9.1`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
@@ -185,9 +193,17 @@ expected to set; defaults match the code exactly.
|---|---|---|---| |---|---|---|---|
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` | | `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` | | `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *DMARC*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on. When inbound relay is also enabled, **`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`** applies to the shared listener instead. | `20` | `.env` |
| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.env` |
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound relay listener only (requires `INBOUND_RELAY_ENABLE=true`; not 465/587 or DMARC-only port 25). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` |
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` |
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). When both inbound relay and DMARC ingest are on, the shared listener uses the **greater** of this and `DMARC_MESSAGE_SIZE_LIMIT`. | `26214400` (25 MiB) | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` | | `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` | | `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` | | `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of `/data` growth over time. After the first panel start, change retention on **Settings** (global administrator); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` | | `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` | | `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` | | `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
@@ -298,6 +314,18 @@ Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
After sign-in the panel opens on **Status** — the place to answer "is the After sign-in the panel opens on **Status** — the place to answer "is the
service healthy and will mail be accepted?" service healthy and will mail be accepted?"
### In-panel help
The panel carries short operator notes so day-to-day work does not require
opening this guide. **Help** in the navigation opens an index; each Status or
domain card's «?» opens the same text in a side drawer (CSS only, no extra
script). That content explains what a check or control *means* — kernel
counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate
limits, and similar. It is not a second copy of this document: installation,
reverse-proxy setup, backup/restore, environment variables, and security
assumptions stay here in `docs/guide.md` (linked from the drawer and Help
page).
### Status ### Status
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS `/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
@@ -313,13 +341,20 @@ against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS) — see (forward-confirmed reverse DNS) — see
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing [Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
Postfix sender map from the database — use it if daemons drifted from what Postfix sender map from the database (and inbound relay maps when
the panel shows after manual edits under `/data`. `INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel
shows after manual edits under `/data`. It does **not** rebuild DMARC Postfix
maps; a full restore Resync does (see [Restore](#restore)).
### Mail queue and System log ### Mail queue and System log
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still - **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
trying to deliver or deferring. trying to deliver or deferring. A card at the top states this instance's
retry policy — first retry delay, later backoff cap, how long a message
stays in the queue — from `postconf -h`, read once when the panel starts.
A `postconf -e` override inside the container is visible after the next
panel (or container) restart. There is no maximum attempt count: Postfix
retries until the message is delivered or the queue lifetime runs out.
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and - **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
related daemon lines). The log rotates daily (14 files kept) with a related daemon lines). The log rotates daily (14 files kept) with a
`postfix reload` after each rotation; a background loop checks every six `postfix reload` after each rotation; a background loop checks every six
@@ -332,8 +367,10 @@ the panel shows after manual edits under `/data`.
`/settings` changes the signed-in user's username and/or password. **Global `/settings` changes the signed-in user's username and/or password. **Global
administrators** also set the panel-wide default DMARC report address (`rua=`) administrators** also set the panel-wide default DMARC report address (`rua=`)
offered when a domain doesn't set its own — see offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc) — and how many days
are separate and are not changed here. **Deliveries** rows are kept before the background sweep deletes them (7–365
days; takes effect on the next six-hour prune cycle without a container
restart). Application SASL logins are separate and are not changed here.
### Users ### Users
@@ -341,12 +378,13 @@ are separate and are not changed here.
There are two roles: There are two roles:
- **Global administrator** — full access to every page and every domain, - **Global administrator** — full access to every page and every domain,
including Users, Backup, Status, Mail queue, and System log. including Users, Backup, Status, Mail queue, System log, Help, Inbound (when
the inbound relay flag is on), and DMARC (when `DMARC_REPORTS_ENABLE=true`).
- **Domain-admin** — scoped to one or more domains assigned by a global - **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and administrator. Sees only those domains' pages, applications, and
Deliveries rows; cannot add or delete domains. `/users`, `/backup`, Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not `/status`, `/mail-queue`, `/system-log`, `/inbound`, and `POST /reload` are
reachable (404). A domain-admin can *export* the not reachable (404). A domain-admin can *export* the
domains assigned to them — see domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain). [Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
@@ -417,27 +455,115 @@ journal-milter (level 2) is down. There is no per-IP bypass.
### Full backup and restore ### Full backup and restore
**Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys, **Full backup** is a self-contained project archive: `data/` (SQLite, all
all applications' SASL credentials, `manifest.json` with the version that domains' DKIM keys, all applications' SASL credentials, the Postfix queue,
created it): panel button (*Backup* → *Full backup*), or from the host: `manifest.json` with the version that created it), plus `docker-compose.yml`,
`.env`, and `certs/` from the operator directory next to `./data`. Delivery
logs under `data/log/` are excluded. The base compose file mounts the project
directory read-only at `/selfpost-deploy` so the panel and CLI can read those
deploy files — without that mount, *Full backup* refuses with an error.
#### Taking a full backup
There are four ways to capture a full backup. They differ in whether the
instance keeps running, what goes into the archive, and what happens on restore.
**1. Panel — while the container is running (no downtime).** *Backup* → *Full
backup* (optionally tick *Encrypt with a password*). Same archive as the CLI
below.
**2. `selfpost-backup` CLI — while the container is running (no downtime).**
The panel button and this command call the same code path: a consistent SQLite
snapshot via `VACUUM INTO`, then a gzip tar of `data/` (minus diagnostics) plus
deploy files.
```sh ```sh
docker exec <container> selfpost-backup > selfpost-backup.tar.gz docker exec <container> selfpost-backup > selfpost-backup.tar.gz
``` ```
**Restore** means unpacking that archive into a fresh `/data` bind mount and Use `-o /path/inside/container` to write inside the container instead of
starting a container of the **exact same image version** that created it — stdout. Optional encryption: set `SELFPOST_BACKUP_PASSWORD` or pass
SelfPost refuses to start otherwise and tells you which tag to use. On the `-password-file` — see [Encrypting a backup or
first successful start after restore, `manifest.json` from the archive is export](#encrypting-a-backup-or-export).
**deleted** — it guards only that one boot, so a later in-place upgrade is
not blocked. On that same first boot the panel also runs one **Resync** — **3. `tar` of the whole project directory — with the service stopped.** Stop
OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and both the instance so nothing is writing to SQLite, then archive the operator
daemons are reloaded, healing any drift between the extracted files and the directory (the folder that contains `docker-compose.yml`, `.env`, `data/`, and
database (the Status page's *Reload configuration* button runs the same step usually `certs/`):
on demand). This is why the compose file pins a fixed tag rather than
`:latest`: without a known version, there'd be no way to tell which image ```sh
restoring a given backup actually requires (see [Fixed image docker compose down
tag](#fixed-image-tag)). tar czf ../selfpost-backup.tar.gz .
docker compose up -d
```
Postfix delivery logs under `data/log/` are included and are often the bulk of
the archive; add `--exclude='./data/log'` if you only want state. Unlike the
panel/CLI backup, this archive has no `data/manifest.json`, so restore does not
run the version guard or the one-time post-restore *Resync* (see
[Restore](#restore) below) — a normal `docker compose up` is enough when the
files were captured cleanly while stopped.
**4. `tar` of `./data` only — with the service stopped.** Same as (3), but only
the data volume:
```sh
docker compose down
tar czf ../selfpost-data.tar.gz ./data
docker compose up -d
```
This is **not** self-contained: you must keep `docker-compose.yml`, `.env`,
and `certs/` separately (another backup, or unchanged on the same host).
Restore is `tar xzf selfpost-data.tar.gz` into an existing project directory,
not into an empty one. Also includes `data/log/` unless you exclude it.
**Do not `tar` while the container is running.** SQLite uses WAL mode; copying
or archiving `./data` (or the whole project directory) while the panel and
Postfix are writing can produce an inconsistent database. The panel and CLI
backup avoid this by snapshotting the database on a live instance; the `tar`
methods avoid it by stopping first.
| | Panel / `selfpost-backup` | `tar` project dir (stopped) | `tar` `./data` only (stopped) |
|--|---------------------------|------------------------------|-------------------------------|
| Downtime | None | Yes (`docker compose down`) | Yes |
| Self-contained archive | Yes | Yes | No — deploy files separate |
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
| `data/manifest.json` | Written (version stamp) | Absent | Absent |
| Restore version check | Yes — must match image tag | No | No |
| Post-restore *Resync* | Yes, on first boot | No | No |
| Password encryption (`.spbk`) | Yes | No — encrypt the `.tar.gz` yourself if needed | No |
Prefer the panel or CLI when you cannot afford downtime. Prefer a stopped `tar`
of the whole project directory when you already plan maintenance and want a
plain archive without going through the container. Use `tar` of `./data` only
when deploy files are managed elsewhere and never change.
#### Restore
**Panel and CLI archives** (with `data/manifest.json`) restore by unpacking into
an **empty project directory** (not into `./data` alone) and starting a
container of the **exact same image version** that created the backup — SelfPost
refuses to start otherwise and tells you which tag to use. On the first
successful start after restore, `data/manifest.json` from the archive is
**deleted** — it guards only that one boot, so a later in-place upgrade is not
blocked. On that same first boot the panel also runs one **Resync** — OpenDKIM's
tables and Postfix's sender map are re-derived from SQLite (inbound relay maps
when `INBOUND_RELAY_ENABLE=true`; DMARC maps when `DMARC_REPORTS_ENABLE=true`)
and both daemons are reloaded, healing any drift between the extracted files and
the database. The Status page's *Reload configuration* button resyncs OpenDKIM,
the sender map, and inbound maps only — not DMARC maps. This is why the
compose file pins a fixed tag rather than `:latest`: without a known version,
there'd be no way to tell which image restoring a given backup actually requires
(see [Fixed image tag](#fixed-image-tag)).
**Stopped-`tar` archives** (methods 3 and 4 above) have no `manifest.json`, so
there is no version guard and no automatic post-restore *Resync*. Unpack a
self-contained project archive into an empty directory (or replace `./data` in
place for a data-only archive) and `docker compose up -d`. Use an image tag
compatible with the data on disk; when in doubt, match the tag that was running
when the archive was taken.
**Restoring in place** (same host — recovering from data loss, or rolling **Restoring in place** (same host — recovering from data loss, or rolling
back after a bad change): back after a bad change):
@@ -446,16 +572,16 @@ back after a bad change):
# 1. Stop the instance being replaced # 1. Stop the instance being replaced
docker compose down docker compose down
# 2. Move the current /data aside rather than deleting it, start from empty # 2. Move the current project aside rather than deleting it
mv ./data ./data.before-restore mv . ../selfpost.before-restore
mkdir ./data mkdir selfpost && cd selfpost
# 3. Unpack the backup into the fresh directory # 3. Unpack the backup into the fresh directory
tar xzf selfpost-backup.tar.gz -C ./data tar xzf ../selfpost-backup.tar.gz
# 4. docker-compose.yml must already pin the exact tag the backup was made # 4. docker-compose.yml in the archive must pin the exact tag the backup was
# with — check the archive's manifest if unsure: # made with — check if unsure:
tar xzf selfpost-backup.tar.gz -O manifest.json tar xzf ../selfpost-backup.tar.gz -O data/manifest.json
# 5. Start it and watch the boot # 5. Start it and watch the boot
docker compose up -d docker compose up -d
@@ -466,35 +592,38 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
the panel exits with a message naming the tag to use, e.g.: the panel exits with a message naming the tag to use, e.g.:
``` ```
backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.0 — restore into the matching image (selfpost:1.2.3) backup: this backup was created by SelfPost 1.2.3 but this image is 1.5.0 — restore into the matching image (selfpost:1.2.3)
``` ```
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
-d` again — the manifest is still there because the failed boot never got to -d` again — the manifest is still there because the failed boot never got to
delete it. delete it.
**Moving to a different host** is the same five steps, just starting cold: **Moving to a different host** is the same flow: create an empty project
bring the compose files and the correct pinned image tag to the new host directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if
(step 1 of [Full deployment](#full-deployment)), put the backup archive in needed) for the new hostname or proxy, then `docker compose up -d`. The archive
place of step 3 above, then redo the reverse-proxy/TLS and DNS steps of a carries `certs/` from the old host — re-issue certificates when the hostname or
[Full deployment](#full-deployment) — the PTR record and the certificate both IP changes. Set up the reverse-proxy vhost separately (not in the backup).
belong to the old IP/host and have to be reissued for the new one; nothing in
the backup carries them.
**Restoring an encrypted (`.spbk`) backup** needs a running container to **Restoring an encrypted (`.spbk`) backup** needs a running container to
decrypt it first — any container with the `selfpost-backup` CLI works; decryption decrypt it first — any container with the `selfpost-backup` CLI works; decryption
does not read `/data` and performs no version check. Start one normally does not read `/data` and performs no version check. Start one normally
(step 5, but on an empty `/data` you haven't unpacked yet), then: (step 5, but on an empty project you have not unpacked yet), then:
```sh ```sh
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
``` ```
Stop it, wipe `/data` again, and continue from step 2 above with the Stop it, wipe the project directory again, and continue from step 2 above with
resulting `.tar.gz` — see [Encrypting a backup or the resulting `.tar.gz` — see [Encrypting a backup or
export](#encrypting-a-backup-or-export) for the decrypt command's password export](#encrypting-a-backup-or-export) for the decrypt command's password
options. options.
**Archives from older SelfPost versions** (flat layout: `manifest.json` and
`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore
with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project
that already has `docker-compose.yml` and `.env`.
Restoring an archive taken **before** a session row was removed can bring Restoring an archive taken **before** a session row was removed can bring
that session back: session rows travel with the backup, and a browser that that session back: session rows travel with the backup, and a browser that
still holds the matching cookie is signed in again on the next request if the still holds the matching cookie is signed in again on the next request if the
@@ -504,25 +633,14 @@ on `/settings` deletes your other sessions, but a global administrator
resetting another user's password on `/users` does not invalidate that user's resetting another user's password on `/users` does not invalidate that user's
existing sessions. existing sessions.
**Alternative: archive `./data` while stopped.** If the service can be taken
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
bulk of the archive; add `--exclude=./data/log` if you only want the state.
Do **not** tar `./data` while the container is running: the database uses
WAL mode and a naive copy can capture an inconsistent snapshot. The
panel/CLI backup remains preferable when you cannot afford downtime because
it takes a consistent SQLite snapshot via the Backup API on a live
container.
See also [Exporting and importing a single See also [Exporting and importing a single
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
operation that also lives on the *Backup* page (`/backup`). operation that also lives on the *Backup* page (`/backup`).
Both a full backup and a domain export are **secrets** — they contain the Both a full backup and a domain export are **secrets** — they contain the
admin password hash (full backup) or working application credentials (domain admin password hash (full backup), TLS private keys and `.env` (full backup),
export) in the clear or in directly reversible form. Treat them like any or working application credentials (domain export) in the clear or in
other credential material: restrict who can read them, don't email them directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them
around — and encrypt them, which SelfPost can do for you. around — and encrypt them, which SelfPost can do for you.
#### Encrypting a backup or export #### Encrypting a backup or export
@@ -565,6 +683,110 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > back
With no password set, the CLI keeps writing the plain `.tar.gz` it always has. With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
### Inbound relay
Optional backup-MX / forwarder: Postfix accepts mail on port **25** for
domains you list under *Inbound* and hands each message to the upstream host
you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never
stores the message locally.
**Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the
container. Until then there is no inbound-relay `smtp inet` listener, no
*Inbound* item in the nav, and `/inbound` is 404. (Port 25 can still listen
when only `DMARC_REPORTS_ENABLE=true` — see [DMARC reports](#dmarc-reports).)
Outbound 465/587 is unchanged.
**Panel** (`/inbound`, global administrator only): add a domain, set the
upstream host/port and TLS to that hop (opportunistic / required / off), and
choose recipients — an allow-list, or any address at that domain. A domain
with an empty upstream is kept in the database but is **not** published into
Postfix maps, so mail is never accepted with nowhere to send it.
**DNS.** Unlike sending domains, an inbound domain needs an **MX** record that
points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish
(`10 <hostname>.`) and a check that succeeds when *any* MX host matches this
server — other MX targets (a primary mail server) are fine; this is how
backup-MX is meant to work. Use *Re-check* after publishing.
**Not an open relay.** The inbound smtpd offers no SASL. It accepts only
domains in `relay_domains` and only listed recipients (`relay_recipient_maps`);
everything else is `reject_unauth_destination` / `reject_unlisted_recipient`.
Prefer an explicit recipient list so unknown addresses are refused at RCPT
and never generate a bounce (backscatter).
**Anti-spam.** SelfPost does not ship a filter. To attach one, set
`INBOUND_ANTISPAM_MILTER` (requires inbound relay enabled; not on DMARC-only
port 25) and merge
[deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml)
the same way as the nginx/Caddy fragments:
```sh
docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
```
The milter sees the real client IP, HELO and PTR — unlike the upstream, which
only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
defer instead.
**Inbound mail is not in Deliveries.** Port 25 runs its own milter chain: the
optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed
mail (and a signing outage therefore cannot defer it), and the journal-milter
does not file it — [Deliveries](#deliveries) stays a record of what *you* sent.
This also keeps a forged `From:` on inbound mail from counting against a
sending domain's [level-2 rate limit](#rate-limiting--level-2-domain-and-application).
Inbound configuration lives in SQLite and `/data/postfix/` map files, so it
is included in a [full backup](#full-backup-and-restore). Single-domain
export/import is sending domains only.
### DMARC reports
Optional ingest of DMARC **aggregate** reports (`rua=`): Postfix accepts mail on
port **25** only for addresses you configure, pipes each message to the panel
ingest worker, and stores parsed summaries in SQLite. Forensic reports (`ruf=`)
are not stored. This is separate from [Inbound relay](#inbound-relay) — no
backup-MX, no forwarding upstream.
**Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the
container. Until then there is no report ingest, no *DMARC* item in the nav,
and `/dmarc` is 404. Outbound 465/587 is unchanged.
**Addresses.** In *Settings* (global administrator), set the default
`rua=` mailbox to an address on `SELFPOST_HOSTNAME`, e.g.
`dmarc-reports@mail.example.com`. Per domain you can choose **SelfPost hosted**
(`dmarc-reports+<domain>@<hostname>`) under *Domain settings → DMARC reports*.
Only those allow-listed addresses are accepted on port 25.
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
`rua=mailto:…` pointing at your hosted address. Receivers deliver to the
address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report
address domain if different) so reports reach this server. If a hub domain
authorises external destinations, publish `_report._dmarc` there too; the panel
checks it on the domain page.
**Panel.** *DMARC* (global administrator) lists recent reports and ingest
health. Open a domain's roll-up from the list or from *View DMARC reports* on
the domain page. Domain administrators see reports only for domains assigned to
them. Summaries are pruned (500 kept, 90 days max).
**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest
alone, `check_recipient_access` permits only configured report addresses;
everything else is rejected. With inbound relay enabled too, both allow-lists
apply.
**Reports are checked before they are stored.** The address form is
predictable, so the report XML is treated as untrusted input: a report is
accepted only when the domain it claims in `policy_published` is a sending
domain configured here, and — when it arrived at a per-domain hosted address —
only when that domain matches the address's `+tag`. Anything else is refused
and counted in *parse failures* on the DMARC page, so a stranger cannot file
reports under your domain or push genuine ones out through the retention cap.
Reports are accepted as gzip, zip, or plain XML.
Parsed report data lives in SQLite and is included in a
[full backup](#full-backup-and-restore).
## Domain administration ## Domain administration
### Domains page ### Domains page
@@ -572,8 +794,8 @@ With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
`/domains` lists sending domains and hosts the add-domain form (**global `/domains` lists sending domains and hosts the add-domain form (**global
administrator only**). Domain administrators see only domains assigned to administrator only**). Domain administrators see only domains assigned to
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
applications. Per-domain rate limits (level 2) and trusted-IP application applications. Per-domain rate limits (level 2), per-application limits, and
overrides are configured here — see [Rate limiting — optional client IP allow-lists are configured here — see [Rate limiting —
level 2](#rate-limiting--level-2-domain-and-application). *Export domain* level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
writes a single-domain archive; *Import a domain* on the Backup page reads writes a single-domain archive; *Import a domain* on the Backup page reads
one back in (**global administrator only**) — see [Exporting and importing a single one back in (**global administrator only**) — see [Exporting and importing a single
@@ -581,17 +803,19 @@ domain](#exporting-and-importing-a-single-domain).
### Domain-level DNS (SPF, DKIM, DMARC) ### Domain-level DNS (SPF, DKIM, DMARC)
For *every* sending domain you add in the panel: For *every* sending domain you add in the panel (outbound). An inbound
forwarding domain is a different object — it needs an MX, not these TXT
records; see [Inbound relay](#inbound-relay).
- **SPF** — a TXT record on the domain authorizing this server to send on its - **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup). behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
- **DKIM** — a TXT record with the exact value the panel shows on that - **DKIM** — a TXT record with the exact value the panel shows on that
domain's page (`domain page → DKIM TXT record`), one selector per domain. domain's page (`domain page → DKIM TXT record`), one selector per domain.
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring - **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
only, safe to publish immediately). On a send-only relay the sending domain only, safe to publish immediately). Set `rua=` to receive aggregate reports:
often has no inbox, so `rua=` is optional — configure a default report address with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address
in *Settings* (see [Settings](#settings)) or per domain when you have a from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a
mailbox that receives inbound mail elsewhere. If `rua=` points at another mailbox elsewhere that receives inbound mail. If `rua=` points at another
domain, publish `_report._dmarc` on that hub domain too; the panel checks domain, publish `_report._dmarc` on that hub domain too; the panel checks
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
report destinations. report destinations.
@@ -635,18 +859,42 @@ returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
sending as that domain. When unset, only level 1 applies for non-privileged sending as that domain. When unset, only level 1 applies for non-privileged
senders. senders.
**Level 2 — application (trusted IPs)** — optional override on an **Client IP allow-list (application)** — optional restriction on an
application: list one or more client IPs and a ceiling **strictly above** application: when enabled, list one or more client IPs that may **submit** mail
the domain limit (still ≤ level 1). Connections from those IPs use the as that application; a session from any other address is refused with a 4xx at
application ceiling and skip the domain check. Other IPs stay under the `MAIL FROM`. When disabled, any client IP is allowed. This is independent of
domain limit (or level 1 alone). An application override without trusted rate limits, and it takes exact addresses only — CIDR ranges are not parsed.
IPs is inactive.
It is **not an authentication boundary.** The SASL login itself still succeeds;
the check runs in the journal-milter, on the same fail-open path as level 2
(below), so if the milter is unavailable the restriction is skipped rather than
enforced. Use it to narrow where an application may send from, not to contain a
leaked password — for that, regenerate the password.
**Level 2 — application** — optional override of the domain limit for one
application (≤ level 1). The ceiling may be **higher or lower** than the domain
limit. When active, it replaces the domain limit for that login; when unset,
the domain limit (or level 1 alone) applies.
**Manual and auto mode** — each domain and application limit can be
**Manual** (you set the ceiling and window) or **Auto**. Auto derives
`max_messages` from sending statistics: `ceil(average msg/h × multiplier)`
over the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`), capped at level 1.
The panel shows 30-day statistics (total, peak and average msg/h) on the
domain page; level-1 refusals are not in the send log, so totals
under-count strict IP limits. When retention is below 30 days, statistics
use `min(30, retention)` days. With no traffic in the window, auto stays
inactive until messages are sent. Auto limits are recalculated every six
hours and on demand via **Recalculate now**.
**Level 2 is best-effort, not a guarantee.** It runs inside the **Level 2 is best-effort, not a guarantee.** It runs inside the
journal-milter and is deliberately fail-open: if the rate-limit lookup hits journal-milter and is deliberately fail-open: if the rate-limit lookup hits
a store error, or the connecting client's IP is not available to the a store error, or the connecting client's IP is not available to the
milter, level 2 is skipped and the message is accepted rather than held up. milter, level 2 is skipped and the message is accepted rather than held up.
Level 1 is the backstop that keeps working even when level 2 cannot run. The same applies if the milter itself is down — Postfix is configured to
accept rather than defer when it cannot reach it. Level 1 is the backstop
that keeps working even when level 2 cannot run, and it is the only one of
the two that does not depend on the panel process.
### Deliveries ### Deliveries
@@ -659,20 +907,25 @@ by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
*Details* opens that row's own page (`/deliveries/{id}`). That page carries *Details* opens that row's own page (`/deliveries/{id}`). That page carries
the sending domain, the application it was submitted under, the Postfix the sending domain, the application it was submitted under, the Postfix
queue id and the journal id, beside the message's history — when it was queue id and the journal id, beside the message's history — when it was
accepted and what Postfix later reported for the recipient — and, under accepted and what Postfix later reported for the recipient. A `deferred`
both, the `mail.log` lines for its queue id: the connection to the or `bounced` row includes this Postfix's retry intervals (first delay,
backoff cap, queue lifetime), the same numbers Mail queue shows; domain
administrators see them here because they cannot open Mail queue. Under
both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed receiving server, the server's reply, and the status that reply was filed
as. Rows outlive `mail.log`, so an older message's lines may have rotated as. Rows outlive `mail.log`, so an older message's lines may have rotated
away; the page says so. Retention is controlled by away; the page says so. Retention is set on **Settings** (global
`SEND_LOG_RETENTION_DAYS`. administrator); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial
default until it is changed there.
### Exporting and importing a single domain ### Exporting and importing a single domain
Domain page → *Export domain* to write the file, *Backup* → *Import a Domain page → *Export domain* to write the file, *Backup* → *Import a
domain* to read it back in. This moves one domain — its DKIM key and its domain* to read it back in. This moves one domain — its DKIM key, its
applications' **working** SASL passwords — to a different SelfPost instance applications' **working** SASL passwords, and configured **rate limits**
without regenerating anything, so DNS (the DKIM TXT record) doesn't need to (mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost
change. Unlike a full restore (see [Full backup and instance without regenerating anything, so DNS (the DKIM TXT record)
doesn't need to change. Unlike a full restore (see [Full backup and
restore](#full-backup-and-restore)), this works across different restore](#full-backup-and-restore)), this works across different
hostnames/instances. *Import* is global-administrator only; *export* is hostnames/instances. *Import* is global-administrator only; *export* is
available to any user who can access the domain, **including a domain-admin** available to any user who can access the domain, **including a domain-admin**
-50
View File
@@ -1,50 +0,0 @@
# Plan: dmarc-reports
**Status:** candidate
---
## Goal
SelfPost **receives** DMARC aggregate reports on SMTP, parses them inside the
image, and **shows summaries in the panel** — pass/fail by source, hints when
`tighten p=` is reasonable. No external DMARC SaaS and no IMAP workflow for the
operator.
## Scope
**In:**
- Inbound SMTP for configured report addresses only (not a general backup-MX).
- gzip + XML aggregate parsing → SQLite summaries per sending domain.
- Panel page and/or per-domain section: recent reports, third-party senders,
delivery health of report ingestion.
- Reuse the `dmarc_report_email` setting (moved off the old `admin` table into
`settings` by migration `0005`) and `domains.dmarc_rua` for DNS templates;
when enabled, suggest a SelfPost-hosted report address.
**Out:**
- Forensic reports (`ruf=`).
- Full dashboards, APIs, email alerting.
- Mailboxes for people (IMAP/POP3/webmail).
## Architecture (sketch)
1. Receiving MTAs → SMTP to SelfPost (hub MX).
2. Postfix virtual alias or dedicated listener → panel ingest worker.
3. Parse XML → `dmarc_reports` table (domain, reporter, counts, date).
4. Panel reads SQLite; links from domain DNS card.
May share port-25 plumbing with [inbound-relay.md](inbound-relay.md) but must
remain a separate, opt-in feature that does not forward mail upstream.
## Done when
- Operator can point `rua=` at an address SelfPost accepts and see parsed
summaries in the panel within one reporting cycle.
- With the feature off, outbound-only behaviour is unchanged.
- Documented in [guide.md](../guide.md); migrations are backward-compatible.
## Risks
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
- Report volume and retention (mitigate: caps + pruning).
+253
View File
@@ -0,0 +1,253 @@
# Plan: inbound-antispam-panel
**Status:** agreed
**Date:** 2026-08-19
**Version:** `1.10.0` MINOR (opt-in; no change to outbound-only or inbound-without-filter paths).
---
## Goal
Give the operator a **panel view of inbound anti-spam decisions** and **editable
allow/deny lists** that tune filter behaviour — without SelfPost shipping or
starting an anti-spam engine ([product.md](../product.md) out-of-scope list
unchanged).
Typical journal row:
| When | From | To | Subject | Decision |
|------|------|----|---------|----------|
| 2026-08-19 09:14 | `spammer@evil.example` | `user@inbound.example` | Cheap pills | reject (rspamd: BAYES_SPAM +0.95) |
Lists let the operator correct false positives/negatives (e.g. always allow
`billing@vendor.example`, block `192.0.2.0/24`).
## Context (as-built)
- Inbound relay ships in `[1.4.0]` (`INBOUND_RELAY_ENABLE`); optional
`INBOUND_ANTISPAM_MILTER` attaches an **external** filter (documented rspamd
sidecar in [deploy/antispam/docker-compose.antispam.yml](../../deploy/antispam/docker-compose.antispam.yml)).
- The inbound smtpd has **no journal-milter** today — only outbound 465/587
writes `send_log` ([architecture.md](../architecture.md)).
- Inbound UI and handlers are **global-administrator only** today
(`requireInbound` → `requireGlobal`).
## Quarantine
**Not in this plan.** Held-mail review/release is tracked separately as roadmap
candidate [inbound-quarantine](inbound-quarantine.md). The journal may still
record a `quarantine` **decision label** when the external filter reports it,
without SelfPost storing the message.
**DMARC `p=quarantine`** remains unrelated (outbound policy for receivers).
## Decisions (closed 2026-08-19)
### Capture mechanism
**Inbound journal-milter in `panel` only** — no rspamd history API polling in v1.
A separate unix socket (`INBOUND_JOURNAL_MILTER_SOCKET`, default
`/run/selfpost/inbound-journal.sock`) on the inbound smtpd only; fail-open
(`default_action=accept`).
**Milter order on port 25** (when antispam is enabled):
```text
smtpd_milters = { antispam, inbound-journal }
```
Antispam runs first; inbound-journal runs at end-of-message on the **accept**
path and reads rspamd-added headers (`X-Rspamd-Action`, `X-Rspamd-Score`, symbol
summary when present).
**Antispam rejects** do not reach end-of-message on the journal milter. v1 also
tails `mail.log` for inbound `postfix/smtpd` **`milter-reject`** / **`reject`**
lines and inserts journal rows (client IP, envelope from/to when logged, reason
text, subject empty if rejected before DATA). DMARC-ingest pipe traffic is out
of scope for this tailer.
### Journal without `INBOUND_ANTISPAM_MILTER`
Journal stays **on** whenever `INBOUND_RELAY_ENABLE=true` (not tied to the
antispam hook). Without an external filter:
- **accept** rows — message relayed (engine `selfpost`, detail empty);
- **reject** / **tempfail** rows — from the mail.log tailer (Postfix policy,
size limit, unknown recipient, etc.).
Panel copy explains that rspamd symbols appear only when the antispam milter is
configured.
### RBAC
**Global administrator only** for journal and lists — same as inbound relay. No
domain-admin access in v1.
### Retention
Mirror send-log retention:
- Settings key `inbound_spam_log_retention_days` on `/settings` (global admin).
- Default **90** days; validation **7–365**; seed from env
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
- Prune every **6 hours** (same cadence as send-log retention).
- Hard cap **10 000** rows after age prune (drop oldest) — busier than DMARC
reports, lighter than unbounded growth.
### Allow/deny lists
| Topic | Decision |
|-------|----------|
| Entry types | sender address, sender domain (`@domain` suffix), client IP / CIDR |
| Precedence | **deny overrides allow** |
| Scope | instance-wide (not per inbound domain in v1) |
| Where applied | **rspamd map files** under `/data/antispam/` (allow.map, deny.map); documented compose fragment mounts them into the sidecar |
| SelfPost pre-check | **out of v1** — lists live in rspamd only |
| Edit access | global administrator only |
| When inactive | lists editable in the panel but UI warns that sync applies only when `INBOUND_ANTISPAM_MILTER` is set; `postfix reload` / rspamd reload documented in guide |
List change → atomic map rewrite (same pattern as Postfix maps) → rspamd reload
via documented operator step or sidecar `SIGHUP` in the compose fragment.
### Activation
| Condition | Behaviour |
|-----------|-----------|
| `INBOUND_RELAY_ENABLE=false` | no inbound journal milter, no UI, no tables written |
| inbound on, antispam empty | journal + UI; lists visible but marked inactive for filter sync |
| inbound on + antispam set | full journal (symbols on accept) + list sync |
No separate feature flag beyond inbound relay + existing antispam env vars.
### UI
New nav item **Inbound spam** (or subsection under **Inbound**) — journal table
with filters (decision, domain, date range), list management on the same page or
a tab. Mockup: add `docs/assets/panel-ui/inbound_spam.html` in the panel step.
## Schema (migration `0011_inbound_spam_log.sql`)
**`inbound_spam_log`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `inbound_domain` | TEXT NOT NULL | recipient domain (from `relay_domains`) |
| `client_ip` | TEXT NOT NULL | |
| `from_addr` | TEXT NOT NULL | envelope from |
| `to_addr` | TEXT NOT NULL | envelope to (one row per recipient) |
| `subject` | TEXT NOT NULL | truncated with `mailhdr.SubjectMaxRunes` (200) |
| `decision` | TEXT NOT NULL | `accept`, `reject`, `tempfail`, `quarantine` |
| `engine` | TEXT NOT NULL | `rspamd`, `postfix`, `selfpost` |
| `detail` | TEXT NOT NULL | symbol/score summary or log-line reason; may be empty |
| `created_at` | TEXT NOT NULL | RFC3339 UTC |
Index on `(created_at)`, `(inbound_domain, created_at)`.
**`inbound_spam_list`**
| Column | Type | Notes |
|--------|------|-------|
| `id` | INTEGER PK | |
| `list_type` | TEXT NOT NULL | `allow` or `deny` |
| `entry_type` | TEXT NOT NULL | `address`, `domain`, `ip` |
| `value` | TEXT NOT NULL | normalized entry |
| `note` | TEXT NOT NULL | operator comment; default `''` |
| `created_at` | TEXT NOT NULL | |
Unique on `(list_type, entry_type, value)`.
## Scope
**In:**
- Schema, store CRUD, retention loop, mail.log tailer extension for inbound
rejects.
- Inbound journal-milter (fail-open) + Postfix `postfix-config.sh` wiring.
- rspamd map sync under `/data/antispam/`.
- Panel journal + list UI (global admin).
- Full backup includes `/data/antispam/` and new SQLite tables.
- Tests; [guide.md](../guide.md); [architecture.md](../architecture.md).
**Out:**
- Shipping rspamd, ClamAV, or any filter binary inside the SelfPost image.
- Outbound spam filtering.
- [inbound-quarantine.md](inbound-quarantine.md) storage/release.
- rspamd rule editing, Bayes training UI, antivirus.
- MIME replay from the panel (metadata only).
- Per-domain lists; domain-admin RBAC.
- rspamd history API polling.
## Architecture
```mermaid
flowchart LR
internet["Internet :25"] --> inbound["Postfix inbound smtpd"]
inbound --> antispam["External antispam milter optional"]
antispam --> journalM["inbound-journal milter fail-open"]
journalM --> upstream["smtp upstream transport"]
journalM --> sqlite["inbound_spam_log"]
mailog["mail.log tailer"] --> sqlite
listsUI["Panel allow/deny lists"] --> maps["/data/antispam/*.map"]
maps --> antispam
sqlite --> journalUI["Panel inbound spam journal"]
```
## Security
- Same validation whitelists as inbound relay ([security.md](../security.md)).
- Journal rows contain PII — retention and global-admin-only access.
- Inbound journal milter fail-open must not block mail (backup-MX role).
- List map writes injection-safe; deny wins over allow.
## Done when
- With inbound relay + antispam sidecar, panel shows accept rows with symbols
and reject rows from mail.log tailing.
- Operator can add/remove list entries; a test message reflects allow/deny.
- With antispam hook off, journal still shows Postfix-level decisions; list UI
shows inactive sync notice.
- `go vet`, `go test`, image build green; guide and architecture updated.
## Risks
- rspamd header/symbol format drift — thin parser, pin sidecar tag in compose.
- Journal volume — retention + 10k cap.
- mail.log parse brittleness — unit tests on sample lines; inbound smtpd only.
## Dependencies
- Inbound relay, shipped in `[1.4.0]` — as built in
[architecture.md](../architecture.md) § Mail path. Its plan file was removed
once it shipped.
## Model routing (this plan)
| Role | Model |
|------|-------|
| All implementation (code, tests, docs) | **Composer** |
| Technical review (reviewer ≠ author) | **Opus** |
| Security review | **Fable** |
Composer ships each step; Opus reviews the accumulated diff once implementation
steps are done (fix loop: Composer addresses Opus findings, Opus re-checks).
Fable runs after Opus sign-off.
## Implementation checklist
Target version cut: **`1.10.0`** (MINOR). One commit per step;
[development.md](../development.md) § Plan checklists.
- [x] Agree journal fields, retention, and RBAC (this plan § Decisions) — **Composer**
- [ ] Migration `0011_inbound_spam_log.sql` — **Composer**
- [ ] Inbound journal-milter + Postfix wiring — **Composer**
- [ ] mail.log tailer: inbound reject rows — **Composer**
- [ ] List CRUD + validation + atomic rspamd map sync — **Composer**
- [ ] Panel: journal + lists UI (+ mockup) — **Composer**
- [ ] Backup includes `/data/antispam/` — **Composer**
- [ ] Unit + handler tests — **Composer**
- [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer**
- [ ] `go vet`, `go test`, e2e if applicable — **Composer**
- [ ] Technical review of inbound antispam changes — **Opus**
- [ ] Security review inbound antispam path — **Fable**
+74
View File
@@ -0,0 +1,74 @@
# Plan: inbound-quarantine
**Status:** candidate — design TBD; needs explicit agreement before any code
**Version:** TBD (`1.x` MINOR if opt-in and backward-compatible; may warrant `2.x`
if it changes inbound storage or backup contract).
---
## Goal (draft)
Let the operator **hold suspicious inbound mail** instead of only accept/reject
at the milter — review in the panel, then release to the configured upstream,
delete, or add sender to allow/deny lists.
This is a **product boundary question**, not a settled design. The item exists
so we can compare approaches before committing.
## Why it is separate from inbound-antispam-panel
[inbound-antispam-panel.md](inbound-antispam-panel.md) covers **metadata**
(journal + lists). Quarantine implies **storing message bodies** (or a durable
reference to them) under `/data`, retention, release workflow, and a wider
attack/backup surface — closer to "mini-mailbox" than pure relay.
## Open questions (to decide before a checklist)
1. **Where mail lives**
- SelfPost store under `/data/quarantine/` (panel owns lifecycle), or
- delegate to rspamd/Redis and panel only proxies release (less storage in
SelfPost, tighter rspamd coupling).
2. **What "release" means**
- inject into Postfix as a new delivery to the domain's upstream transport;
- or manual download only (no automatic forward).
3. **Retention and caps**
- max age, max total bytes, per-domain limits; interaction with backup size.
4. **RBAC**
- global admin only vs domain-admin for assigned inbound domains.
5. **Threat model**
- malware in stored MIME, path traversal on extract, quota DoS on busy MX.
6. **Product fit**
- does this violate [product.md](../product.md) "no mailboxes" if we only
hold spam suspects briefly? Explicit product decision required.
## Context (as-built)
- Inbound relay forwards or rejects; no local delivery
([architecture.md](../architecture.md) § Mail path).
- Optional `INBOUND_ANTISPAM_MILTER` — external rspamd may quarantine on its
own today, without panel integration.
- DMARC `p=quarantine` is unrelated (outbound policy for receivers).
## Likely out of scope (until revisited)
- IMAP/webmail for quarantined mail.
- End-user self-service quarantine (non-admin recipients).
- Outbound quarantine.
## Dependencies
- Inbound relay, shipped in `[1.4.0]` — as built in
[architecture.md](../architecture.md) § Mail path.
- Sensible to decide **after** or **alongside**
[inbound-antispam-panel.md](inbound-antispam-panel.md) (journal/lists), but
not blocked on it for design discussion.
## Done when (placeholder)
TBD once approach is chosen. Minimum bar would include: opt-in flag, panel
list/detail, release or discard action, retention, backup inclusion documented,
security review.
## Implementation checklist
None yet — expand this plan after the open questions above are answered.
-171
View File
@@ -1,171 +0,0 @@
# Plan: inbound-relay (inbound relay)
**Status:** agreed
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
the implementation lands (do not fix a major in advance).
**Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG
`[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
The domain-admin role
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
`[1.2.0]`.
---
## Goal
The ability to accept mail on port 25 for explicitly configured domains and
forward it to a given upstream backend (a backup-MX / relay-forwarder role), as
a **module disabled by default** that changes neither the behaviour nor the
attack surface of the base outbound relay.
## What it is for (scenarios)
- **Backup-MX** — accept mail while the domain's primary mail server is
temporarily unreachable, and hand it over when it comes back.
- **A front for a server without a public IP** — the operator runs their own
mail server which, for whatever reason, **cannot accept mail from the
internet itself** (no static or public IP, behind NAT, a private address,
inbound port 25 blocked, and so on). SelfPost, with a public IP and a correct
PTR, acts as the domain's public entry node (the MX points at it) and
forwards mail to that internal or otherwise unreachable server.
## Scope boundary (critical — what this is NOT)
- **IT IS:** acceptance on 25 for domains from an explicit list, plus
forwarding (relay/forward) to an upstream (`relay_domains` +
`transport_maps` + `relay_recipient_maps`). Postfix here is a pure forwarder,
with no local delivery.
- **IT IS NOT (out of scope, [product.md](../product.md)):** local delivery to
mailboxes, IMAP/POP3, webmail, Dovecot. No mailboxes at all. SelfPost also
**neither implements nor bundles** an anti-spam or anti-virus engine
(rspamd/ClamAV) — but, unlike the earlier wording, it does **not** push
filtering onto the backend either (see the "Anti-spam" section below): it
provides an attachment point for an external filter.
## Why as an option / plugin
- Accepting on port 25 changes the threat model (open relay for inbound,
backscatter, spam ingress). So it is **off** by default behind the
`INBOUND_RELAY_ENABLE=false` env flag; turning it on is a deliberate step by
the operator.
- Isolation: separate SQLite tables, separate panel handlers and pages, a
separate branch of config generation. With the flag off, the inbound
listener, the tables and the UI are absent — the base outbound path is
byte-for-byte unchanged.
## What to do
- The `INBOUND_RELAY_ENABLE` env flag (default false); when `true`, generate
the inbound service and its config from panel state the same way the rest of
the config is generated (`postfix-config.sh`).
- **`master.cf`:** an inbound `smtp inet` on 25 for accepting from the internet
(today 25 is used only for outbound delivery). Separate from 465/587: on 25
SASL is **not** offered and sending outwards is **not** allowed — inbound
only, for `relay_domains`.
- **Anti-open-relay for inbound (mandatory):** the inbound smtpd's
`smtpd_relay_restrictions` / `smtpd_recipient_restrictions` accept mail
**only** for domains in `relay_domains` and **only** for known recipients
(`relay_recipient_maps`); everything else gets
`reject_unauth_destination` / `reject_unlisted_recipient`. An open relay, or
accepting "for anyone", is impossible.
- **Backscatter:** knowing the valid recipients is preferable (reject unknown
recipient at RCPT stage) so that bounces to non-existent addresses are never
generated.
- **The panel manages:** the list of inbound domains; for each one the upstream
destination (`host:port`, transport), an optional list of valid recipients,
and optional TLS to the upstream. Strict validation of domain, host and port
(whitelist), injection-safe writing of map files (as with
`sender_login_maps` in Phase 4), `os/exec` without a shell
([security.md](../security.md)).
- **Milters:** OpenDKIM is not needed on the inbound path (we do not sign
someone else's inbound mail). The journal-milter can optionally be reused for
an inbound journal (extra work), or the inbound path can go without it in the
first stage; fail-open behaviour is preserved.
- **Rate limit / size:** a coarse per-client-IP limit (`anvil`, as L1) and
`message_size_limit` on the inbound smtpd.
## Anti-spam (important, but optional)
This is a valuable option, but it is **not mandatory**: some operators will be
perfectly served by **blind forwarding without filtering** — when the backend
can filter on content itself, when the upstream is trusted, or when the volume
and risk are low. So the anti-spam hook is **off** by default (an empty
`INBOUND_ANTISPAM_MILTER`), and the inbound relay is fully functional without
it.
What matters is something else: where filtering is technically possible. With a
"blind" relay the destination backend sees **SelfPost's** address as the
connecting IP, not the original sender's, so everything on the backend that
depends on the origin IP breaks (DNSBL and reputation are checked against
SelfPost's IP; SPF returns fail, since SelfPost is not in the sending domain's
SPF). **The only point where the real client IP is still visible is the inbound
hop at SelfPost** — so for those who need filtering, it has to be *attachable
right here*, not delegated to a backend that has already lost the information.
The attachment design:
- **The anti-spam engine is a separate optional container** (rspamd or
similar), which the operator runs **only if this option is wanted** (the same
principle as the reverse proxy — a separate container outside the SelfPost
image). SelfPost **neither contains nor starts it** — the image and the "one
container, three processes" principle are unchanged, and
[product.md](../product.md)'s out-of-scope list is not violated (SelfPost
does not implement anti-spam).
- **SelfPost provides the attachment point:** a milter hook on the inbound
smtpd. The engine's address is set via env (for example,
`INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, empty → the hook is off) and
is added to `smtpd_milters` for the **inbound path only** (not on 465/587).
Postfix passes the milter the real client IP, HELO and PTR — the filter sees
the true origin. `milter_default_action` for that milter is configurable
(fail-open vs tempfail); the default is to be decided during implementation.
- **A native backstop with no dependencies:** on that same inbound hop,
Postfix's own origin-IP facilities are available — `reject_rbl_client`
(DNSBL) and HELO/PTR checks — and they work even without an external
container. Plus preserving authentication results for downstream through ARC
or `Received`, where part of the filtering does remain on the backend.
- **docker-compose:** document an optional anti-spam sidecar fragment (like the
alternative reverse-proxy fragments) — the container comes up with the stack
only when the option is enabled.
- **Persistence:** new tables and map files under `/data` — they land in the
full backup automatically (Phase 9). Domain export/import can be extended
with the inbound configuration — optional, to be flagged.
- **DNS documentation:** an inbound domain needs an `MX` record pointing at the
server (unlike outbound, where no MX is required) — to be reflected in the
README's DNS section.
## Security
[security.md](../security.md): server-side input validation, escaped writes to
config files, `exec` without interpolation, no open relay, protection against
backscatter.
## Done when
With `INBOUND_RELAY_ENABLE=true` and a configured domain, mail arriving on port
25 for that domain is forwarded to the given upstream; mail for unconfigured
domains or recipients is rejected (not an open relay, no backscatter); with
`INBOUND_ANTISPAM_MILTER` set, inbound mail passes through the external filter
with the real origin IP (verified with a sidecar container), and with it empty
the hook stays out of the way; with `INBOUND_RELAY_ENABLE=false` the inbound
port, tables and UI are absent and the base outbound relay is unchanged;
`build`/`vet`/`test`/image green.
## Risks
- open relay / backscatter — removed by `relay_domains` +
`relay_recipient_maps` + `reject_unauth_destination`;
- the loss of the origin IP for filtering on the backend when forwarding —
removed by the anti-spam milter hook plus native DNSBL on the inbound hop,
where the origin IP is still visible;
- port 25 accepting mail widens the attack surface (off by default);
- semver: if the contract turns out incompatible (ports, backup, behaviour with
the flag off) a major `2.x` is possible; the decision comes after the
implementation.
**External deployment dependency:** the optional anti-spam container — outside
the SelfPost image, brought up by the operator when the option is enabled.
## Dependencies
A finished outbound path (already implemented). Agreement obtained — see the
status above.
+197
View File
@@ -0,0 +1,197 @@
# Plan: panel-redesign
**Status:** candidate
**Date:** 2026-09-08
**Version:** `1.x` MINOR once implementation is agreed; this file is givens, not a
build plan.
---
## What this file is
Source data for a visual redesign of the control panel. Mockups have not been
drawn yet. Implementation in `internal/web` waits on those mockups and on
roadmap status **agreed**.
HTML click-throughs in [docs/assets/panel-ui/](../assets/panel-ui/index.html)
are **not** the brief. They were an earlier layout experiment; new screens are
drawn from this file's vocabulary, not copied from that folder.
## Goal
Stop hand-tuning every card on every page to hide meaningless empty space.
Operators should get a panel whose width rules are the same on Status, a
delete-confirm, and a six-column log: a form stays at a reading measure, ops
content uses the remaining column, and a card fills its cell.
This is a visual and CSS-grammar change. Routes, POST behaviour, and htmx
fragments stay as they are unless a later mockup proves a control must move.
## Pain (why)
[panel.css](../../internal/web/view/static/panel.css) already juggles several
widths (`64rem` column, `48rem` measure, `main.wide`, `.card.narrow`,
`page-login`) and two names for one pairing (`.split` everywhere, `.pair` on
DMARC). Each new screen picks a width by eye. On a wide monitor that leaves
voids; stretching a single input to the window is unreadable. The fix is a
layout grammar, not another button library.
## Delivery constraints (fixed unless product says otherwise)
The panel is not an SPA. Changing any of these needs an explicit decision, not
a mockup flourish:
| Constraint | Where |
|---|---|
| Go `html/template`, server-rendered pages | [internal/web/view/](../../internal/web/view/) |
| Vendored htmx 2.x for polling fragments | [layout.html](../../internal/web/view/templates/layout.html), [development.md](../development.md) § Vendored front-end |
| Assets embedded (`//go:embed`), no CDN at runtime | [view.go](../../internal/web/view/view.go) |
| CSP `default-src 'self'` — no inline script or `style=""` | [security.go](../../internal/web/security.go), `TestNoTemplateUsesInlineScriptOrStyle` |
| State changes are native HTML POST | templates under `internal/web/view/templates/` |
| AGPL-3.0; new front-end must be MIT-or-better and listed in NOTICE | [NOTICE](../../NOTICE), [development.md](../development.md) |
JavaScript is not a product requirement. [security.md](../security.md) records
progressive enhancement so **authorization does not depend on JS** (origin
check, session, RBAC). `data-confirm` is deliberately not a security boundary.
The redesign may use JS for chrome (copy, `<dialog>`). It must not make a
script the only way to submit a form.
Brand carriers today: stamp, brick, IBM Plex — [product.md](../product.md) and
[docs/assets/selfpost-proof.html](../assets/selfpost-proof.html). New mockups
may restyle; they must not silently drop the mark or invent a second product
look.
## Screens in scope (information architecture)
Same pages as the live panel. Visibility flags (inbound, DMARC, global vs
domain-admin) stay as in [web.go](../../internal/web/web.go).
| Kind | Pages |
|---|---|
| Signed-out | login, setup |
| Global ops | Status (plus polled fragment), mail queue, system log, backup, users |
| Shared | domains, domain detail, deliveries, delivery, help, settings |
| Optional | inbound (+ domain, delete), DMARC hub / domain / report |
| Confirms / forms | domain delete, inbound delete, user create/edit/delete |
## Layout layer (decided)
**Hybrid.** Page-level regions are ours. In-page composition is vendored Web
Awesome **layout CSS only** (MIT Core), not the component library and not the
full `utilities.css` barrel.
Every Layout ([every-layout.dev](https://every-layout.dev/)) is the *school*
(Stack, Cluster, Sidebar, Grid). It is a paid book; redistribution is
forbidden. Do not vendor it, do not buy it for the image, do not put their
custom elements in the tree. Web Awesome `layout.css` is the MIT packaging of
those recipes (`wa-stack` ≈ Stack, `wa-cluster` ≈ Cluster, `wa-flank` ≈
Sidebar, `wa-grid` ≈ Grid/Switcher).
### Regions (our CSS)
Shell: navigation column plus content that takes the rest (today's `.shell`,
without a centred `64rem` well that leaves side voids).
Inside the content column, **two modes only**. A page must not invent a third
width:
- **`measure`** — one readable form (login, setup, delete confirm, user form).
Cap around `42rem`, **left-aligned**. Fields fill the measure, not the
window. Empty space to the right of a form on a wide monitor is acceptable.
- **`fill`** — ops: Status, tables, logs, queue, DNS, paired cards. One column
cap for all fill pages (pick a single value in mockups, e.g. ~`90rem`; do
not keep both `48rem` and `64rem`). A card fills its grid cell.
There is no `main.wide` or `.card.narrow` as a width exception. A narrow card
lives in `measure`. A wide table lives in `fill`.
### In-page (vendored WA CSS)
Embed under `/static`, same as htmx — never a runtime CDN.
| Take | Do not take |
|---|---|
| `utilities/layout.css` | barrel `utilities.css` |
| `utilities/gap.css` | `themes/default.css`, `native.css` |
| `align-items.css`, `justify-content.css`, `flex-wrap.css` if a mockup needs them | variants, prose, FOUCE, JS, icons, Pro |
| A short `:root` stub for `--wa-space-*` only | their `layers.css` (it styles `wa-page`) |
Declare `@layer wa-utilities` locally. One spacing scale for the whole layer
(map to today's `1.2rem` gaps if that looks right; do not keep a second scale).
`.card` with slots (head / body / actions) stays ours so a `wa-grid` cell can
stretch. Do not use `wa-card`.
### Class vocabulary
**Paired cards are `wa-grid`, not `wa-split`.** In Web Awesome, `wa-split` is
space-between (title ‖ action). `wa-grid` is `auto-fit` + `--min-column-size`,
which is today's `.split { minmax(22rem, 1fr) }`. Freeze `--min-column-size`
once (same order as `22rem`); do not retune it per page.
| Class | Use |
|---|---|
| `measure` / `fill` | page region only, never inside a card |
| `wa-stack` | form or card body; children stretch on the block axis |
| `wa-cluster` | button rows, tags, Copy beside a value |
| `wa-grid` | two or more equal cards or field columns |
| `wa-split` | card header: title ‖ help/action; label ‖ value |
| `wa-flank` | rare: fixed + fluid (icon beside a field) |
| `wa-gap-*` | change a gap; not a substitute for a region |
`wa-frame` is for media boxes; the panel has none — omit from mockups.
One child in `wa-grid` occupies the row (`auto-fit`, not `auto-fill`): a
domain-admin Settings card must not leave an empty second column.
**Forbidden in mockups and later in templates:** a one-off `max-width` “for
this page”; a second name for the same pairing (today `.pair` vs `.split`);
`margin-inline: auto` on a card inside a grid (that already collapsed `.split`
tracks).
**Ready when:** Status (`fill` + `wa-grid` pairs) and login (`measure` +
`wa-stack`) can be drawn with no extra width rule. If a third screen wants a
new `max-width`, the vocabulary is unfinished.
## Components (decided)
**No JavaScript component library.** Pico, Bootstrap JS, DaisyUI, FAST,
Shoelace, and Web Awesome custom elements (`wa-button`, `wa-dialog`,
`wa-drawer`, `wa-card`) are out. They do not fix empty cards; they add Lit or
another runtime, Shadow DOM, CSP risk, and friction with htmx swaps.
What the live panel actually uses — and what mockups should assume:
| Need | Source |
|---|---|
| Button, input, select, textarea, table | native HTML, our CSS (brick / Plex) |
| Card | our `.card` + slots |
| Flash, status tint | our CSS |
| Help / application panels | CSS disclosure (checkbox today); or `<details>` / Popover if a mockup prefers |
| Destructive confirm | `data-confirm` / `window.confirm`, plus existing confirm pages for high blast radius |
| Overlay, if a mockup truly needs one | HTML [`<dialog>`](https://developer.mozilla.org/docs/Web/HTML/Element/dialog), not `wa-dialog` |
| Copy-to-clipboard | existing `panel.js` |
There is no combobox, date picker, tab set, tree, or chart in the panel. If a
future mockup invents a widget native HTML cannot express, choose a library
then, for that widget only.
## Out of scope for this item
- New panel features, routes, or copy (those are other roadmap rows).
- Weakening CSP to accommodate a widget library.
- Web Awesome Pro, Font Awesome Kit, a JS bundler for Lit.
- Replacing htmx with a front-end framework.
- Implementing `docs/assets/panel-ui/` as-is.
## Next (not this file's job)
1. Draw new mockups using only the vocabulary above.
2. After mockups are accepted and this item is **agreed**: vendor the WA CSS
files, NOTICE / development.md rows, then restyle templates without changing
behaviour.
## Implementation checklist
None until mockups exist and the roadmap status is **agreed**. Do not start
vendoring or rewriting `internal/web` from this file alone.
+71
View File
@@ -0,0 +1,71 @@
# Plan: preflight (installation check page)
**Status:** candidate
**Date:** 2026-08-19
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
---
## Goal
A dedicated `/preflight` page (global-admin only) that runs infrastructure-level
checks and helps the administrator confirm the instance is fully configured and
ready to deliver mail. Each failed check shows an actionable recommendation
(what to do or where to look).
## Scope
**In:**
- rDNS / PTR verification (reuse `dnscheck.Checker.checkServer`)
- TLS certificate validity + hostname match (CN/SAN contains `SELFPOST_HOSTNAME`)
- Port connectivity: 25, 465, and 587 (if `SUBMISSION_ENABLE`) — dial own
public IP from inside the container, expect a 220 banner
- HELO/EHLO banner — connect to localhost:25, verify Postfix announces
`SELFPOST_HOSTNAME` (not `localhost` or a container ID)
- Trusted proxy headers:
- If `TRUSTED_PROXY_CIDR` set: verify peer IP is in range and
`X-Forwarded-For` is present
- If `PANEL_COOKIE_SECURE=true`: verify `X-Forwarded-Proto: https`
- If `TRUSTED_PROXY_CIDR` empty: informational warning (panel exposed
directly)
- DKIM signing — OpenDKIM milter socket reachable (reuse `health.CheckSocket`)
- Send test email — admin enters recipient address, system sends via
localhost:25, shows queued/error result
**Out:**
- Per-domain DNS checks (SPF, DKIM TXT, DMARC) — those live on domain detail
pages.
- Delivery tracking of test email (only confirms local MTA accepted).
## Done when
An administrator can open `/preflight`, see a traffic-light summary of all
infrastructure prerequisites, read specific fix instructions for any failures,
and send a test email to verify end-to-end flow.
## Risks
- Port-connectivity check from inside the container may not reflect external
reachability if NAT hairpin is unsupported (document limitation).
- Test email requires at least one configured domain (or falls back to
`postmaster@SELFPOST_HOSTNAME`).
## Implementation checklist
Target version cut: TBD (MINOR). One commit per step; code only after roadmap
status is **agreed**. See [development.md](../development.md) § Plan checklists.
- [ ] Port-connectivity helper (`internal/health/port.go`) — dial own IP, read
SMTP banner — **Sonnet**
- [ ] HELO banner check (connect localhost:25, parse 220 line) — **Sonnet**
- [ ] Hostname-in-certificate check (extend or wrap `health.CheckCertificate`)
— **Sonnet**
- [ ] Handler `handlers_preflight.go` (assemble all checks, actionable
recommendations) — **Sonnet**
- [ ] Test-email handler (`POST /preflight/test-email`, `net/smtp` to
localhost:25) — **Sonnet**
- [ ] Template `preflight.html` (traffic-light cards + test-email form) —
**Sonnet**
- [ ] Routes in `web.go`, nav link — **Sonnet**
-141
View File
@@ -1,141 +0,0 @@
# Plan: queue-retries (Postfix retry policy in the panel)
**Status:** agreed
**Date:** 2026-08-13
**Version:** patch; no schema, no configuration surface.
**Order:** small panel item; does not wait on inbound-relay.
---
## Goal
Show the operator how Postfix retries deferred mail: first retry delay, later
backoff cap, and how long a message stays in the queue before it bounces. The
numbers come from this container's effective Postfix config, not from
hard-coded copy.
## Scope
**In:**
- A static «How delivery retries work» card on `/mail-queue` (global
administrator). Not inside the HTMX poll fragment — the snapshot is taken at
panel start.
- The same human-readable intervals in `/deliveries/{id}` history for
`deferred` and `bounced` (domain administrators never see Mail queue).
- Operator docs: [guide.md](../guide.md) Mail queue bullet;
[architecture.md](../architecture.md) notes the one-shot `postconf -h` at
panel start. [CHANGELOG.md](../../CHANGELOG.md) `### Added`.
**Out:**
- Changing Postfix retry parameters, or exposing them as panel settings
([product.md](../product.md): Postfix is used as-is).
- An `attempts` column on `send_log`, or «attempt 3 of N» — Postfix has no
attempt budget; it is time-based.
- Counting `status=deferred` lines in `mail.log` or reading `postcat`. Each
attempt is already on the delivery page's log table; the journal stores only
the last status.
- Duplicating the card on Status (summary + link to Mail queue already exist).
- Re-reading `postconf` on every HTTP request.
## Architecture
Acceptance is still synchronous SMTP. Delivery stays in Postfix's on-disk
queue. SelfPost does not enqueue, retry, or deliver.
```mermaid
flowchart LR
smtpAccept["SMTP 250 accepted"] --> postfixQueue["Postfix queue"]
postfixQueue -->|"deferred: backoff until queue lifetime"| retry["Retry MX"]
retry -->|sent| done["sent"]
retry -->|lifetime elapsed or 5xx| bounce["bounced"]
postfixQueue --> mailQueuePage["/mail-queue policy card"]
postfixQueue --> deliveryPage["/deliveries/id history copy"]
```
[build/postfix-config.sh](../../build/postfix-config.sh) does not set
`queue_run_delay`, `minimal_backoff_time`, `maximal_backoff_time`,
`maximal_queue_lifetime`, `bounce_queue_lifetime`, or `delay_warning_time`.
Debian/Postfix 3.x compiled-in defaults therefore apply unless the operator
overrides them (`postconf -e` inside the container).
### Loading the numbers
Once, when the HTTP role starts ([cmd/panel/httpserver.go](../../cmd/panel/httpserver.go),
after `postfix-config.sh` has run):
```
postconf -h queue_run_delay minimal_backoff_time maximal_backoff_time maximal_queue_lifetime bounce_queue_lifetime delay_warning_time
```
`postconf -h`, not a parse of `/etc/postfix/main.cf`: stock values are not
written to the file. `postconf` is the effective config, including a manual
override.
- Fixed argv, no user input — same pattern as
[postfix.Queue](../../internal/postfix/queue.go) /
[security.md](../security.md).
- Cache on `handlers.Config` (via `web.Config`). The HTMX fragment does not
call `postconf`.
- A live `postconf -e` is visible after the next panel (or container) restart.
While the process is up, the panel shows the start-up snapshot.
- Parse Postfix time units (`300s`, `5d`, `1h`, a bare number is seconds) in
`internal/postfix`. Format human strings (`5 minutes`, `5 days`,
`about 1 hour 7 minutes`) in one place so the Mail queue card and
`deliveryEvents` cannot drift.
- If `postconf` is missing (unit tests on Windows, binary outside the
container): log a warning, fall back to Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`), and put a muted note on the card. Tests
stub the lookup (as `queueIDs` in the log-tailer) or pass a fixture on
`Config`. Do not fail panel start.
Typical stock values, for orientation only — the UI prints whatever
`postconf` returned:
| Parameter | Stock | Meaning |
|---|---|---|
| `queue_run_delay` / `minimal_backoff_time` | `300s` | First retry and deferred-queue scan |
| `maximal_backoff_time` | `4000s` | Cap on the doubling gap (~1 h 7 min) |
| `maximal_queue_lifetime` | `5d` | Then bounce |
| `delay_warning_time` | `0` | No delay warning to the sender |
## Panel copy
Mail queue card facts: first retry; later retries (doubling, capped);
kept in queue; then bounced. Short prose: there is no fixed attempt count; a
`deferred` message stays in this listing until it is delivered or the queue
lifetime runs out.
`deliveryEvents(row, policy)`:
- `deferred`: retries, first after X, then with increasing gaps up to Y, for
up to Z.
- `bounced`: or Postfix gave up after Z in the queue.
## Tests
- Duration parser: `5d`, `300s`, `4000s`, `1h`, `0`, bare number.
- `/mail-queue` handler: card shows the fixture policy's human strings, not a
live `postconf`.
- Delivery page / `deliveryEvents`: `deferred` and `bounced` contain those
strings ([handlers_monitor_test.go](../../internal/web/handlers/handlers_monitor_test.go)).
- [templates_test.go](../../internal/web/view/templates_test.go): pass the new
fields if rendering `mail_queue` requires them.
`go test` / `go vet` on the touched packages.
## Done when
- `/mail-queue` states this Postfix's first retry, backoff cap, and queue
lifetime.
- A `deferred` / `bounced` delivery page uses the same intervals.
- A manual `postconf -e maximal_queue_lifetime=2d` followed by a panel restart
changes what the panel prints.
- Guide and architecture describe the snapshot; CHANGELOG has an Added entry.
## Risks
- Showing compiled-in fallbacks when `postconf` failed would mislead if the
operator had overridden them — mitigate with the muted note on the card.
- Inventing a max-attempt count would be false; the copy must stay time-based.
+24 -9
View File
@@ -10,14 +10,25 @@ in [guide.md](guide.md); as-built technical detail in
## Purpose ## Purpose
SelfPost is a self-hosted **outbound SMTP relay** with a web control panel, SelfPost is a self-hosted **SMTP relay** with a web control panel, shipped as a
shipped as a single Docker image. It sends mail directly to the internet from single Docker image. It sends mail directly from your server with per-domain
your own IP with per-domain DKIM signing. DKIM signing; an optional inbound relay forwards mail on port 25 without
storing it.
**Primary workflow:** configure the relay once (domains, DKIM, SASL **Primary workflow:** configure the relay once (domains, DKIM, SASL
applications), then point scripts and apps at the SMTP endpoint. SelfPost applications), then point scripts and apps at the SMTP endpoint. SelfPost
delivers as the configured sending domain. delivers as the configured sending domain.
## Positioning
| | Text |
|---|---|
| Stamp subtitle | SELF-HOSTED SMTP RELAY |
| Tagline | YOUR SMTP. YOUR RULES. |
| Hero line (README, GitHub) | Self-hosted SMTP relay with a web panel — one Docker image, no provider. |
Brand assets and metrics: [docs/assets/selfpost-proof.html](assets/selfpost-proof.html).
--- ---
## Deployment context (fixed assumptions) ## Deployment context (fixed assumptions)
@@ -50,16 +61,20 @@ Explicitly excluded to prevent scope creep:
- Organisations / tenancy beyond global + domain-admin roles; managing - Organisations / tenancy beyond global + domain-admin roles; managing
**multiple sending domains** under one global administrator is in scope (see **multiple sending domains** under one global administrator is in scope (see
below) below)
- Inbound antispam/antivirus (rspamd, ClamAV, etc.) - Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
expose a milter hook; it does not ship or start a filter
- A custom MTA — Postfix is used as-is - A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only - Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
The **domain-admin** role ships in the current line (global administrator plus The **domain-admin** role ships in the current line (global administrator plus
domain administrators with assigned domains). The optional **inbound relay** is domain administrators with assigned domains). The optional **inbound relay**
the main agreed **1.x+** extension still on the (backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major `INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log
remains possible pending implementation. Items marked *candidate* in the retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and
roadmap require explicit approval before coding. **auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report
ingest** ships in `[1.7.0]`, off by default behind `DMARC_REPORTS_ENABLE`.
Items marked *candidate* in the
[roadmap](roadmap.md) require explicit approval before coding.
--- ---
+262 -108
View File
@@ -26,66 +26,40 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
## Index ## Index
| ID | Topic | Status | Plan | | ID | Topic | Status | Progress | Plan |
|---|---|---|---| |---|---|---|---|---|
| queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | [plans/queue-retries.md](plans/queue-retries.md) | | preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | | contributing | `CONTRIBUTING.md` | candidate | — | — |
| contributing | `CONTRIBUTING.md` | candidate | — | | inbound-antispam-panel | Inbound antispam journal + allow/deny lists | agreed | 1/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | | inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
| panel-docs | In-panel operator documentation | candidate | — | | csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | | template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
| panel-redesign | Panel visual redesign (layout grammar) | candidate | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): **queue-retries** is a small panel item **Recommended order** (not binding): the next feature is
that can land first or in parallel; the next feature is **inbound-relay**. **inbound-antispam-panel** (`1.10.0`). panel-docs shipped in
The 2026-08-13 full-tree review follow-ups (send-log authorization, [CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
fail-closed paths, docs, GUI, tests, licence) are closed — history in (security review of the ingest path pending); domain-stats-auto-ratelimit in
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]` and git. Candidates need `[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`;
explicit agreement before they join the queue. queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in
`[1.3.0]`. Candidates need explicit agreement before they join the queue.
The 2026-08-19 full-tree code review is closed except for four items, all
listed above and none of them urgent: [csrf-tokens](#csrf-tokens),
[template-data-typing](#template-data-typing),
[structured-logging](#structured-logging) and
[review-2026-08-followups](#review-2026-08-followups). What that review
produced is in [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`; its own document
was removed once these four items carried everything still open, and stays in
git history.
After a context reset, pick an item marked `agreed` or `in progress`, then work After a context reset, pick an item marked `agreed` or `in progress`, then work
the checklist in its linked plan. the **Implementation checklist** in its linked plan. The `Progress` column above
is `done/total` checklist steps in that plan ([development.md](development.md)
--- § Plan checklists).
## queue-retries
**Goal:** show on Mail queue and on a delivery's history how this Postfix
retries deferred mail — first delay, backoff cap, queue lifetime — reading
the effective config (`postconf -h`) once at panel start so a manual
override is visible.
**Boundary:** explanation only. Postfix stays as-is; no attempt counter, no
panel knobs for queue lifetime, no schema change. Domain administrators see
the intervals on `/deliveries/{id}` (they cannot open Mail queue).
**Done when:** see the criteria in
[plans/queue-retries.md](plans/queue-retries.md).
**Dependencies / risks:** `postconf` unavailable outside the container
(fallback + muted note). Copy must stay time-based — Postfix has no max
attempt count.
**Version:** patch.
---
## inbound-relay
**Goal:** optional acceptance of mail on port 25 for explicitly configured
domains, forwarded to an upstream (backup-MX / relay-forwarder). Off by default
(`INBOUND_RELAY_ENABLE=false`); without the flag the outbound path is
unchanged.
**Boundary:** an extension of v1.0 — [product.md](product.md) excludes inbound
mail and mailboxes. This is relay/forward, not IMAP/POP3/webmail; an anti-spam
engine stays outside the image, only the attachment point is provided.
**Done when:** see the criteria in
[plans/inbound-relay.md](plans/inbound-relay.md).
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
a wider attack surface (port 25 accepting mail).
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
implementation lands.
--- ---
@@ -107,35 +81,12 @@ priority.
--- ---
## dmarc-reports
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so
the operator does not need an external DMARC service or a separate mailbox
workflow.
**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general
inbound relay. A dedicated inbound path for report messages only; forensic
reports (`ruf=`) out of scope for v1.
**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md).
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
storage and retention of parsed summaries; the `dmarc_report_email` setting
(migration `0005` moved it off the old `admin` table into `settings`) and
`domains.dmarc_rua` added in the DMARC template work must stay the source of
truth for `rua=` in DNS guidance.
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
inbound-relay depending on how port 25 acceptance is structured.
**Version:** `1.x` MINOR.
---
## panel-docs ## panel-docs
**Status:** done — shipped in `[1.8.0]` (2026-08-18). The implementation plan
file was removed as shipped cleanup; release details remain in
[CHANGELOG.md](../CHANGELOG.md).
**Goal:** built-in operator documentation in the panel — short pages (or a **Goal:** built-in operator documentation in the panel — short pages (or a
help drawer) that explain what each Status check and other controls mean, help drawer) that explain what each Status check and other controls mean,
without sending the operator out to `docs/guide.md`. without sending the operator out to `docs/guide.md`.
@@ -154,42 +105,245 @@ read the git tree for day-to-day meaning of a card.
**Dependencies / risks:** copy ownership and translation; keeping help in sync **Dependencies / risks:** copy ownership and translation; keeping help in sync
when checks change; not bloating every page with a second column of prose. when checks change; not bloating every page with a second column of prose.
**Version:** `1.x` MINOR; `candidate` until explicitly agreed. **Version:** shipped `1.8.0`.
---
## inbound-antispam-panel
**Goal:** panel view of inbound anti-spam decisions (when, from, to, subject,
action) plus editable allow/deny lists that tune the external filter — without
shipping rspamd or another engine inside the image.
**Boundary:** metadata journal and list sync only; SelfPost does not run the
filter ([product.md](product.md)). Quarantine:
[inbound-quarantine](#inbound-quarantine). Design closed in
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Decisions.
**Done when:** with inbound relay enabled, the operator sees journal rows
(accepts via inbound-journal milter, rejects via mail.log tailer) and can
maintain instance-wide allow/deny lists synced to rspamd maps when the antispam
hook is set.
**Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling;
journal PII; global-admin-only RBAC.
**Version:** `1.10.0` MINOR; **agreed**.
---
## inbound-quarantine
**Goal:** optional hold for suspicious inbound mail — review in the panel, then
release to upstream, discard, or tune lists. Design not settled.
**Boundary:** expands inbound from pure relay toward short-lived message
storage; may conflict with "no mailboxes" in [product.md](product.md) until
product explicitly agrees. Alternatives (rspamd-only quarantine vs SelfPost
store) are listed in [plans/inbound-quarantine.md](plans/inbound-quarantine.md).
**Done when:** TBD after open questions in the plan are answered.
**Dependencies / risks:** inbound relay `[1.4.0]`; storage/retention/backup
size; malware in held MIME; overlap with
[inbound-antispam-panel](#inbound-antispam-panel). No implementation checklist
yet.
**Version:** TBD; `candidate` until explicitly agreed.
---
## preflight
**Goal:** a `/preflight` page (global-admin only) that verifies instance-level
infrastructure: rDNS/PTR, TLS certificate + hostname match, port 25/465/587
reachability, HELO banner correctness, reverse-proxy headers, DKIM milter
socket, and a test-email sender. Each check shows an actionable fix
recommendation on failure.
**Boundary:** instance infrastructure only; per-domain DNS checks remain on
domain detail pages. Does not track test-email delivery beyond local MTA
acceptance.
**Done when:** the page loads, all checks run with traffic-light results and
recommendations, and the test-email form successfully submits a message through
Postfix.
**Dependencies / risks:** port-connectivity check may not reflect external
reachability behind NAT without hairpin; test email needs at least one domain or
falls back to `postmaster@SELFPOST_HOSTNAME`.
**Version:** `1.x` MINOR; `candidate`.
---
## csrf-tokens
**Goal:** a per-session CSRF token on every state-changing form, checked
server-side in addition to the `Sec-Fetch-Site` / `Origin` check the panel
relies on today (`originAllowed` in
[internal/web/security.go](../internal/web/security.go)).
**Boundary:** defence in depth, not a replacement — the header check stays and
keeps covering requests that carry no form. The token is bound to the existing
session record; no new session model, no cookie changes.
**Done when:** every POST form and htmx fragment that changes state carries a
token, a missing or mismatched token is refused with a test that proves it, and
[security.md](security.md) describes the layered model in one place instead of
two.
**Dependencies / risks:** [security.md](security.md) currently records the
opposite decision — origin checking without tokens, with the accepted risk and
its revisit triggers written out in the ADR § *CSRF via origin checking*. The
2026-08-19 review recorded "add tokens" without turning it into a task, which is
why this item exists. Settle which decision stands before writing code; both
outcomes are honest, and "keep headers" means deleting this item rather than
leaving the contradiction standing. The work itself is a hidden field in roughly
two dozen forms plus the htmx swap paths.
**Version:** `1.x` MINOR; `candidate`.
---
## template-data-typing
**Goal:** replace the `map[string]any` template payloads in
`internal/web/handlers` with typed structs (`DomainDetailData`, `SettingsData`,
...), so a renamed or mistyped key is a compile error instead of a silently
empty page section.
**Boundary:** handler-to-template plumbing only; no template redesign, no route
or behaviour change. `renderDomainDetail`
([handlers_apps.go](../internal/web/handlers/handlers_apps.go)) sets around
forty keys on one map and is the case that motivates the item; roughly ten
render sites still pass maps.
**Done when:** the page handlers pass structs, the shared footer/base fields
(`Version`, `Copyright`, `SourceURL`) still arrive on every page, and the
template guard tests pass unchanged.
**Dependencies / risks:** mechanical but wide — it touches every
`handlers_*.go` and every template that reads those keys, and a missed key is
invisible unless a test renders that page. Worth pairing with the handler tests
in [review-2026-08-followups](#review-2026-08-followups) so the pages are
covered before they are rewired. Reviewer's estimate: 2–3 hours.
**Version:** no bearing on semver; `candidate`.
---
## structured-logging
**Goal:** move from `log.Printf` to `log/slog` — levels, structured fields, and
one logger injected through constructors, instead of the package-level
`logf = log.Printf` alias repeated in five packages.
**Boundary:** internal logging only. The destination stays stdout/stderr under
supervisord; no log shipping, no new dependency. Lines the panel's own Status
page and log tailer depend on must keep parsing.
**Done when:** packages take a `*slog.Logger`, the per-package `logf` aliases
are gone, and levels are used deliberately (an error is not an info).
**Dependencies / risks:** the log tailer reads Postfix's log rather than the
panel's, so the blast radius is small — but anything that greps the panel's
output (e2e assertions, operator habits) sees a new format. Reviewer's
estimate: 3–4 hours.
**Version:** no bearing on semver; `candidate`.
---
## review-2026-08-followups
**Goal:** the leftovers of the 2026-08-19 full-tree code review that are too
small to be roadmap items of their own. Everything still open from that review
is in the table below; the rest of it shipped in
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`.
| Area | Item |
|---|---|
| Security | Re-authenticate (ask for the current password) before a backup download — [handlers_backup.go](../internal/web/handlers/handlers_backup.go) hands the archive to any signed-in session |
| Tests | The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests |
| Tests | No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly |
| Tests | Nothing covers error page templates, a corrupted backup archive, malformed or oversized DMARC XML, delivery-log pagination edges, concurrent store operations, or zero-traffic `store` stats |
| Edge case | `internal/secretfile` accepts an empty password at library level — guard it, or state that validation is the caller's job |
| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode |
| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` |
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
| UI | `.pair` (DMARC templates) and `.split` (every other page) are two names for one layout |
| UI | Two `back_link` elements in a row in `dmarc_domain.html` |
| UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does |
**Boundary:** no feature work and no behaviour change an operator would notice,
apart from the backup re-auth prompt. Anything here that grows past an hour
becomes its own roadmap item instead.
**Done when:** each row is either implemented or struck with a written reason.
**Dependencies / risks:** the test rows overlap
[template-data-typing](#template-data-typing) — write the handler tests first if
both are taken up. One finding of that review was **declined** and is
deliberately absent: `//go:build linux` for the `/proc` reader in
[internal/health/machine.go](../internal/health/machine.go). That code is
parameterised by root directory, calls nothing platform-specific, already
degrades gracefully when `/proc` is missing (`TestMachineSamplerWithoutProc`),
and tagging it would only delete cross-platform test coverage.
**Version:** no bearing on semver; `candidate`.
---
## panel-redesign
**Goal:** a panel layout grammar so screens stop inventing per-page widths to
hide empty space. Forms stay on a reading measure; ops pages use the remaining
column; cards fill their cells. Givens (hybrid WA layout CSS + own `measure` /
`fill`, no JS component library, new mockups rather than
[docs/assets/panel-ui/](assets/panel-ui/index.html)):
[plans/panel-redesign.md](plans/panel-redesign.md).
**Boundary:** visual CSS and templates after mockups. No new routes or mail-path
behaviour. Not a front-end framework, not Web Awesome components, not Pico /
Bootstrap. The older HTML mockups in `docs/assets/panel-ui/` are not the brief.
**Done when:** mockups exist that use only the frozen vocabulary, then — once
this item is agreed — the live panel matches them without one-off `max-width`
exceptions. The givens file itself is not “done”.
**Dependencies / risks:** competes with feature work for attention; easy to
start restyling `internal/web` before the vocabulary is proven on paper.
Vendoring WA CSS needs NOTICE / licence rows. CSP stays `default-src 'self'`.
**Version:** `1.x` MINOR when implementation is agreed; `candidate` until then.
--- ---
## schema-squash ## schema-squash
**Goal:** when 2.x is cut, stop shipping the 1.x migration files **Goal:** when 2.x is cut, stop shipping the 1.x migration chain in the binary
(`0001_init.sql` … `0005_panel_users.sql`) in the binary and replace them with and replace it with one baseline equal to the schema at the then-current head.
one baseline that is the schema as of `user_version = 5`. Fresh 2.x data Fresh 2.x data directories no longer create-then-drop the historical `admin`
directories no longer create-then-drop the historical `admin` table. table. Current chain, legacy notes, and gate thresholds:
[schema-migrations.md](schema-migrations.md) (update that file when migrations
ship; adjust the squash gate to its head at cut time).
**Boundary:** 1.x keeps the full chain so a 1.0.0 data directory still boots. **Boundary:** 1.x keeps the full chain so a `1.0.0` data directory still boots.
Do not delete, rename, or reorder those files while MINOR compatibility with Do not delete, rename, or reorder migration files while MINOR compatibility with
`1.0.0` holds. `migrate()` maps **file order** to `PRAGMA user_version` (`target `1.0.0` holds. Git history keeps the old files either way; only the embedded
= i + 1`); dropping a file in 1.x would skip or mis-apply steps on existing set in the 2.x image changes.
databases. Git history keeps the old files either way; only the embedded set
in the 2.x image changes.
**Upgrade gate (required with the squash):**
| `user_version` | 2.x behaviour |
|---|---|
| `0` (empty file) | Apply the baseline; set `user_version` to the new chain’s head |
| `>= 5` (fully migrated 1.x) | Skip; schema is already the baseline |
| `1`…`4` (mid-chain 1.x) | **Refuse to start** — boot the last 1.x once, then 2.x |
Restore remains a separate lock: the backup manifest version must match the Restore remains a separate lock: the backup manifest version must match the
running binary ([architecture.md](architecture.md) § Persistence). It does not running binary ([architecture.md](architecture.md) § Persistence). It does not
replace this gate. replace the squash upgrade gate.
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after **Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
it); the gate above is tested; the operator guide says a 2.x image will not it); the gate in [schema-migrations.md](schema-migrations.md) is tested; the
open an unfinished 1.x database. operator guide says a 2.x image will not open an unfinished 1.x database.
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an **Dependencies / risks:** a decided 2.x cut (another breaking change, or an
explicit major). Squashing five short files is not a reason to cut 2.x on its explicit major). Squashing the current short chain is not a reason to cut 2.x on
own. A missed gate leaves a `user_version = 3` database silently stuck. its own. A missed gate leaves a mid-chain 1.x database silently stuck.
**Version:** `2.x` major only; not a 1.x item. **Version:** `2.x` major only; not a 1.x item.
+207
View File
@@ -0,0 +1,207 @@
# SQLite schema and migrations
**What this file is.** A living reference for the embedded SQLite migration chain,
historical legacy that still runs on fresh 1.x installs, and the planned 2.x
schema squash. When you add or review a migration, update this file in the same
change.
**Source of truth in code:** `internal/store/migrations/*.sql`, applied by
`internal/store/store.go` (`migrate()`).
**Related:** [architecture.md](architecture.md) § Persistence;
[roadmap.md](roadmap.md) § schema-squash; [development.md](development.md) §
Documentation.
---
## Current snapshot
| Field | Value |
|---|---|
| Chain head | `user_version = 10` |
| Files | `0001_init.sql` … `0010_send_log_app_login_index.sql` (10 files) |
| Database file | `/data/selfpost.db` (bind mount) |
| Compatibility | 1.x MINOR releases must boot a `1.0.0` data directory |
Last updated in **[Unreleased]** (`0010_send_log_app_login_index`); **1.9.1**
had no schema change. The release column below records the cut a migration
shipped in — fill `0010`'s in at the next version cut.
---
## How migrations run
1. Migrations are embedded at build time (`//go:embed migrations/*.sql`).
2. Filenames are sorted lexicographically; **file order = version number**.
3. `PRAGMA user_version` records progress: after file *i* (0-based), version is
*i + 1*.
4. Each pending migration runs in its own transaction, then bumps `user_version`.
5. There is no down-migration; fixes ship as a new `00NN_*.sql` file.
```text
0001_init.sql → user_version 1
0002_sessions.sql → user_version 2
…
0009_application_auth_ips.sql → user_version 9
0010_send_log_app_login_index.sql → user_version 10
```
**Implication:** never delete, rename, or reorder migration files while 1.x must
stay compatible with `1.0.0`. Git history keeps old files; only a 2.x cut may
replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)).
---
## Migration chain
| Ver | File | Shipped | Kind | Summary |
|-----|------|---------|------|---------|
| 1 | `0001_init.sql` | ≤ 1.0.0 | DDL | Core schema: `admin`, `settings`, `domains`, `applications`, `application_addresses`, `send_log`, `rate_limits` |
| 2 | `0002_sessions.sql` | ≤ 1.0.0 | DDL | `sessions` (token hash, sliding idle expiry) |
| 3 | `0003_logtail_state.sql` | 0.5.0 | DDL | `logtail_state` (log-tailer read offset + fingerprint) |
| 4 | `0004_dmarc_report_email.sql` | 1.1.0 | DDL | `admin.dmarc_report_email`, `domains.dmarc_rua` |
| 5 | `0005_panel_users.sql` | 1.2.0 | DDL + data | `users`, `user_domains`; migrate single `admin` row; copy profile `dmarc_report_email` to `settings`; **DROP `admin`** |
| 6 | `0006_inbound_relay.sql` | 1.4.0 | DDL | `inbound_domains`, `inbound_transports`, `inbound_recipients` |
| 7 | `0007_rate_limit_auto.sql` | 1.6.0 | DDL | `rate_limits.mode`, `auto_multiplier`, `auto_updated_at` |
| 8 | `0008_dmarc_reports.sql` | 1.7.0 | DDL | `dmarc_reports`, `dmarc_report_records` |
| 9 | `0009_application_auth_ips.sql` | 1.9.0 | DDL + data | `applications.auth_ip_restrict`, `auth_allowed_ips`; move legacy app `rate_limits.allowed_ips` into auth columns; clear those IPs on rate limits |
| 10 | `0010_send_log_app_login_index.sql` | Unreleased | DDL | `idx_send_log_app_login_created_at` — app-scoped rate-limit counts and per-application send statistics were scanning the `created_at` range |
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 1.x images.
The feature plans that introduced schema work (`inbound-relay`,
`domain-stats-auto-ratelimit`, `dmarc-reports`) were deleted once they
shipped; what each migration was for is the table above, and the release it
came with is in [CHANGELOG.md](../CHANGELOG.md).
---
## Schema after head (v10)
Tables present in a fully migrated database:
| Table | Introduced | Role |
|-------|------------|------|
| `settings` | 0001 | Key/value panel settings (retention, profile flags) |
| `domains` | 0001 (+ `dmarc_rua` in 0004) | Sending domains |
| `applications` | 0001 (+ auth IP cols in 0009) | SASL applications per domain |
| `application_addresses` | 0001 | Explicit From addresses (`list` mode) |
| `send_log` | 0001 | Delivery journal |
| `rate_limits` | 0001 (+ auto cols in 0007) | Level-2 limits per domain/application |
| `sessions` | 0002 | Panel login sessions |
| `logtail_state` | 0003 | Log-tailer persistence |
| `users` | 0005 | Panel users (`global`, `domain_admin`) |
| `user_domains` | 0005 | Domain-admin assignments |
| `inbound_domains` | 0006 | Inbound relay domains |
| `inbound_transports` | 0006 | Upstream host/port/TLS per inbound domain |
| `inbound_recipients` | 0006 | Allow-list when `recipient_mode = list` |
| `dmarc_reports` | 0008 | Parsed aggregate report summaries |
| `dmarc_report_records` | 0008 | Per-source rows inside a report |
**Not present after v10:** `admin` (dropped in 0005).
---
## Legacy and fresh-install artefacts
These are intentional in 1.x; they are the main motivation for
[schema-squash](roadmap.md#schema-squash) at 2.x.
### `admin` table (0001 → 0005)
On a **new** 1.x data directory the chain still:
1. Creates `admin` (0001),
2. Adds `admin.dmarc_report_email` (0004),
3. Copies the row into `users` and drops `admin` (0005).
Functionally harmless; confusing when reading migrations or inferring schema from
code. A 2.x baseline should define `users` directly and omit `admin`.
### Application trusted IPs (0009)
Before 1.9.0, client IPs for an application lived in `rate_limits.allowed_ips`
(`scope = application`). Migration 0009 copies non-empty values into
`applications.auth_allowed_ips`, sets `auth_ip_restrict = 1`, and clears
`rate_limits.allowed_ips` for application scope. Level-2 limits no longer carry
IP bindings; auth and rate limiting are separate concerns.
Note that the two columns do not mean the same thing: the old list was
*permissive* (those IPs got the application ceiling, every other IP fell back
to the domain limit), the new one is *restrictive* (every IP not on the list is
refused for that application). The conversion branch only fires for a database
that carried application-scope `allowed_ips` from before 1.9.0.
### Profile DMARC email (0004 → 0005)
`0004` adds `admin.dmarc_report_email`. `0005` copies it into the migrated global
`users` row and into `settings` key `dmarc_report_email`. Per-domain overrides
remain on `domains.dmarc_rua`.
### Documentation references to migration numbers
Other docs cite migrations by number (e.g. architecture § sessions → `0002`).
After a 2.x squash, those references remain valid for **upgrade history** and
git; fresh 2.x installs only run the baseline plus post-2.x files.
---
## Rules for 1.x changes
1. **Add** the next `00NN_descriptive_name.sql`; do not edit shipped migrations.
2. **Bump this file:** snapshot table, chain row, schema table if needed.
3. **CHANGELOG** under the release that ships the migration.
4. **Operator impact:** if upgrade behaviour matters, note it in [guide.md](guide.md).
5. **Backup manifest** is separate: restore requires matching binary version
([architecture.md](architecture.md) § Persistence); it does not replace
running pending SQLite migrations.
### Checklist for a new migration
- [ ] File name is next integer, zero-padded four digits.
- [ ] SQL is idempotent in spirit (runs once per DB; guard with schema state, not
“IF NOT EXISTS” everywhere unless needed).
- [ ] Data migrations handle empty/partial state (e.g. no `admin` row on re-run is
impossible; mid-upgrade failure is recovered by re-running the same file
only if the transaction failed before `user_version` bump).
- [ ] Row added to [Migration chain](#migration-chain) and [Current snapshot](#current-snapshot).
- [ ] `go test ./...` (store and dependents).
---
## Planned 2.x squash
Tracked as [schema-squash](roadmap.md#schema-squash). **Not** a reason to cut 2.x
on its own — only bundled with another breaking change or an explicit major.
**Goal:** embed one baseline SQL file equal to the schema at whatever the 1.x
chain head is when the squash ships (`v10` today), instead of the full 1.x
chain. Fresh 2.x `/data` directories skip create-then-drop `admin`.
**Upgrade gate (required when squash ships):**
| `user_version` | 2.x behaviour |
|---|---|
| `0` (empty DB) | Apply baseline; set `user_version` to new chain head |
| `>= N` (fully migrated 1.x) | Skip; schema already matches baseline |
| `1`…`N-1` (mid-chain 1.x) | **Refuse to start** — run the last 1.x image once, then 2.x |
`N` is the chain head at cut time (`10` today).
**Done when:** baseline embedded; gate tested; [guide.md](guide.md) states that
2.x will not open an unfinished 1.x database.
---
## Restore vs migrate
| Mechanism | What it checks |
|-----------|----------------|
| SQLite `user_version` | Which embedded migrations have run on this `selfpost.db` |
| Backup `manifest.json` | Binary/image version after full restore |
An operator can have a matching manifest after restore but still need migrations
if they restored an older DB snapshot with a newer binary — normal `migrate()`
applies pending files. The 2.x gate adds a **refusal** for half-upgraded 1.x DBs
when the old chain is no longer embedded.
+57 -7
View File
@@ -10,7 +10,14 @@ login in the `saslpasswd2` argv
2026-08-13 full-tree review against this document — send-log authorization for 2026-08-13 full-tree review against this document — send-log authorization for
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
session creation, and application-delete ordering: no findings, and nothing session creation, and application-delete ordering: no findings, and nothing
needed adding to the accepted risks. (2) **Accepted risks** — needed adding to the accepted risks. The 2026-08-19 review (Fable) covered the
whole diff from 1.3.0 (`7052395`) to HEAD past 1.9.1 — the inbound-relay path
(port 25, maps, panel), DMARC aggregate-report ingestion, automatic rate
limiting, application auth-IP allow-lists, and the new panel surface
(handlers, templates, settings): no exploitable findings; two deliberate
behaviours the pass examined are now recorded under Accepted risks
(unauthenticated DMARC ingest, fail-open auth-IP enforcement).
(2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not deliberate departures beyond the mandatory, recorded so the decision is not
lost. lost.
@@ -77,8 +84,17 @@ The panel is exposed to the internet — the items below are **not optional**.
### Mail path (security-relevant) ### Mail path (security-relevant)
- **Not an open relay** — SASL only; `reject_unauth_destination`; - **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`. `smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
open relay either: SASL is off; `smtpd_relay_restrictions` /
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
`reject_unlisted_recipient`; maps list only configured domains and
recipients. Domains with no upstream host are omitted from the maps so mail
is never accepted with nowhere to send it. Prefer recipient mode `list` to
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
on inbound. An optional antispam milter is inbound-only; default action is
fail-open (`accept`).
- TLS is mandatory before credentials are transmitted (465 wrapper / 587 - TLS is mandatory before credentials are transmitted (465 wrapper / 587
`encrypt`). `encrypt`).
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply - `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
@@ -86,10 +102,11 @@ The panel is exposed to the internet — the items below are **not optional**.
### Backup and domain export ### Backup and domain export
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the - Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
administrator's password hash; a domain export carries the DKIM key and administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
**working** application passwords in the clear (otherwise a transfer without private key from `certs/` when present; a domain export carries the DKIM key
recreating credentials would be impossible). and **working** application passwords in the clear (otherwise a transfer
without recreating credentials would be impossible).
- Both downloads can be encrypted with a password (a checkbox on the form): - Both downloads can be encrypted with a password (a checkbox on the form):
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
authenticated with the header, the chunk number, and an end-of-stream flag — authenticated with the header, the chunk number, and an end-of-stream flag —
@@ -131,7 +148,10 @@ deferred item from the roadmap.
(see the ADR below). A token would not save the panel from XSS inside it (see the ADR below). A token would not save the panel from XSS inside it
either: code executing in the panel's origin sends the request itself — either: code executing in the panel's origin sends the request itself —
against that, `html/template` auto-escaping and CSP do the work, which is why against that, `html/template` auto-escaping and CSP do the work, which is why
templates must contain no inline scripts and no inline styles. templates must contain no inline scripts and no inline styles. Tracked as
[csrf-tokens](roadmap.md#csrf-tokens): implementing that item replaces this
bullet and the ADR below, and dropping it leaves them as the single record of
the decision. The two must not disagree.
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.** - **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
Delete, regenerate-password, and clear-rate-limit forms carry a Delete, regenerate-password, and clear-rate-limit forms carry a
`data-confirm` prompt handled entirely in `data-confirm` prompt handled entirely in
@@ -166,6 +186,36 @@ deferred item from the roadmap.
until the tailer has read the log to the end, and touches nothing if until the tailer has read the log to the end, and touches nothing if
`postqueue` is unreadable. See [architecture.md](architecture.md) § Log `postqueue` is unreadable. See [architecture.md](architecture.md) § Log
tailer. tailer.
- **DMARC aggregate reports are accepted without authentication.** Anyone on
the internet can mail a fabricated report (or displace a genuine one — the
`UNIQUE(reporter, report_id, domain)` upsert is delete-then-insert) and skew
the alignment statistics the panel shows. Inherent to how `rua` works:
reporters are arbitrary third parties and the mailbox address is published in
DNS. The ingest already constrains the meaningful part — a report cannot be
filed under a domain this instance does not host, and the claimed domain is
cross-checked against the `+tag` of the delivery address — and the data is
advisory statistics only; nothing enforces off it. The trigger to revisit is
any feature that lets report contents drive an automatic action (policy
changes, rate limiting, alerting an operator into a config change).
- **The application auth-IP allow-list is enforced fail-open.** The check runs
in the journal milter (`internal/milter/authip.go`) with
`default_action=accept` and skips on store errors, so if the milter is down
the restriction is not applied; the SASL login itself always succeeds. This
is documented in [guide.md](guide.md) as **not an authentication boundary** —
it is a blast-radius limiter for a leaked application password, on the same
fail-open path as level-2 rate limiting, and a milter outage already stops
the journal from recording sends at all. The trigger to revisit is a
requirement for the allow-list to hold as a security boundary — at which
point it must move to the SASL/smtpd layer and fail closed.
- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.**
The journal milter cannot observe connection-close directly through
go-milter, so if a client disconnects without an explicit `Abort`, its
in-memory reservation is released by TTL expiry, not immediately. The map is
bounded by key and TTL (currently 10 minutes), so the effect is temporary
over-counting on that app/domain key under churn. Accepted deliberately:
immediate cleanup would require protocol hooks the current milter interface
does not expose; the TTL bound keeps it finite. Trigger to revisit: sustained
high-volume false throttling attributable to orphaned reservations.
- **Access to `mail.log` from the unprivileged panel.** The `/data/log` - **Access to `mail.log` from the unprivileged panel.** The `/data/log`
directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user directory is `2750 postfix:selfpost` and the file is `0640`: `postlogd` (user
`postfix`) writes, the panel reads through the shared `selfpost` group, and `postfix`) writes, the panel reads through the shared `selfpost` group, and
+14 -11
View File
@@ -199,17 +199,14 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeApp, appID) return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
} }
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate // SaveRateLimit stores the application-level rate limit (guide § Rate
// limiting). The caller has validated the IPs and numbers (security.md); the // limiting). The caller has validated the numbers (security.md); the milter
// milter reads the row live, so no reload is needed. // reads the row live, so no reload is needed.
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error { func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
return s.store.SetRateLimit(store.RateLimit{ rl.Scope = store.RateLimitScopeApp
Scope: store.RateLimitScopeApp, rl.RefID = appID
RefID: appID, rl.AllowedIPs = nil
AllowedIPs: ips, return s.store.SetRateLimit(rl)
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
} }
// ClearRateLimit removes the application-level rate limit (guide § Rate // ClearRateLimit removes the application-level rate limit (guide § Rate
@@ -218,6 +215,12 @@ func (s *Service) ClearRateLimit(appID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID) return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
} }
// UpdateAuthIPs sets client IP restriction for an application (guide § Rate
// limiting — authorization). The caller has validated the IPs (security.md).
func (s *Service) UpdateAuthIPs(appID int64, restrict bool, ips []string) error {
return s.store.UpdateApplicationAuthIPs(appID, restrict, ips)
}
// PurgeDomainSASL removes the SASL accounts of every application bound to a // PurgeDomainSASL removes the SASL accounts of every application bound to a
// domain. It must be called before the domain's registry rows are cascade- // domain. It must be called before the domain's registry rows are cascade-
// deleted, while the logins are still known (product.md). The registry rows and // deleted, while the logins are still known (product.md). The registry rows and
+45
View File
@@ -0,0 +1,45 @@
package atomicfile
import (
"fmt"
"os"
"path/filepath"
)
// Write writes data to path via a temp file in the same directory followed by
// a rename, so readers only ever see the complete old or new file.
func Write(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp %s: %w", tmpName, err)
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return fmt.Errorf("chmod temp %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync temp %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
}
cleanup = false
return nil
}
+109 -39
View File
@@ -1,15 +1,17 @@
// Package backup implements SelfPost's full-server backup and the restore // Package backup implements SelfPost's full-server backup and the restore
// version guard (architecture.md § Persistence). A full backup is a // version guard (architecture.md § Persistence). A full backup is a
// gzip-compressed tar of the consolidated persistent state under /data — the // gzip-compressed tar of the consolidated persistent state under /data — the
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the // SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
// SASL database — plus a manifest recording the SelfPost version that produced // SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix // certs/ from the operator's deploy directory, and a manifest recording the
// queue are deliberately excluded (architecture.md § Persistence). // SelfPost version that produced it. Postfix delivery logs under log/ are
// excluded (diagnostics, not state).
// //
// Restore is not a separate code path in the panel: a backup is extracted into // Restore is not a separate code path in the panel: a backup is extracted into
// the /data bind mount before first start, and the archive already carries // the operator's project directory (data/, docker-compose.yml, .env, certs/)
// everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender // before first start. The archive carries everything needed to bring the
// map — so the daemons can start on the extracted files alone. The // instance back — DKIM keys, sasldb2, sender map, queue, and deploy files —
// so the operator only adjusts hostname or proxy settings on a new host. The
// restore-specific steps the panel runs are CheckRestore, which refuses to // restore-specific steps the panel runs are CheckRestore, which refuses to
// boot if the manifest's version does not match the running binary so // boot if the manifest's version does not match the running binary so
// schema/format skew between versions cannot silently corrupt state // schema/format skew between versions cannot silently corrupt state
@@ -40,11 +42,21 @@ import (
// FormatFull identifies a full-server backup manifest. // FormatFull identifies a full-server backup manifest.
const FormatFull = "selfpost-full-backup" const FormatFull = "selfpost-full-backup"
// ManifestName is the manifest's filename, both inside the archive and, after a // ManifestName is the manifest's filename inside the data directory. After
// restore extraction, at the root of the data directory where CheckRestore // restore extraction it lives at data/manifest.json under the project root;
// looks for it. // CheckRestore reads it from the /data bind mount.
const ManifestName = "manifest.json" const ManifestName = "manifest.json"
// DataArchivePrefix is the path prefix for every /data entry in the archive.
const DataArchivePrefix = "data/"
// ComposeFileName and EnvFileName are required deploy files at the archive root.
const (
ComposeFileName = "docker-compose.yml"
EnvFileName = ".env"
CertsDirName = "certs"
)
// Manifest is the small JSON document embedded in every backup archive. Its // Manifest is the small JSON document embedded in every backup archive. Its
// Version is the single fact that makes restore safe: the panel refuses to // Version is the single fact that makes restore safe: the panel refuses to
// boot a data directory whose manifest version does not match its own binary // boot a data directory whose manifest version does not match its own binary
@@ -58,21 +70,23 @@ type Manifest struct {
// Params configures a backup. DataDir is the consolidated state root (/data); // Params configures a backup. DataDir is the consolidated state root (/data);
// DBPath is the live SQLite file within it, snapshotted consistently rather than // DBPath is the live SQLite file within it, snapshotted consistently rather than
// copied byte-for-byte while it may be mid-write; Version is stamped into the // copied byte-for-byte while it may be mid-write; Version is stamped into the
// manifest. // manifest; DeployRoot is the host project directory mounted read-only (holds
// docker-compose.yml, .env, and optionally certs/). OnWarn is called for
// non-fatal issues such as a missing certs/ directory.
type Params struct { type Params struct {
DataDir string DataDir string
DBPath string DBPath string
Version string Version string
DeployRoot string
OnWarn func(string)
} }
// excludedFromArchive lists the data-directory entries a backup never carries. // excludedFromArchive lists the data-directory entries a backup never carries.
// The live database files are replaced by a consistent VACUUM INTO snapshot // The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap // written under the canonical name; the setup token is transient bootstrap
// state; a stale manifest from a previous restore must not be re-captured (a // state; a stale manifest from a previous restore must not be re-captured (a
// fresh one is written instead); a "tls" directory holds the reverse proxy's // fresh one is written instead); a "tls" directory under /data is skipped when
// certificates, which are explicitly out of scope for a SelfPost backup // an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// (architecture.md § Persistence) — excluding it keeps that guarantee even when
// an operator points TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// delivery log plus its fourteen rotated files, which is diagnostic output, not // delivery log plus its fourteen rotated files, which is diagnostic output, not
// state to restore, and by far the largest thing under /data. // state to restore, and by far the largest thing under /data.
var excludedFromArchive = map[string]bool{ var excludedFromArchive = map[string]bool{
@@ -86,16 +100,22 @@ var excludedFromArchive = map[string]bool{
ManifestName: true, ManifestName: true,
} }
// Create writes a gzip-compressed tar backup to w. Archive entries are named // Create writes a gzip-compressed tar backup to w. Archive layout:
// relative to DataDir, so extracting the archive into the /data bind mount //
// reconstructs the state in place (architecture.md § Persistence). The SQLite // data/manifest.json, data/selfpost.db, data/<rest of /data>
// database is added as a consistent snapshot under "selfpost.db"; everything // docker-compose.yml, .env, certs/...
// else under DataDir is copied as-is except the entries in //
// excludedFromArchive. // Extract the archive into an empty project directory, then docker compose up.
func Create(w io.Writer, p Params) error { func Create(w io.Writer, p Params) error {
if p.DataDir == "" || p.DBPath == "" { if p.DataDir == "" || p.DBPath == "" {
return fmt.Errorf("backup: DataDir and DBPath are required") return fmt.Errorf("backup: DataDir and DBPath are required")
} }
if p.DeployRoot == "" {
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
}
if err := validateDeployRoot(p.DeployRoot); err != nil {
return err
}
snapshot, cleanup, err := snapshotDB(p.DBPath) snapshot, cleanup, err := snapshotDB(p.DBPath)
if err != nil { if err != nil {
@@ -115,17 +135,21 @@ func Create(w io.Writer, p Params) error {
if err != nil { if err != nil {
return fmt.Errorf("backup: encode manifest: %w", err) return fmt.Errorf("backup: encode manifest: %w", err)
} }
if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil { if err := writeTarBytes(tw, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil {
return err return err
} }
// The consistent SQLite snapshot, under the canonical filename the panel // The consistent SQLite snapshot, under the canonical filename the panel
// opens on start (the live file and its WAL/SHM are excluded from the walk). // opens on start (the live file and its WAL/SHM are excluded from the walk).
if err := writeTarFile(tw, "selfpost.db", 0o640, snapshot); err != nil { if err := writeTarFile(tw, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil {
return err return err
} }
if err := addTree(tw, p.DataDir); err != nil { if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil {
return err
}
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil {
return err return err
} }
@@ -138,33 +162,79 @@ func Create(w io.Writer, p Params) error {
return nil return nil
} }
// addTree walks dataDir and adds every regular file (and directory, to preserve // ValidateDeployRoot checks that the operator project directory is mounted and
// empty ones and modes) to tw under its path relative to dataDir, skipping the // contains the files a full backup requires. Call before streaming a response.
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are func ValidateDeployRoot(deployRoot string) error {
// skipped: /data holds none in normal operation, and copying them into a backup if deployRoot == "" {
// would be meaningless or unsafe. return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
func addTree(tw *tar.Writer, dataDir string) error { }
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error { return validateDeployRoot(deployRoot)
}
func validateDeployRoot(deployRoot string) error {
for _, name := range []string{ComposeFileName, EnvFileName} {
path := filepath.Join(deployRoot, name)
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("backup: deploy root %q is missing %s: %w", deployRoot, name, err)
}
}
return nil
}
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
for _, name := range []string{ComposeFileName, EnvFileName} {
src := filepath.Join(deployRoot, name)
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("backup: stat deploy file %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("backup: deploy file %s is not a regular file", name)
}
if err := writeTarFile(tw, name, info.Mode().Perm(), src); err != nil {
return err
}
}
certsDir := filepath.Join(deployRoot, CertsDirName)
if _, err := os.Stat(certsDir); err != nil {
if os.IsNotExist(err) {
if onWarn != nil {
onWarn("certs/ not found in deploy root; backup will not include TLS material")
}
return nil
}
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
}
return addTree(tw, certsDir, CertsDirName+"/", nil)
}
// addTree walks root and adds every regular file (and directory, to preserve
// empty ones and modes) to tw under archivePrefix + path relative to root.
// When exclude is non-nil, top-level names relative to root are skipped.
// Non-regular, non-directory entries (symlinks, sockets) are skipped.
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
rel, err := filepath.Rel(dataDir, path) rel, err := filepath.Rel(root, path)
if err != nil { if err != nil {
return err return err
} }
if rel == "." { if rel == "." {
return nil // the data root itself is implicit return nil
} }
// filepath.Rel yields OS separators; tar names use forward slashes. name := archivePrefix + filepath.ToSlash(rel)
name := filepath.ToSlash(rel) if exclude != nil {
// Exclude by top-level name (the live DB, setup token and stale manifest top := strings.Split(filepath.ToSlash(rel), "/")[0]
// all live at the data root). if exclude[top] {
if excludedFromArchive[name] {
if d.IsDir() { if d.IsDir() {
return fs.SkipDir return fs.SkipDir
} }
return nil return nil
} }
}
info, err := d.Info() info, err := d.Info()
if err != nil { if err != nil {
@@ -182,7 +252,7 @@ func addTree(tw *tar.Writer, dataDir string) error {
case info.Mode().IsRegular(): case info.Mode().IsRegular():
return writeTarFile(tw, name, info.Mode().Perm(), path) return writeTarFile(tw, name, info.Mode().Perm(), path)
default: default:
return nil // skip symlinks/sockets/devices return nil
} }
}) })
} }
+41 -15
View File
@@ -15,10 +15,15 @@ import (
) )
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the // seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
// DKIM key, SASL and transient files a backup must include or exclude. // DKIM key, SASL and transient files a backup must include or exclude. It also
func seedDataDir(t *testing.T) (dataDir, dbPath string) { // seeds a deploy root beside data/ with compose, .env, and certs/.
func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
t.Helper() t.Helper()
dataDir = t.TempDir() deployRoot = t.TempDir()
dataDir = filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
dbPath = filepath.Join(dataDir, "selfpost.db") dbPath = filepath.Join(dataDir, "selfpost.db")
st, err := store.Open(dbPath) st, err := store.Open(dbPath)
@@ -35,6 +40,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY") writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB") writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login") writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
// Transient files that must NOT be archived. // Transient files that must NOT be archived.
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token") writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal") writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
@@ -43,7 +49,11 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
// state, and the bulkiest thing under /data. // state, and the bulkiest thing under /data.
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)") writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older") writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
return dataDir, dbPath writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
writeFile(t, filepath.Join(deployRoot, EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
writeFile(t, filepath.Join(deployRoot, CertsDirName, "fullchain.pem"), "CERT")
writeFile(t, filepath.Join(deployRoot, CertsDirName, "privkey.pem"), "KEY")
return dataDir, dbPath, deployRoot
} }
func writeFile(t *testing.T, path, content string) { func writeFile(t *testing.T, path, content string) {
@@ -87,21 +97,26 @@ func readArchive(t *testing.T, data []byte) map[string]string {
} }
func TestCreateIncludesStateExcludesTransient(t *testing.T) { func TestCreateIncludesStateExcludesTransient(t *testing.T) {
dataDir, dbPath := seedDataDir(t) dataDir, dbPath, deployRoot := seedDataDir(t)
var buf bytes.Buffer var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil { if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
files := readArchive(t, buf.Bytes()) files := readArchive(t, buf.Bytes())
// Present. // Present.
for _, name := range []string{ for _, name := range []string{
ManifestName, DataArchivePrefix + ManifestName,
"selfpost.db", DataArchivePrefix + "selfpost.db",
"opendkim/keys/example.com/selfpost.private", DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
"sasl/sasldb2", DataArchivePrefix + "sasl/sasldb2",
"postfix/sender_login_maps", DataArchivePrefix + "postfix/sender_login_maps",
DataArchivePrefix + "postfix/queue/deferred/sample",
ComposeFileName,
EnvFileName,
CertsDirName + "/fullchain.pem",
CertsDirName + "/privkey.pem",
} { } {
if _, ok := files[name]; !ok { if _, ok := files[name]; !ok {
t.Errorf("archive missing %s", name) t.Errorf("archive missing %s", name)
@@ -109,8 +124,11 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
} }
// Excluded. // Excluded.
for _, name := range []string{ for _, name := range []string{
"setup-token", "selfpost.db-wal", "selfpost.db-shm", DataArchivePrefix + "setup-token",
"log/mail.log", "log/mail.log.1", DataArchivePrefix + "selfpost.db-wal",
DataArchivePrefix + "selfpost.db-shm",
DataArchivePrefix + "log/mail.log",
DataArchivePrefix + "log/mail.log.1",
} { } {
if _, ok := files[name]; ok { if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name) t.Errorf("archive should not contain %s", name)
@@ -119,7 +137,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
// Manifest is well-formed and carries the version. // Manifest is well-formed and carries the version.
var m Manifest var m Manifest
if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil { if err := json.Unmarshal([]byte(files[DataArchivePrefix+ManifestName]), &m); err != nil {
t.Fatalf("manifest json: %v", err) t.Fatalf("manifest json: %v", err)
} }
if m.Format != FormatFull || m.Version != "1.2.3" { if m.Format != FormatFull || m.Version != "1.2.3" {
@@ -128,7 +146,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
// The archived selfpost.db is a real, openable SQLite snapshot with our data. // The archived selfpost.db is a real, openable SQLite snapshot with our data.
snapPath := filepath.Join(t.TempDir(), "restored.db") snapPath := filepath.Join(t.TempDir(), "restored.db")
if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil { if err := os.WriteFile(snapPath, []byte(files[DataArchivePrefix+"selfpost.db"]), 0o640); err != nil {
t.Fatalf("write snapshot: %v", err) t.Fatalf("write snapshot: %v", err)
} }
st, err := store.Open(snapPath) st, err := store.Open(snapPath)
@@ -145,6 +163,14 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
} }
} }
func TestCreateRequiresDeployRoot(t *testing.T) {
dataDir, dbPath, _ := seedDataDir(t)
var buf bytes.Buffer
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0"}); err == nil {
t.Fatal("Create without DeployRoot succeeded")
}
}
func writeManifest(t *testing.T, dir, format, version string) string { func writeManifest(t *testing.T, dir, format, version string) string {
t.Helper() t.Helper()
path := filepath.Join(dir, ManifestName) path := filepath.Join(dir, ManifestName)
+28
View File
@@ -0,0 +1,28 @@
// Package configsafe holds the defence-in-depth check that every generated
// configuration file shares: a value about to be written into a table line must
// be non-empty and must not carry a character that could end the field, end the
// line, or otherwise change what the file means (security.md).
//
// The forbidden set stays with the caller rather than living here, because what
// breaks a line differs per format — a comma separates values in a Postfix map,
// a colon separates fields in an OpenDKIM table — and the set belongs next to
// the writer it protects, where a reviewer of that writer can see it.
package configsafe
import (
"fmt"
"strings"
)
// Token reports whether value is safe to interpolate into a config line. what
// names the field for the error message ("domain", "login", …) and forbidden
// lists the characters the format cannot carry.
func Token(what, value, forbidden string) error {
if value == "" {
return fmt.Errorf("empty %s", what)
}
if strings.ContainsAny(value, forbidden) {
return fmt.Errorf("unsafe character in %s %q", what, value)
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package configsafe
import "testing"
// The check is the last line of defence before a value reaches a config file,
// so it must reject both halves of the problem: an empty value that would write
// a malformed line, and any character from the caller's forbidden set.
func TestToken(t *testing.T) {
if err := Token("domain", "example.com", " \t\r\n,\\"); err != nil {
t.Fatalf("clean value rejected: %v", err)
}
if err := Token("domain", "", " "); err == nil {
t.Fatal("empty value accepted")
}
for _, bad := range []string{"a b", "a\tb", "a\rb", "a\nb", "a,b", "a\\b"} {
if err := Token("domain", bad, " \t\r\n,\\"); err == nil {
t.Errorf("Token(%q) = nil, want error", bad)
}
}
// A character outside the caller's set is that format's business, not this
// function's: a colon is fatal in an OpenDKIM table and fine in a texthash.
if err := Token("domain", "a:b", " \t\r\n,\\"); err != nil {
t.Errorf("character outside the forbidden set rejected: %v", err)
}
}
+49
View File
@@ -0,0 +1,49 @@
package dmarc
import (
"strings"
)
// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address.
const DefaultLocalPart = "dmarc-reports"
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
func HostedReportAddress(hostname, domain string) string {
hostname = strings.ToLower(strings.TrimSpace(hostname))
domain = strings.ToLower(strings.TrimSpace(domain))
return DefaultLocalPart + "+" + domain + "@" + hostname
}
// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on.
func DefaultHostedReportAddress(hostname string) string {
return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname))
}
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
func IsHostedOnHostname(addr, hostname string) bool {
addr = strings.ToLower(strings.TrimSpace(addr))
hostname = strings.ToLower(strings.TrimSpace(hostname))
if hostname == "" || !strings.Contains(addr, "@") {
return false
}
at := strings.LastIndex(addr, "@")
return addr[at+1:] == hostname
}
// TaggedDomain returns the domain a hosted report address is tagged for
// (dmarc-reports+<domain>@<hostname> → <domain>), or "" when addr carries no
// such tag. Used to check a report's claimed domain against the address it
// actually arrived at.
func TaggedDomain(addr string) string {
addr = strings.ToLower(strings.TrimSpace(addr))
at := strings.LastIndex(addr, "@")
if at <= 0 {
return ""
}
local := addr[:at]
plus := strings.Index(local, "+")
if plus < 0 || local[:plus] != DefaultLocalPart {
return ""
}
return local[plus+1:]
}
+166
View File
@@ -0,0 +1,166 @@
package dmarc
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/textproto"
"strings"
"time"
"github.com/mixeme/selfpost/internal/store"
)
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
// aggregate attachment it finds.
//
// The report XML is unauthenticated input: anyone who can reach an allow-listed
// rua= address can post one, and the address form is predictable. So the domain
// the report claims is checked twice before anything is stored — it must be a
// domain this relay actually sends for, and, when the report arrived at a
// per-domain hosted address (dmarc-reports+<domain>@<hostname>), it must be the
// domain that address is tagged for. Without this a stranger could file reports
// under someone else's domain, skew the panel's alignment figures, and push
// genuine reports out through the retention cap.
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
if err != nil {
return fmt.Errorf("read message: %w", err)
}
payload, err := extractAggregatePayload(raw)
if err != nil {
return err
}
parsed, err := ParseAggregate(payload)
if err != nil {
return err
}
recipient = strings.ToLower(strings.TrimSpace(recipient))
if err := checkReportDomain(st, parsed.Domain, recipient); err != nil {
return err
}
rep := store.DMARCReport{
Domain: parsed.Domain,
Reporter: parsed.Reporter,
ReportID: parsed.ReportID,
PeriodBegin: parsed.PeriodBegin,
PeriodEnd: parsed.PeriodEnd,
ReceivedAt: receivedAt.UTC(),
ContactEmail: parsed.ContactEmail,
PolicyP: parsed.PolicyP,
PolicySP: parsed.PolicySP,
PolicyPct: parsed.PolicyPct,
PolicyADKIM: parsed.PolicyADKIM,
PolicyASPF: parsed.PolicyASPF,
PassCount: parsed.PassCount,
FailCount: parsed.FailCount,
Recipient: recipient,
}
for _, rec := range parsed.Records {
rep.Records = append(rep.Records, store.DMARCReportRecord{
SourceIP: rec.SourceIP,
Count: rec.Count,
Disposition: rec.Disposition,
SPFResult: rec.SPFResult,
DKIMResult: rec.DKIMResult,
HeaderFrom: rec.HeaderFrom,
})
}
if _, err := st.InsertDMARCReport(rep); err != nil {
return err
}
return st.PruneDMARCReports()
}
// checkReportDomain refuses a report whose policy domain is not one of this
// relay's sending domains, or that contradicts the +tag of the address it was
// delivered to.
func checkReportDomain(st *store.Store, domain, recipient string) error {
if domain == "" {
return fmt.Errorf("dmarc report: no policy domain")
}
if tagged := TaggedDomain(recipient); tagged != "" && tagged != domain {
return fmt.Errorf("dmarc report: domain %q does not match the address it was sent to (%s)", domain, recipient)
}
ok, err := st.DomainExists(domain)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("dmarc report: %q is not a domain configured on this relay", domain)
}
return nil
}
func extractAggregatePayload(raw []byte) ([]byte, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("parse message: %w", err)
}
ct := msg.Header.Get("Content-Type")
if ct == "" {
return io.ReadAll(io.LimitReader(msg.Body, 8<<20))
}
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
return nil, fmt.Errorf("content-type: %w", err)
}
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil, fmt.Errorf("multipart without boundary")
}
mr := multipart.NewReader(msg.Body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("multipart: %w", err)
}
name := strings.ToLower(part.FileName())
if name == "" {
name = strings.ToLower(part.Header.Get("Content-Type"))
}
if !looksLikeAggregate(name) {
continue
}
data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part)
if err != nil {
return nil, err
}
if len(data) > 0 {
return data, nil
}
}
return nil, fmt.Errorf("no aggregate attachment found")
}
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
}
func looksLikeAggregate(name string) bool {
name = strings.ToLower(name)
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
}
func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding"))
var body io.Reader = r
switch encoding {
case "base64":
body = base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
body = quotedprintable.NewReader(r)
}
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
if err != nil {
return nil, fmt.Errorf("read part body: %w", err)
}
return data, nil
}
+216
View File
@@ -0,0 +1,216 @@
package dmarc
import (
"archive/zip"
"bytes"
"compress/gzip"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
)
// ParsedReport is the structured form of a DMARC aggregate XML payload.
type ParsedReport struct {
Reporter string
ReportID string
ContactEmail string
PeriodBegin time.Time
PeriodEnd time.Time
Domain string
PolicyP string
PolicySP string
PolicyPct int
PolicyADKIM string
PolicyASPF string
Records []ParsedRecord
PassCount int
FailCount int
}
// ParsedRecord is one <record> row from the aggregate XML.
type ParsedRecord struct {
SourceIP string
Count int
Disposition string
SPFResult string
DKIMResult string
HeaderFrom string
}
type feedbackXML struct {
XMLName xml.Name `xml:"feedback"`
Metadata struct {
OrgName string `xml:"org_name"`
Email string `xml:"email"`
ReportID string `xml:"report_id"`
DateRange struct {
Begin int64 `xml:"begin"`
End int64 `xml:"end"`
} `xml:"date_range"`
} `xml:"report_metadata"`
Policy struct {
Domain string `xml:"domain"`
P string `xml:"p"`
SP string `xml:"sp"`
Pct int `xml:"pct"`
ADKIM string `xml:"adkim"`
ASPF string `xml:"aspf"`
} `xml:"policy_published"`
Records []struct {
Row struct {
SourceIP string `xml:"source_ip"`
Count int `xml:"count"`
Policy struct {
Disposition string `xml:"disposition"`
DKIM string `xml:"dkim"`
SPF string `xml:"spf"`
} `xml:"policy_evaluated"`
} `xml:"row"`
Identifiers struct {
HeaderFrom string `xml:"header_from"`
} `xml:"identifiers"`
} `xml:"record"`
}
// ParseAggregate decodes gzip- or zip-compressed, or raw, DMARC aggregate XML.
func ParseAggregate(raw []byte) (ParsedReport, error) {
data, err := decompress(raw)
if err != nil {
return ParsedReport{}, err
}
var doc feedbackXML
if err := xml.Unmarshal(data, &doc); err != nil {
return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err)
}
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
}
out := ParsedReport{
Reporter: strings.TrimSpace(doc.Metadata.OrgName),
ReportID: strings.TrimSpace(doc.Metadata.ReportID),
ContactEmail: strings.TrimSpace(doc.Metadata.Email),
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)),
PolicyP: strings.TrimSpace(doc.Policy.P),
PolicySP: strings.TrimSpace(doc.Policy.SP),
PolicyPct: doc.Policy.Pct,
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM),
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF),
}
if out.PolicyPct == 0 {
out.PolicyPct = 100
}
for _, rec := range doc.Records {
row := ParsedRecord{
SourceIP: strings.TrimSpace(rec.Row.SourceIP),
Count: rec.Row.Count,
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition),
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF),
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM),
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom),
}
if row.Count <= 0 {
row.Count = 1
}
if dmarcRecordPasses(row) {
out.PassCount += row.Count
} else {
out.FailCount += row.Count
}
out.Records = append(out.Records, row)
}
return out, nil
}
func dmarcRecordPasses(r ParsedRecord) bool {
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
}
// maxAggregateXML bounds the decompressed payload, so a small archive cannot
// expand into an unbounded allocation.
const maxAggregateXML = 8 << 20
// decompress unwraps the container the reporter used. Both are seen in the
// wild: most senders gzip the XML, some (notably several Microsoft and Yahoo
// endpoints) ship it as a single-entry zip.
func decompress(raw []byte) ([]byte, error) {
switch {
case len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b:
return gunzip(raw)
case len(raw) >= 4 && raw[0] == 'P' && raw[1] == 'K' && raw[2] == 0x03 && raw[3] == 0x04:
return unzip(raw)
}
return raw, nil
}
func gunzip(raw []byte) ([]byte, error) {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("dmarc gzip: %w", err)
}
defer zr.Close()
data, err := io.ReadAll(io.LimitReader(zr, maxAggregateXML))
if err != nil {
return nil, fmt.Errorf("dmarc gzip read: %w", err)
}
return data, nil
}
// unzip returns the first .xml entry of a zip archive. Directories and any
// other members are skipped rather than trusted.
func unzip(raw []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return nil, fmt.Errorf("dmarc zip: %w", err)
}
for _, f := range zr.File {
if f.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(f.Name), ".xml") {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("dmarc zip open %s: %w", f.Name, err)
}
data, err := io.ReadAll(io.LimitReader(rc, maxAggregateXML))
rc.Close()
if err != nil {
return nil, fmt.Errorf("dmarc zip read %s: %w", f.Name, err)
}
return data, nil
}
return nil, fmt.Errorf("dmarc zip: no xml entry")
}
// TightenPolicyHint summarises whether raising p= looks reasonable.
func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
total := pass + fail
if total == 0 {
return "No messages in the reporting window yet."
}
failPct := float64(fail) * 100 / float64(total)
if fail > 0 {
for _, s := range sources {
if s.FailCount > 0 && !s.ThisRelay {
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
}
}
}
if failPct <= 2 && pass > 0 {
return "Alignment looks strong. Tightening p= may be reasonable."
}
if fail > 0 {
return "Some failures remain. Review sources before tightening p=."
}
return "Alignment looks clean for this window."
}
// SourceHint is a panel-facing rollup row with relay detection.
type SourceHint struct {
SourceIP string
PassCount int
FailCount int
ThisRelay bool
}
+216
View File
@@ -0,0 +1,216 @@
package dmarc
import (
"archive/zip"
"bytes"
"compress/gzip"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
const sampleXML = `<?xml version="1.0"?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply@google.com</email>
<report_id>12345</report_id>
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<p>none</p><sp>none</sp><pct>100</pct>
<adkim>r</adkim><aspf>r</aspf>
</policy_published>
<record>
<row>
<source_ip>203.0.113.10</source_ip>
<count>10</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim><spf>pass</spf>
</policy_evaluated>
</row>
<identifiers><header_from>example.com</header_from></identifiers>
</record>
<record>
<row>
<source_ip>198.51.100.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>fail</dkim><spf>fail</spf>
</policy_evaluated>
</row>
<identifiers><header_from>example.com</header_from></identifiers>
</record>
</feedback>`
func TestParseAggregateGzip(t *testing.T) {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write([]byte(sampleXML)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
got, err := ParseAggregate(buf.Bytes())
if err != nil {
t.Fatalf("ParseAggregate: %v", err)
}
if got.Domain != "example.com" || got.Reporter != "google.com" {
t.Fatalf("metadata = %+v", got)
}
if got.PassCount != 10 || got.FailCount != 2 {
t.Fatalf("counts = %d pass %d fail", got.PassCount, got.FailCount)
}
if !got.PeriodBegin.Equal(time.Unix(1723593600, 0).UTC()) {
t.Fatalf("period begin = %v", got.PeriodBegin)
}
}
func TestIngestMessageStoresReport(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
_, _ = zw.Write([]byte(sampleXML))
_ = zw.Close()
msg := bytes.NewBufferString(
"From: noreply@google.com\r\n" +
"To: dmarc-reports@mail.example.com\r\n" +
"Subject: Report\r\n" +
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
"\r\n",
)
// append raw gzip for simple single-part test
msg.Write(gz.Bytes())
if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil {
t.Fatalf("IngestMessage: %v", err)
}
list, err := st.ListDMARCReports(nil, 10)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("reports = %d", len(list))
}
if list[0].PassCount != 10 {
t.Fatalf("pass = %d", list[0].PassCount)
}
}
func TestHostedReportAddress(t *testing.T) {
got := HostedReportAddress("mail.example.com", "Example.COM")
want := "dmarc-reports+example.com@mail.example.com"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestIngestMessageRejectsUnknownDomain(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)), "dmarc-reports@mail.example.com", time.Now().UTC()); err == nil {
t.Fatal("report for an unconfigured domain was accepted")
}
list, err := st.ListDMARCReports(nil, 10)
if err != nil {
t.Fatal(err)
}
if len(list) != 0 {
t.Fatalf("stored %d report(s) for an unconfigured domain", len(list))
}
}
// A report claiming example.com must not be accepted at the address tagged for
// another domain, even when both domains are configured here.
func TestIngestMessageRejectsTagMismatch(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
if _, err := st.AddDomain("other.example", "selfpost"); err != nil {
t.Fatal(err)
}
err = IngestMessage(st, bytes.NewBufferString(gzipMessage(t, sampleXML)),
"dmarc-reports+other.example@mail.example.com", time.Now().UTC())
if err == nil {
t.Fatal("report was accepted at an address tagged for another domain")
}
}
func TestTaggedDomain(t *testing.T) {
cases := map[string]string{
"dmarc-reports+example.com@mail.example.com": "example.com",
"dmarc-reports@mail.example.com": "",
"postmaster+example.com@mail.example.com": "",
"not-an-address": "",
}
for addr, want := range cases {
if got := TaggedDomain(addr); got != want {
t.Errorf("TaggedDomain(%q) = %q, want %q", addr, got, want)
}
}
}
func TestParseAggregateFromZip(t *testing.T) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("report.xml")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(sampleXML)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
got, err := ParseAggregate(buf.Bytes())
if err != nil {
t.Fatalf("ParseAggregate(zip): %v", err)
}
if got.Domain != "example.com" || got.PassCount != 10 {
t.Fatalf("zip report = %+v", got)
}
}
// gzipMessage wraps xml in the single-part gzip message the ingest pipe sees.
func gzipMessage(t *testing.T, xml string) string {
t.Helper()
var gz bytes.Buffer
zw := gzip.NewWriter(&gz)
if _, err := zw.Write([]byte(xml)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return "From: noreply@google.com\r\n" +
"To: dmarc-reports@mail.example.com\r\n" +
"Subject: Report\r\n" +
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
"\r\n" + gz.String()
}
+131
View File
@@ -0,0 +1,131 @@
// Package dmarc receives DMARC aggregate reports: Postfix pipes messages here,
// gzip/XML is parsed, and summaries land in SQLite for the panel.
package dmarc
import (
"fmt"
"sort"
"strings"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// Maps is the Postfix manager surface the DMARC service needs.
type Maps interface {
RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error
}
// Service coordinates DMARC ingest allow-lists across SQLite and Postfix.
type Service struct {
store *store.Store
maps Maps
hostname string
enabled bool
}
// NewService builds the DMARC ingest service.
func NewService(st *store.Store, maps Maps, hostname string, enabled bool) *Service {
return &Service{store: st, maps: maps, hostname: strings.ToLower(strings.TrimSpace(hostname)), enabled: enabled}
}
// Enabled reports whether DMARC ingest is active in this deployment.
func (s *Service) Enabled() bool { return s.enabled }
// Resync rebuilds the Postfix allow-list and transport maps from SQLite.
func (s *Service) Resync() error {
if !s.enabled {
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{})
}
addrs, err := s.AllowedRecipients()
if err != nil {
return err
}
domains := recipientDomains(addrs)
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{
Recipients: addrs,
Domains: domains,
})
}
// AllowedRecipients returns every address Postfix may accept for DMARC ingest.
func (s *Service) AllowedRecipients() ([]string, error) {
if !s.enabled || s.hostname == "" {
return nil, nil
}
profile, err := s.store.GlobalDMARCReportEmail()
if err != nil {
return nil, err
}
seen := make(map[string]bool)
var out []string
add := func(addr string) {
addr = strings.ToLower(strings.TrimSpace(addr))
if addr == "" || !IsHostedOnHostname(addr, s.hostname) || seen[addr] {
return
}
seen[addr] = true
out = append(out, addr)
}
add(profile)
if profile == "" {
add(DefaultHostedReportAddress(s.hostname))
}
domains, err := s.store.ListDomains()
if err != nil {
return nil, err
}
for _, d := range domains {
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
if rua == "" {
continue
}
if IsHostedOnHostname(rua, s.hostname) {
add(rua)
continue
}
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
continue
}
hosted := HostedReportAddress(s.hostname, d.Name)
if strings.EqualFold(rua, hosted) {
add(rua)
}
}
sort.Strings(out)
return out, nil
}
func recipientDomains(addrs []string) []string {
seen := make(map[string]bool)
var out []string
for _, addr := range addrs {
d := dnscheck.EmailDomain(addr)
if d == "" || seen[d] {
continue
}
seen[d] = true
out = append(out, d)
}
sort.Strings(out)
return out
}
// HostedSuggestion returns the address the panel should suggest for a domain.
func (s *Service) HostedSuggestion(domain string) string {
return HostedReportAddress(s.hostname, domain)
}
// DefaultHostedSuggestion is the settings-level hosted address.
func (s *Service) DefaultHostedSuggestion() string {
return DefaultHostedReportAddress(s.hostname)
}
// ValidateHostedAddress ensures addr is on this hostname before saving.
func (s *Service) ValidateHostedAddress(addr string) error {
if !IsHostedOnHostname(addr, s.hostname) {
return fmt.Errorf("hosted report address must be on %s", s.hostname)
}
return nil
}
+71
View File
@@ -13,6 +13,7 @@ package dnscheck
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net" "net"
"strings" "strings"
"sync" "sync"
@@ -98,6 +99,7 @@ type Checker struct {
mu sync.Mutex mu sync.Mutex
servers map[string]cached[Server] servers map[string]cached[Server]
domains map[string]cached[Domain] domains map[string]cached[Domain]
inbound map[string]cached[Result]
} }
type cached[T any] struct { type cached[T any] struct {
@@ -120,6 +122,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
domainTTL: domTTL, domainTTL: domTTL,
servers: make(map[string]cached[Server]), servers: make(map[string]cached[Server]),
domains: make(map[string]cached[Domain]), domains: make(map[string]cached[Domain]),
inbound: make(map[string]cached[Result]),
} }
} }
@@ -172,9 +175,77 @@ func (c *Checker) Domain(q Query, force bool) Domain {
func (c *Checker) Forget(domainName string) { func (c *Checker) Forget(domainName string) {
c.mu.Lock() c.mu.Lock()
delete(c.domains, domainName) delete(c.domains, domainName)
delete(c.inbound, domainName)
c.mu.Unlock() c.mu.Unlock()
} }
// InboundMX reports whether any MX for name points at this server's hostname
// (the inbound-relay check). Other MX values are the domain's own primaries
// and are not an error. force skips the cache.
func (c *Checker) InboundMX(name, hostname string, force bool) Result {
key := name + "\x00" + hostname
if !force {
c.mu.Lock()
entry, ok := c.inbound[key]
c.mu.Unlock()
if ok && time.Now().Before(entry.expires) {
return entry.value
}
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
result := c.checkInboundMX(ctx, name, hostname)
c.mu.Lock()
c.inbound[key] = cached[Result]{value: result, expires: time.Now().Add(c.domainTTL)}
c.mu.Unlock()
return result
}
func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result {
mxs, err := c.resolver.LookupMX(ctx, name)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
}
}
return lookupFailed("the MX record", err)
}
if len(mxs) == 0 {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
}
}
want := normalizeName(hostname)
var records []string
matched := false
for _, mx := range mxs {
host := normalizeName(mx.Host)
records = append(records, fmt.Sprintf("%d %s.", mx.Pref, host))
if host == want {
matched = true
}
}
if matched {
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("An MX points at %s (this server). Other MX values are the domain's own primaries — they are not an error.", hostname),
Records: records,
}
}
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX points at %s (this server). Publish the record below, or wait for DNS to propagate and Re-check.", hostname),
Records: records,
}
}
// checkDomain runs the three record checks concurrently: they are independent, // checkDomain runs the three record checks concurrently: they are independent,
// and in series three timeouts would stack up into a page that looks hung. // and in series three timeouts would stack up into a page that looks hung.
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain { func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
+34
View File
@@ -374,3 +374,37 @@ func TestReportAuth(t *testing.T) {
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample()) t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
} }
} }
func TestInboundMXPointsAtServer(t *testing.T) {
f := &fakeResolver{
mx: map[string][]*net.MX{
"lists.example.com": {
{Host: "mail.primary.example.net.", Pref: 10},
{Host: "mail.example.org.", Pref: 20},
},
},
}
got := newTestChecker(f).InboundMX("lists.example.com", "mail.example.org", false)
if got.Status != health.StatusOK {
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
}
}
func TestInboundMXMissingThisServer(t *testing.T) {
f := &fakeResolver{
mx: map[string][]*net.MX{
"backup.example.net": {{Host: "mail.primary.example.net.", Pref: 10}},
},
}
got := newTestChecker(f).InboundMX("backup.example.net", "mail.example.org", false)
if got.Status != health.StatusError {
t.Fatalf("status = %q, want error", got.Status)
}
}
func TestInboundMXAbsent(t *testing.T) {
got := newTestChecker(&fakeResolver{}).InboundMX("none.example", "mail.example.org", false)
if got.Status != health.StatusError {
t.Fatalf("status = %q, want error", got.Status)
}
}
+3 -34
View File
@@ -13,7 +13,8 @@ import (
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"os" "os"
"path/filepath"
"github.com/mixeme/selfpost/internal/atomicfile"
) )
// dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM // dkimKeyBits is the RSA key size for DKIM signing keys. 2048 is the DKIM
@@ -91,37 +92,5 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables // It is the single safe-write primitive for DKIM keys and OpenDKIM tables
// (security.md). // (security.md).
func writeFileAtomic(path string, data []byte, perm os.FileMode) error { func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path) return atomicfile.Write(path, data, perm)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temp %s: %w", tmpName, err)
}
if err := tmp.Chmod(perm); err != nil {
tmp.Close()
return fmt.Errorf("chmod temp %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync temp %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename %s -> %s: %w", tmpName, path, err)
}
cleanup = false
return nil
} }
+17 -16
View File
@@ -5,10 +5,13 @@ import (
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"os" "os"
"os/exec" "path"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"github.com/mixeme/selfpost/internal/configsafe"
"github.com/mixeme/selfpost/internal/supervisor"
) )
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for // OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
@@ -191,7 +194,7 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
} }
keyName := d.Name // one key per domain; the domain name is a fine handle keyName := d.Name // one key per domain; the domain name is a fine handle
// Absolute key path so OpenDKIM resolves it independently of its CWD. // Absolute key path so OpenDKIM resolves it independently of its CWD.
keyFile := filepath.Join(keysDir, d.Name, d.Selector+".private") keyFile := path.Join(keysDir, d.Name, d.Selector+".private")
// KeyTable: <key-name> <domain>:<selector>:<key-path> // KeyTable: <key-name> <domain>:<selector>:<key-path>
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile) fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
// SigningTable (refile): <address-pattern> <key-name> // SigningTable (refile): <address-pattern> <key-name>
@@ -200,18 +203,23 @@ func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTab
return []byte(kt.String()), []byte(st.String()), nil return []byte(kt.String()), []byte(st.String()), nil
} }
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
// value: whitespace and the newline that would end the line, and the colon and
// path separators that carry meaning inside a table entry.
const opendkimForbidden = " \t\r\n:/\\"
// assertConfigSafe rejects any domain/selector value that could break out of a // assertConfigSafe rejects any domain/selector value that could break out of a
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors // single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
// to a similar set before they reach here (security.md); this is defence in depth // to a similar set before they reach here (security.md); this is defence in depth
// against a validation gap ever letting whitespace, a newline or a field // against a validation gap ever letting whitespace, a newline or a field
// separator through into a config file (security.md). // separator through into a config file (security.md).
func assertConfigSafe(domainName, selector string) error { func assertConfigSafe(domainName, selector string) error {
for _, v := range []string{domainName, selector} { for _, f := range []struct{ what, value string }{
if v == "" { {"domain", domainName},
return fmt.Errorf("opendkim: empty domain or selector") {"selector", selector},
} } {
if strings.ContainsAny(v, " \t\r\n:/\\") { if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil {
return fmt.Errorf("opendkim: unsafe character in %q", v) return fmt.Errorf("opendkim: %w", err)
} }
} }
return nil return nil
@@ -226,12 +234,5 @@ func assertConfigSafe(domainName, selector string) error {
// Arguments are fixed literals — no user input is interpolated into the command, // Arguments are fixed literals — no user input is interpolated into the command,
// and it never goes through a shell (security.md). // and it never goes through a shell (security.md).
func reloadViaSupervisor() error { func reloadViaSupervisor() error {
cmd := exec.Command("supervisorctl", return supervisor.Signal("USR1", "opendkim")
"-c", "/etc/supervisor/supervisord.conf",
"signal", "USR1", "opendkim")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("reload opendkim via supervisor: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
} }
+5 -8
View File
@@ -147,14 +147,11 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
// SaveRateLimit stores the domain-level rate limit. The caller has validated the // SaveRateLimit stores the domain-level rate limit. The caller has validated the
// numbers (security.md); the milter reads the row live, so no reload is needed. // numbers (security.md); the milter reads the row live, so no reload is needed.
// Domain limits do not use an IP allowlist. // Domain limits do not use an IP allowlist.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error { func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
return s.store.SetRateLimit(store.RateLimit{ rl.Scope = store.RateLimitScopeDomain
Scope: store.RateLimitScopeDomain, rl.RefID = domainID
RefID: domainID, rl.AllowedIPs = nil
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel return s.store.SetRateLimit(rl)
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
} }
// ClearRateLimit removes the domain-level rate limit, falling back to level 1 // ClearRateLimit removes the domain-level rate limit, falling back to level 1
+98 -2
View File
@@ -25,15 +25,28 @@ type DomainExport struct {
DKIMSelector string `json:"dkim_selector"` DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none) DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
Applications []AppExport `json:"applications"` Applications []AppExport `json:"applications"`
} }
// RateLimitExport is the transferable level-2 limit for a domain or application.
type RateLimitExport struct {
Mode string `json:"mode,omitempty"`
MaxMessages int `json:"max_messages,omitempty"`
WindowSeconds int `json:"window_seconds,omitempty"`
AutoMultiplier float64 `json:"auto_multiplier,omitempty"`
AllowedIPs []string `json:"allowed_ips,omitempty"`
}
// AppExport is one application within a DomainExport. // AppExport is one application within a DomainExport.
type AppExport struct { type AppExport struct {
Login string `json:"login"` Login string `json:"login"`
AddressMode string `json:"address_mode"` AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"` Password string `json:"password"`
AuthIPRestrict bool `json:"auth_ip_restrict,omitempty"`
AuthAllowedIPs []string `json:"auth_allowed_ips,omitempty"`
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
} }
// Export builds the transferable representation of a domain: its DKIM key, its // Export builds the transferable representation of a domain: its DKIM key, its
@@ -65,17 +78,34 @@ func (s *Service) Export(id int64) (DomainExport, error) {
s := d.DMARCRua.String s := d.DMARCRua.String
exp.DMARCRua = &s exp.DMARCRua = &s
} }
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
if err != nil {
return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", d.Name, err)
}
if ok {
exp.RateLimit = exportRateLimit(rl)
}
for _, a := range apps { for _, a := range apps {
password, err := s.apps.Secret(a.Login) password, err := s.apps.Secret(a.Login)
if err != nil { if err != nil {
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err) return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
} }
exp.Applications = append(exp.Applications, AppExport{ appExp := AppExport{
Login: a.Login, Login: a.Login,
AddressMode: a.AddressMode, AddressMode: a.AddressMode,
Addresses: a.Addresses, Addresses: a.Addresses,
Password: password, Password: password,
}) AuthIPRestrict: a.AuthIPRestrict,
AuthAllowedIPs: a.AuthAllowedIPs,
}
appRL, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID)
if err != nil {
return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", a.Login, err)
}
if ok {
appExp.RateLimit = exportRateLimit(appRL)
}
exp.Applications = append(exp.Applications, appExp)
} }
return exp, nil return exp, nil
} }
@@ -123,12 +153,45 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
} }
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua} d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
} }
if exp.RateLimit != nil {
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
}
for _, a := range exp.Applications { for _, a := range exp.Applications {
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil { if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
s.importRollback(d.ID) s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err) return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
} }
restrict, ips := a.AuthIPRestrict, a.AuthAllowedIPs
// Legacy exports stored client IPs on the rate-limit row as trusted IPs.
if a.RateLimit != nil && len(a.RateLimit.AllowedIPs) > 0 {
restrict = true
ips = a.RateLimit.AllowedIPs
}
needAuthIPs := restrict || len(ips) > 0
needRateLimit := a.RateLimit != nil
if needAuthIPs || needRateLimit {
app, err := s.store.GetApplicationByLogin(a.Login)
if err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
}
if needAuthIPs {
if err := s.store.UpdateApplicationAuthIPs(app.ID, restrict, ips); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import auth IPs for %q: %w", a.Login, err)
}
}
if needRateLimit {
if err := s.importRateLimit(store.RateLimitScopeApp, app.ID, *a.RateLimit); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err)
}
}
}
} }
if err := s.apps.Resync(); err != nil { if err := s.apps.Resync(); err != nil {
s.importRollback(d.ID) s.importRollback(d.ID)
@@ -145,3 +208,36 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
func (s *Service) importRollback(id int64) { func (s *Service) importRollback(id int64) {
_ = s.Delete(id) _ = s.Delete(id)
} }
func exportRateLimit(rl store.RateLimit) *RateLimitExport {
mode := rl.Mode
if mode == "" {
mode = store.RateLimitModeManual
}
exp := &RateLimitExport{
Mode: mode,
MaxMessages: rl.MaxMessages,
WindowSeconds: rl.WindowSeconds,
AutoMultiplier: rl.AutoMultiplier,
}
return exp
}
func (s *Service) importRateLimit(scope string, refID int64, exp RateLimitExport) error {
mode := exp.Mode
if mode == "" {
mode = store.RateLimitModeManual
}
rl := store.RateLimit{
Scope: scope,
RefID: refID,
Mode: mode,
MaxMessages: exp.MaxMessages,
WindowSeconds: exp.WindowSeconds,
AutoMultiplier: exp.AutoMultiplier,
}
if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
return nil
}
return s.store.SetRateLimit(rl)
}
+156
View File
@@ -0,0 +1,156 @@
// Package inbound owns backup-MX / relay-forwarder domains: the SQLite
// registry and the Postfix lookup tables (relay_domains, transport_maps,
// relay_recipient_maps, smtp_tls_policy_maps). It does not listen on port 25
// itself — postfix-config.sh does that when INBOUND_RELAY_ENABLE is true.
package inbound
import (
"fmt"
"strings"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// Maps is the slice of the Postfix manager the inbound service needs.
type Maps interface {
RebuildInboundMaps(routes []postfix.InboundRoute) error
}
// Service coordinates inbound-domain state across SQLite and the Postfix maps.
type Service struct {
store *store.Store
maps Maps
}
// NewService builds the inbound service over the shared store and Postfix maps.
func NewService(st *store.Store, maps Maps) *Service {
return &Service{store: st, maps: maps}
}
// List returns every inbound domain.
func (s *Service) List() ([]store.InboundDomain, error) {
return s.store.ListInboundDomains()
}
// Get returns one inbound domain by id.
func (s *Service) Get(id int64) (store.InboundDomain, error) {
return s.store.GetInboundDomain(id)
}
// Add validates the domain name, inserts it, and rebuilds the maps.
func (s *Service) Add(name string) (store.InboundDomain, error) {
name = normalizeDomain(name)
if err := checkDomain(name); err != nil {
return store.InboundDomain{}, err
}
d, err := s.store.AddInboundDomain(name)
if err != nil {
return store.InboundDomain{}, err
}
if err := s.Resync(); err != nil {
_ = s.store.DeleteInboundDomain(d.ID)
return store.InboundDomain{}, err
}
return d, nil
}
// SetTransport validates and saves the upstream, then rebuilds the maps.
func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
if _, err := s.store.GetInboundDomain(id); err != nil {
return err
}
host = normalizeHost(host)
if err := checkHost(host); err != nil {
return err
}
port, err := parsePort(portRaw)
if err != nil {
return err
}
if err := checkTLSMode(tlsMode); err != nil {
return err
}
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
return err
}
return s.Resync()
}
// SetRecipients validates the mode and, in list mode, every address, then
// rebuilds the maps.
func (s *Service) SetRecipients(id int64, mode string, rawAddresses []string) error {
d, err := s.store.GetInboundDomain(id)
if err != nil {
return err
}
if err := checkRecipientMode(mode); err != nil {
return err
}
var addrs []string
if mode == store.RecipientModeList {
addrs, err = parseRecipientAddresses(rawAddresses, d.Name)
if err != nil {
return err
}
}
if err := s.store.UpdateInboundRecipients(id, mode, addrs); err != nil {
return err
}
return s.Resync()
}
// Delete removes the domain and rebuilds the maps.
func (s *Service) Delete(id int64) error {
if err := s.store.DeleteInboundDomain(id); err != nil {
return err
}
return s.Resync()
}
// Resync rebuilds the inbound Postfix maps from SQLite.
func (s *Service) Resync() error {
list, err := s.store.ListInboundDomains()
if err != nil {
return err
}
routes := make([]postfix.InboundRoute, 0, len(list))
for _, d := range list {
full, err := s.store.GetInboundDomain(d.ID)
if err != nil {
return err
}
routes = append(routes, postfix.InboundRoute{
Domain: full.Name,
Host: full.Host,
Port: full.Port,
TLSMode: full.TLSMode,
RecipientMode: full.RecipientMode,
Recipients: full.Recipients,
})
}
return s.maps.RebuildInboundMaps(routes)
}
func parseRecipientAddresses(raw []string, domain string) ([]string, error) {
seen := make(map[string]bool)
out := make([]string, 0, len(raw))
for _, r := range raw {
addr := strings.ToLower(strings.TrimSpace(r))
if addr == "" {
continue
}
if err := checkMailbox(addr, domain); err != nil {
return nil, err
}
if seen[addr] {
continue
}
seen[addr] = true
out = append(out, addr)
}
if len(out) == 0 {
return nil, fmt.Errorf("listed-recipients mode requires at least one address")
}
return out, nil
}
+111
View File
@@ -0,0 +1,111 @@
package inbound
import (
"errors"
"path/filepath"
"testing"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
type fakeMaps struct {
routes []postfix.InboundRoute
err error
rebuild int
}
func (f *fakeMaps) RebuildInboundMaps(routes []postfix.InboundRoute) error {
f.rebuild++
f.routes = append([]postfix.InboundRoute(nil), routes...)
return f.err
}
func testService(t *testing.T) (*Service, *store.Store, *fakeMaps) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
m := &fakeMaps{}
return NewService(st, m), st, m
}
func TestAddAndSetTransport(t *testing.T) {
s, _, m := testService(t)
d, err := s.Add("Lists.Example.COM")
if err != nil {
t.Fatal(err)
}
if d.Name != "lists.example.com" {
t.Fatalf("name = %q", d.Name)
}
if m.rebuild != 1 {
t.Fatalf("rebuild after add = %d", m.rebuild)
}
// Empty host is omitted from maps.
if len(m.routes) != 1 || m.routes[0].Host != "" {
t.Fatalf("routes after add: %+v", m.routes)
}
if err := s.SetTransport(d.ID, "10.0.0.8", "25", store.TLSModeEncrypt); err != nil {
t.Fatal(err)
}
if m.routes[0].Host != "10.0.0.8" || m.routes[0].TLSMode != store.TLSModeEncrypt {
t.Fatalf("routes after transport: %+v", m.routes[0])
}
}
func TestSetRecipientsValidatesDomain(t *testing.T) {
s, _, _ := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
err = s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@other.com"})
if err == nil {
t.Fatal("expected foreign-domain error")
}
err = s.SetRecipients(d.ID, store.RecipientModeList, nil)
if err == nil {
t.Fatal("expected empty-list error")
}
if err := s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@lists.example.com"}); err != nil {
t.Fatal(err)
}
}
func TestRejectsUnsafeHost(t *testing.T) {
s, _, _ := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
if err := s.SetTransport(d.ID, "10.0.0.8; rm", "25", store.TLSModeMay); err == nil {
t.Fatal("expected unsafe host to be rejected")
}
}
func TestDeleteResyncs(t *testing.T) {
s, _, m := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
if err := s.Delete(d.ID); err != nil {
t.Fatal(err)
}
if !errors.Is(mustGet(t, s, d.ID), store.ErrInboundDomainNotFound) {
t.Fatal("domain still present")
}
if len(m.routes) != 0 {
t.Fatalf("maps after delete: %+v", m.routes)
}
}
func mustGet(t *testing.T, s *Service, id int64) error {
t.Helper()
_, err := s.Get(id)
return err
}
+131
View File
@@ -0,0 +1,131 @@
package inbound
import (
"fmt"
"net"
"strconv"
"strings"
)
func normalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func normalizeHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return host
}
func checkDomain(name string) error {
if name == "" {
return fmt.Errorf("domain is required")
}
if len(name) > 253 {
return fmt.Errorf("domain must be at most 253 characters")
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
}
for _, label := range labels {
if err := checkLabel(label); err != nil {
return err
}
}
return nil
}
func checkHost(host string) error {
if host == "" {
return fmt.Errorf("host is required")
}
if len(host) > 253 {
return fmt.Errorf("host must be at most 253 characters")
}
if ip := net.ParseIP(host); ip != nil {
return nil
}
for _, label := range strings.Split(host, ".") {
if err := checkLabel(label); err != nil {
return fmt.Errorf("host is invalid: %w", err)
}
}
return nil
}
func checkLabel(label string) error {
if len(label) == 0 {
return fmt.Errorf("must not contain an empty label")
}
if len(label) > 63 {
return fmt.Errorf("each label must be at most 63 characters")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("labels must not start or end with '-'")
}
for i := 0; i < len(label); i++ {
c := label[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '-' {
return fmt.Errorf("may contain only lower-case letters, digits, '.' and '-'")
}
}
return nil
}
func parsePort(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("port is required")
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 65535 {
return 0, fmt.Errorf("port must be between 1 and 65535")
}
return n, nil
}
func checkTLSMode(mode string) error {
switch mode {
case "may", "encrypt", "none":
return nil
default:
return fmt.Errorf("invalid TLS mode")
}
}
func checkRecipientMode(mode string) error {
switch mode {
case "list", "any":
return nil
default:
return fmt.Errorf("invalid recipient mode")
}
}
func checkMailbox(addr, domain string) error {
at := strings.LastIndexByte(addr, '@')
if at <= 0 || at >= len(addr)-1 {
return fmt.Errorf("%q is not a valid email address", addr)
}
local, host := addr[:at], addr[at+1:]
if host != domain {
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
}
if local == "" || local[0] == '.' || local[len(local)-1] == '.' {
return fmt.Errorf("%q: invalid local part", addr)
}
for i := 0; i < len(local); i++ {
c := local[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
return fmt.Errorf("%q: local part contains invalid characters", addr)
}
}
return nil
}
+21 -13
View File
@@ -39,17 +39,20 @@ type StatusStore interface {
// is a var so tests can shorten it. // is a var so tests can shorten it.
var pollInterval = time.Second var pollInterval = time.Second
// retentionInterval is how often the retention sweep runs (also once at
// startup). It is a var so tests can shorten it.
var retentionInterval = 6 * time.Hour
// RetentionInterval returns how often background send-log pruning runs.
func RetentionInterval() time.Duration {
return retentionInterval
}
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep. // queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
// It is a var so tests can answer without a running Postfix. // It is a var so tests can answer without a running Postfix.
var queueIDs = postfix.QueueIDs var queueIDs = postfix.QueueIDs
const ( const (
// retentionInterval is how often the retention sweep runs (also once at
// startup). The window itself is configurable; the cadence need not be.
retentionInterval = 6 * time.Hour
// defaultRetentionDays applies when the configured value is unset/invalid
// (guide § Environment variables: SEND_LOG_RETENTION_DAYS).
defaultRetentionDays = 90
// reconcileInterval is how often the sweep compares stuck rows against the // reconcileInterval is how often the sweep compares stuck rows against the
// Postfix queue, and reconcileGrace how long a row is left alone first. // Postfix queue, and reconcileGrace how long a row is left alone first.
// The grace covers the ordinary lag between the milter writing the row and // The grace covers the ordinary lag between the milter writing the row and
@@ -59,6 +62,10 @@ const (
reconcileGrace = 2 * time.Minute reconcileGrace = 2 * time.Minute
) )
// RetentionDays returns the send-log retention window in days. The log-tailer
// calls it on every prune cycle so a panel change takes effect without restart.
type RetentionDays func() int
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient // deliveryRe matches a Postfix delivery line and captures queue-id, recipient
// and status, e.g. // and status, e.g.
// //
@@ -98,11 +105,11 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
} }
// Run follows path and updates send-log statuses until ctx is cancelled, while // Run follows path and updates send-log statuses until ctx is cancelled, while
// a background sweep prunes rows older than retentionDays. Reading resumes at // a background sweep prunes rows older than retention(). Reading resumes at
// the offset the previous run persisted, so a restart parses the delivery lines // the offset the previous run persisted, so a restart parses the delivery lines
// written while the panel was down. It returns nil on a clean shutdown. // written while the panel was down. It returns nil on a clean shutdown.
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error { func Run(ctx context.Context, path string, st StatusStore, retention RetentionDays) error {
go retentionLoop(ctx, st, retentionDays) go retentionLoop(ctx, st, retention)
// The reconcile sweep must not run against a backlog the tailer has not // The reconcile sweep must not run against a backlog the tailer has not
// read yet: on a restart the log holds the very lines that resolve the rows // read yet: on a restart the log holds the very lines that resolve the rows
@@ -192,11 +199,12 @@ func reconcile(st StatusStore, cutoff time.Time) {
} }
// retentionLoop prunes expired send-log rows immediately and then periodically. // retentionLoop prunes expired send-log rows immediately and then periodically.
func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) { func retentionLoop(ctx context.Context, st StatusStore, retention RetentionDays) {
if retentionDays <= 0 {
retentionDays = defaultRetentionDays
}
prune := func() { prune := func() {
retentionDays := retention()
if retentionDays <= 0 {
retentionDays = store.SendLogRetentionDaysDefault
}
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays) cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
n, err := st.DeleteSendLogBefore(cutoff) n, err := st.DeleteSendLogBefore(cutoff)
if err != nil { if err != nil {
+2 -2
View File
@@ -212,7 +212,7 @@ func TestFollowTailsAndRotates(t *testing.T) {
cs := &captureStore{} cs := &captureStore{}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- Run(ctx, path, cs, 90) }() go func() { done <- Run(ctx, path, cs, func() int { return 90 }) }()
// Give follow() time to open at EOF (it seeks to end immediately on start, // Give follow() time to open at EOF (it seeks to end immediately on start,
// so the seed line above is ignored), then append a delivery line. // so the seed line above is ignored), then append a delivery line.
@@ -295,7 +295,7 @@ func startRun(t *testing.T, path string, cs *captureStore) func() {
t.Helper() t.Helper()
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- Run(ctx, path, cs, 90) }() go func() { done <- Run(ctx, path, cs, func() int { return 90 }) }()
// follow() opens and seeks on start; give it a moment before the caller // follow() opens and seeks on start; give it a moment before the caller
// appends, so the append is not raced by the initial open. // appends, so the append is not raced by the initial open.
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
+83
View File
@@ -0,0 +1,83 @@
package logtail
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
type retentionProbeStore struct {
mu sync.Mutex
cutoff time.Time
}
func (s *retentionProbeStore) UpdateStatus(string, string, string) (int64, error) {
return 0, nil
}
func (s *retentionProbeStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
return nil, nil
}
func (s *retentionProbeStore) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
s.mu.Lock()
s.cutoff = cutoff
s.mu.Unlock()
return 0, nil
}
func (s *retentionProbeStore) LogtailState(string) (store.LogtailState, bool, error) {
return store.LogtailState{}, false, nil
}
func (s *retentionProbeStore) SaveLogtailState(string, store.LogtailState) error {
return nil
}
func (s *retentionProbeStore) lastCutoff() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.cutoff
}
// retentionLoop must re-read the configured window every cycle so a panel
// change takes effect without restarting the process.
func TestRetentionLoopUsesUpdatedValue(t *testing.T) {
old := retentionInterval
retentionInterval = 20 * time.Millisecond
t.Cleanup(func() { retentionInterval = old })
var days atomic.Int32
days.Store(30)
st := &retentionProbeStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go retentionLoop(ctx, st, func() int { return int(days.Load()) })
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if age := time.Since(st.lastCutoff()); age < 31*24*time.Hour && age > 29*24*time.Hour {
break
}
time.Sleep(5 * time.Millisecond)
}
if age := time.Since(st.lastCutoff()); age < 29*24*time.Hour || age > 31*24*time.Hour {
t.Fatalf("first prune cutoff age %v, want about 30 days", age)
}
days.Store(7)
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if age := time.Since(st.lastCutoff()); age < 8*24*time.Hour && age > 6*24*time.Hour {
return
}
time.Sleep(5 * time.Millisecond)
}
age := time.Since(st.lastCutoff())
t.Fatalf("second prune cutoff age %v, want about 7 days", age)
}
+31
View File
@@ -0,0 +1,31 @@
package milter
import (
"errors"
"log"
"github.com/mixeme/selfpost/internal/store"
)
// authIPAllowed reports whether the authenticated application may submit from
// the connecting client IP. When the application has no IP restriction, or the
// client IP is not known, the check passes. Store errors are fail-open (see
// overLimit).
func (s *session) authIPAllowed() bool {
if s.login == "" || s.clientIP == "" {
return true
}
a, err := s.rec.ApplicationByLogin(s.login)
if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) {
return true
}
log.Printf("journal-milter: auth IP lookup application %q: %v (fail-open)", s.login, err)
return true
}
if a.AllowsAuthFromIP(s.clientIP) {
return true
}
log.Printf("journal-milter: application %q refused from %s — client IP not allowed", s.login, s.clientIP)
return false
}

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