48 Commits
Author SHA1 Message Date
mixandClaude Sonnet 5 1ee509dcab Version cut 1.9.3: close the panel-redesign docs prep, bump the pin
test / test (push) Canceled after 0s
CHANGELOG.md audited against the full commit history from the repo's
first commit (8ad56ed) through HEAD: version/date headings, entry
completeness, and technical claims against their diffs all check out,
no discrepancies found.

The Unreleased section (panel-redesign mockups, contract, and
decisions; preflight plan) is docs-only prep — development.md's own
rule would hold the cut until a product stage ships — but the owner
asked for a version cut now, so it becomes 1.9.3 (PATCH) and a fresh
Unreleased opens. Pin bumped in deploy/docker-compose.yml, README's
quickstart, guide.md, and development.md's planned-cuts table, which
also gains the new row (renumbering inbound-antispam-panel to 11).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:17:50 +03:00
mixandClaude Sonnet 5 7c8a98a1cf Version cut 1.9.2: close the code review pass, bump the pin
The 2026-08-19 full-tree code review's fixes were sitting in Unreleased
alongside in-progress, docs-only panel-redesign prep. Per development.md
§ Roadmap track, docs-only prep gets no version cut until its product
stage ships, so the review-pass entries (all real code/behaviour fixes,
plus migration 0010, the DMARC-zip and port-25 e2e additions) move to a
new [1.9.2] section; the panel-redesign and preflight roadmap items stay
in a fresh Unreleased. Also drops two duplicate CHANGELOG bullets
(bcrypt cost, rate-limiter eviction) that restated an earlier entry
verbatim, and updates every 1.9.1 pin reference (docker-compose image,
README quickstart, guide.md, development.md's planned-cuts table,
schema-migrations.md's migration-0010 row, roadmap.md's review-closure
links) to 1.9.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:22:22 +03:00
mixandClaude Fable 5.1 d83d326217 docs: panel header, type scale, account e-mail and the fixes of a full review
test / test (push) Canceled after 0s
Header. The navbar carries the wordmark instead of the stamp - at nav
size the stamp dissolves into the brick bar - and under the bar runs the
stamp's paper margin with the perforation along its outer edge: round
holes, flat teeth, ink hairline, 8px under a 60px bar as on the mark.
docs/assets/selfpost-wordmark.svg and -ink are the stamp's own outlines;
the header comparison sheet is removed.

Type and tables. About 40 % of the text measured under 13px. Body is
16px with two tokens under it, 14px for supporting text and 13px for
upper-case mono labels and tags; Bulma's small sizes are overridden or
dropped (sp-small replaces is-size-7, is-small only on a progress bar).
Every table sits in a table-container, addresses break after the @, and
all screens fit without scrolling at 1280px and up.

Contrast. A sweep of every text node against its real ground found the
solid red button at 3:1, the muted grey at 4.1:1, the amber postmark and
Bulma's menu labels just under 4.5. All 33 screens now measure 4.5:1 or
better. The sweep is kept as panel/audit.js and its clean output joins
the evidence a restyle step must show.

Account and roles. A user's e-mail is its own field (users.email in the
2.0 baseline), edited under Account and seen by the global user on the
user form and list. The DMARC default report address is a user's
setting, not the server's: it moves to Account with its authorization
record, and a domain follows the default of a named user. Roles are
labelled by reach - global, domain - and each assignment list gains All,
a per-user flag that covers domains added later. Account gains
Notifications for the new roadmap candidate panel-notifications;
password-reset is added as a candidate too.

Also: the inbound spam filter is visible where a domain user looks (a
box on each inbound domain, a tag on the list, its socket on Health);
the health row is compact again and splits 6/3/2; a read-only value is
as tall as its Copy button; Preflight and Account lose boxes that only
filled space, and the plan's layout rule and contract now forbid them.

check.py gains the type floor, the table-container rule and the is-small
rule, and no longer counts min-width: 0 as setting a width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 00:05:32 +03:00
mixandClaude Fable 5.1 4921416384 docs: compare three panel headers against the current one
test / test (push) Canceled after 0s
At nav size the stamp cannot carry itself: its perforation is under a
pixel and its brick field dissolves into the brick bar, leaving a thin
frame around the name. The perforated edge under the bar was also drawn
inverted - round bumps with pointed gaps, where a real perforation is
round holes with flat teeth.

header/ puts the current header beside three replacements on a real
page: A, the wordmark alone with round holes of page colour bitten into
the bar at the mark's own hole-to-tooth ratio; B, the wordmark on a plain
bar; C, a stamp-paper header with an ink hairline, the mark's
perforation, and brick only behind the name. The wordmark files are the
stamp's own outlines, cut out of selfpost-stamp-compact.svg.

A comparison sheet pending the owner's choice; the 33 mockups and the
design contract are unchanged. Also tracks .claude/launch.json, the
local static server the mockups are previewed with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:24:37 +03:00
mixandClaude Fable 5.1 2f7b5d95c3 docs: draw the roadmap's screens in the panel design
test / test (push) Canceled after 0s
Four screens for open roadmap items, all built from existing components:
Inbound > Filter lists (allow / deny, where the maps go) and an adjusted
Inbound > Log (client, decision, engine and reason, Allow / Deny sender)
for inbound-antispam-panel; Inbound > Quarantine, list and held message
with Release beside a danger-zone Discard, for inbound-quarantine; and
Server > Preflight, a verdict postmark over a table of checks with a
"what to do" column and a test e-mail form, for preflight.

A mockup is the target look, not a decision to build: each item keeps
its status, and quarantine's open questions stay open - the mockup only
assumes answers to them. The paths join the plan's Routes table, the
quarantine and preflight plans link their mockups, and the components
page maps the quarantine decision to a tag. 33 screens; lint clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:07:39 +03:00
mixandClaude Fable 5.1 5e0adb2de5 docs: record the owner's decisions on routes, compatibility and inbound roles
panel-redesign.md gains four sections. Routes: paths follow the new names
and structure (/overview, /outbound/..., /inbound/..., /server/...,
/account), with one <page>/fragment name, the application form as a
single POST and TestRoutesFollowNavigation. No compatibility: the redesign
is 2.0.0, old paths are removed without redirects, the migration chain is
replaced by one baseline and 2.0 starts from an empty data directory (the
only live installation is the owner's); schema-squash ships as a step of
this plan without its upgrade gate. Who sees what: a domain administrator
manages assigned inbound domains, granted separately from outbound
(user_inbound_domains, per-domain authorization under /inbound/). Room for
roadmap items: where antispam lists, quarantine and preflight land, and
that a feature's screens are drawn mockup-first in this system.

Mockups: the Reload configuration box the first pass missed (now on
Server > Health), the flash component and the sp-help link that replaces
the help drawer, and the user form with two assignment lists. The
antispam plan no longer points its mockup at the superseded panel-ui
folder. Checklist 3/21.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:02:12 +03:00
mixandClaude Fable 5.1 1bcfca1586 docs: turn the accepted panel design into an enforceable contract
panel-redesign.md is rewritten for the accepted direction: three layers
(Bulma as shipped, adapters, own sp- components), the page layouts, what
an implementer must not do, and how that is enforced — guard tests that
land before any template, a design-first CI step, and acceptance by
evidence (test output plus a screenshot of every page beside its mockup,
reviewed by a model that did not write the step).

A components page shows every building block in every state; a screen
may be built from nothing else. panel/check.py lints the mockups against
the contract (class vocabulary, CSS bans on !important, id and page
selectors and stray widths, page structure) and writes
panel/outlines.json, the per-screen component skeleton an implementation
is compared with, so a different design cannot pass as this one.

Own modifier classes now carry the sp- prefix (sp-warn, sp-fail, sp-big,
sp-danger-zone, ...) so the vocabulary is machine-checkable; the lint
also caught a width set outside the layout in the mockup's own CSS.

Agent rule .cursor/rules/panel-design.mdc; roadmap row and section
updated (0/15, still candidate).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:55:18 +03:00
mixandClaude Fable 5.1 d1f22fbcc8 docs: draw every panel screen in the accepted redesign direction
Stage 2 of the panel redesign. Direction C (Bulma) is the base — the
important things in view, details one or two clicks away — with A's
postal signature (postmark verdict, numbered mono section heads, the
stamp's perforated edge) and B's icons. 29 screens under
docs/assets/panel-redesign/panel/, linked into a click-through.

Overview keeps verdicts only; the process, socket and interface tables
move to a new Server > Health page. The own password becomes Account,
queues are drawn as tables (needs postqueue -j), and Inbound gets its
own Log and Queue. Every page is one of three layouts, so none picks
its own width.

Pages are built from panel/src fragments by panel/build.py so the navbar
lives in one place; the built HTML is committed. The cover sheet now
leads with the screen map and keeps the three stage-1 directions below.

Mockups only; internal/web is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 19:11:15 +03:00
mixandClaude Fable 5.1 2b69605cf9 docs: draw three panel-redesign directions on the key screens
Stage 1 of the panel redesign: Overview, an outbound domain and sign-in,
each drawn on a ready-made library instead of hand-tuned CSS — A Pico
("Blank"), B Tabler ("Console"), C Bulma ("Counter") — plus a cover sheet
comparing size, JavaScript, CSP fit and the amount of own CSS.

All three share the new information architecture: navigation grouped by
mail direction (Overview, Outbound, Inbound, Server) with a symmetric Log
and Queue on both sides, one block per DNS record, applications as a
table, rarely changed domain settings behind one click.

Mockups only; internal/web is untouched. Pico and Tabler embed data: SVGs
and would need img-src 'self' data: in the CSP; Bulma does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 14:05:13 +03:00
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
280 changed files with 23245 additions and 1605 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mockups",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1"],
"port": 8765
}
]
}
+39
View File
@@ -0,0 +1,39 @@
---
description: The panel's design is fixed by accepted mockups; implement it, do not reinterpret it.
globs: internal/web/view/**,docs/assets/panel-redesign/**
alwaysApply: false
---
# Panel design contract
Source of truth, in this order: the mockups in
[docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html),
the [components page](docs/assets/panel-redesign/panel/components.html),
`panel/outlines.json`, then
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md). Read the plan's
**The contract** and **Enforcement** sections before touching a template.
You are implementing a finished design. You are not designing.
- Build pages only from Bulma classes in the `BULMA` list of `panel/check.py`
and from the `sp-` components on the components page, through their
partials. No other class, no hand-written component markup, no inline style.
- No selector in `panel.css` that names a page or an id, uses `!important`, or
sets a width outside `WIDTH_OK`. If a page looks wrong, the layout choice is
wrong — do not patch it locally.
- If a screen seems to need a component, state or layout that does not exist:
**stop and ask.** Do not approximate it, and do not substitute a look of
your own.
- Never edit a mockup, `outlines.json`, `check.py`, the vendored Bulma or a
guard test to make your work pass. A failing guard means the implementation
is wrong. Design changes go mockup-first in a separate `design:` commit on
the owner's explicit instruction.
- "Done" is evidence, not a description: passing `go test ./internal/web/...`
output plus a screenshot of each restyled page from the running container,
saved under `docs/assets/panel-redesign/evidence/` beside its mockup's name.
Report every difference from the mockup you could not remove; do not
describe a different result as the requested one.
After editing a fragment in `panel/src/`: `python panel/build.py`, then
`python panel/check.py` (add `--write` only when the structure change is the
point of the commit).
+22
View File
@@ -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
# 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
# GitHub Release runs this workflow directly (same pattern as gosentry / imap-scrub).
# You can also run it manually via workflow_dispatch with an explicit SemVer X.Y.Z.
#
# Native per-architecture builds (see docs/development.md), not qemu:
# running the full Postfix/OpenDKIM stack under emulation for the e2e gate
# below is impractically slow. Each arch builds, e2e-gates and pushes its own
# tag on its own native runner; a merge job then combines them into the one
# manifest tag documented in deploy/docker-compose.yml. "test, then push" (not
# push-by-digest then test) is deliberate: it means the bytes that get tagged
# are exactly the bytes that passed e2e. Per-arch tags are pushed only so
# imagetools can assemble the multi-arch manifest; merge removes them from GHCR
# so operators see a single version tag (spec 10.1).
# Native per-architecture builds (see docs/development.md), not qemu: running
# the full Postfix/OpenDKIM stack under emulation for the e2e gate below is
# impractically slow. Each arch builds, e2e-gates and pushes its own tag on its
# own native runner; a merge job then combines them into the one manifest tag
# documented in deploy/docker-compose.yml. "test, then push" (not push-by-digest
# then test) is deliberate: it means the bytes that get tagged are exactly the
# bytes that passed e2e. Per-arch tags are pushed only so imagetools can
# assemble the multi-arch manifest; merge removes them from GHCR so operators
# see a single version tag.
on:
release:
types: [published]
+568 -20
View File
@@ -5,6 +5,553 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
## [1.9.3] - 2026-09-22
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the
accepted header/type-scale/contrast corrections, the account e-mail and
inbound-delegation decisions, and the routes/quarantine/preflight roadmap
screens. Nothing in `internal/web` changes; the design stays `candidate`
(3/21) until the redesign itself ships as `2.0.0`.
### Added
- Panel redesign, account e-mail and notifications: a user's e-mail is its own
field (2.0 baseline `users.email`, today `dmarc_report_email`) — edited under
**Account**, seen and set by the global administrator on the user form and in
the users list; DMARC may *use* it, as one choice beside the hosted address,
another address or none (Settings and domain settings). **Account** gains a
Notifications box and **Settings** a switch for the new roadmap candidate
`panel-notifications`; `password-reset` is added as a candidate too. The
inbound spam filter becomes visible: a *Spam filter* box on every inbound
domain, a tag on the inbound domains list, its socket among the milter
sockets. Preflight loses a box that only filled space — the test form now
shares its box with its result — and the plan's layout rule and contract say
so: what stands beside a form must be real. The role is labelled by its reach,
`global` or `domain` — everyone in the panel is an administrator — and each
assignment list on the user form gains **All**, a per-user flag that covers
domains added later. The DMARC default report address is a **user's**
setting, not the server's: it moves from Server › Settings to **Account**
with its report-authorization record, and a domain follows the default of a
*named* user, so two people sharing a domain never pull it two ways.
- Panel redesign, contrast: a sweep of every text node against its real ground
found the solid red button at 3:1 (Bulma derives a solid button's text from
the fill's lightness; brick had been patched, red had not), the muted grey
at 4.1:1, the amber postmark at 4.0 and Bulma's menu labels at 4.2. Solid
buttons are white on colour, muted is `#5c6774`, the postmark uses the warn
token; all 33 screens now measure 4.5:1 or better. The sweep is kept as
`panel/audit.js` — text size, contrast, overflow, clipped values, missing
icons — and its clean output joins the evidence a restyle step must show.
- Panel redesign, header (owner's choice: variant A, with the perforation on a
light edge as on the mark): the navbar carries the **wordmark** instead of
the stamp — `docs/assets/selfpost-wordmark.svg` and `-ink`, the stamp's own
outlines, recorded in `docs/product.md` — and under the brick bar runs the
stamp's paper margin with the perforation along its outer edge: round holes,
flat teeth, the ink hairline (`panel/perf-edge.svg`). All 33 screens rebuilt;
the components page documents the shell; the comparison sheet is removed.
The six health cards now split into six, three or two columns, so one card
is never left alone on a row. Three corrections after the owner's review:
the paper edge is 8px under the 60px bar — it had been scaled from the hole
size to 16px, where the mark's margin is a tenth of the brick field; the
health row is compact again (71px: smaller icons, each line a block with its
own line height, a verdict never wraps and stretches the row); and a
read-only value is as tall as the Copy button joined to it. `check.py` no
longer counts `min-width: 0` as setting a width. Text sizes: the filter
controls in a box head and the navbar's dropdown entries are 15px like the
bar itself (they had Bulma's `is-small` and its .875rem dropdown); `check.py`
now rejects `is-small` on anything but a progress bar.
- Panel redesign, type scale: about 40 % of the panel's text measured under
13px (labels 11.7, table heads 10.5, fact labels 10.2, tags and help 11.3).
Body is now 16px with two tokens under it — `--sp-fs-s` 14px for supporting
text and `--sp-fs-xs` 13px for upper-case mono labels and tags; Bulma's
small sizes are overridden or dropped (`sp-small` replaces `is-size-7`), and
`check.py` fails any font size under the floor. Every table now sits in a
`table-container` (enforced), addresses in tables break after the `@`, the
inbound log folds the client IP under the sender; all screens fit without
scrolling at 1280px and up. Documented on the components page and in the
plan (§ Type and tables).
- Panel redesign, header comparison sheet
([docs/assets/panel-redesign/header/](docs/assets/panel-redesign/header/index.html)):
at nav size the stamp dissolves into the brick bar and the perforated edge
was drawn inverted (round bumps instead of round holes). Three replacements
beside the current header — **A** wordmark with the stamp's real hole-to-tooth
perforation bitten into the bar, **B** wordmark on a plain bar, **C** a
stamp-paper header with an ink hairline and brick only behind the name. The
wordmark is cut from the stamp's own outlines. Pending the owner's choice;
the mockups are unchanged. `.claude/launch.json` serves the mockups locally
(`python -m http.server 8765` from the repository root).
- Panel redesign, the roadmap's screens: **Inbound › Filter lists** and the
adjusted **Inbound › Log** (client, decision, engine and reason; *Allow /
Deny sender*) for inbound-antispam-panel, **Inbound › Quarantine** (list and
held message with Release / Discard) for inbound-quarantine, and **Server ›
Preflight** for preflight — all from existing components; 33 screens. Their
paths join § Routes; the quarantine and preflight plans link their mockups.
A mockup is the target look, not a decision to build: each item keeps its
status, and quarantine's open questions stay open.
- Panel redesign, two owner decisions recorded in
`docs/plans/panel-redesign.md`: **no compatibility** — the redesign is
`2.0.0`, old paths are removed without redirects, the SQLite migration chain
is replaced by one baseline and 2.0 starts from an empty data directory
(schema-squash ships as a step of this plan, without its upgrade gate; the
only live installation is the owner's); and
**inbound delegation** — a domain administrator manages assigned inbound
domains, granted separately from outbound (`user_inbound_domains`, a second
list on the user form, per-domain authorization under `/inbound/`). Mockups `users`, `user`,
`user-delete` redrawn for the two lists. Checklist `3/21`.
- Panel redesign, routes: the owner decided that paths follow the new names
and structure. `docs/plans/panel-redesign.md` § Routes maps every screen to
its new path (`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`,
`/account`) with the rules that come along — one `<page>/fragment` name, the
application form as a single POST, 301s from old GET paths for one MINOR,
subtree `requireGlobal()` under security review, and
`TestRoutesFollowNavigation`. Mockups gained what the first pass missed: the
*Reload configuration* box (now on Server › Health), the `flash` component
and the `sp-help` link that replaces the help drawer. Checklist `1/17`.
- Panel redesign, design contract:
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md) rewritten for
the accepted direction — three layers (Bulma as shipped, adapters, own `sp-`
components), three page layouts, what an implementer must not do, the guard
tests that land before any template, a `design-first` CI step and
evidence-based acceptance. A
[components page](docs/assets/panel-redesign/panel/components.html) shows
every building block in every state; `panel/check.py` lints the mockups
against the contract and writes `panel/outlines.json`, the per-screen
component skeleton an implementation is compared with. Own modifier classes
renamed to carry the `sp-` prefix so the vocabulary is machine-checkable.
Agent rule `.cursor/rules/panel-design.mdc`; roadmap row updated (`0/15`).
- Panel redesign, stage 2: the accepted direction drawn on every screen —
29 pages under [docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html).
Direction C (Bulma) is the base, with A's postal signature (postmark verdict,
numbered mono section heads, the stamp's perforated edge) and B's icons.
Overview keeps verdicts only and the tables move to a new **Server › Health**
page; the own password becomes **Account**; queues are drawn as tables
(needs `postqueue -j`) and **Inbound** gets its own Log and Queue. Pages are
built from fragments by `panel/build.py`; the built HTML is committed.
Mockups only; nothing in `internal/web` changes.
- Panel redesign, stage 1: three directions drawn on the three key screens
(Overview, an outbound domain, sign-in) under
[docs/assets/panel-redesign/](docs/assets/panel-redesign/index.html) —
**A** Pico CSS ("Blank"), **B** Tabler ("Console"), **C** Bulma ("Counter"),
with a cover sheet comparing size, JavaScript, CSP fit and own CSS. All three
share a navigation grouped by mail direction (Overview · Outbound · Inbound ·
Server, with a symmetric Log and Queue on both sides), one block per DNS
record instead of separate "status" and "records to publish" cards, and
applications as a table. Mockups only; nothing in `internal/web` changes.
The brief behind them supersedes the "no component library" decision in
`docs/plans/panel-redesign.md`; that file is rewritten once a direction is
accepted.
- 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.
## [1.9.2] - 2026-09-22
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.
### Added
- 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.
### 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.
### 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.
- 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.
- 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
Security and quality after 1.2.5: domain-admin send-log authorization,
@@ -22,7 +569,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
- docs: agreed roadmap item **queue-retries** — show this Postfix's retry
policy (first delay, backoff cap, queue lifetime) on Mail queue and on a
delivery's history, reading `postconf -h` once at panel start so a manual
override is visible. Plan: [docs/plans/queue-retries.md](docs/plans/queue-retries.md).
override is visible. Plan: `docs/plans/queue-retries.md` (removed once shipped).
Explanation only; no attempt counter and no panel knobs. Not yet
implemented.
@@ -89,11 +636,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
this behaviour.
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
after the manifest merge via the GitHub Packages API. The merge job had called
`docker buildx imagetools rm`, which is not a valid subcommand — cleanup failed
with a warning and the side-effect tags stayed in the registry.
### Changed
- docs: operator and as-built docs aligned with the code after a full
@@ -143,15 +685,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
bare git tag push no longer starts the build. `release.yml` listens for
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each
native arch build, merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one manifest,
then removes the per-arch tags from GHCR via the GitHub Packages API so
operators see only `ghcr.io/mixeme/selfpost:X.Y.Z` (what
`deploy/docker-compose.yml` pins). A dispatch whose version input is missing
or not `X.Y.Z` fails in `prepare`. [development.md](docs/development.md)
documents draft vs published releases, why deleting a release tag converts
it back to draft, and Gitea → GitHub tag-mirror pitfalls (do not prune release
tags on GitHub; a mirrored `v1.0.0` still runs that tag's `on: push: tags`
workflow).
native arch build, and merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one
manifest. Reliable removal of the per-arch side-effect tags from GHCR landed
in `[1.3.1]`. A dispatch whose version input is missing or not `X.Y.Z` fails
in `prepare`. [development.md](docs/development.md) documents draft vs
published releases, why deleting a release tag converts it back to draft, and
Gitea → GitHub tag-mirror pitfalls (do not prune release tags on GitHub; a
mirrored `v1.0.0` still runs that tag's `on: push: tags` workflow).
- test: the authorization and sign-in surfaces that had no tests now have them.
The login limiter is covered for its ceiling, its per-address scope, the reset
@@ -221,7 +761,7 @@ Plex. Upgrading from 1.2.x is a tag bump; no migration.
RBAC in the doc-alignment pass above). Corrected stale
`admin.dmarc_report_email` references in
[roadmap.md](docs/roadmap.md) and
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's
`docs/plans/dmarc-reports.md` (removed once shipped) to the setting's
actual home after migration `0005`. No behaviour change.
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
@@ -248,7 +788,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
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
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
@@ -1055,9 +1597,10 @@ database, or the on-disk layout. Upgrading is a tag bump.
Bookmarks to the old paths stop working.
- 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.
The icons 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
so the bar is scannable at a glance instead of a row of similar-length words —
Backup uses a filing-cabinet mark; Sign out has its own icon too. The icons
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.
- panel: the navigation bar is laid out as two rows on purpose — the signed-in
@@ -1197,6 +1740,11 @@ database, or the on-disk layout. Upgrading is a tag bump.
login/password and on the sending server name.
- panel: the *Addresses* field is hidden while an application's address mode is
*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
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
+3 -2
View File
@@ -1,12 +1,13 @@
# SelfPost build.
#
# 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
#
# 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
MODULE := github.com/mixeme/selfpost
+27 -9
View File
@@ -4,13 +4,16 @@
# SelfPost
Self-hosted outbound SMTP relay with a web control panel, shipped as a single
Docker image. Postfix, OpenDKIM, and a small Go panel run together under
`supervisord`; you configure domains, DKIM keys, and SASL applications once,
then point your apps at the SMTP endpoint.
Self-hosted SMTP relay with a web panel — one Docker image, no provider.
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
Postfix, OpenDKIM, and a small Go panel run together under `supervisord`; you
configure domains, DKIM keys, and SASL applications once, then point your apps
at the SMTP endpoint.
SelfPost sends mail straight to the internet from **your own IP**, with
per-domain DKIM signing. No mailboxes or webmail. An optional inbound relay
(backup-MX / forwarder on port 25) forwards to an upstream; it does not store
mail.
**For:** operators who run their own VPS or home server and want a simple relay
they control, without a third-party SMTP provider.
@@ -21,6 +24,10 @@ send log and DNS checks in the panel, encrypted backups.
## Features
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help 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
- Multi-domain relay — each SASL application is bound to one sending domain
- 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.
### Per inbound domain (optional)
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
if this host is backup-MX)
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
## Quick start
> **First boot — create the admin account.** On a fresh container SelfPost prints
@@ -96,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.3.0
ghcr.io/mixeme/selfpost:1.9.3
```
**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
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file always publishes **465** and **587**; Postfix listens on 587
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
The compose file always publishes **465**, **587**, and **25**; Postfix listens
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 when
`INBOUND_RELAY_ENABLE=true` and/or `DMARC_REPORTS_ENABLE=true` (see
[Ports](docs/guide.md#ports)). Bump the
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables).
+21 -21
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
#
# 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 .
@@ -10,7 +10,7 @@ FROM golang:1.26-bookworm AS build
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
# Module metadata first for layer caching.
@@ -52,13 +52,13 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
curl \
&& 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
# Shared group bridging the unprivileged services (spec 5.1, 6): the panel
# generates per-domain DKIM keys, application SASL accounts (sasldb2) and the
# Postfix sender map, while OpenDKIM and Postfix (different users) must read
# them. Membership in this group — plus setgid dirs under /data (set up in
# Shared group bridging the unprivileged services: the panel generates
# per-domain DKIM keys, application SASL accounts (sasldb2) and the Postfix
# sender map, while OpenDKIM and Postfix (different users) must read them.
# Membership in this group — plus setgid dirs under /data (set up in
# 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
# 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 postfix
# The panel reads the mail queue with `postqueue -p` (spec 7.2.11, and the
# status page's queue card). postqueue is setgid postdrop, which normally gives
# it the group needed to reach Postfix's showq socket — but the documented
# deployment runs with `no-new-privileges`, which disables setgid transitions,
# so the panel would always see "Permission denied". Making `panel` a real
# member of postdrop grants the same access without relying on a setgid
# escalation the hardening deliberately forbids. postdrop membership is
# read-side only: it does not let the panel bypass any Postfix restriction that
# a local user does not already have through the world-executable sendmail.
# The panel reads the mail queue with `postqueue -p` (and the status page's
# queue card). postqueue is setgid postdrop, which normally gives it the group
# needed to reach Postfix's showq socket — but the documented deployment runs
# with `no-new-privileges`, which disables setgid transitions, so the panel
# would always see "Permission denied". Making `panel` a real member of postdrop
# grants the same access without relying on a setgid escalation the hardening
# deliberately forbids. postdrop membership is read-side only: it does not let
# the panel bypass any Postfix restriction that a local user does not already
# have through the world-executable sendmail.
RUN usermod -aG postdrop panel
# 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=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
# which needs no inbound listener or EXPOSE.
EXPOSE 8080 465 587
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 25
# 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.
@@ -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
# 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"]
+1 -1
View File
@@ -4,7 +4,7 @@
If any managed program exhausts its restart retries and enters FATAL, this
listener signals supervisord (its parent) to terminate, so the whole container
exits and Docker's `restart` policy can recreate it cleanly — rather than
lingering "alive" with a dead Postfix/OpenDKIM/panel (spec 4).
lingering "alive" with a dead Postfix/OpenDKIM/panel.
Communication with supervisord uses the event listener protocol over stdin and
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
# 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
# postfix-config.sh each fall back independently when it's unset — to
# `localhost` and to the container hostname respectively — so accounts get
# written under one realm and looked up under another and authentication
# silently fails for every application, while HELO also stops matching the
# PTR record and mail that does go out lands in spam. No fallback can be
# correct, so fail loudly here, before either side of that split has a chance
# to run, rather than leave a green panel with broken mail.
# Cyrus SASL realm. The panel (main.go saslRealm()) and postfix-config.sh each
# fall back independently when it's unset — to `localhost` and to the container
# hostname respectively — so accounts get written under one realm and looked up
# under another and authentication silently fails for every application, while
# HELO also stops matching the PTR record and mail that does go out lands in
# spam. No fallback can be correct, so fail loudly here, before either side of
# that split has a chance to run, rather than leave a green panel with broken
# mail.
if [ -z "$SELFPOST_HOSTNAME" ]; then
cat >&2 <<'EOF'
FATAL: SELFPOST_HOSTNAME is not set.
@@ -52,11 +52,11 @@ case "$SELFPOST_HOSTNAME" in
;;
esac
# The persistent root /data is a host bind mount (spec 9), so it arrives owned
# by the host user (typically root), not by the unprivileged panel user that
# actually writes the SQLite database, setup token and DKIM keys (spec 7.6.8).
# Fix its ownership here — the one place still running as root — before handing
# off to supervisord, which starts the panel as the panel user.
# The persistent root /data is a host bind mount, so it arrives owned by the
# host user (typically root), not by the unprivileged panel user that actually
# writes the SQLite database, setup token and DKIM keys. Fix its ownership here
# — the one place still running as root — before handing off to supervisord,
# which starts the panel as the panel user.
#
# 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
@@ -69,9 +69,9 @@ chmod 755 /data
# later phase deliberately hands to another service. /data/log is exempt: it is
# deliberately owned by postfix (postlogd writes the delivery log there) and is
# normalised on its own below.
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} +
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
# DKIM key tree. The panel (user `panel`) generates keys and writes
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
# tree on every start so it is correct whether /data is fresh, restarted, or
# just restored from a backup:
@@ -89,7 +89,7 @@ find /data/opendkim -type d -exec chmod 2750 {} +
chmod 0640 /data/opendkim/KeyTable /data/opendkim/SigningTable
find /data/opendkim/keys -type f -name '*.private' -exec chmod 0640 {} +
# Application SASL accounts (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
# 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
@@ -99,13 +99,20 @@ chown -R panel:selfpost /data/sasl
chmod 2750 /data/sasl
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
# references it never fails on a missing file, and keep it group-readable.
mkdir -p /data/postfix
# Postfix state under /data (architecture.md § Persistence). The panel writes
# sender_login_maps; Postfix owns the on-disk queue tree under queue/.
mkdir -p /data/postfix/queue
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
chown -R panel:selfpost /data/postfix
# 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
chown panel:selfpost /data/postfix/sender_login_maps
chmod 0640 /data/postfix/sender_login_maps
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
@@ -125,14 +132,14 @@ chown -R postfix:selfpost /data/log
chmod 2750 /data/log
find /data/log -type f -exec chmod 0640 {} +
# Milter socket directories (spec 5 p.3, 7.3). Postfix (user `postfix`) must
# actually CONNECT to both milter sockets — OpenDKIM's and the panel's
# journal-milter — not just probe them at start-up. The sockets are
# created by the opendkim and panel users respectively, so bridge them to
# `postfix` through the shared `selfpost` group: group-owned + setgid dirs mean
# each socket created inside inherits group `selfpost`, and group-traversable
# (2750) lets postfix reach it. Without this, smtpd cannot talk to OpenDKIM and,
# because signing is strict (default_action=tempfail), rejects all mail.
# Milter socket directories. Postfix (user `postfix`) must actually CONNECT to
# both milter sockets — OpenDKIM's and the panel's journal-milter — not just
# probe them at start-up. The sockets are created by the opendkim and panel
# users respectively, so bridge them to `postfix` through the shared `selfpost`
# group: group-owned + setgid dirs mean each socket created inside inherits
# group `selfpost`, and group-traversable (2750) lets postfix reach it. Without
# this, smtpd cannot talk to OpenDKIM and, because signing is strict
# (default_action=tempfail), rejects all mail.
mkdir -p /run/opendkim /run/selfpost
chown opendkim:selfpost /run/opendkim
chown panel:selfpost /run/selfpost
@@ -144,4 +151,11 @@ chmod 2750 /run/opendkim /run/selfpost
# start the same way the /data normalisation above is.
/usr/local/bin/postfix-config.sh
# Initialise the persistent queue tree on first start or after restore. postfix
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
if [ ! -d /data/postfix/queue/active ]; then
postfix set-permissions
fi
chown -R postfix:postfix /data/postfix/queue
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+7 -7
View File
@@ -1,13 +1,13 @@
#!/bin/sh
# Periodic logrotate for /data/log/mail.log (spec 9, 10). Rotation renames the
# file, recreates it (`create 0640 postfix selfpost`, matching a cold container
# Periodic logrotate for /data/log/mail.log. Rotation renames the file,
# recreates it (`create 0640 postfix selfpost`, matching a cold container
# start), then runs `postfix reload` (the same mechanism `postfix logrotate`
# uses): postlogd keeps writing to the renamed inode until reload, and the
# panel's log-tailer holds its own descriptor on that inode, so nothing
# written before the reload is lost. `create` (rather than `nocreate`) matters
# here beyond timing: a postlogd-triggered recreate lands the file at 0600
# owned by postfix, which the unprivileged panel process cannot read —
# confirmed on a live container — so logrotate must be the one to create it.
# panel's log-tailer holds its own descriptor on that inode, so nothing written
# before the reload is lost. `create` (rather than `nocreate`) matters here
# beyond timing: a postlogd-triggered recreate lands the file at 0600 owned by
# postfix, which the unprivileged panel process cannot read — confirmed on a
# live container — so logrotate must be the one to create it.
#
# logrotate itself only rotates once the configured "daily" period has elapsed
# (tracked in /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
# selector. The panel generates keys under /data/opendkim/keys/<domain>/ and
# keeps KeyTable/SigningTable in sync as domains are added or removed, then asks
# supervisord to send this process SIGUSR1 to reload the tables (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,
# 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
# 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
# starts the Postfix wrapper. Like the rest of SelfPost's runtime state it is
# re-derived from the environment on each start rather than persisted, so a
# fresh, restarted or restored container always ends up with the same config
# (the only panel-edited Postfix file, sender_login_maps, lives under /data and
# is generated separately by the panel — spec 5.1).
# is generated separately by the panel).
#
# main.cf is written with `postconf -e`, the master.cf submission services with
# `postconf -M`/`-P`. No user input is interpolated: every value here comes from
# a fixed literal or a container environment variable (spec 7.6.3).
# a fixed literal or a container environment variable.
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
# sasldb2 accounts are looked up under. The panel creates accounts under realm
# $SELFPOST_HOSTNAME (SASL_REALM), so myhostname MUST match or authentication
# fails. Fall back to the container hostname only outside a real deployment.
HOSTNAME_VALUE="${SELFPOST_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
# TLS material supplied by the reverse-proxy through a read-only bind mount
# (spec 5.2). The relay requires TLS on 465; if these files are absent the
# master still starts but TLS handshakes on 465 fail until they appear.
# TLS material supplied by the reverse-proxy through a read-only bind mount. The
# relay requires TLS on 465; if these files are absent the master still starts
# but TLS handshakes on 465 fail until they appear.
#
# Postfix 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
@@ -44,8 +75,8 @@ if [ -f "$TLS_CERT_SRC" ] && [ -f "$TLS_KEY_SRC" ]; then
TLS_KEY="$TLS_INTERNAL_DIR/privkey.pem"
fi
# Level-1 rate limit (native Postfix anvil, spec 5 p.5 / 7.4). Conservative
# defaults, sensible during IP warm-up (spec 10).
# Level-1 rate limit (native Postfix anvil). Conservative defaults, sensible
# during IP warm-up.
RATE_MSGS="${RATE_LIMIT_MESSAGES_PER_IP:-100}"
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}"
JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
# the unprivileged panel can rewrite it and just ask for a reload.
# Persistent panel-managed sender map; texthash needs no postmap, so the
# unprivileged panel can rewrite it and just ask for a reload.
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
# Transit mail queue under /data so deferred/active messages survive container
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
# Optional submission service on 587 (off by default, enabled only when a client
# library needs STARTTLS on 587 instead of implicit TLS on 465).
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
# absent. When true, smtp inet on 25 accepts only relay_domains + known
# recipients (docs/plans/inbound-relay.md).
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default:
# port 25 does not accept report mail and the panel ingest maps stay empty.
DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}"
DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}"
DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}"
DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}"
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
# for messages still marked "queued" survive a container recreate — without
@@ -96,13 +153,13 @@ postconf -e \
"alias_maps=" \
"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 \
"smtp_tls_security_level=may" \
"smtp_tls_note_starttls_offer=yes"
# TLS server material shared by every inbound service (spec 5.2). auth_only
# guarantees credentials are never accepted before TLS is up on any port.
# TLS server material shared by every inbound service. auth_only guarantees
# credentials are never accepted before TLS is up on any port.
postconf -e \
"smtpd_tls_cert_file=${TLS_CERT}" \
"smtpd_tls_key_file=${TLS_KEY}" \
@@ -110,11 +167,11 @@ postconf -e \
"smtpd_tls_auth_only=yes" \
"smtpd_tls_loglevel=1"
# SASL: Cyrus with the local sasldb2 the panel maintains (spec 5.1). The realm
# is left implicit (smtpd_sasl_local_domain empty) so the authenticated name
# Postfix uses for sender_login_maps is the BARE login the panel writes into the
# map; the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname
# as the server realm, which equals the realm the accounts were created under.
# SASL: Cyrus with the local sasldb2 the panel maintains. The realm is left
# implicit (smtpd_sasl_local_domain empty) so the authenticated name Postfix
# uses for sender_login_maps is the BARE login the panel writes into the map;
# the sasldb2 lookup still resolves because Postfix hands Cyrus $myhostname as
# the server realm, which equals the realm the accounts were created under.
postconf -e \
"smtpd_sasl_auth_enable=yes" \
"smtpd_sasl_type=cyrus" \
@@ -124,42 +181,42 @@ postconf -e \
"smtpd_sasl_tls_security_options=noanonymous" \
"broken_sasl_auth_clients=yes"
# Sender binding (spec 5.1 p.3, the critical anti-spoofing control). texthash
# resolves the full address first, then the "@domain" wildcard, so both address
# modes work from the same map.
# Sender binding (the critical anti-spoofing control). texthash resolves the
# full address first, then the "@domain" wildcard, so both address modes work
# from the same map.
postconf -e \
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
"queue_directory=${QUEUE_DIR}"
# Restrictions: authenticated clients only, no relay to foreign destinations,
# and every authenticated sender address must be owned by its login. NO
# permit_mynetworks anywhere — authorisation is by credentials, never by network
# (spec 5 p.1/p.4, 5.1). This is what makes an open relay impossible.
# permit_mynetworks anywhere — authorisation is by credentials, never by
# network. This is what makes an open relay impossible.
postconf -e \
"smtpd_helo_required=yes" \
"smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
"smtpd_recipient_restrictions=permit_sasl_authenticated, reject_unauth_destination" \
"smtpd_sender_restrictions=reject_sender_login_mismatch, permit"
# Level-1 rate limit by client IP (spec 5 p.5). Backstop that keeps working even
# if the journal-milter (level 2) is down.
# Level-1 rate limit by client IP. Backstop that keeps working even if the
# journal-milter (level 2) is down.
postconf -e \
"smtpd_client_message_rate_limit=${RATE_MSGS}" \
"anvil_rate_time_unit=${RATE_WINDOW}s"
# Milter chain (spec 5 p.3, 7.3). OpenDKIM signs and is treated strictly
# (default_action=tempfail: if it is unreachable, defer rather than send
# unsigned). The journal-milter is monitoring only and is fail-open
# (default_action=accept): its failure must never block the relay. Per-milter
# settings use Postfix 3.0+ brace syntax.
# Milter chain. OpenDKIM signs and is treated strictly (default_action=tempfail:
# if it is unreachable, defer rather than send unsigned). The journal-milter is
# monitoring only and is fail-open (default_action=accept): its failure must
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
postconf -e \
"milter_protocol=6" \
"milter_default_action=tempfail" \
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
"non_smtpd_milters="
# Bounded milter timeouts (spec 7.3): a *hung* milter (socket accepts but never
# replies) must fail open just like a crash, not stall mail acceptance until the
# Postfix defaults (300s content) elapse. With default_action per milter, a
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
# must fail open just like a crash, not stall mail acceptance until the Postfix
# defaults (300s content) elapse. With default_action per milter, a
# journal-milter hang then resolves to accept and an OpenDKIM hang to tempfail,
# but within seconds rather than minutes. Values are well above any healthy
# response time (signing/DB insert are sub-second), so they never fire in normal
@@ -170,8 +227,8 @@ postconf -e \
"milter_content_timeout=${MILTER_CONTENT_TIMEOUT:-30s}"
# --- master.cf: inbound submission services ----------------------------------
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission service
# (spec 5 p.1). chroot=n so smtpd can read the sasldb2 and sender map under /data
# smtps (465, implicit/wrapper TLS) — the primary, always-on submission
# service. chroot=n so smtpd can read the sasldb2 and sender map under /data
# and the Cyrus config outside any chroot.
postconf -M "smtps/inet=smtps inet n - n - - smtpd"
postconf -P \
@@ -179,10 +236,10 @@ postconf -P \
"smtps/inet/smtpd_sasl_auth_enable=yes" \
"smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject"
# submission (587, STARTTLS) — optional (spec 5 p.1). Same SASL/milter/limits;
# the only difference is TLS is negotiated via STARTTLS, so require encryption
# before auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a
# restart after disabling it does not leave the port listening.
# submission (587, STARTTLS) — optional. Same SASL/milter/limits; the only
# difference is TLS is negotiated via STARTTLS, so require encryption before
# auth. Added only when SUBMISSION_ENABLE=true, otherwise removed so a restart
# after disabling it does not leave the port listening.
if [ "${SUBMISSION_ENABLE}" = "true" ]; then
postconf -M "submission/inet=submission inet n - n - - smtpd"
postconf -P \
@@ -193,18 +250,131 @@ else
postconf -MX "submission/inet" 2>/dev/null || true
fi
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
# agent and others chrooted to /var/spool/postfix, where they cannot read
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
# never leaves. Inside a container the chroot buys little (the container is the
# isolation boundary) and breaks DNS/TLS trust-store access, so turn it off
# uniformly. Our own smtps/submission services are already n; this covers the
# delivery agents and the rest.
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_DOMAINS_SETTING=""
TRANSPORT_SETTING=""
RECIPIENT_RESTRICTIONS="reject"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
[ -e "$f" ] || : > "$f"
done
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
RECIPIENT_RESTRICTIONS="reject_unauth_destination,reject_unlisted_recipient"
fi
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
[ -e "$f" ] || : > "$f"
done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
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"
# --- Cyrus SASL app config for smtpd -----------------------------------------
# Tells the Cyrus library (invoked by smtpd via smtpd_sasl_path=smtpd) to verify
# passwords straight from the panel-maintained sasldb2 (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.
mkdir -p /etc/postfix/sasl
cat > /etc/postfix/sasl/smtpd.conf <<EOF
+2 -2
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# Postfix start wrapper (spec 4): solves the cold-start race where Postfix would
# try to reach the milter sockets before they are listening.
# Postfix start wrapper: solves the cold-start race where Postfix would try to
# reach the milter sockets before they are listening.
#
# 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
+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
; 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
; (running as the unprivileged `panel` user) can ask supervisord to send
; OpenDKIM a reload signal after it rewrites the DKIM tables (spec 6.2). It is
; still unreachable by world; only root and the two SelfPost services are in the
; OpenDKIM a reload signal after it rewrites the DKIM tables. It is still
; unreachable by world; only root and the two SelfPost services are in the
; group. No HTTP auth is configured because access is gated by socket
; permissions, not the network.
[unix_http_server]
@@ -67,15 +67,15 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; One-shot reload helper (spec 5.2, 7.2.12). The panel runs unprivileged and
; cannot run `postfix reload` itself, and it cannot signal the Postfix master
; directly: `postfix start-fg` forks a separate master process, so a signal to
; the supervised foreground process never reaches it (unlike OpenDKIM, which
; runs in the foreground as the supervised process itself). Instead the panel
; asks supervisord — over the group-accessible control socket — to run this
; program, which executes the canonical `postfix reload` as root. autostart is
; off (it only runs on demand) and a fast, clean exit is expected, so it never
; trips the crashexit listener.
; One-shot reload helper. The panel runs unprivileged and cannot run `postfix
; reload` itself, and it cannot signal the Postfix master directly: `postfix
; start-fg` forks a separate master process, so a signal to the supervised
; foreground process never reaches it (unlike OpenDKIM, which runs in the
; foreground as the supervised process itself). Instead the panel asks
; supervisord — over the group-accessible control socket — to run this program,
; which executes the canonical `postfix reload` as root. autostart is off (it
; only runs on demand) and a fast, clean exit is expected, so it never trips the
; crashexit listener.
[program:postfix-reload]
command=/usr/sbin/postfix reload
autostart=false
@@ -88,7 +88,7 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
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.
; 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.
@@ -103,10 +103,10 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; Periodic logrotate for /data/log/mail.log (spec 9, 10: daily, 7-14 files kept
; in the image). Runs as root so logrotate can read/rotate the log. Exits
; non-zero when the config is group/other-writable (logrotate would ignore it
; silently); autorestart surfaces BACKOFF on the Status page.
; Periodic logrotate for /data/log/mail.log (daily, 7-14 files kept in the
; image). Runs as root so logrotate can read/rotate the log. Exits non-zero when
; the config is group/other-writable (logrotate would ignore it silently);
; autorestart surfaces BACKOFF on the Status page.
[program:logrotate]
command=/usr/local/bin/logrotate-loop.sh
priority=400
+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",
"SELFPOST_DNS_RESOLVERS",
"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".
@@ -33,6 +41,15 @@ var documentedInternal = []string{
"SASL_REALM",
"POSTFIX_DIR",
"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_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
@@ -70,6 +87,9 @@ var loadConfigKeys = []string{
"SASL_DB_PATH",
"SASL_REALM",
"POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE",
"DMARC_REPORTS_ENABLE",
}
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
@@ -84,8 +104,24 @@ var buildScriptKeys = []string{
"JOURNAL_MILTER_SOCKET",
"MAIL_LOG",
"POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"SASL_DB_PATH",
"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_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
+51 -21
View File
@@ -10,7 +10,9 @@ import (
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web"
@@ -19,10 +21,14 @@ import (
// mailStack is the panel's domain and application services plus the on-disk
// mail-path adapters they write through.
type mailStack struct {
Domains *domain.Service
Apps *app.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
Domains *domain.Service
Apps *app.Service
Inbound *inbound.Service
DMARC *dmarc.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool
dmarcEnabled bool
}
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)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
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
@@ -42,6 +53,16 @@ func (m *mailStack) Resync() error {
if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err)
}
if m.inboundEnabled {
if err := m.Inbound.Resync(); err != nil {
return fmt.Errorf("inbound maps resync: %w", err)
}
}
if m.dmarcEnabled {
if err := m.DMARC.Resync(); err != nil {
return fmt.Errorf("dmarc maps resync: %w", err)
}
}
return nil
}
@@ -66,22 +87,31 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
// the environment describes it, with nothing bound to a port yet.
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
ms := newMailStack(cfg, st)
return web.New(st, ms.Domains, ms.Apps, web.Config{
Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled,
MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir,
DBPath: cfg.dbPath,
Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile,
OpenDKIMSocket: cfg.opendkimSocket,
JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
// 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,
CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled,
MailLogPath: cfg.mailLog,
DataDir: cfg.dataDir,
DBPath: cfg.dbPath,
DeployRoot: cfg.deployRoot,
Version: buildinfo.Version,
TrustedProxyCIDRs: cfg.trustedProxies,
TLSCertFile: cfg.tlsCertFile,
OpenDKIMSocket: cfg.opendkimSocket,
JournalSocket: cfg.journalSocket,
SessionIdleDays: cfg.sessionIdleDays,
DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled,
DMARCEnabled: cfg.dmarcEnabled,
SendLogRetentionEnvDefault: cfg.retentionDays,
}, cfg.setupTokenPath)
}
+44 -2
View File
@@ -28,6 +28,16 @@ import (
)
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")
flag.Parse()
@@ -78,6 +88,10 @@ type config struct {
saslDBPath string
saslRealm string
postfixDir string
deployRoot string
inboundEnabled bool
dmarcEnabled bool
}
func loadConfig() config {
@@ -138,6 +152,11 @@ func loadConfig() config {
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
saslRealm: saslRealm(),
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// 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 {
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
errc := make(chan error, 3)
errc := make(chan error, 4)
roles := []struct {
name string
@@ -252,7 +281,20 @@ func run() error {
}{
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
{"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 {
+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()
}
}
}
+60 -26
View File
@@ -22,7 +22,7 @@ import (
)
// 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
// 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
@@ -37,9 +37,10 @@ const (
// restored is the outcome of a full backup-and-restore round trip.
type restored struct {
panel http.Handler // panel booted on the restored data directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
panel http.Handler // panel booted on the restored data directory
deployRoot string // the restored project directory
dataDir string // the restored /data
session *http.Cookie // a session opened before the backup was taken
}
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
@@ -50,7 +51,7 @@ type restored struct {
func restoreFromOwnBackup(t *testing.T, password string) restored {
t.Helper()
live := seedPanelData(t)
live := seedPanelProject(t)
panel := bootPanel(t, live)
session := signIn(t, panel)
archive := downloadBackup(t, panel, session, password)
@@ -58,7 +59,12 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
target := t.TempDir()
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
@@ -91,6 +97,12 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
t.Errorf("the restored project directory has no %s: %v", name, err)
}
}
}
// The one-time setup link is closed by the presence of a panel user, and the
@@ -138,8 +150,9 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
// drifted from the database, that step puts it back before mail flows.
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
dataDir := seedPanelData(t)
cfg := panelConfig(t, dataDir)
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
cfg := panelConfig(t, deployRoot)
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
@@ -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
// backup names, and it has to be there when they do.
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
live := seedPanelData(t)
deployRoot := seedPanelProject(t)
dataDir := filepath.Join(deployRoot, "data")
var archive bytes.Buffer
if err := backup.Create(&archive, backup.Params{
DataDir: live,
DBPath: filepath.Join(live, "selfpost.db"),
Version: "9.9.9",
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "selfpost.db"),
Version: "9.9.9",
DeployRoot: deployRoot,
}); err != nil {
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
// administrator, a sending domain with an application and one logged message,
// and the daemon state the mail path needs (a DKIM key, the SASL database and
// Postfix's sender map).
func seedPanelData(t *testing.T) string {
// seedPanelProject builds an operator project tree: data/ with a panel that has
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
func seedPanelProject(t *testing.T) string {
t.Helper()
dataDir := t.TempDir()
deployRoot := t.TempDir()
dataDir := filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
@@ -268,16 +285,33 @@ func seedPanelData(t *testing.T) string {
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
// than where it put them. Cookies are marked insecure for the same reason the
// e2e stand does it: the test client speaks plain HTTP.
func panelConfig(t *testing.T, dataDir string) config {
func panelConfig(t *testing.T, deployRoot string) config {
t.Helper()
dataDir := filepath.Join(deployRoot, "data")
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
t.Setenv("PANEL_COOKIE_SECURE", "false")
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
// MAIL_LOG's default is an absolute path, not one derived from the data
@@ -288,13 +322,13 @@ func panelConfig(t *testing.T, dataDir string) config {
// bootPanel performs the startup sequence run() performs, in the same order,
// and returns the panel's HTTP handler.
func bootPanel(t *testing.T, dataDir string) http.Handler {
func bootPanel(t *testing.T, deployRoot string) http.Handler {
t.Helper()
cfg := panelConfig(t, dataDir)
cfg := panelConfig(t, deployRoot)
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
if err != nil {
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
}
if restored {
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
}
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
// bind mount before the image is started.
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
// directory before the image is started.
func extract(t *testing.T, archive []byte, dir string) {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(archive))
+10 -6
View File
@@ -6,9 +6,9 @@
//
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
//
// Use -o to write to a file instead. The resulting archive contains DKIM private
// keys, the admin password hash and SASL credentials — treat it as a secret
// (architecture.md § Persistence).
// Use -o to write to a file instead. The resulting archive is self-contained:
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
// secret (architecture.md § Persistence).
//
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
// argument, which would show up in the process list) the archive is written as
@@ -87,9 +87,13 @@ func run(outPath, password string) error {
}
if err := backup.Create(sink, backup.Params{
DataDir: dataDir,
DBPath: dbPath,
Version: buildinfo.Version,
DataDir: dataDir,
DBPath: dbPath,
Version: buildinfo.Version,
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
OnWarn: func(msg string) {
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
},
}); err != nil {
return err
}
+32 -7
View File
@@ -9,13 +9,18 @@ import (
"strings"
"testing"
"github.com/mixeme/selfpost/internal/backup"
"github.com/mixeme/selfpost/internal/store"
)
// seedDataDir builds the minimum /data tree a backup can be taken from.
func seedDataDir(t *testing.T) string {
// seedProject builds the minimum operator project tree a backup can be taken from.
func seedProject(t *testing.T) (deployRoot, dataDir string) {
t.Helper()
dataDir := t.TempDir()
deployRoot = t.TempDir()
dataDir = filepath.Join(deployRoot, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("mkdir data: %v", err)
}
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
if err != nil {
t.Fatalf("open store: %v", err)
@@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string {
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
t.Setenv("SELFPOST_DATA_DIR", dataDir)
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
return dataDir
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
return deployRoot, dataDir
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// An encrypted backup is only worth having if the container it came from can
// hand it back as an ordinary archive during a restore, so the two halves of
// the CLI are tested as the one round trip an operator actually performs.
func TestEncryptedBackupRoundTrip(t *testing.T) {
seedDataDir(t)
seedProject(t)
dir := t.TempDir()
encrypted := filepath.Join(dir, "backup.spbk")
plain := filepath.Join(dir, "backup.tar.gz")
@@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
}
names[hdr.Name] = true
}
for _, want := range []string{"manifest.json", "selfpost.db"} {
for _, want := range []string{
backup.DataArchivePrefix + backup.ManifestName,
backup.DataArchivePrefix + "selfpost.db",
backup.ComposeFileName,
backup.EnvFileName,
} {
if !names[want] {
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
}
@@ -91,7 +116,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
// Without a password the CLI keeps producing the plain archive that existing
// backup scripts consume.
func TestUnencryptedBackupStaysPlain(t *testing.T) {
seedDataDir(t)
seedProject(t)
out := filepath.Join(t.TempDir(), "backup.tar.gz")
if err := run(out, ""); err != nil {
t.Fatalf("create backup: %v", err)
+24
View File
@@ -11,6 +11,30 @@ SELFPOST_HOSTNAME=mail.example.com
# primary 465/smtps listener.
SUBMISSION_ENABLE=false
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
# mail on port 25 only for domains configured in the panel and forwards them
# to the upstream you set. Off by default — the outbound path is unchanged.
INBOUND_RELAY_ENABLE=false
# Optional DMARC aggregate report ingest. When true, Postfix accepts mail on port
# 25 only for report addresses configured in the panel (global default and/or
# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker.
DMARC_REPORTS_ENABLE=false
# Coarse per-client-IP limit and message size for DMARC ingest on port 25.
# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20
# DMARC_MESSAGE_SIZE_LIMIT=5242880
# Optional milter on the inbound listener only (not 465/587). Empty = off.
# Example with deploy/antispam/docker-compose.antispam.yml:
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
# INBOUND_ANTISPAM_MILTER_ACTION=accept
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
RATE_LIMIT_MESSAGES_PER_IP=100
RATE_LIMIT_WINDOW_SECONDS=3600
@@ -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
# scenario). Install on the host running Apache — NOT inside the SelfPost
# container. Requires mod_ssl and mod_proxy/mod_proxy_http enabled
# (`a2enmod ssl proxy proxy_http`).
# Example host Apache vhost for the SelfPost panel (default scenario). Install
# on the host running Apache — NOT inside the SelfPost container. Requires
# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`).
#
# Certificates: obtain with the Apache certbot plugin against THIS vhost
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place
# to add the SSLCertificateFile/SSLCertificateKeyFile directives and a
# :80 -> :443 redirect vhost, and renews in the background via its own timer.
# The resulting PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host
# — bind-mount that directory read-only into the SelfPost container (see
# (`certbot --apache -d mail.example.com`). Certbot edits this file in place to
# add the SSLCertificateFile/SSLCertificateKeyFile directives and a :80 -> :443
# redirect vhost, and renews in the background via its own timer. The resulting
# PEM files land at
# /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem on the host —
# bind-mount that directory read-only into the SelfPost container (see
# ../docker-compose.yml, the `./certs:/etc/postfix/tls:ro` volume) so Postfix
# can present the very same certificate on 465/587. One certificate, two
# consumers (spec 10 p.4) — no separate cert just for the panel.
# consumers — no separate cert just for the panel.
<VirtualHost *:443>
ServerName mail.example.com
@@ -26,9 +25,9 @@
ProxyPass "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "http://127.0.0.1:8080/"
# The panel is plain HTTP behind the proxy; it never terminates TLS
# itself (spec 10 p.2). Cookies are still marked Secure by the panel
# (spec 7.6.6) because the browser only ever sees this HTTPS vhost.
# The panel is plain HTTP behind the proxy; it never terminates TLS itself.
# Cookies are still marked Secure by the panel because the browser only ever
# sees this HTTPS vhost.
</VirtualHost>
<VirtualHost *:80>
+7 -5
View File
@@ -1,10 +1,10 @@
# Alternative reverse-proxy: Caddy (spec 10.3) — the simplest option, fully
# automatic ACME with no separate certbot container. Caddy writes certificates
# as plain PEM under its own data directory, which this fragment bind-mounts
# to the host so SelfPost can read the same files.
# Alternative reverse-proxy: Caddy — the simplest option, fully automatic ACME
# with no separate certbot container. Caddy writes certificates as plain PEM
# under its own data directory, which this fragment bind-mounts to the host so
# SelfPost can read the same files.
#
# 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.
# <data>/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>/<hostname>.crt
# This has been stable across recent Caddy releases but is an internal
@@ -23,6 +23,7 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
environment:
# Caddy names certificate files after the hostname, not
# fullchain.pem/privkey.pem like certbot — override the base file's
@@ -31,6 +32,7 @@ services:
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
volumes: !override
- ./data:/data
- .:/selfpost-deploy:ro
# Replace <hostname> with your actual mail/panel hostname, matching
# the Caddyfile below and SELFPOST_HOSTNAME.
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
+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
# installed on the HOST (the target audience for this project typically runs
# Apache there already — spec 10.5) and reverse-proxies HTTPS for the panel;
# it is not containerised here. See ../apache/selfpost-vhost.conf for a ready
# vhost fragment, and docs/guide.md "Reverse proxy" for the
# certbot steps that produce the PEM files this compose file mounts.
# This file only runs SelfPost itself. Apache is assumed to already be installed
# on the HOST (the target audience for this project typically runs Apache there
# already) and reverse-proxies HTTPS for the panel; it is not containerised
# here. See ../apache/selfpost-vhost.conf for a ready vhost fragment, and
# docs/guide.md "Reverse proxy" for the certbot steps that produce the PEM files
# this compose file mounts.
#
# Usage:
# 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.
# 3. docker compose up -d
#
# The image tag below is FIXED on purpose (spec 10 p.10, 7.5.A): backup
# restore compares the manifest version against the running binary's version,
# so ":latest" would make that check meaningless. Bump the tag deliberately
# when you want to upgrade.
# The image tag below is FIXED on purpose: backup restore compares the manifest
# version against the running binary's version, so ":latest" would make that
# check meaningless. Bump the tag deliberately when you want to upgrade.
services:
selfpost:
image: ghcr.io/mixeme/selfpost:1.3.0
image: ghcr.io/mixeme/selfpost:1.9.3
restart: unless-stopped
environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
# Path Postfix reads inside the container — matches the certs bind mount
# below. Point these at your reverse-proxy's PEM output (spec 10 p.2).
# below. Point these at your reverse-proxy's PEM output.
TLS_CERT_FILE: /etc/postfix/tls/fullchain.pem
TLS_KEY_FILE: /etc/postfix/tls/privkey.pem
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
# alongside the primary 465/smtps listener (spec 5).
# Set to true to also publish RFC 6409 submission (587/STARTTLS) alongside
# the primary 465/smtps listener.
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
# limits (level 2) are configured later from the panel itself.
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
# Level-1 backstop rate limit (anvil); per-domain/app limits (level 2) are
# configured later from the panel itself.
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
RATE_LIMIT_WINDOW_SECONDS: "${RATE_LIMIT_WINDOW_SECONDS:-3600}"
# How long the send log keeps rows before the background sweep deletes
# them (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}"
# 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}"
# 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
@@ -51,30 +58,36 @@ services:
# client spoof its rate-limit key.
TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-}"
volumes:
# All persistent state lives under /data (spec 9): SQLite DB, DKIM keys,
# sasldb2, sender map, setup token. Back this up (panel button or the
# selfpost-backup CLI) before you touch it directly.
# Postfix's mail.log is here too (./data/log, rotated daily, 14 files
# kept) so the delivery lines that resolve a queued send-log row survive
# a container recreate; it is diagnostics and is left out of backups.
# All persistent state lives under /data: SQLite DB, DKIM keys, sasldb2,
# sender map, setup token. Back this up (panel button or the
# selfpost-backup CLI) before you touch it directly. Postfix's mail.log is
# here too (./data/log, rotated daily, 14 files kept) so the delivery
# lines that resolve a queued send-log row survive a container recreate;
# it is diagnostics and is left out of backups.
- ./data:/data
# Read-only: SelfPost only ever reads certificates, never manages them
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
# writes PEM files, e.g. /etc/letsencrypt on the host.
# Read-only project root for full backups: docker-compose.yml, .env, and
# certs/ are archived alongside /data (architecture.md § Persistence).
- .:/selfpost-deploy:ro
# Read-only: SelfPost only ever reads certificates, never manages them.
# Point this at wherever your reverse-proxy/certbot writes PEM files, e.g.
# /etc/letsencrypt on the host.
- ./certs:/etc/postfix/tls:ro
ports:
# 465 (smtps, primary) and optionally 587 (submission/STARTTLS) are
# published directly — mail traffic bypasses Apache entirely, it only
# ever proxies the panel's HTTP(S) (spec 10 p.2-3). The panel itself
# (8080) is intentionally NOT published here: Apache reaches it over
# the host network at 127.0.0.1:8080 (see the vhost fragment), so the
# panel is never directly reachable from the internet without TLS.
# published directly — mail traffic bypasses Apache entirely, it only ever
# proxies the panel's HTTP(S). The panel itself (8080) is intentionally
# NOT published here: Apache reaches it over the host network at
# 127.0.0.1:8080 (see the vhost fragment), so the panel is never directly
# reachable from the internet without TLS.
- "465:465"
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
# when the variable is true — see docs/guide.md "Published ports".
- "587:587"
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
# when the variable is true.
- "25:25"
- "127.0.0.1:8080:8080"
# Hardening (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
# under the shared `selfpost` group (see build/entrypoint.sh) before
# 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
# image already grants, and every Linux capability dropped except the
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data
# dirs/files while still root (owner-check bypass);
+8 -6
View File
@@ -1,7 +1,7 @@
# Alternative reverse-proxy: nginx (spec 10.3). PEM files land on the host
# exactly like the Apache+certbot scenario — nginx and certbot are close
# cousins here, both writing/reading plain PEM under /etc/letsencrypt.
# Alternative reverse-proxy: nginx. PEM files land on the host exactly like the
# Apache+certbot scenario — nginx and certbot are close cousins here, both
# writing/reading plain PEM under /etc/letsencrypt.
#
# 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
@@ -23,13 +23,15 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
# No host publish for 8080 here: nginx reaches it over the compose
# network at selfpost:8080 instead (see nginx.conf.example).
volumes: !override
- ./data:/data
# Same host directory certbot below writes into — plain bind mount,
# no named volume, so the PEM files are as directly inspectable as in
# the Apache scenario (spec 10.3).
- .:/selfpost-deploy:ro
# Same host directory certbot below writes into — plain bind mount, no
# named volume, so the PEM files are as directly inspectable as in the
# Apache scenario.
- ./nginx/certbot-etc/live/mail.example.com:/etc/postfix/tls:ro
nginx:
+3 -3
View File
@@ -1,6 +1,6 @@
# nginx vhost for the SelfPost panel (spec 10.3). Proxies HTTPS to the panel
# over the compose network; mail (465/587) is published directly by the
# selfpost container and never touches nginx.
# nginx vhost for the SelfPost panel. Proxies HTTPS to the panel over the
# compose network; mail (465/587) is published directly by the selfpost
# container and never touches nginx.
server {
listen 80;
+5 -3
View File
@@ -1,7 +1,7 @@
# Alternative reverse-proxy: Traefik (spec 10.3). Traefik does NOT write plain
# PEM files — everything (cert + key) lives bundled inside acme.json. SelfPost
# needs separate PEM files for Postfix, so an extraction step is required; see
# Alternative reverse-proxy: Traefik. Traefik does NOT write plain PEM files —
# everything (cert + key) lives bundled inside acme.json. SelfPost needs
# separate PEM files for Postfix, so an extraction step is required; see
# 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
@@ -15,8 +15,10 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
volumes: !override
- ./data:/data
- .:/selfpost-deploy:ro
# Populated by extract-cert.sh from traefik's acme.json — see below.
- ./traefik/extracted-certs:/etc/postfix/tls:ro
labels:
+5 -5
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json
# (spec 10.3: "Traefik keeps certificates in acme.json, so a PEM extraction
# step is required"). Run this on the host, after Traefik has issued or
# renewed the certificate, and again on a schedule (cron/systemd timer) since
# acme.json is not itself watched by SelfPost/Postfix.
# Extracts a PEM cert/key pair for one domain out of Traefik's acme.json —
# Traefik keeps its certificates there, so a PEM extraction step is required.
# Run this on the host, after Traefik has issued or renewed the certificate, and
# again on a schedule (cron/systemd timer) since acme.json is not itself watched
# by SelfPost/Postfix.
#
# Requires jq. Usage: ./extract-cert.sh <acme.json path> <domain> <output dir>
set -eu
+106 -22
View File
@@ -27,7 +27,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
| Program | User | Priority | Role |
|---|---|---|---|
| `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-reload` | root | — | On-demand `postfix reload` (autostart off) |
| `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)
├─► journal-milter (send log + L2 rate limits, fail-open)
└─► 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))
- **465/smtps** — implicit TLS, SASL required; primary listener.
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`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`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **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))
One process, three roles:
One process, four roles:
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
proxy only.
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`delay_warning_time`) and caches the snapshot on the handlers config. The
Mail queue card and a delivery's `deferred` / `bounced` history print those
numbers; they never call `postconf` per request. If `postconf` is missing,
the panel logs a warning and uses Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain
ceilings apply to every client IP; an application ceiling with trusted IPs
raises the limit for those IPs only and skips the domain check (guide § Rate
limiting). The level-2 count is the stored send-log rows plus the messages
ceilings apply to every client IP; an application ceiling **overrides** the
domain one for that login, higher or lower (guide § Rate limiting). Since
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`),
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.
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`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).
@@ -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/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
| `/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) |
| `/help` | In-panel operator help (any authenticated user) |
| `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/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
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
session idle timeout (only non-`HX-Request` GET and mutating requests count as
activity).
activity). The Mail queue retry-policy card is outside that fragment: it is the
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
not a live re-read.
### Sessions
@@ -242,6 +291,8 @@ flowchart TB
subgraph services ["Services — multi-store operations + rollback"]
domainSvc["internal/domain"]
appSvc["internal/app"]
inboundSvc["internal/inbound"]
dmarcSvc["internal/dmarc"]
end
subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"]
@@ -263,20 +314,27 @@ flowchart TB
web --> store
web --> domainSvc
web --> appSvc
web --> inboundSvc
web --> dmarcSvc
web --> backupPkg
web --> dnscheck
web --> health
web --> secretfile
domainSvc --> store
appSvc --> store
inboundSvc --> store
dmarcSvc --> store
milterPkg --> store
logtail --> store
domainSvc --> postfix
appSvc --> postfix
inboundSvc --> postfix
dmarcSvc --> postfix
```
The three roles inside the `panel` process (HTTP server, journal-milter,
log-tailer goroutine) share one binary and one SQLite handle on purpose — see
The four roles inside the `panel` process (HTTP server, journal-milter,
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
single-connection trade-off that follows from it.
@@ -286,28 +344,42 @@ single-connection trade-off that follows from it.
| 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 |
| `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials |
| `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) |
| `manifest.json` | Backup version stamp (consumed on restore) |
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
(transit mail not migrated by design).
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
full backups also archive the operator's `./certs` PEM files when present.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above).
**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
restore. On the first successful boot after restore, the panel runs one
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
SQLite and both daemons are reloaded, so drift between the extracted archive
and the database is healed before mail flows (same step as `POST /reload` on
demand). Stopped-container `tar` of `./data` is safe (see guide).
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
tables, 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
([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`
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains).
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_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
`MILTER_WAIT_TIMEOUT` (`30` seconds).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 KiB

+164
View File
@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<header class="sp-bar">
<div class="sp-wrap">
<nav>
<ul>
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
</ul>
<ul class="sp-sections">
<li><a href="status.html">Overview</a></li>
<li><a href="domain.html" aria-current="true">Outbound</a></li>
<li><a href="#">Inbound</a></li>
<li><a href="#">Server</a></li>
</ul>
<ul class="sp-user">
<li>
<details class="dropdown">
<summary>admin</summary>
<ul dir="rtl">
<li><a href="#">Account</a></li>
<li><a href="#">Help</a></li>
<li><a href="login.html">Sign out</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</div>
</header>
<div class="sp-pages">
<div class="sp-wrap">
<a href="#" aria-current="true">Domains</a>
<a href="#">Log</a>
<a href="#">Queue</a>
<a href="#">DMARC reports</a>
</div>
</div>
<main class="sp-wrap">
<div class="sp-head">
<div>
<p class="sp-kicker"><a href="#">Outbound domains</a> /</p>
<h1>example.org</h1>
</div>
<small>1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h</small>
</div>
<section class="sp-sheet">
<header><span class="sp-no">01</span><h2>Connection</h2><small>The same for every domain</small></header>
<div class="sp-body sp-cols">
<dl class="sp-kv">
<dt>Server</dt>
<dd><span class="sp-value">mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
<dl class="sp-kv">
<dt>Ports</dt>
<dd><span class="sp-value">465 — SSL/TLS (implicit)<br>587 — STARTTLS (submission)</span></dd>
</dl>
</div>
</section>
<section class="sp-sheet">
<header>
<span class="sp-no">02</span><h2>DNS records</h2>
<small>Checked 3 min ago</small>
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check</button></form>
</header>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="sp-tag ok">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">sp2026._domainkey.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="sp-tag fail">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=spf1 ip4:203.0.113.25 ~all</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>In DNS now</dt><dd><span class="sp-value">v=spf1 include:_spf.google.com ~all</span></dd>
</dl>
<p class="sp-note fail">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="sp-tag ok">published</span><small>Report address from <a href="#">Settings</a></small></div>
<dl class="sp-kv">
<dt>Host</dt><dd><span class="sp-value">_dmarc.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
<dt>Type</dt><dd><span class="sp-value plain">TXT</span></dd>
<dt>Value</dt><dd><span class="sp-value">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="outline sp-copy">Copy</button></dd>
</dl>
<p class="sp-note"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="#">reports</a> look clean.</p>
</div>
</section>
<section class="sp-sheet">
<header>
<span class="sp-no">03</span><h2>Applications</h2>
<small>SASL logins that may send as this domain</small>
<a href="#" role="button" class="sp-btn-sm">Add application</a>
</header>
<table class="sp-apps">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
<tr>
<td class="sp-login">prod-server</td>
<td>*@example.org</td>
<td>1 102 msg · peak 96/h</td>
<td><span class="sp-tag ok">200 / h</span></td>
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
</tr>
<tr>
<td class="sp-login">alerts</td>
<td>alerts@example.org, noc@example.org</td>
<td>182 msg · peak 14/h</td>
<td><span class="sp-tag">domain</span> <span class="sp-tag ok">2 IPs</span></td>
<td><a class="sp-link-btn" href="#">Edit</a><a class="sp-link-btn" href="#">New password</a><a class="sp-link-btn danger" href="#">Delete</a></td>
</tr>
</tbody>
</table>
</section>
<section class="sp-sheet sp-ledger">
<header><span class="sp-no">04</span><h2>Domain settings</h2><small>Rarely changed</small></header>
<details>
<summary><strong>DMARC reports</strong><span class="sp-muted">Same as Settings — dmarc@mail.example.org</span><span class="sp-tag">default</span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Rate limit</strong><span class="sp-muted">Auto ×3 — 288 messages / hour, recalculated yesterday</span><span class="sp-tag ok">active</span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Export</strong><span class="sp-muted">DKIM key and applications as one file, optionally encrypted</span><span></span></summary>
<div class="sp-detail"><p class="sp-muted">The form opens here.</p></div>
</details>
<details>
<summary><strong>Delete domain</strong><span class="sp-muted">Removes the DKIM key and both applications</span><span></span></summary>
<div class="sp-detail"><p class="sp-muted">The confirmation opens here.</p></div>
</details>
</section>
<footer class="sp-foot">
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="sp-signin">
<div class="sp-signin-card">
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<section class="sp-sheet">
<header><span class="sp-no">→</span><h2>Sign in</h2></header>
<div class="sp-body">
<form method="post" action="status.html">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
</div>
</section>
<p class="sp-tagline">YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<footer class="sp-wrap sp-foot">
© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</body>
</html>
+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2.1.1/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<header class="sp-bar">
<div class="sp-wrap">
<nav>
<ul>
<li><a class="sp-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a></li>
</ul>
<ul class="sp-sections">
<li><a href="status.html" aria-current="page">Overview</a></li>
<li><a href="domain.html">Outbound</a></li>
<li><a href="#">Inbound</a></li>
<li><a href="#">Server</a></li>
</ul>
<ul class="sp-user">
<li>
<details class="dropdown">
<summary>admin</summary>
<ul dir="rtl">
<li><a href="#">Account</a></li>
<li><a href="#">Help</a></li>
<li><a href="login.html">Sign out</a></li>
</ul>
</details>
</li>
</ul>
</nav>
</div>
</header>
<main class="sp-wrap">
<div class="sp-head">
<div>
<p class="sp-kicker">mail.example.org</p>
<h1>Overview</h1>
</div>
<small>Updated 14:02:31 UTC · refreshes every 10 s</small>
</div>
<section class="sp-sheet sp-verdict">
<div class="sp-postmark warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div>
<h2>Mail is flowing. One thing needs attention.</h2>
<p>The TLS certificate expires in 12 days and has not renewed yet. Everything else is healthy.</p>
</div>
</section>
<section class="sp-sheet sp-ledger">
<header><span class="sp-no">01</span><h2>Checks</h2><small>Open a row for its detail</small></header>
<details>
<summary><strong>Processes</strong><span class="sp-muted">5 of 5 running</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="sp-tag ok">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
</tbody>
</table>
</div>
</details>
<details open>
<summary><strong>TLS certificate</strong><span class="sp-muted">Expires in 12 days — renewal has not run</span><span class="sp-tag warn">warn</span></summary>
<div class="sp-detail">
<dl class="sp-kv">
<dt>Expires</dt><dd><span class="sp-value">2026-10-03 09:14 UTC</span></dd>
<dt>Names</dt><dd><span class="sp-value">mail.example.org</span></dd>
</dl>
<p class="sp-note">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="#">system log</a>.</p>
</div>
</details>
<details>
<summary><strong>Machine</strong><span class="sp-muted">CPU 12 % · memory 41 % · ↓ 18 kB/s ↑ 44 kB/s</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><meter value="12" min="0" max="100" low="70" high="90" optimum="10"></meter>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><meter value="41" min="0" max="100" low="70" high="90" optimum="10"></meter>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table>
</div>
</details>
<details>
<summary><strong>Queues</strong><span class="sp-muted">Outbound empty · inbound empty</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail"><p>No messages are waiting. Open the <a href="#">outbound queue</a> or the <a href="#">inbound queue</a>.</p></div>
</details>
<details>
<summary><strong>Milter sockets</strong><span class="sp-muted">2 of 2 answering</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<table>
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>opendkim</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">inet:127.0.0.1:8891</td></tr>
<tr><td>journal-milter</td><td><span class="sp-tag ok">ok</span></td><td class="sp-muted">unix:/run/selfpost/journal.sock</td></tr>
</tbody>
</table>
</div>
</details>
<details>
<summary><strong>Hostname and reverse DNS</strong><span class="sp-muted">mail.example.org ⇄ 203.0.113.25</span><span class="sp-tag ok">ok</span></summary>
<div class="sp-detail">
<dl class="sp-kv">
<dt>Hostname</dt><dd><span class="sp-value">mail.example.org</span></dd>
<dt>Lookup</dt><dd><span class="sp-value">mail.example.org → 203.0.113.25<br>203.0.113.25 → mail.example.org</span></dd>
</dl>
<form method="post" action="#"><button type="submit" class="outline secondary sp-btn-sm">Re-check DNS</button></form>
</div>
</details>
</section>
<footer class="sp-foot">
SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty
</footer>
</main>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
/* Direction A — "Blank" (the postal form). Pico CSS v2 carries every generic
control (type, buttons, inputs, tables, details, dropdown); this file maps
the brand onto Pico's variables and adds only what is SelfPost's own:
the two-row header, the sheet, numbered form sections, the postmark and
the DNS record block. */
/* :root[...] rather than [data-theme=light] alone: Pico declares its light
palette on :root:not([data-theme=dark]), which a bare attribute selector
loses to. */
:root[data-theme=light] {
--pico-font-family-sans-serif: var(--sp-sans);
--pico-font-family-monospace: var(--sp-mono);
--pico-font-family: var(--sp-sans);
--pico-border-radius: 2px;
--pico-spacing: .9rem;
--pico-typography-spacing-vertical: .9rem;
--pico-form-element-spacing-vertical: .5rem;
--pico-form-element-spacing-horizontal: .75rem;
--pico-background-color: var(--sp-paper);
--pico-color: var(--sp-ink);
--pico-muted-color: var(--sp-muted);
--pico-muted-border-color: var(--sp-rule);
--pico-h1-color: var(--sp-ink); --pico-h2-color: var(--sp-ink);
--pico-h3-color: var(--sp-ink); --pico-h4-color: var(--sp-ink);
--pico-primary: var(--sp-brick);
--pico-primary-background: var(--sp-brick);
--pico-primary-border: var(--sp-brick);
--pico-primary-underline: rgb(122 59 46 / .35);
--pico-primary-hover: var(--sp-brick-dark);
--pico-primary-hover-background: var(--sp-brick-dark);
--pico-primary-hover-border: var(--sp-brick-dark);
--pico-primary-hover-underline: var(--sp-brick-dark);
--pico-primary-focus: rgb(122 59 46 / .25);
--pico-primary-inverse: #fff;
--pico-secondary: var(--sp-ink-2);
--pico-secondary-background: var(--sp-ink-2);
--pico-secondary-border: var(--sp-ink-2);
--pico-secondary-hover: var(--sp-ink);
--pico-secondary-hover-background: var(--sp-ink);
--pico-secondary-hover-border: var(--sp-ink);
--pico-secondary-focus: rgb(18 22 28 / .2);
--pico-secondary-inverse: #fff;
--pico-form-element-background-color: #fff;
--pico-form-element-border-color: var(--sp-rule-strong);
--pico-form-element-active-border-color: var(--sp-brick);
--pico-form-element-focus-color: rgb(122 59 46 / .25);
--pico-table-border-color: var(--sp-rule);
--pico-code-background-color: var(--sp-well);
--pico-code-color: var(--sp-ink);
--pico-dropdown-background-color: #fff;
--pico-dropdown-border-color: var(--sp-rule);
--pico-dropdown-hover-background-color: var(--sp-brick-tint);
}
/* Pico grows the root font with the viewport (up to 131%). An ops panel wants
one size everywhere. */
:root { font-size: 15px; }
body { min-height: 100vh; display: flex; flex-direction: column; }
h1 { font-size: 1.7rem; font-weight: 600; margin: 0; }
small, .sp-muted { color: var(--sp-muted); }
code, .sp-mono { font-family: var(--sp-mono); font-size: .88em; }
/* ---- header: sections on the first row, the section's pages on the second */
.sp-wrap { width: 100%; max-width: 72rem; margin-inline: auto; padding-inline: 1.5rem; }
.sp-bar { background: var(--sp-sheet); border-bottom: 1px solid var(--sp-rule); }
.sp-bar nav { align-items: center; }
.sp-bar nav li { padding-block: .45rem; }
.sp-brand img { height: 46px; width: auto; display: block; }
.sp-sections a {
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600;
letter-spacing: .09em; text-transform: uppercase; text-decoration: none;
color: var(--sp-muted); padding: .5rem .8rem; margin: 0; border-radius: 0;
}
.sp-sections a:hover { color: var(--sp-ink); }
.sp-sections a[aria-current] { color: var(--sp-brick); box-shadow: inset 0 -3px 0 var(--sp-brick); }
.sp-user summary { font-size: .9rem; }
.sp-pages { background: var(--sp-paper); border-bottom: 1px solid var(--sp-rule); }
.sp-pages .sp-wrap { display: flex; gap: 1.6rem; }
.sp-pages a { color: var(--sp-muted); text-decoration: none; padding: .6rem 0; font-size: .95rem; }
.sp-pages a:hover { color: var(--sp-ink); }
.sp-pages a[aria-current] { color: var(--sp-ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sp-ink); }
/* ---- the sheet: one column, one width, every page */
main.sp-wrap { flex: 1; padding-block: 1.8rem 2.5rem; }
.sp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.4rem; }
.sp-head .sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); margin: 0 0 .15rem; }
.sp-head .sp-kicker a { color: inherit; }
.sp-head-actions { display: flex; gap: .5rem; align-items: center; }
.sp-head-actions > * { margin: 0; }
.sp-sheet { background: var(--sp-sheet); border: 1px solid var(--sp-rule); margin-bottom: 1.4rem; }
.sp-sheet > header {
display: flex; align-items: baseline; gap: .8rem; padding: .7rem 1.2rem;
border-bottom: 1px solid var(--sp-rule);
}
.sp-sheet > header .sp-no { font-family: var(--sp-mono); font-size: .75rem; color: var(--sp-brick); font-weight: 600; }
.sp-sheet > header h2 {
font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em;
text-transform: uppercase; margin: 0; flex: 1;
}
.sp-sheet > header small { font-size: .85rem; }
.sp-sheet > .sp-body { padding: 1.1rem 1.2rem; }
.sp-sheet > .sp-body > :last-child { margin-bottom: 0; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; border-top: 1px solid var(--sp-rule); padding-block: 1rem; }
.sp-foot a { color: inherit; }
/* ---- status tag and the postmark */
.sp-tag {
display: inline-block; font-family: var(--sp-mono); font-size: .72rem; font-weight: 600;
letter-spacing: .08em; text-transform: uppercase; padding: .1rem .5rem;
border: 1px solid var(--sp-idle-rule); background: var(--sp-idle-bg); color: var(--sp-idle);
border-radius: 2px; white-space: nowrap; text-decoration: none;
}
.sp-tag.ok { color: var(--sp-ok); background: var(--sp-ok-bg); border-color: var(--sp-ok-rule); }
.sp-tag.warn { color: var(--sp-warn); background: var(--sp-warn-bg); border-color: var(--sp-warn-rule); }
.sp-tag.fail { color: var(--sp-fail); background: var(--sp-fail-bg); border-color: var(--sp-fail-rule); }
.sp-verdict { display: flex; align-items: center; gap: 1.5rem; padding: 1.3rem 1.4rem; }
.sp-verdict h2 { margin: 0 0 .2rem; font-size: 1.35rem; font-weight: 600; }
.sp-verdict p { margin: 0; color: var(--sp-muted); }
.sp-postmark {
flex: none; width: 5.6rem; height: 5.6rem; border-radius: 50%;
border: 2px solid currentColor; outline: 1px solid currentColor; outline-offset: 3px;
display: grid; place-content: center; text-align: center; transform: rotate(-9deg);
font-family: var(--sp-mono); font-weight: 600; letter-spacing: .08em; line-height: 1.15;
color: var(--sp-ok);
}
.sp-postmark.warn { color: var(--sp-warn); }
.sp-postmark.fail { color: var(--sp-fail); }
.sp-postmark b { font-size: 1.25rem; display: block; }
.sp-postmark span { font-size: .55rem; display: block; }
/* ---- ledger: one row per check, the row opens into its detail */
.sp-ledger details { margin: 0; padding: 0; border-bottom: 1px solid var(--sp-rule); }
.sp-ledger details:last-child { border-bottom: 0; }
.sp-ledger summary {
display: grid; grid-template-columns: 13rem 1fr auto 1.2rem; gap: 1rem; align-items: center;
padding: .75rem 1.2rem; margin: 0; color: var(--sp-ink);
}
.sp-ledger summary::after { grid-column: 4; margin: 0; float: none; }
.sp-ledger summary:hover { background: var(--sp-paper); }
.sp-ledger summary strong { font-weight: 600; }
.sp-ledger summary span.sp-muted { font-size: .93rem; }
.sp-ledger .sp-detail { padding: .2rem 1.2rem 1.1rem 15.2rem; }
.sp-ledger .sp-detail table { margin: 0; font-size: .93rem; }
.sp-ledger .sp-detail > :last-child { margin-bottom: 0; }
.sp-ledger meter { width: 9rem; height: .7rem; vertical-align: middle; margin-right: .5rem; }
/* ---- label / value / copy: the DNS record block and connection settings */
.sp-record { padding: 1.1rem 1.2rem; border-bottom: 1px solid var(--sp-rule); }
.sp-record:last-child { border-bottom: 0; }
.sp-record-head { display: flex; align-items: center; gap: .7rem; margin-bottom: .7rem; }
.sp-record-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
.sp-record-head small { margin-left: auto; }
.sp-kv { display: grid; grid-template-columns: 6.5rem 1fr; gap: .45rem 1rem; align-items: start; margin: 0; }
.sp-kv dt {
font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--sp-muted); padding-top: .5rem; margin: 0;
}
.sp-kv dd { margin: 0; display: flex; gap: .4rem; align-items: stretch; min-width: 0; }
.sp-value {
flex: 1; min-width: 0; font-family: var(--sp-mono); font-size: .86rem; line-height: 1.5;
background: var(--sp-well); border: 1px solid var(--sp-rule); padding: .4rem .65rem;
overflow-wrap: anywhere; border-radius: 2px;
}
.sp-value.plain { background: none; border-color: transparent; padding-left: 0; font-family: var(--sp-sans); font-size: .95rem; }
.sp-copy {
flex: none; align-self: flex-start; height: 2.15rem; margin: 0; padding: 0 .7rem; font-size: .8rem;
--pico-background-color: #fff; --pico-border-color: var(--sp-rule-strong); --pico-color: var(--sp-ink-2);
}
.sp-copy:hover { --pico-background-color: var(--sp-paper); --pico-border-color: var(--sp-ink-2); --pico-color: var(--sp-ink); }
.sp-note { margin: .7rem 0 0 7.5rem; font-size: .9rem; color: var(--sp-muted); }
.sp-note.fail { color: var(--sp-fail); }
.sp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 0 2.4rem; }
/* ---- applications */
.sp-apps { margin: 0; font-size: .95rem; }
.sp-apps td, .sp-apps th { padding-inline: 1.2rem .4rem; vertical-align: middle; }
.sp-apps th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
.sp-apps td:last-child { text-align: right; padding-right: 1.2rem; white-space: nowrap; }
.sp-apps .sp-login { font-family: var(--sp-mono); font-weight: 600; }
.sp-link-btn { font-size: .9rem; margin-left: .9rem; }
.sp-link-btn.danger { color: var(--sp-fail); --pico-underline: rgb(180 35 24 / .35); text-decoration-color: rgb(180 35 24 / .35); }
.sp-btn-sm { padding: .35rem .8rem; font-size: .9rem; margin: 0; width: auto; }
/* ---- sign-in */
.sp-signin { flex: 1; display: grid; place-content: center; padding: 2rem 1.5rem; }
.sp-signin-card { width: 23rem; max-width: 100%; }
.sp-signin-card img { display: block; width: 15rem; height: auto; margin: 0 auto 1.6rem; }
.sp-signin-card .sp-body { padding: 1.4rem 1.4rem 1.2rem; }
.sp-signin-card button { margin-bottom: 0; }
.sp-tagline { text-align: center; font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .16em; color: var(--sp-muted); margin-top: 1.2rem; }
@media (max-width: 48rem) {
.sp-ledger summary { grid-template-columns: 1fr auto 1.2rem; }
.sp-ledger summary span.sp-muted { grid-column: 1 / -1; grid-row: 2; }
.sp-ledger summary::after { grid-column: 3; }
.sp-ledger .sp-detail { padding-left: 1.2rem; }
.sp-kv { grid-template-columns: 1fr; gap: .15rem; }
.sp-kv dt { padding-top: .5rem; }
.sp-note { margin-left: 0; }
.sp-pages .sp-wrap { overflow-x: auto; white-space: nowrap; }
}
+212
View File
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
<div class="navbar-collapse collapse show">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
<li class="sp-nav-label">Outbound</li>
<li class="nav-item active"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
<li class="sp-nav-label">Inbound</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="sp-nav-label">Server</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
</ul>
<ul class="navbar-nav sp-nav-foot">
<li class="sp-nav-user">Signed in as admin</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
</ul>
</div>
</div>
</aside>
<div class="page-wrapper">
<div class="page-header">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle"><a href="#" class="text-reset">Outbound domains</a> /</div>
<h2 class="page-title">example.org <span class="badge bg-red-lt ms-2">DNS: 1 mismatch</span></h2>
</div>
<div class="col-auto">
<div class="btn-list">
<form method="post" action="#"><button type="submit" class="btn"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form>
<a href="#" class="btn btn-primary"><i class="ti ti-plus me-1"></i>Add application</a>
</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="row row-cards">
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">DNS records</h3><div class="card-actions text-secondary small">Checked 3 min ago</div></div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">DKIM</h4><span class="badge bg-green-lt">published</span>
<span class="ms-auto text-secondary small">Not a secret · selector <code>sp2026</code></span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="sp2026._domainkey.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><textarea class="form-control" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
</div>
</div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">SPF</h4><span class="badge bg-red-lt">mismatch</span>
<span class="ms-auto text-secondary small">Merge into the existing record — never publish a second one</span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><input class="form-control" readonly value="v=spf1 ip4:203.0.113.25 ~all"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-12">
<label class="form-label">In DNS now</label>
<input class="form-control" readonly value="v=spf1 include:_spf.google.com ~all">
<div class="text-danger small mt-2">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</div>
</div>
</div>
</div>
<div class="card-body sp-record">
<div class="d-flex align-items-center mb-3">
<h4 class="m-0 me-2">DMARC</h4><span class="badge bg-green-lt">published</span>
<span class="ms-auto text-secondary small">Report address from <a href="#">Settings</a></span>
</div>
<div class="row g-3">
<div class="col-md-9">
<label class="form-label">Host / name</label>
<div class="input-group"><input class="form-control" readonly value="_dmarc.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<input class="form-control" readonly value="TXT">
</div>
<div class="col-12">
<label class="form-label">Value to publish</label>
<div class="input-group"><input class="form-control" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"><button class="btn" type="button"><i class="ti ti-copy me-1"></i>Copy</button></div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Connection</h3></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Server</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
<div class="datagrid-item"><div class="datagrid-title">Ports</div><div class="datagrid-content font-monospace">465 SSL/TLS<br>587 STARTTLS</div></div>
</div>
<p class="text-secondary small mt-3 mb-0">The same for every domain. Authenticate with an application login.</p>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title">Sent, 30 days</h3></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Messages</div><div class="datagrid-content h2 m-0">1 284</div></div>
<div class="datagrid-item"><div class="datagrid-title">Peak</div><div class="datagrid-content h2 m-0">96 <span class="text-secondary fs-5">/h</span></div></div>
<div class="datagrid-item"><div class="datagrid-title">Average</div><div class="datagrid-content h2 m-0">1.8 <span class="text-secondary fs-5">/h</span></div></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Domain settings</h3></div>
<div class="list-group list-group-flush">
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>DMARC reports</div><div class="text-secondary small">Same as Settings</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Rate limit <span class="badge bg-green-lt ms-1">active</span></div><div class="text-secondary small">Auto ×3 — 288 messages / hour</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center"><div><div>Export</div><div class="text-secondary small">Key and applications as one file</div></div><i class="ti ti-chevron-right ms-auto text-secondary"></i></a>
<a href="#" class="list-group-item list-group-item-action d-flex align-items-center text-danger"><div>Delete domain</div><i class="ti ti-chevron-right ms-auto"></i></a>
</div>
</div>
</div>
<div class="col-12">
<div class="card">
<div class="card-header"><h3 class="card-title">Applications</h3><div class="card-actions text-secondary small">SASL logins that may send as this domain</div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th class="w-1"></th></tr></thead>
<tbody>
<tr>
<td class="font-monospace fw-semibold">prod-server</td><td>*@example.org</td><td class="text-secondary">1 102 msg · peak 96/h</td>
<td><span class="badge bg-green-lt">200 / h</span></td>
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
</tr>
<tr>
<td class="font-monospace fw-semibold">alerts</td><td>alerts@example.org, noc@example.org</td><td class="text-secondary">182 msg · peak 14/h</td>
<td><span class="badge bg-secondary-lt">domain</span> <span class="badge bg-green-lt">2 IPs</span></td>
<td><div class="btn-list flex-nowrap"><a href="#" class="btn btn-sm">Edit</a><a href="#" class="btn btn-sm">New password</a><a href="#" class="btn btn-sm btn-ghost-danger">Delete</a></div></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer footer-transparent">
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</footer>
</div>
</div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body class="d-flex flex-column">
<div class="page page-center">
<div class="container container-tight py-4">
<div class="text-center mb-4"><img class="sp-signin-mark" src="../../selfpost-stamp.svg" alt="SelfPost"></div>
<div class="card card-md">
<div class="card-body">
<h2 class="h2 text-center mb-4">Sign in</h2>
<form method="post" action="status.html">
<div class="mb-3">
<label class="form-label" for="username">Username</label>
<input class="form-control" id="username" name="username" autocomplete="username" required>
</div>
<div class="mb-2">
<label class="form-label" for="password">Password</label>
<input class="form-control" id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="form-footer"><button type="submit" class="btn btn-primary w-100">Sign in</button></div>
</form>
</div>
</div>
<div class="text-center sp-tagline mt-4">YOUR SMTP. YOUR RULES.</div>
<div class="text-center sp-foot mt-3">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</div>
</div>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.5.1/dist/css/tabler.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="page">
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
<div class="navbar-collapse collapse show">
<ul class="navbar-nav">
<li class="nav-item active"><a class="nav-link" href="status.html"><span class="nav-link-icon"><i class="ti ti-activity-heartbeat"></i></span><span class="nav-link-title">Overview</span></a></li>
<li class="sp-nav-label">Outbound</li>
<li class="nav-item"><a class="nav-link" href="domain.html"><span class="nav-link-icon"><i class="ti ti-world-upload"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-report-analytics"></i></span><span class="nav-link-title">DMARC reports</span></a></li>
<li class="sp-nav-label">Inbound</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-world-download"></i></span><span class="nav-link-title">Domains</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-list-details"></i></span><span class="nav-link-title">Log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-stack-2"></i></span><span class="nav-link-title">Queue</span></a></li>
<li class="sp-nav-label">Server</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-file-text"></i></span><span class="nav-link-title">System log</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-archive"></i></span><span class="nav-link-title">Backup</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-users"></i></span><span class="nav-link-title">Users</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-settings"></i></span><span class="nav-link-title">Settings</span></a></li>
</ul>
<ul class="navbar-nav sp-nav-foot">
<li class="sp-nav-user">Signed in as admin</li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-user-circle"></i></span><span class="nav-link-title">Account</span></a></li>
<li class="nav-item"><a class="nav-link" href="#"><span class="nav-link-icon"><i class="ti ti-help"></i></span><span class="nav-link-title">Help</span></a></li>
<li class="nav-item"><a class="nav-link" href="login.html"><span class="nav-link-icon"><i class="ti ti-logout"></i></span><span class="nav-link-title">Sign out</span></a></li>
</ul>
</div>
</div>
</aside>
<div class="page-wrapper">
<div class="page-header">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">mail.example.org</div>
<h2 class="page-title">Overview</h2>
</div>
<div class="col-auto text-secondary small">Updated 14:02:31 UTC · refreshes every 10 s</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="alert alert-warning bg-white" role="alert">
<div class="d-flex">
<div><i class="ti ti-alert-triangle alert-icon"></i></div>
<div>
<h4 class="alert-title">Mail is flowing. One thing needs attention.</h4>
<div class="text-secondary">The TLS certificate expires in 12 days and has not renewed yet.</div>
</div>
</div>
</div>
<div class="row row-cards mb-3">
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-cpu sp-tile-icon"></i><div><div class="fw-semibold">Machine</div><div class="text-secondary small">CPU 12 % · mem 41 %</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-server-cog sp-tile-icon"></i><div><div class="fw-semibold">Processes</div><div class="text-secondary small">5 of 5 running</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-stack-2 sp-tile-icon"></i><div><div class="fw-semibold">Queues</div><div class="text-secondary small">Both empty</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-certificate sp-tile-icon"></i><div><div class="fw-semibold">TLS certificate</div><div class="text-secondary small">Expires in 12 days</div></div><span class="status status-yellow"><span class="status-dot"></span>Warn</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-plug-connected sp-tile-icon"></i><div><div class="fw-semibold">Milter sockets</div><div class="text-secondary small">2 of 2 answering</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
<div class="col-sm-6 col-lg-4"><div class="card card-sm sp-tile"><div class="card-body"><i class="ti ti-arrows-exchange sp-tile-icon"></i><div><div class="fw-semibold">Reverse DNS</div><div class="text-secondary small">Forward = reverse</div></div><span class="status status-green"><span class="status-dot"></span>OK</span></div></div></div>
</div>
<div class="row row-deck row-cards">
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Machine</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><progress class="sp-bar" value="12" max="100"></progress>12 %</td><td class="text-secondary">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><progress class="sp-bar" value="41" max="100"></progress>41 %</td><td class="text-secondary">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td>↓ 18 kB/s · ↑ 44 kB/s</td><td class="text-secondary">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Processes</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 39, uptime 18 days</td></tr>
<tr><td>journal-milter</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 40, uptime 18 days</td></tr>
<tr><td>panel</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 44, uptime 18 days</td></tr>
<tr><td>dmarc-ingest</td><td><span class="badge bg-green-lt">running</span></td><td class="text-secondary">pid 47, uptime 18 days</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">TLS certificate</h3><div class="card-actions"><span class="badge bg-yellow-lt">warn</span></div></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Expires</div><div class="datagrid-content font-monospace">2026-10-03 09:14 UTC</div></div>
<div class="datagrid-item"><div class="datagrid-title">Names</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
</div>
<p class="text-secondary mt-3 mb-0">Renewal normally runs 30 days before expiry. Check port 80 and the <a href="#">system log</a>.</p>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Milter sockets</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<table class="table table-vcenter card-table">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>
<tr><td>opendkim<div class="text-secondary small font-monospace">inet:127.0.0.1:8891</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
<tr><td>journal-milter<div class="text-secondary small font-monospace">unix:/run/selfpost/journal.sock</div></td><td><span class="badge bg-green-lt">ok</span></td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Hostname and reverse DNS</h3><div class="card-actions"><span class="badge bg-green-lt">ok</span></div></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Hostname</div><div class="datagrid-content font-monospace">mail.example.org</div></div>
<div class="datagrid-item"><div class="datagrid-title">Lookup</div><div class="datagrid-content font-monospace">→ 203.0.113.25<br>← mail.example.org</div></div>
</div>
</div>
<div class="card-footer"><form method="post" action="#"><button type="submit" class="btn btn-sm"><i class="ti ti-refresh me-1"></i>Re-check DNS</button></form></div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer footer-transparent">
<div class="container-xl sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</div>
</footer>
</div>
</div>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
/* Direction B — "Console". Tabler (Bootstrap 5 admin kit) carries the shell,
cards, tables, badges, input groups and the sign-in page as shipped; this
file only re-points Tabler's variables at the brand and adds the two things
Tabler has no word for: a sidebar group label and the DNS record block. */
:root, [data-bs-theme=light] {
--tblr-font-sans-serif: var(--sp-sans);
--tblr-font-monospace: var(--sp-mono);
--tblr-body-bg: var(--sp-paper);
--tblr-body-color: var(--sp-ink);
--tblr-secondary: var(--sp-muted);
--tblr-border-color: var(--sp-rule);
--tblr-border-radius: 3px;
--tblr-border-radius-lg: 4px;
--tblr-primary: #7a3b2e;
--tblr-primary-rgb: 122, 59, 46;
--tblr-link-color: #7a3b2e;
--tblr-link-color-rgb: 122, 59, 46;
--tblr-link-hover-color: #632f25;
--tblr-link-hover-color-rgb: 99, 47, 37;
--tblr-green: #2c6b43; --tblr-green-rgb: 44, 107, 67;
--tblr-yellow: #b5700f; --tblr-yellow-rgb: 181, 112, 15;
--tblr-red: #b42318; --tblr-red-rgb: 180, 35, 24;
--tblr-success: #2c6b43; --tblr-success-rgb: 44, 107, 67;
--tblr-warning: #b5700f; --tblr-warning-rgb: 181, 112, 15;
--tblr-danger: #b42318; --tblr-danger-rgb: 180, 35, 24;
}
body { font-feature-settings: normal; }
.btn-primary { --tblr-btn-hover-bg: #632f25; --tblr-btn-active-bg: #632f25; }
code, .font-monospace { font-family: var(--sp-mono); }
/* ---- sidebar: ink, with the stamp on its own paper */
.navbar-vertical {
--tblr-navbar-bg: var(--sp-ink); background: var(--sp-ink);
--tblr-navbar-color: #aab2bd; --tblr-navbar-hover-color: #fff; --tblr-navbar-active-color: #fff;
--tblr-navbar-border-color: transparent;
}
.navbar-vertical .navbar-brand { padding: 1.1rem 0 .9rem; justify-content: flex-start; }
.navbar-vertical .navbar-brand img { height: 52px; width: auto; }
.navbar-vertical .nav-link { padding-block: .42rem; }
.navbar-vertical .nav-link-icon { font-size: 1.15rem; }
.navbar-vertical .nav-item.active > .nav-link {
background: rgb(255 255 255 / .07); box-shadow: inset 3px 0 0 #c4705e; color: #fff;
}
.sp-nav-label {
font-family: var(--sp-mono); font-size: .66rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; color: #6f7a88; padding: 1.1rem 1.5rem .3rem;
}
.sp-nav-foot { margin-top: auto; padding-bottom: .8rem; border-top: 1px solid rgb(255 255 255 / .08); padding-top: .6rem; }
.sp-nav-user { color: #6f7a88; font-size: .8rem; padding: .3rem 1.5rem; }
/* ---- page */
.page-pretitle { font-family: var(--sp-mono); letter-spacing: .1em; }
.card-title { font-weight: 600; }
.table thead th { font-family: var(--sp-mono); letter-spacing: .08em; }
.sp-tile .card-body { display: flex; align-items: center; gap: .8rem; padding: .85rem 1rem; }
.sp-tile .sp-tile-icon { font-size: 1.5rem; color: var(--sp-muted); }
.sp-tile .fw-semibold { line-height: 1.2; }
.sp-tile .status { margin-left: auto; }
progress.sp-bar {
appearance: none; -webkit-appearance: none; width: 8rem; height: .4rem; border: 0;
border-radius: 1rem; overflow: hidden; vertical-align: middle; margin-right: .6rem; background: var(--sp-well);
}
progress.sp-bar::-webkit-progress-bar { background: var(--sp-well); }
progress.sp-bar::-webkit-progress-value { background: var(--tblr-green); }
progress.sp-bar::-moz-progress-bar { background: var(--tblr-green); }
/* ---- the DNS record block: Tabler's read-only input group, plus a status */
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); }
.sp-record .form-control[readonly], .sp-record textarea[readonly] {
font-family: var(--sp-mono); font-size: .8rem; background: var(--sp-well);
}
.sp-record textarea { resize: none; }
.sp-record .form-label { font-size: .75rem; color: var(--sp-muted); margin-bottom: .25rem; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; }
.sp-signin-mark { width: 15rem; height: auto; }
.sp-tagline { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .16em; color: var(--sp-muted); }
+200
View File
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="status.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="domain.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="domain.html">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
<a class="navbar-item" href="#">DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">System log</a>
<a class="navbar-item" href="#">Backup</a>
<a class="navbar-item" href="#">Users</a>
<a class="navbar-item" href="#">Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="#">Account</a>
<a class="navbar-item" href="#">Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html">Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<main>
<div class="container sp-page">
<div class="level mb-5">
<div class="level-left">
<div>
<p class="sp-kicker"><a href="status.html">Outbound domains</a> /</p>
<h1 class="title is-3 mb-0">example.org</h1>
</div>
</div>
<div class="level-right">
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button">Re-check DNS</button></form>
<a class="button is-primary" href="#">Add application</a>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">This domain</p>
<ul class="menu-list">
<li><a class="is-active" href="#dns">DNS records <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#apps">Applications <span class="tag is-light">2</span></a></li>
<li><a href="#connection">Connection</a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a href="#">DMARC reports</a></li>
<li><a href="#">Rate limit <span class="tag is-success is-light">active</span></a></li>
<li><a href="#">Export</a></li>
<li><a class="has-text-danger" href="#">Delete domain</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field">
<label class="label">In DNS now</label>
<div class="control"><input class="input" readonly value="v=spf1 include:_spf.google.com ~all"></div>
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>.</p>
</div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address from <a href="#">Settings</a></small></div>
<div class="sp-row">
<div class="field">
<label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field">
<label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button">Copy</button></div></div>
</div>
</div>
</div>
<div class="box" id="apps">
<div class="sp-box-head"><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
<tr>
<td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td>
<td><span class="tag is-success is-light">200 / h</span></td>
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
</tr>
<tr>
<td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td>
<td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td><div class="buttons are-small is-right is-flex-wrap-nowrap"><a class="button" href="#">Edit</a><a class="button" href="#">New password</a><a class="button is-danger is-light" href="#">Delete</a></div></td>
</tr>
</tbody>
</table>
</div>
<div class="columns">
<div class="column">
<div class="box" id="connection">
<div class="sp-box-head"><h2>Connection</h2><div class="sp-end">Same for every domain</div></div>
<p class="sp-mono">mail.example.org</p>
<p class="sp-mono sp-muted">465 SSL/TLS · 587 STARTTLS</p>
</div>
</div>
<div class="column">
<div class="box">
<div class="sp-box-head"><h2>Sent, 30 days</h2></div>
<div class="sp-stats">
<div><b>1 284</b><span class="sp-muted">messages</span></div>
<div><b>96</b><span class="sp-muted">peak / h</span></div>
<div><b>1.8</b><span class="sp-muted">average / h</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form method="post" action="status.html">
<h1 class="title is-3">Sign in</h1>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control"><input class="input" id="username" name="username" autocomplete="username" required></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="status.html"><img src="../../selfpost-stamp-compact.svg" alt="SelfPost"></a>
</div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item is-active" href="status.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="domain.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="domain.html">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
<a class="navbar-item" href="#">DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">Domains</a>
<a class="navbar-item" href="#">Log</a>
<a class="navbar-item" href="#">Queue</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">System log</a>
<a class="navbar-item" href="#">Backup</a>
<a class="navbar-item" href="#">Users</a>
<a class="navbar-item" href="#">Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="#">admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="#">Account</a>
<a class="navbar-item" href="#">Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html">Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<main>
<div class="container sp-page">
<div class="level mb-5">
<div class="level-left">
<div>
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3 mb-0">Overview</h1>
</div>
</div>
<div class="level-right sp-muted is-size-7">Updated 14:02:31 UTC · refreshes every 10 s</div>
</div>
<div class="box sp-health">
<a href="#"><div class="sp-h-name">Machine</div><div class="sp-h-value">12 % CPU</div><div class="sp-h-sub">41 % memory</div></a>
<a href="#"><div class="sp-h-name">Processes</div><div class="sp-h-value">5 of 5</div><div class="sp-h-sub">running</div></a>
<a href="#"><div class="sp-h-name">Queues</div><div class="sp-h-value">Empty</div><div class="sp-h-sub">outbound and inbound</div></a>
<a href="#" class="warn"><div class="sp-h-name">TLS certificate</div><div class="sp-h-value">12 days</div><div class="sp-h-sub">renewal has not run</div></a>
<a href="#"><div class="sp-h-name">Milter sockets</div><div class="sp-h-value">2 of 2</div><div class="sp-h-sub">answering</div></a>
<a href="#"><div class="sp-h-name">Reverse DNS</div><div class="sp-h-value">Match</div><div class="sp-h-sub">203.0.113.25</div></a>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><h2>Outbound domains</h2><div class="sp-end"><a class="button is-small is-primary" href="#">Add domain</a></div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>30 days</th></tr></thead>
<tbody>
<tr><td><a href="domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>2</td><td class="sp-muted">1 284 msg</td></tr>
<tr><td><a href="#"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>1</td><td class="sp-muted">18 920 msg</td></tr>
<tr><td><a href="#"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td>3</td><td class="sp-muted">402 msg</td></tr>
</tbody>
</table>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><h2>Inbound domains</h2><div class="sp-end"><a class="button is-small" href="#">Add domain</a></div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
<tbody>
<tr><td><a href="#"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">mx.internal:25</td></tr>
<tr><td><a href="#"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono is-size-7">10.0.4.12:2525</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
/* Direction C — "Counter". Bulma v1 (pure CSS, no JavaScript, no data: URIs)
carries the navbar with hover dropdowns, columns, boxes, tables, tags,
the side menu and input-with-addon fields. This file points Bulma's HSL
variables at the brand and adds the health strip and the record block. */
:root {
--bulma-family-primary: var(--sp-sans);
--bulma-family-secondary: var(--sp-sans);
--bulma-family-code: var(--sp-mono);
--bulma-primary-h: 10deg; --bulma-primary-s: 45%; --bulma-primary-l: 33%;
--bulma-link-h: 10deg; --bulma-link-s: 45%; --bulma-link-l: 33%;
--bulma-success-h: 138deg; --bulma-success-s: 42%; --bulma-success-l: 30%;
--bulma-warning-h: 36deg; --bulma-warning-s: 85%; --bulma-warning-l: 55%;
--bulma-danger-h: 4deg; --bulma-danger-s: 76%; --bulma-danger-l: 40%;
--bulma-radius: 6px; --bulma-radius-large: 10px;
--bulma-body-size: 15px;
/* Bulma derives link text and the on-primary colour from lightness steps;
at the brick's 33 % both land off-brand, so they are pinned. */
--bulma-link-text: var(--sp-brick);
--bulma-link-text-hover: var(--sp-brick-dark);
}
.button.is-primary, .button.is-primary:hover { color: #fff; }
a strong { color: inherit; }
html { background: var(--sp-paper); }
body { background: var(--sp-paper); color: var(--sp-ink); min-height: 100vh; display: flex; flex-direction: column; }
main { flex: 1; }
code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; padding: .1em .35em; font-size: .85em; }
/* ---- navbar: brick, the stamp on it, hover dropdowns */
.navbar.is-primary { --bulma-navbar-height: 4rem; }
.navbar-brand img { max-height: 3rem; height: 3rem; }
.navbar.is-primary .navbar-item, .navbar.is-primary .navbar-link { color: var(--sp-on-brick); font-weight: 500; }
.navbar.is-primary .navbar-link::after { border-color: var(--sp-on-brick); }
.navbar.is-primary .navbar-start > .navbar-item.is-active,
.navbar.is-primary .navbar-start > .has-dropdown.sp-current > .navbar-link { box-shadow: inset 0 -3px 0 var(--sp-on-brick); }
.navbar-dropdown { border-top: 0; box-shadow: 0 8px 24px rgb(18 22 28 / .14); }
.navbar.is-primary .navbar-dropdown .navbar-item { color: var(--sp-ink); font-weight: 400; }
.navbar.is-primary .navbar-dropdown .navbar-item.is-active { color: var(--sp-brick); font-weight: 600; background: var(--sp-brick-tint); }
/* ---- page */
.sp-page { padding-block: 2rem 2.5rem; }
.sp-kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-muted); }
.sp-kicker a { color: inherit; }
.title { color: var(--sp-ink); font-weight: 600; }
.box { box-shadow: none; border: 1px solid var(--sp-rule); }
.box > .sp-box-head { display: flex; align-items: center; gap: .75rem; margin-bottom: 1rem; }
.box > .sp-box-head h2 { font-size: 1.1rem; font-weight: 600; margin: 0; }
.box > .sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; }
.table { background: transparent; }
.table thead th { font-family: var(--sp-mono); font-size: .7rem; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.table td { vertical-align: middle; }
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
.sp-muted { color: var(--sp-muted); }
.sp-mono { font-family: var(--sp-mono); }
/* ---- health strip */
.sp-health { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0; padding: 0; overflow: hidden; }
.sp-health > a {
display: block; padding: 1rem 1.2rem; color: var(--sp-ink); border-right: 1px solid var(--sp-rule);
box-shadow: inset 0 3px 0 var(--sp-ok);
}
.sp-health > a:last-child { border-right: 0; }
.sp-health > a:hover { background: var(--sp-paper); }
.sp-health > a.warn { box-shadow: inset 0 3px 0 #d9962b; background: var(--sp-warn-bg); }
.sp-health > a.fail { box-shadow: inset 0 3px 0 var(--sp-fail); background: var(--sp-fail-bg); }
.sp-health .sp-h-name { font-family: var(--sp-mono); font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); }
.sp-health .sp-h-value { font-size: 1.15rem; font-weight: 600; line-height: 1.3; }
.sp-health .sp-h-sub { font-size: .8rem; color: var(--sp-muted); }
/* ---- domain page */
.sp-side { position: sticky; top: 1.5rem; }
.menu-label { font-family: var(--sp-mono); letter-spacing: .1em; }
.menu-list a { display: flex; align-items: center; gap: .5rem; }
.menu-list a .tag { margin-left: auto; }
.sp-record + .sp-record { border-top: 1px solid var(--sp-rule); margin-top: 1.25rem; padding-top: 1.25rem; }
.sp-record-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .75rem; }
.sp-record-head h3 { font-size: 1rem; font-weight: 600; margin: 0; }
.sp-record-head small { margin-left: auto; color: var(--sp-muted); }
.sp-record .label { font-size: .75rem; font-weight: 400; color: var(--sp-muted); margin-bottom: .25rem; }
.sp-record .input[readonly], .sp-record .textarea[readonly] {
font-family: var(--sp-mono); font-size: .82rem; background: var(--sp-well); box-shadow: none;
}
.sp-record .textarea { resize: none; min-height: 0; }
.sp-record .field:not(:last-child) { margin-bottom: .6rem; }
.sp-row { display: grid; grid-template-columns: 1fr 7rem; gap: .75rem; }
.sp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.sp-stats b { display: block; font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
/* ---- sign-in */
.sp-signin { min-height: 100vh; margin: 0; }
.sp-signin-brand { background: var(--sp-brick); display: grid; place-content: center; text-align: center; padding: 3rem; }
.sp-signin-brand img { width: 20rem; max-width: 100%; height: auto; }
.sp-signin-brand p { font-family: var(--sp-mono); letter-spacing: .18em; font-size: .8rem; color: var(--sp-on-brick); margin-top: 1.6rem; }
.sp-signin-form { display: flex; flex-direction: column; justify-content: center; padding: 3rem; }
.sp-signin-form form { width: 21rem; max-width: 100%; margin-inline: auto; }
.sp-foot { color: var(--sp-muted); font-size: .8rem; padding-block: 1.2rem; }
.sp-foot a { color: inherit; text-decoration: underline; }
.sp-signin-form .sp-foot { text-align: center; margin-top: 2.5rem; }
+22
View File
@@ -0,0 +1,22 @@
/* Cover sheet for the three directions. No library on purpose: it must not
look like any one of them. */
body { margin: 0; background: var(--sp-paper); color: var(--sp-ink); font: 16px/1.55 var(--sp-sans); }
main { max-width: 68rem; margin: 0 auto; padding: 3rem 1.5rem 4rem; }
h1 { font-size: 2rem; font-weight: 600; margin: .2rem 0 1rem; text-wrap: balance; }
h2 { font-size: 1.15rem; font-weight: 600; margin: 2.6rem 0 .8rem; }
h3 { font-size: 1.4rem; font-weight: 600; margin: .1rem 0 .6rem; }
.kicker { font-family: var(--sp-mono); font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: var(--sp-brick); margin: 0; }
.lead { max-width: 46rem; color: var(--sp-ink-2); }
ul { padding-left: 1.2rem; max-width: 52rem; }
li { margin-bottom: .5rem; }
a { color: var(--sp-brick); }
code { font-family: var(--sp-mono); font-size: .85em; background: var(--sp-well); padding: .1em .35em; border-radius: 3px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1.2rem; }
.cards section { background: var(--sp-sheet); border: 1px solid var(--sp-rule); border-top: 3px solid var(--sp-brick); padding: 1.3rem 1.4rem; display: flex; flex-direction: column; }
.cards p { margin: 0 0 1rem; }
.links { margin-top: auto !important; margin-bottom: 0 !important; display: flex; gap: 1.1rem; font-weight: 600; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; background: var(--sp-sheet); border: 1px solid var(--sp-rule); font-size: .93rem; }
th, td { text-align: left; vertical-align: top; padding: .6rem .9rem; border-bottom: 1px solid var(--sp-rule); }
thead th { font-family: var(--sp-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--sp-muted); font-weight: 400; }
tbody th { font-weight: 600; white-space: nowrap; }
+138
View File
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel redesign · three directions</title>
<link rel="stylesheet" href="shared/brand.css">
<link rel="stylesheet" href="index.css">
</head>
<body>
<main>
<p class="kicker">SelfPost · panel redesign · stage 2</p>
<h1>The accepted direction, every screen</h1>
<p class="lead">Direction <strong>C</strong> (Bulma) was picked as the base: the important things in view, details one
or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section
heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-three screens and a components page live in
<a href="panel/overview.html">panel/</a>. Serve this folder from the repository root: the pages load the panel's own
fonts and mark by relative path.</p>
<h2>Screen map</h2>
<div class="scroll">
<table>
<tbody>
<tr><th>Signed out</th><td><a href="panel/login.html">Sign in</a> · <a href="panel/setup.html">Create administrator</a></td></tr>
<tr><th>Overview</th><td><a href="panel/overview.html">Overview</a> — postmark verdict, six health cards, both domain lists</td></tr>
<tr><th>Outbound</th><td><a href="panel/out-domains.html">Domains</a> · <a href="panel/out-domain.html">domain</a> ·
<a href="panel/out-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> ·
<a href="panel/out-app-created.html">password shown once</a> · <a href="panel/out-domain-delete.html">delete domain</a><br>
<a href="panel/out-log.html">Log</a> · <a href="panel/out-message.html">message</a> ·
<a href="panel/out-queue.html">Queue</a> · <a href="panel/dmarc.html">DMARC reports</a> ·
<a href="panel/dmarc-domain.html">by domain</a> · <a href="panel/dmarc-report.html">one report</a></td></tr>
<tr><th>Inbound</th><td><a href="panel/in-domains.html">Domains</a> · <a href="panel/in-domain.html">domain</a> ·
<a href="panel/in-domain-delete.html">delete domain</a> · <a href="panel/in-log.html">Log</a> ·
<a href="panel/in-queue.html">Queue</a> (empty state)<br>
<a href="panel/in-quarantine.html">Quarantine</a> · <a href="panel/in-quarantine-message.html">held message</a> ·
<a href="panel/in-filter-lists.html">Filter lists</a> — roadmap items, drawn as their target look</td></tr>
<tr><th>Server</th><td><a href="panel/health.html">Health</a> · <a href="panel/preflight.html">Preflight</a> (roadmap) · <a href="panel/system-log.html">System log</a> ·
<a href="panel/backup.html">Backup</a> · <a href="panel/users.html">Users</a> · <a href="panel/user.html">user form</a> ·
<a href="panel/user-delete.html">delete user</a> · <a href="panel/settings.html">Settings</a></td></tr>
<tr><th>User menu</th><td><a href="panel/account.html">Account</a> · <a href="panel/help.html">Help</a></td></tr>
<tr><th>Contract</th><td><a href="panel/components.html">Components</a> — every building block in every state ·
<a href="../../plans/panel-redesign.md">the plan</a> · <code>panel/check.py</code> lints the mockups and writes <code>panel/outlines.json</code></td></tr>
</tbody>
</table>
</div>
<h2>What changed against today's panel</h2>
<ul>
<li><strong>Overview shows verdicts, Health shows tables.</strong> Process lists, socket paths and interface counters moved to
<em>Server › Health</em>, one click from each card.</li>
<li><strong>Every page is one of three layouts</strong>: a list (form on top, table below), a detail page (sticky side menu +
numbered boxes), or a form page (boxes in equal columns). No page picks its own width.</li>
<li><strong>Own password is Account</strong>, instance defaults are Settings. Export sits with the domain, import with Backup,
and each links to the other.</li>
<li><strong>Queues are tables</strong>, not a <code>postqueue -p</code> dump — this needs <code>postqueue -j</code> parsing.
The inbound queue is the same queue filtered by recipient domain.</li>
<li><strong>Confirm pages say what will be lost</strong> as a list, beside a hint for the usual alternative (export before delete).</li>
<li>Own components on top of Bulma: postmark, perforated edge, numbered box head, health card, DNS record block, facts grid,
timeline, log pane. The mockup's <code>theme.css</code> is about 200 lines.</li>
<li><code>panel/build.py</code> wraps the fragments in <code>panel/src/</code> with the shared navbar; the built pages are committed,
so nothing has to be run to view them.</li>
</ul>
<h2>Stage 1 · three directions on three key screens</h2>
<p class="lead">Kept for the record: Overview, an outbound domain and sign-in, drawn three ways.</p>
<h2>Shared by all three</h2>
<ul>
<li><strong>Navigation by mail direction.</strong> Overview · <em>Outbound</em> (Domains, Log, Queue, DMARC reports) ·
<em>Inbound</em> (Domains, Log, Queue) · <em>Server</em> (System log, Backup, Users, Settings). Account, Help and
Sign out live with the user name. Eleven flat entries become four groups.</li>
<li><strong>One block per DNS record.</strong> Today "DNS status" and "records to publish" show every host twice.
Here a record is one block: its status, what to publish, and — only on a mismatch — what DNS holds now.</li>
<li><strong>The domain page stops being nine cards.</strong> DNS and applications are the page; limits, report address,
export and delete are "rarely changed" and sit behind one click.</li>
<li><strong>Applications are a table</strong>; editing one is its own form page instead of a three-column panel inside a list row.</li>
<li>Brand carriers stay: the stamp, brick, IBM Plex. No inline <code>style</code> or script in any mockup.</li>
</ul>
<h2>The directions</h2>
<div class="cards">
<section>
<p class="kicker">A · Pico CSS 2.1</p>
<h3>Blank</h3>
<p>The panel as a postal form: one centred sheet, numbered sections, mono field labels, a postmark for the
verdict. Overview is a ledger — one row per check, the row opens into its detail. Sections on the first
header row, the section's pages on the second.</p>
<p class="links"><a href="a/status.html">Overview</a><a href="a/domain.html">Domain</a><a href="a/login.html">Sign in</a></p>
</section>
<section>
<p class="kicker">B · Tabler 1.5 (Bootstrap 5)</p>
<h3>Console</h3>
<p>The classic admin kit: ink sidebar with every page one click away, status tiles, cards and tables in a
12-column grid, read-only input groups with Copy. Almost nothing is ours; it also looks the most like
every other admin panel.</p>
<p class="links"><a href="b/status.html">Overview</a><a href="b/domain.html">Domain</a><a href="b/login.html">Sign in</a></p>
</section>
<section>
<p class="kicker">C · Bulma 1.0</p>
<h3>Counter</h3>
<p>Brick top bar with CSS-only hover menus. Overview is the whole service on one screen: a health strip over
the outbound and inbound domain lists. The domain page gets a sticky side menu. Softer, rounder, the
boldest use of the brand colour.</p>
<p class="links"><a href="c/status.html">Overview</a><a href="c/domain.html">Domain</a><a href="c/login.html">Sign in</a></p>
</section>
</div>
<h2>What each one costs</h2>
<div class="scroll">
<table>
<thead><tr><th></th><th>A · Pico</th><th>B · Tabler</th><th>C · Bulma</th></tr></thead>
<tbody>
<tr><th>Licence</th><td>MIT</td><td>MIT</td><td>MIT</td></tr>
<tr><th>Vendored CSS (minified)</th><td>83 KB</td><td>694 KB</td><td>678 KB</td></tr>
<tr><th>JavaScript</th><td>none</td><td>none for these screens; Bootstrap bundle only if dropdowns / modals are wanted</td><td>none</td></tr>
<tr><th>Build step</th><td>none</td><td>none</td><td>none (a Sass build could trim it; not needed)</td></tr>
<tr><th>CSP <code>default-src 'self'</code></th>
<td>needs <code>img-src 'self' data:</code> — 14 <code>data:</code> SVGs (select chevron, checkbox tick)</td>
<td>needs <code>img-src 'self' data:</code> — 28 <code>data:</code> SVGs</td>
<td>works as is — no <code>data:</code> URIs</td></tr>
<tr><th>Our own CSS in the mockup</th><td>≈ 200 lines</td><td>≈ 80 lines</td><td>≈ 100 lines</td></tr>
<tr><th>Markup style</th><td>semantic HTML, few classes — closest to today's templates</td><td>class-heavy Bootstrap markup</td><td>class-based, moderate</td></tr>
<tr><th>Own components</th><td>sheet, ledger row, postmark, record block</td><td>sidebar group label, record block</td><td>health strip, record block</td></tr>
<tr><th>Recognisable</th><td>most</td><td>least</td><td>middle</td></tr>
</tbody>
</table>
</div>
<h2>Open points</h2>
<ul>
<li>Icons in B come from Tabler Icons (MIT) through its webfont for the mockup; the panel would vendor only the used glyphs as an SVG sprite.</li>
<li><em>Inbound log</em> rides on the agreed <code>inbound-antispam-panel</code> journal; <em>Inbound queue</em> is a new filter over the one Postfix queue and needs its own roadmap row.</li>
<li>Dark scheme and the narrow-screen shell are not drawn at this stage; all three libraries ship both.</li>
<li>The earlier <a href="../panel-ui/index.html">panel-ui</a> experiment and <a href="../../plans/panel-redesign.md">panel-redesign.md</a> are superseded by whichever direction is accepted.</li>
</ul>
</main>
</body>
</html>
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Account · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel, what it should tell you about, and where reports about your domains go. Applications keep their own logins and passwords and are not affected.</p>
</div>
</div>
<div class="columns">
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you: the notifications on the right and a password-reset link. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="cur">Current password</label><div class="control"><input class="input" id="cur" name="current_password" type="password" autocomplete="current-password" required></div></div>
<div class="sp-pair">
<div class="field"><label class="label" for="new">New password</label><div class="control"><input class="input" id="new" name="new_password" type="password" autocomplete="new-password" required></div></div>
<div class="field"><label class="label" for="new2">Repeat</label><div class="control"><input class="input" id="new2" name="new_password_confirm" type="password" autocomplete="new-password" required></div></div>
</div>
<p class="help mb-3">Changing it signs out every other session of this account; this one stays signed in.</p>
<button type="submit" class="button">Change password</button>
</form>
</div>
</div>
</div>
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><div class="sp-end">Sent to mix@example.org</div></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<p class="sp-kicker mb-2">Your domains</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A DNS record stops matching — DKIM, SPF, DMARC or an inbound MX</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A domain or an application reaches its rate limit and mail is being refused</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> An inbound domain's upstream cannot be reached and mail is piling up</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> DMARC reports start showing failures for a domain</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Mail is held in quarantine</label></div>
<p class="sp-kicker mt-4 mb-2">The server · global role</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> The TLS certificate is within 14 days of expiry</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A process or a milter socket stops answering</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Messages sit in a queue for more than a day</label></div>
<p class="help mb-3">One message when a problem starts and one when it clears — never a stream. A domain administrator is told only about the domains assigned to them.</p>
<button type="submit" class="button is-primary">Save notifications</button>
</form>
</div>
</div>
<div class="box" id="dmarc">
<div class="sp-box-head"><span class="sp-no">04</span><h2>DMARC reports</h2><div class="sp-end"><span class="tag is-success is-light">authorized</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_default">
<option>SelfPost hosted — read them under DMARC reports</option>
<option selected>My account e-mail — mix@example.org</option>
<option>Another address…</option>
<option>No reports</option>
</select></div></div>
<p class="help">Used by every outbound domain whose report address is set to <em>admin's default</em> — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.</p></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Report authorization</h3><span class="tag is-success is-light">published</span><small>Needed because the address is on another domain than the senders</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="*._report._dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
/* Rendered-page audit for the panel: what check.py cannot see because it needs
a browser. Open any page of the mockups (or of the running panel), paste this
file into the console, then:
await panelAudit(["overview.html", "out-domain.html", ...]) // same-origin URLs
await panelAudit() // the page you are on
It loads each page in a 1440px and a 1280px frame and reports
- text under the 13px floor (the postmark's imprint excepted),
- text under 4.5:1 against the ground it actually sits on,
- anything wider than its box, and tables that have to scroll,
- health-card values that got clipped.
An empty `problems` list is the pass. Attach the output to the step's evidence
(docs/plans/panel-redesign.md, Enforcement). */
async function panelAudit(urls = [location.href], widths = [1440, 1280]) {
const lum = (c) => {
const [r, g, b] = c.map((v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const rgba = (s) => { const m = s.match(/[\d.]+/g).map(Number); return { rgb: m.slice(0, 3), a: m.length > 3 ? m[3] : 1 }; };
const ground = (el, win) => {
for (let e = el; e; e = e.parentElement) { const b = rgba(win.getComputedStyle(e).backgroundColor); if (b.a > 0.5) return b.rgb; }
return [255, 255, 255];
};
const problems = new Set();
let texts = 0;
for (const url of urls) for (const width of widths) {
const f = document.createElement("iframe");
f.width = width; f.height = 900; f.src = url;
document.body.appendChild(f);
await new Promise((r) => (f.onload = r));
await f.contentDocument.fonts.ready;
await new Promise((r) => setTimeout(r, 150));
const d = f.contentDocument, win = f.contentWindow, at = `${width} ${url.split("/").pop()}`;
const walker = d.createTreeWalker(d.body, NodeFilter.SHOW_TEXT);
for (let n; (n = walker.nextNode()); ) {
const text = n.textContent.trim(), el = n.parentElement;
if (!text || el.closest(".sp-log")) continue; // the log pane has its own dark ground and palette
texts++;
const cs = win.getComputedStyle(el), what = `${el.tagName.toLowerCase()}.${[...el.classList].join(".")} "${text.slice(0, 24)}"`;
if (parseFloat(cs.fontSize) < 12.9 && !el.closest(".sp-postmark")) problems.add(`${at}: ${parseFloat(cs.fontSize)}px ${what}`);
const fg = rgba(cs.color).rgb, bg = ground(el, win), a = lum(fg), b = lum(bg);
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const large = parseFloat(cs.fontSize) >= 24 || (parseFloat(cs.fontSize) >= 18.6 && +cs.fontWeight >= 600);
if (ratio < (large ? 3 : 4.5)) problems.add(`${at}: contrast ${ratio.toFixed(2)} ${what}`);
}
if (d.documentElement.scrollWidth > win.innerWidth) problems.add(`${at}: the page scrolls sideways`);
for (const e of d.querySelectorAll(".box, .sp-head, .sp-box-head")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: overflow in .${e.className}`);
for (const e of d.querySelectorAll(".table-container")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: a table scrolls by ${e.scrollWidth - e.clientWidth}px`);
for (const e of d.querySelectorAll(".sp-h-name, .sp-h-value, .sp-h-sub")) if (e.scrollWidth > e.clientWidth + 1) problems.add(`${at}: clipped "${e.textContent}"`);
for (const i of d.querySelectorAll("i.ti")) if (win.getComputedStyle(i, "::before").content === "none") problems.add(`${at}: missing icon ${i.className}`);
f.remove();
}
return { pages: urls.length, widths, textsChecked: texts, problems: [...problems] };
}
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item is-active" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html" aria-current="page"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Backup</h1>
<p class="sp-lead">Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets.</p>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2></div>
<div class="sp-box-body">
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="#">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
<div class="sp-pair">
<div class="field"><label class="label" for="pw">Password</label><div class="control"><input class="input" id="pw" type="password" placeholder="at least 12 characters"></div></div>
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div>
</div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2></div>
<div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p>
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
<div class="field"><label class="label">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
<div class="field"><label class="label" for="ipw">Password</label><div class="control"><input class="input" id="ipw" name="import_password" type="password" autocomplete="off"></div>
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
<button type="submit" class="button is-primary"><i class="ti ti-file-import"></i>Import domain</button>
</form>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
"""Wrap every fragment in src/ with the panel shell and write it next to this file.
The mockups are static HTML, but thirty pages share one navbar; keeping that
navbar in one place is the only reason this script exists. Run it from
anywhere: python docs/assets/panel-redesign/panel/build.py
A fragment starts with one meta comment:
<!-- title: Outbound domains | section: outbound | page: domains -->
section/page pick the highlighted entries; "shell: bare" skips the navbar
(sign-in, setup).
"""
import pathlib
import re
HERE = pathlib.Path(__file__).parent
NAV = [
("overview", "Overview", "overview.html", "ti-activity-heartbeat", []),
("outbound", "Outbound", "out-domains.html", "ti-send", [
("domains", "Domains", "out-domains.html", "ti-world-upload"),
("log", "Log", "out-log.html", "ti-list-details"),
("queue", "Queue", "out-queue.html", "ti-stack-2"),
("dmarc", "DMARC reports", "dmarc.html", "ti-report-analytics"),
]),
("inbound", "Inbound", "in-domains.html", "ti-inbox", [
("domains", "Domains", "in-domains.html", "ti-world-download"),
("log", "Log", "in-log.html", "ti-list-details"),
("queue", "Queue", "in-queue.html", "ti-stack-2"),
("quarantine", "Quarantine", "in-quarantine.html", "ti-mail-pause"),
("filter-lists", "Filter lists", "in-filter-lists.html", "ti-filter"),
]),
("server", "Server", "health.html", "ti-server-2", [
("health", "Health", "health.html", "ti-heartbeat"),
("preflight", "Preflight", "preflight.html", "ti-checklist"),
("system-log", "System log", "system-log.html", "ti-file-text"),
("backup", "Backup", "backup.html", "ti-archive"),
("users", "Users", "users.html", "ti-users"),
("settings", "Settings", "settings.html", "ti-settings"),
]),
]
HEAD = """<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
"""
FOOT = ('<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a>'
' · <a href="#">Source</a> · No warranty</footer>')
def navbar(section, page):
out = ['<nav class="navbar is-primary" aria-label="main navigation">', ' <div class="container">',
' <div class="navbar-brand"><a class="navbar-item" href="overview.html">'
'<img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>',
' <div class="navbar-menu is-active">', ' <div class="navbar-start">']
for key, label, href, _icon, pages in NAV:
cur = " sp-current" if key == section else ""
if not pages:
out.append(f' <a class="navbar-item{cur}" href="{href}">{label}</a>')
continue
out.append(f' <div class="navbar-item has-dropdown is-hoverable{cur}">')
out.append(f' <a class="navbar-link" href="{href}">{label}</a>')
out.append(' <div class="navbar-dropdown">')
for pkey, plabel, phref, picon in pages:
act = " is-active" if key == section and pkey == page else ""
out.append(f' <a class="navbar-item{act}" href="{phref}"><i class="ti {picon}"></i>{plabel}</a>')
out.append(' </div>')
out.append(' </div>')
out += [' </div>', ' <div class="navbar-end">',
' <div class="navbar-item has-dropdown is-hoverable%s">' % (" sp-current" if section == "user" else ""),
' <a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>',
' <div class="navbar-dropdown is-right">',
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>',
' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
' <hr class="navbar-divider">',
' <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>',
' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
'<div class="sp-perf" aria-hidden="true"></div>']
return "\n".join(out)
def subnav(section, page):
for key, _label, _href, _icon, pages in NAV:
if key == section and pages:
cur = ' aria-current="page"'
links = "".join(
f'<a href="{phref}"{cur if pkey == page else ""}><i class="ti {picon}"></i>{plabel}</a>'
for pkey, plabel, phref, picon in pages)
return f'<div class="sp-subnav"><div class="container">{links}</div></div>'
return ""
def build(path):
text = path.read_text(encoding="utf-8")
m = re.match(r"\s*<!--(.*?)-->\s*", text, re.S)
meta = dict(part.split(":", 1) for part in m.group(1).split("|"))
meta = {k.strip(): v.strip() for k, v in meta.items()}
body = text[m.end():]
html = HEAD.format(title=meta["title"])
if meta.get("shell") == "bare":
html += f"<body>\n{body}\n</body>\n</html>\n"
else:
section, page = meta.get("section", ""), meta.get("page", "")
html += (f"<body>\n{navbar(section, page)}\n{subnav(section, page)}\n<main>\n"
f' <div class="container sp-page">\n{body}\n {FOOT}\n </div>\n</main>\n</body>\n</html>\n')
(HERE / path.name).write_text(html, encoding="utf-8", newline="\n")
if __name__ == "__main__":
pages = sorted((HERE / "src").glob("*.html"))
for p in pages:
build(p)
print(f"built {len(pages)} pages")
+240
View File
@@ -0,0 +1,240 @@
"""Design-contract lint for the panel mockups, and the source of outlines.json.
The contract (docs/plans/panel-redesign.md) is only worth something if a
machine can say "no". This script is that machine for the mockups; the same
rules are ported to a Go test over internal/web templates when the redesign is
implemented, and that test compares rendered pages with outlines.json.
python docs/assets/panel-redesign/panel/check.py # lint
python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json
Exit code 1 on any violation.
"""
import json
import pathlib
import re
import sys
from html.parser import HTMLParser
HERE = pathlib.Path(__file__).parent
VOID = {"input", "img", "br", "hr", "meta", "link"}
# The subset of Bulma the panel uses. Reaching for a Bulma class outside this
# list is a design decision, not an implementation detail: add it here and to
# the components page in the same change, or do without it.
BULMA = set("""
box button buttons checkbox column columns control field file file-cta file-input file-label
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
menu-list notification progress select table table-container tag textarea title
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
is-hoverable is-left is-light is-primary is-small is-success is-text is-warning
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
""".split())
# Classes the shell (build.py, later layout.html) owns; screens never write them.
SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"}
# The signed-out screens have no shell, so they carry their own footer.
SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
# Selectors allowed to set a width. Everything else gets its width from the
# layout it sits in — that is the whole point of the redesign.
WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
class Node:
def __init__(self, tag, attrs, parent):
self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], ""
@property
def classes(self):
return (self.attrs.get("class") or "").split()
def has(self, cls):
return cls in self.classes
def walk(self):
yield self
for k in self.kids:
yield from k.walk()
def find(self, cls):
return [n for n in self.walk() if n.has(cls)]
class Tree(HTMLParser):
def __init__(self):
super().__init__()
self.root = Node("root", [], None)
self.cur = self.root
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs, self.cur)
self.cur.kids.append(n)
if tag not in VOID:
self.cur = n
def handle_endtag(self, tag):
n = self.cur
while n is not self.root and n.tag != tag:
n = n.parent
if n is not self.root:
self.cur = n.parent
def handle_data(self, data):
self.cur.text += data
def parse(text):
t = Tree()
t.feed(text)
return t.root
def text_of(node):
return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip()
def css_rules(css):
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
css = re.sub(r"@media[^{]*\{", "", css)
for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
yield sel.strip(), body
def box_kind(box):
kinds = []
for k in box.kids:
if k.has("sp-box-head") or k.has("sp-box-foot"):
continue
for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"),
("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")):
if k.has(cls):
kinds.append(name)
break
else:
kinds.append("table" if k.tag == "table" or k.has("table-container") else k.tag)
out = []
for k in kinds:
if not out or out[-1].split("×")[0] != k:
out.append(k)
else:
n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2
out[-1] = f"{k}×{n}"
return "+".join(out)
def outline(node):
"""The component skeleton of a page: what a reviewer sees with the text blurred."""
out = []
for k in node.kids:
if k.has("sp-head"):
parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)]
if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()):
parts.append("actions")
out.append("+".join(parts))
elif k.has("box"):
variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "")
head = k.find("sp-box-head")
title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?"
out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}")
elif k.has("columns"):
cols = []
for c in k.kids:
size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto")
cols.append({size: outline(c)})
out.append({"columns": cols})
elif k.tag == "form" or k.has("menu") or k.tag == "aside":
out.append({"form": outline(k)} if k.tag == "form" else "side_menu")
elif k.has("buttons"):
out.append("submit_row")
return out
def main():
errors = []
css = (HERE / "theme.css").read_text(encoding="utf-8")
defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css))
for sel, body in css_rules(css):
if "!important" in body:
errors.append(f"theme.css: !important in `{sel}`")
if re.search(r"(^|[\s,>+~])#[a-z]", sel):
errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page")
# The type floor: 13px. Icons (.ti) and the postmark imprint are not running text.
for value, unit in re.findall(r"font-size\s*:\s*([\d.]+)(rem|em|px)", body):
floor = {"rem": 0.8125, "em": 0.85, "px": 13}[unit]
if float(value) < floor and ".sp-postmark" not in sel and ".ti" not in sel:
errors.append(f"theme.css: `{sel}` sets font-size {value}{unit}, below the 13px floor — use --sp-fs-xs or --sp-fs-s")
# `min-width: 0` sizes nothing: it only lets a flex or grid child shrink so its text can be clipped.
sized = re.sub(r"min-width\s*:\s*0\s*(;|$)", "", body)
if re.search(r"(?<![-\w])(max-width|min-width|width)\s*:", sized) and not any(sel.startswith(w) or w in sel for w in WIDTH_OK):
errors.append(f"theme.css: `{sel}` sets a width; widths come from the page layout (WIDTH_OK)")
used, documented, outlines = set(), set(), {}
for src in sorted((HERE / "src").glob("*.html")):
text = src.read_text(encoding="utf-8")
name = src.stem
bare = "shell: bare" in text.split("-->")[0]
for bad in ("style=", "<style", "<script", " onclick=", " id=\"page-"):
if bad in text:
errors.append(f"{name}: `{bad}` is forbidden in a screen")
root = parse(text)
for n in root.walk():
for c in n.classes:
if c.startswith("sp-"):
(documented if name == "components" else used).add(c)
if c in SHELL and not (bare and c in SIGNED_OUT):
errors.append(f"{name}: `{c}` belongs to the shell")
elif c not in defined:
errors.append(f"{name}: `{c}` is not defined in theme.css")
elif c == "ti" or c.startswith("ti-"):
continue
elif c == "is-small" and "progress" not in n.classes:
errors.append(f"{name}: `is-small` on <{n.tag}> — controls keep the normal size; only a progress bar is small")
elif c not in BULMA:
errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component")
if bare:
continue
top = [k for k in root.kids if k.tag != "root"]
if not top or not top[0].has("sp-head"):
errors.append(f"{name}: the page must open with page_head (.sp-head)")
if len([n for n in root.walk() if n.tag == "h1"]) != 1:
errors.append(f"{name}: exactly one <h1>")
marks = root.find("sp-postmark")
if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)):
errors.append(f"{name}: at most one postmark, inside page_head")
for tbl in (n for n in root.walk() if n.tag == "table"):
if not tbl.parent.has("table-container"):
errors.append(f"{name}: a <table> must sit in a table-container so a wide one can scroll")
for box in root.find("box"):
if not box.kids or not box.kids[0].has("sp-box-head"):
errors.append(f"{name}: a box must start with box_head")
for k in top[1:]:
# A flash is conditional, so it is allowed at the top level but never part of an outline.
ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form"))
if not ok:
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
if name != "components":
outlines[name] = outline(root)
for c in sorted(used - documented - SIGNED_OUT):
errors.append(f"components page does not show `{c}` — document it or stop using it")
shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8")))
for c in sorted(defined - used - documented - shell_used):
errors.append(f"theme.css defines `{c}` but nothing uses it")
if "--write" in sys.argv and not errors:
(HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8", newline="\n")
golden = HERE / "outlines.json"
if golden.exists() and "--write" not in sys.argv:
if json.loads(golden.read_text(encoding="utf-8")) != outlines:
errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit")
for e in errors:
print("FAIL", e)
print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Components · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark"><span>SELFPOST</span><b>REF</b><span>DESIGN CONTRACT</span></div>
<div class="sp-grow">
<p class="sp-kicker">Reference · page_head</p>
<h1 class="title is-3">Components</h1>
<p class="sp-lead">Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.</p>
</div>
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
</div>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i><strong>flash</strong> — the result of the last action, between page_head and the first box. Success, or…</div>
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>…<strong>flash, error</strong> — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's <code>help is-danger</code>.</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
</dl>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
<div class="sp-box-body">
<div class="buttons">
<div class="sp-postmark"><span>SELFPOST</span><b>OK</b><span>6 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
</div>
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
<tbody>
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
</div>
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>box · sp-danger-zone</h2></div>
<div class="sp-box-body"><p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p></div>
</div>
<div class="box sp-credential">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>box · sp-credential</h2><div class="sp-end">Shown once</div></div>
<div class="sp-box-body"><p>Only for a secret the panel will never show again.</p></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>health_card</h2><div class="sp-end">Overview only · each card links to its detail</div></div>
<div class="sp-health">
<a href="#"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Verdict in words</span><span class="sp-h-sub">ok · one supporting fact</span></span></a>
<a href="#" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">sp-warn</span></span></a>
<a href="#" class="sp-fail"><span class="sp-h-icon"><i class="ti ti-plug-connected-x"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">1 of 2 answering</span><span class="sp-h-sub">sp-fail</span></span></a>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="copy_field — single line"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
<div class="field"><label class="label">In DNS now</label>
<div class="control"><input class="input is-danger" readonly value="what the resolver returned"></div>
<p class="help is-danger">What is wrong and the exact fix.</p></div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">06</span><h2>facts</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Label</dt><dd>Value</dd></div>
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
</ol>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">10</span><h2>empty_state</h2></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>One sentence: what is absent and why that is fine.</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">11</span><h2>confirm_list</h2></div>
<div class="sp-box-body">
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>each thing that will be lost, one per line;</li>
<li><i class="ti ti-x"></i>inside an sp-danger-zone box, above the danger button.</li>
</ul>
</div>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">side_menu</p>
<ul class="menu-list">
<li><a class="is-active" href="#"><i class="ti ti-world-check"></i>Current <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#"><i class="ti ti-apps"></i>Section <span class="tag is-light">2</span></a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a class="has-text-danger" href="#"><i class="ti ti-trash has-text-danger"></i>Delete</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">12</span><h2>Form rows · route · help_topic</h2></div>
<div class="sp-box-body">
<div class="sp-form">
<div class="sp-row">
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
</div>
<div class="sp-pair">
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
</div>
</div>
<p class="sp-route"><span>from@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>to@example.net</span><span class="tag is-warning is-light">deferred</span></p>
</div>
<div class="sp-help-topic"><h3>help_topic</h3><p>A heading and a paragraph; topics stack inside one box on the Help page.</p></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">13</span><h2>shell</h2><div class="sp-end">Owned by the layout — a screen never writes it</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Name</dt><dd>The wordmark, not the stamp: at nav size the stamp's perforation is under a pixel and its brick field dissolves into the bar. The stamp lives on sign-in, setup and the favicon.</dd></div>
<div><dt>Edge</dt><dd>Built like the mark: the brick field, then the stamp's paper margin, and the perforation along the outer edge of that margin — round holes, flat teeth, the ink hairline. One tile, <code>perf-edge.svg</code>.</dd></div>
<div><dt>Sibling strip</dt><dd>The pages of the current group, under the edge. Five or six entries; past that the group is split.</dd></div>
<div><dt>Menus</dt><dd>Bulma hover dropdowns, no JavaScript. The user menu holds Account, Help and Sign out.</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">14</span><h2>Type scale</h2><div class="sp-end">Three sizes · nothing to be read is under 13px</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Size</th><th>Looks like</th><th>Used for</th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">body · 16px</td><td>The quick brown fox, 203.0.113.25</td><td class="sp-muted">Text, table cells, inputs, selects, buttons, menus and their dropdowns</td></tr>
<tr><td class="sp-mono sp-small">--sp-fs-s · 14px</td><td class="sp-small">The quick brown fox, <span class="sp-mono">203.0.113.25</span></td><td class="sp-muted">Supporting text: labels, <code>help</code>, notes in a box head or foot, mono values (<code>sp-small</code>), logs, the footer</td></tr>
<tr><td class="sp-mono sp-small">--sp-fs-xs · 13px</td><td><span class="sp-kicker">Upper-case mono label</span> <span class="tag is-light">tag</span></td><td class="sp-muted">Upper-case mono micro-labels (kicker, table head, <code>dt</code>, box title) and tags — nothing else</td></tr>
</tbody>
</table></div>
<div class="sp-box-foot">Bulma's <code>is-small</code> and <code>is-size-7</code> are not used: a control is never small, and <code>sp-small</code> replaces the helper. The postmark's fine print is an imprint, not text.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shop.example.org · DMARC reports · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark sp-warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> /</p>
<h1 class="title is-3">shop.example.org</h1>
<p class="sp-lead">11 204 pass · 41 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening <code>p=</code>.</p>
</div>
<a class="button" href="out-domain.html"><i class="ti ti-world-upload"></i>Domain page</a>
</div>
<div class="columns">
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>11 204</td><td>0</td><td><span class="tag is-success is-light">none</span></td></tr>
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>0</td><td>41</td><td><span class="tag is-warning is-light">none</span></td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 04:38</td><td class="sp-muted">Outlook.com</td><td>19 Sep</td><td>1 877</td><td><span class="tag is-warning is-light">34</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Outlook.com · 20 Sep · DMARC · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
</dl>
</div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Published policy</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>p / sp / pct</dt><dd class="sp-mono">none / none / 100</dd></div>
<div><dt>adkim / aspf</dt><dd class="sp-mono">r / r</dd></div>
<div><dt>rua</dt><dd class="sp-mono sp-small">dmarc@mail.example.org</dd></div>
</dl>
</div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Records</h2><div class="sp-end">Parsed from the aggregate XML</div></div>
<div class="table-container"><table class="table is-fullwidth">
<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="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>1 903</td><td><span class="tag is-success is-light">none</span></td><td><span class="tag is-success is-light">pass</span></td><td><span class="tag is-success is-light">pass</span></td><td>shop.example.org</td></tr>
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>7</td><td><span class="tag is-warning is-light">none</span></td><td><span class="tag is-danger is-light">fail</span></td><td><span class="tag is-danger is-light">fail</span></td><td>shop.example.org</td></tr>
</tbody>
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DMARC reports · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item is-active" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html" aria-current="page"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">DMARC reports</h1>
<p class="sp-lead">How receivers saw mail claiming to be from your domains. Aggregate (<code>rua=</code>) only; open a report for the parsed XML.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted address</dt><dd class="sp-mono">dmarc@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 23:58</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">Yahoo</td><td>19 Sep</td><td>38</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 06:09</td><td><a href="dmarc-domain.html">notify.acme.io</a></td><td class="sp-muted">google.com</td><td>19 Sep</td><td>61</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
</tbody>
</table></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Health · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html" aria-current="page"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Health</h1>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
<div class="columns">
<div class="column is-6">
<div class="box" id="machine">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td class="sp-nowrap">↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-6">
<div class="box" id="processes">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<div class="columns">
<div class="column is-4">
<div class="box" id="certificate">
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
</dl>
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
</div>
</div>
</div>
<div class="column is-4">
<div class="box" id="sockets">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-4">
<div class="box" id="hostname">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
</dl>
</div>
</div>
</div>
</div>
<div class="box" id="configuration">
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
<div class="sp-box-body">
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Help · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Reference</p>
<h1 class="title is-3">Help</h1>
<p class="sp-lead">What each screen checks and what to do when it is not green. Every numbered section on a page links to its topic here.</p>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">Overview</p>
<ul class="menu-list">
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
<li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li>
</ul>
<p class="menu-label">Outbound</p>
<ul class="menu-list">
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
<li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
</ul>
<p class="menu-label">Inbound</p>
<ul class="menu-list">
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now.</p></div>
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. It warns 14 days before expiry and fails once expired — clients that verify certificates then refuse to connect.</p></div>
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login. It may send as any address of its domain or only as listed addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, manual or computed from 30-day statistics, and can never exceed level 1.</p></div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete lists.example.org · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> / <a href="in-domain.html">lists.example.org</a> /</p>
<h1 class="title is-3">Delete lists.example.org</h1>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the inbound domain <strong>lists.example.org</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>stop accepting mail for it on port 25 — senders get a relay-denied reply;</li>
<li><i class="ti ti-x"></i>remove its upstream and its 4 listed recipients.</li>
</ul>
<p class="sp-muted mb-4">Remove or repoint the MX record first, or mail for the domain will bounce. Outbound domains are not touched.</p>
<form method="post" action="in-domains.html">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete lists.example.org</button>
<a class="button is-text" href="in-domain.html">Keep it</a>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lists.example.org · Inbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark"><span>DNS</span><b>OK</b><span>MX POINTS HERE</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p>
<h1 class="title is-3">lists.example.org</h1>
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over required TLS · 4 recipients</p>
</div>
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">This domain</p>
<ul class="menu-list">
<li><a class="is-active" href="#dns"><i class="ti ti-world-check"></i>MX record <span class="tag is-success is-light">ok</span></a></li>
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
<li><a href="#recipients"><i class="ti ti-address-book"></i>Recipients <span class="tag is-light">4</span></a></li>
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter <span class="tag is-success is-light">on</span></a></li>
</ul>
<p class="menu-label">See also</p>
<ul class="menu-list">
<li><a href="in-log.html"><i class="ti ti-list-details"></i>Log for this domain</a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a class="has-text-danger" href="in-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="lists.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="MX"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box" id="upstream">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="sp-row">
<div class="field"><label class="label" for="host">Host</label><div class="control"><input class="input sp-mono" id="host" name="host" value="mx.internal" autocomplete="off" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="port">Port</label><div class="control"><input class="input sp-mono" id="port" name="port" inputmode="numeric" value="25" required></div></div>
</div>
<div class="field"><label class="label" for="tls">TLS to upstream</label>
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option selected>Required</option><option>Off</option></select></div></div></div>
<button type="submit" class="button is-primary">Save upstream</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="recipients">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rmode">Who to accept</label>
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="recipient_mode"><option selected>Listed addresses only</option><option>Any recipient at this domain</option></select></div></div></div>
<div class="field"><label class="label" for="addresses">Addresses</label>
<div class="control"><textarea class="textarea sp-mono" id="addresses" name="addresses" rows="4">announce@lists.example.org
dev@lists.example.org
owner@lists.example.org
security@lists.example.org</textarea></div>
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<button type="submit" class="button is-primary">Save recipients</button>
</form>
</div>
</div>
</div>
</div>
<div class="box" id="filter">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
</dl>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html" aria-current="page"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound domains</h1>
<p class="sp-lead">Backup-MX / forwarder. Port 25 accepts mail only for the domains listed here and hands it to their upstream. No mailboxes.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add a domain</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="in-domain.html">
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" placeholder="lists.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
</div>
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.</p>
</form>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody>
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">required</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="in-domain.html"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">10.0.4.12:2525</td><td>any address</td><td><span class="tag is-light">opportunistic</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Filter lists · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item is-active" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html" aria-current="page"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound · global role</p>
<h1 class="title is-3">Filter lists</h1>
<p class="sp-lead">Correct the spam filter by hand: senders it must always let through, and senders it must always turn away. One pair of lists for the whole instance.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#inbound" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="columns">
<div class="column is-3">
<div class="field"><label class="label" for="list">List</label>
<div class="control"><div class="select is-fullwidth"><select id="list" name="list_type"><option>Deny — always turn away</option><option>Allow — always let through</option></select></div></div></div>
</div>
<div class="column is-3">
<div class="field"><label class="label" for="etype">Entry is</label>
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client IP or CIDR</option></select></div></div></div>
</div>
<div class="column is-6">
<div class="field"><label class="label" for="value">Value</label>
<div class="control"><input class="input sp-mono" id="value" name="value" placeholder="promo@bulk.example" autocomplete="off" autocapitalize="none" spellcheck="false" required></div></div>
</div>
</div>
<div class="field"><label class="label" for="note">Note</label>
<div class="control"><input class="input" id="note" name="note" placeholder="Why — for whoever reads this list next year"></div></div>
<button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add entry</button>
</form>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">@bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
<tr><td class="sp-mono sp-small">198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">dictionary attack on lists.example.org</td><td class="sp-muted sp-nowrap sp-small">2026-08-19</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
<tr><td class="sp-mono sp-small">win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td><td class="sp-muted sp-nowrap sp-small">2026-07-30</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">@partner.example <span class="tag is-light">domain</span></td><td class="sp-muted">invoices were scored as spam</td><td class="sp-muted sp-nowrap sp-small">2026-09-11</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
<tr><td class="sp-mono sp-small">noreply@github.example <span class="tag is-light">address</span></td><td class="sp-muted">security advisories</td><td class="sp-muted sp-nowrap sp-small">2026-06-04</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Applied by</dt><dd>rspamd, from map files</dd></div>
<div><dt>Files</dt><dd class="sp-mono sp-small">/data/antispam/allow.map<br>/data/antispam/deny.map</dd></div>
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
<div><dt>When the filter is off</dt><dd class="sp-muted">The lists are kept and editable, but nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.</dd></div>
</dl>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inbound log · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item is-active" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound log</h1>
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from <code>mail.log</code>; the filter's reasons appear when the spam filter is on.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head">
<span class="sp-no">01</span><h2>Messages</h2>
<form class="sp-end" method="get" action="#">
<div class="select"><select name="domain"><option>All domains</option><option>lists.example.org</option><option>acme.io</option></select></div>
<div class="select"><select name="decision"><option>Any decision</option><option>accept</option><option>reject</option><option>tempfail</option><option>quarantine</option></select></div>
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form>
</div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · score 0.4</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-muted sp-small">rspamd · score 9.8 · PHISHING, FORGED_SENDER</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">postfix · 550 5.1.1 unknown recipient</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">rspamd · score 16.2 · BAYES_SPAM</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip">Application: backend engineer</td><td><span class="tag is-warning is-light">tempfail</span></td><td class="sp-muted sp-small">selfpost · upstream 10.0.4.12 timed out</td><td class="sp-actions"></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · allow list</td><td class="sp-actions"></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in · global administrator only</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Held message · Quarantine · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item is-active" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html" aria-current="page"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="in-quarantine.html">Quarantine</a> /</p>
<h1 class="title is-4">Outstanding invoice — action required</h1>
<p class="sp-route"><span>invoice@partner-billing.example</span><i class="ti ti-arrow-right sp-muted"></i><span>owner@lists.example.org</span><span class="tag is-primary is-light">quarantine</span></p>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Message</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div>
<div><dt>Deleted on</dt><dd>2026-10-05 · in 14 days</dd></div>
<div><dt>Size</dt><dd>48 kB · 1 attachment (<span class="sp-mono">invoice.html</span>)</dd></div>
<div><dt>Would go to</dt><dd class="sp-mono">mx.internal:25</dd></div>
</dl>
</div>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Why it was held</h2><div class="sp-end">rspamd · score 9.8</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Symbol</th><th>Score</th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">PHISHING</td><td>5.0</td></tr>
<tr><td class="sp-mono sp-small">FORGED_SENDER</td><td>3.0</td></tr>
<tr><td class="sp-mono sp-small">HTML_ATTACHMENT</td><td>1.8</td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Headers</h2><div class="sp-end">The body and attachments are never rendered in the panel</div></div>
<pre class="sp-log"><span class="sp-d">Received:</span> from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
<span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt;
<span class="sp-d">Reply-To:</span> <span class="sp-w">payments.dept@freemail.example</span>
<span class="sp-d">To:</span> owner@lists.example.org
<span class="sp-d">Subject:</span> Outstanding invoice — action required
<span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
<span class="sp-d">Authentication-Results:</span> <span class="sp-e">spf=fail dkim=none dmarc=fail</span></pre>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Release</h2></div>
<div class="sp-box-body">
<p class="mb-3">Hands the message to <span class="sp-mono">mx.internal:25</span> unchanged, as if it had just arrived. It leaves the quarantine.</p>
<form class="sp-form" method="post" action="in-quarantine.html">
<div class="field"><label class="checkbox"><input type="checkbox" name="allow_sender"> Also add <span class="sp-mono">invoice@partner-billing.example</span> to the Allow list</label></div>
<button type="submit" class="button is-primary"><i class="ti ti-send"></i>Release to upstream</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>Discard</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deletes the message now instead of in 14 days. The sender is not told. This cannot be undone.</p>
<form class="sp-form" method="post" action="in-quarantine.html">
<div class="field"><label class="checkbox"><input type="checkbox" name="deny_sender" checked> Also add <span class="sp-mono">@partner-billing.example</span> to the Deny list</label></div>
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Discard message</button>
</form>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quarantine · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item is-active" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html" aria-current="page"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Quarantine</h1>
<p class="sp-lead">Mail the filter found suspicious enough to hold, but not to refuse. Release hands a message to its upstream as if it had just arrived; Discard deletes it. Anything left is deleted after 14 days.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head">
<span class="sp-no">01</span><h2>Held messages</h2>
<form class="sp-end" method="get" action="#">
<div class="select"><select name="domain"><option>All domains</option><option>lists.example.org</option><option>acme.io</option></select></div>
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form>
</div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Outstanding invoice — action required</strong></a></td><td class="sp-muted sp-small">score 9.8 · PHISHING</td><td class="sp-muted sp-nowrap">48 kB</td><td class="sp-nowrap">14 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 22:14:40</td><td>no-reply@<wbr>shipping-update.example</td><td>jobs@<wbr>acme.io</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Your parcel could not be delivered</strong></a></td><td class="sp-muted sp-small">score 8.1 · URL_SHORTENER</td><td class="sp-muted sp-nowrap">12 kB</td><td class="sp-nowrap">13 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-09 07:02:11</td><td>m.ortega@<wbr>supplier.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Fwd: scanned contract (3 attachments)</strong></a></td><td class="sp-muted sp-small">score 7.4 · MIME_ARCHIVE</td><td class="sp-muted sp-nowrap">2.9 MB</td><td class="sp-nowrap"><span class="tag is-warning is-light">2 days</span></td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>3 messages · 3.0 MB of 500 MB</span><span class="sp-end">A domain administrator sees only their assigned inbound domains</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inbound queue · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item is-active" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a><a href="in-log.html"><i class="ti ti-list-details"></i>Log</a><a href="in-queue.html" aria-current="page"><i class="ti ti-stack-2"></i>Queue</a><a href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a><a href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound queue</h1>
<p class="sp-lead">Mail accepted on port 25 and still waiting for its upstream. The same Postfix queue as <a href="out-queue.html">outbound</a>, filtered by recipient domain.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>How retries work</h2><div class="sp-end">This Postfix's policy, read at panel start</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>First retry</dt><dd class="sp-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="sp-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="sp-big">5 days</dd></div>
<div><dt>Then</dt><dd class="sp-big">bounced</dd><dd class="sp-muted">back to the original sender</dd></div>
</dl>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="overview.html">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Sign in</h1>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New password · prod-server · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
<h1 class="title is-3">New password for <span class="sp-mono">prod-server</span></h1>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box sp-credential">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>Shown once</h2><div class="sp-end">Not stored — copy it now</div></div>
<div class="sp-record">
<div class="field"><label class="label">Login</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="prod-server"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Password</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="kT7v-Qm2x-Lp9d-Ue4s-Hn6b-Rz3w"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div>
<p class="help">The previous password stopped working when this one was generated. If this one is lost, generate another.</p></div>
</div>
<div class="sp-box-foot"><a class="button is-primary" href="out-domain.html">Done — back to example.org</a></div>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-plug"></i></span><h2>Connect with</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Server</dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Port</dt><dd>465 SSL/TLS · 587 STARTTLS</dd></div>
<div><dt>May send as</dt><dd class="sp-mono">*@example.org</dd></div>
</dl>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>prod-server · example.org · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> / Applications /</p>
<h1 class="title is-3 sp-mono">prod-server</h1>
<p class="sp-lead">1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h. The same form, empty, adds a new application.</p>
</div>
<div class="buttons">
<a class="button" href="out-app-created.html"><i class="ti ti-key"></i>New password</a>
<a class="button is-danger is-light" href="#"><i class="ti ti-trash"></i>Delete</a>
</div>
</div>
<form class="sp-form" method="post" action="out-domain.html">
<div class="columns">
<div class="column is-4">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sender</h2></div>
<div class="sp-box-body">
<div class="field"><label class="label" for="login">Login</label>
<div class="control"><input class="input sp-mono" id="login" name="login" value="prod-server" autocomplete="off" spellcheck="false" required></div>
<p class="help">Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.</p></div>
<div class="field"><label class="label" for="amode">May send as</label>
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode"><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select></div></div></div>
<div class="field"><label class="label" for="addresses">Addresses</label>
<div class="control"><textarea class="textarea" id="addresses" name="addresses" rows="3" placeholder="alerts@example.org" disabled></textarea></div>
<p class="help">One per line. Used only with a list.</p></div>
</div>
</div>
</div>
<div class="column is-4">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Client IP allow-list</h2><div class="sp-end"><span class="tag is-light">off</span></div></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="auth_ip_restrict"> Restrict to listed IPs</label></div>
<div class="field"><label class="label" for="ips">Allowed client IPs</label>
<div class="control"><textarea class="textarea sp-mono" id="ips" name="auth_allowed_ips" rows="3" placeholder="203.0.113.10"></textarea></div>
<p class="help">A login from anywhere else is refused with a 4xx at <code>MAIL FROM</code>. A sending control, fail-open — not an authentication boundary.</p></div>
</div>
</div>
</div>
<div class="column is-4">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div></div>
<div class="sp-box-body">
<div class="field"><label class="label" for="rmode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="rl_mode"><option>Use the domain limit</option><option selected>Manual</option><option>Auto (from statistics)</option></select></div></div></div>
<div class="sp-pair">
<div class="field"><label class="label" for="max">Messages (max 600)</label><div class="control"><input class="input" id="max" name="max_messages" type="number" min="1" max="600" value="200"></div></div>
<div class="field"><label class="label" for="win">Window, s</label><div class="control"><input class="input" id="win" name="window_seconds" type="number" min="1" value="3600"></div></div>
</div>
<p class="help">Overrides the domain limit (288 / h) for this application; may be higher or lower, never above level 1.</p>
</div>
</div>
</div>
</div>
<div class="buttons">
<button type="submit" class="button is-primary">Save application</button>
<a class="button is-text" href="out-domain.html">Cancel</a>
</div>
</form>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete example.org · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> /</p>
<h1 class="title is-3">Delete example.org</h1>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting <strong>example.org</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>permanently delete its DKIM signing key;</li>
<li><i class="ti ti-x"></i>delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;</li>
<li><i class="ti ti-x"></i>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<p class="sp-muted mb-4">The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched.</p>
<form method="post" action="out-domains.html">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete example.org</button>
<a class="button is-text" href="out-domain.html">Keep it</a>
</div>
</form>
</div>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
<div class="sp-box-body">
<p><a href="out-domain-settings.html#export">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings · example.org · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> / <a href="out-domain.html">example.org</a> /</p>
<h1 class="title is-3">Domain settings</h1>
<p class="sp-lead">The things set once and rarely touched: where DMARC reports go, the sending ceiling, moving the domain elsewhere.</p>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box" id="reports">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode">
<option selected>admin's default — mix@example.org</option>
<option>SelfPost hosted (example.org@dmarc.mail.example.org)</option>
<option>My default instead — (you are admin)</option>
<option>No aggregate reports</option>
<option>Custom address</option>
</select></div></div>
<p class="help">A default belongs to a user and is set under their <a href="account.html#dmarc">Account</a>; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.</p></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
</div>
</div>
<div class="box" id="export">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2></div>
<div class="sp-box-body">
<p class="mb-3">One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. <strong>The file is a secret.</strong></p>
<form class="sp-form" method="post" action="#">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spde</code>)</label></div>
<div class="sp-pair">
<div class="field"><label class="label" for="encpw">Password</label><div class="control"><input class="input" id="encpw" type="password" placeholder="at least 12 characters"></div></div>
<div class="field"><label class="label" for="encpw2">Repeat password</label><div class="control"><input class="input" id="encpw2" type="password"></div></div>
</div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
<button type="submit" class="button"><i class="ti ti-download"></i>Export domain</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="limit">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00 UTC.</p></div>
<div class="buttons">
<button type="submit" class="button is-primary">Save limit</button>
<button type="submit" class="button" formaction="#recalc">Recalculate now</button>
<button type="submit" class="button is-danger is-light" formaction="#clear">Remove limit</button>
</div>
</form>
<p class="sp-muted mt-4">Applies to every sender on this domain and cannot exceed level 1 (600 messages / 3600 s per client IP, set in <code>.env</code>). An application may carry its own limit above or below this one.</p>
</div>
</div>
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Delete domain</h2></div>
<div class="sp-box-body">
<p class="mb-3">Removes the DKIM key and both applications. Asks once more before doing it.</p>
<a class="button is-danger is-light" href="out-domain-delete.html"><i class="ti ti-trash"></i>Delete example.org…</a>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>example.org · Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="out-domains.html">Outbound domains</a> /</p>
<h1 class="title is-3">example.org</h1>
<p class="sp-lead">1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h</p>
</div>
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
<a class="button is-primary" href="out-app.html"><i class="ti ti-plus"></i>Add application</a>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">This domain</p>
<ul class="menu-list">
<li><a class="is-active" href="#dns"><i class="ti ti-world-check"></i>DNS records <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#apps"><i class="ti ti-apps"></i>Applications <span class="tag is-light">2</span></a></li>
<li><a href="#connection"><i class="ti ti-plug"></i>Connection</a></li>
</ul>
<p class="menu-label">See also</p>
<ul class="menu-list">
<li><a href="out-log.html"><i class="ti ti-list-details"></i>Log for this domain</a></li>
<li><a href="dmarc-domain.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a href="out-domain-settings.html#reports"><i class="ti ti-mail-forward"></i>Report address</a></li>
<li><a href="out-domain-settings.html#limit"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
<li><a href="out-domain-settings.html#export"><i class="ti ti-file-export"></i>Export</a></li>
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div><a class="sp-help" href="help.html#dns" title="What to publish and why"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="3" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-danger is-light">mismatch</span><small>Merge into the existing record — never publish a second one</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 ip4:203.0.113.25 ~all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">In DNS now</label>
<div class="control"><input class="input is-danger" readonly value="v=spf1 include:_spf.google.com ~all"></div>
<p class="help is-danger">The published record does not authorize 203.0.113.25. Add <code>ip4:203.0.113.25</code> before <code>~all</code>. Shallow check: <code>include:</code> is not followed.</p></div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">admin's default</a></small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div>
<p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div>
</div>
</div>
<div class="box" id="apps">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div><a class="sp-help" href="help.html#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr>
<tr><td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div>
</div>
<div class="box" id="connection">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Connection</h2><div class="sp-end">The same for every domain</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Server</dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Port 465</dt><dd>SSL/TLS (implicit)</dd></div>
<div><dt>Port 587</dt><dd>STARTTLS (submission)</dd></div>
<div><dt>Authentication</dt><dd>Application login, required on every port</dd></div>
</dl>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Outbound domains · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item is-active" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html" aria-current="page"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound domains</h1>
<p class="sp-lead">Domains this relay signs and sends for. Each gets a DKIM key and its own application logins.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add a domain</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="out-domain.html">
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
</div>
<p class="help">A DKIM key is generated at once; the next page shows the three DNS records to publish.
Moving a domain from another SelfPost? <a href="backup.html#import">Import it</a> instead — the key and passwords come across.</p>
</form>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">3 domains · DNS cached a few minutes</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th>30 days</th><th></th></tr></thead>
<tbody>
<tr><td><a href="out-domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td class="sp-muted sp-mono">sp2026</td><td>2</td><td class="sp-muted">1 284 msg · peak 96/h</td><td class="sp-actions"><a href="out-domain.html">Open</a><a class="has-text-danger" href="out-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="out-domain.html"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td class="sp-muted sp-mono">sp2026</td><td>1</td><td class="sp-muted">18 920 msg · peak 410/h</td><td class="sp-actions"><a href="out-domain.html">Open</a><a class="has-text-danger" href="out-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="out-domain.html"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td class="sp-muted sp-mono">sp2025</td><td>3</td><td class="sp-muted">402 msg · peak 22/h</td><td class="sp-actions"><a href="out-domain.html">Open</a><a class="has-text-danger" href="out-domain-delete.html">Delete</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot">Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Outbound log · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item is-active" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound log</h1>
<p class="sp-lead">Every message an application handed to the relay. Rows older than 30 days are deleted — change that in <a href="settings.html">Settings</a>.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head">
<span class="sp-no">01</span><h2>Messages</h2>
<form class="sp-end" method="get" action="#">
<div class="select"><select name="domain"><option>All domains</option><option>example.org</option><option>shop.example.org</option><option>notify.acme.io</option></select></div>
<div class="select"><select name="app"><option>All applications</option><option>prod-server</option><option>alerts</option></select></div>
<div class="select"><select name="status"><option>Any status</option><option>sent</option><option>deferred</option><option>bounced</option><option>rate-limited</option></select></div>
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form>
</div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td>shop@<wbr>shop.example.org</td><td>m.keller@<wbr>gmx.de</td><td class="sp-clip">Your order #48213 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-clip">[FIRING] disk usage above 90 % on db-2</td><td><span class="tag is-warning is-light">deferred</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:41:19</td><td>no-reply@<wbr>notify.acme.io</td><td>j.doe@<wbr>nonexistent.example</td><td class="sp-clip">Reset your password</td><td><span class="tag is-danger is-light">bounced</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:40:02</td><td>prod@<wbr>example.org</td><td>billing@<wbr>partner.example</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-light">rate-limited</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:12:44</td><td>shop@<wbr>shop.example.org</td><td>p.novak@<wbr>seznam.cz</td><td class="sp-clip">We received your return request</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>Page 1 of 412</span><a href="#">Older →</a><span class="sp-end">New rows appear on their own</span></div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Message · Outbound log · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item is-active" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="out-log.html">Outbound log</a> /</p>
<h1 class="title is-4">[FIRING] disk usage above 90 % on db-2</h1>
<p class="sp-route"><span>alerts@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>noc@example.org</span><span class="tag is-warning is-light">deferred</span></p>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Message</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Domain</dt><dd><a href="out-domain.html">example.org</a></dd></div>
<div><dt>Application</dt><dd class="sp-mono"><a href="out-app.html">alerts</a></dd></div>
<div><dt>Accepted</dt><dd>2026-09-21 13:58:07 UTC</dd></div>
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09 UTC</dd></div>
<div><dt>Queue id</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>Journal id</dt><dd class="sp-mono">184220</dd></div>
</dl>
</div>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
<li class="sp-pending"><time>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
</ol>
</div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Delivery log</h2><div class="sp-end">Lines from <code>mail.log</code> with this queue id</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts
<span class="sp-d">13:58:07</span> postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=&lt;a81f0c@alertmanager&gt;
<span class="sp-d">13:58:07</span> opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)
<span class="sp-d">13:58:09</span> <span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=&lt;noc@example.org&gt;, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)</span></pre>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Outbound queue · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item is-active" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a><a href="out-log.html"><i class="ti ti-list-details"></i>Log</a><a href="out-queue.html" aria-current="page"><i class="ti ti-stack-2"></i>Queue</a><a href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">Outbound queue</h1>
<p class="sp-lead">Messages accepted from applications and still waiting for the receiving server. Refreshes every 10 s.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono">4XcB7k2Jm9z1</td><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-muted">451 4.7.1 Greylisted, try again in 5 minutes</td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
</tbody>
</table></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>How retries work</h2><div class="sp-end">This Postfix's policy, read at panel start</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>First retry</dt><dd class="sp-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="sp-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="sp-big">5 days</dd></div>
<div><dt>Then</dt><dd class="sp-big">bounced</dd><dd class="sp-muted">no attempt counter, only time</dd></div>
</dl>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,466 @@
{
"account": [
"head",
{
"columns": [
{
"is-5": [
"box[Profile]:body",
"box[Password]:body"
]
},
{
"is-7": [
"box[Notifications]:body",
"box[DMARC reports]:record×2"
]
}
]
}
],
"backup": [
"head",
{
"columns": [
{
"is-6": [
"box[Full backup]:body"
]
},
{
"is-6": [
"box[Import a domain]:body"
]
}
]
}
],
"dmarc-domain": [
"head+postmark+actions",
{
"columns": [
{
"is-5": [
"box[Sources · 7 days]:table"
]
},
{
"is-7": [
"box[Reports]:table"
]
}
]
}
],
"dmarc-report": [
"head",
{
"columns": [
{
"is-6": [
"box[Report]:body"
]
},
{
"is-6": [
"box[Published policy]:body"
]
}
]
},
"box[Records]:table"
],
"dmarc": [
"head",
"box[Ingest]:body",
"box[Recent reports]:table"
],
"health": [
"head+actions",
{
"columns": [
{
"is-6": [
"box[Machine]:table"
]
},
{
"is-6": [
"box[Processes]:table"
]
}
]
},
{
"columns": [
{
"is-4": [
"box[TLS certificate]:body"
]
},
{
"is-4": [
"box[Milter sockets]:table"
]
},
{
"is-4": [
"box[Hostname and reverse DNS]:body"
]
}
]
},
"box[Configuration]:body"
],
"help": [
"head",
{
"columns": [
{
"is-3": [
"side_menu"
]
},
{
"is-9": [
"box[Overview]:help×3",
"box[Outbound]:help×3"
]
}
]
}
],
"in-domain-delete": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-danger-zone[This cannot be undone]:body"
]
}
]
}
],
"in-domain": [
"head+postmark+actions",
{
"columns": [
{
"is-3": [
"side_menu"
]
},
{
"is-9": [
"box[MX record]:record",
{
"columns": [
{
"is-6": [
"box[Upstream]:body"
]
},
{
"is-6": [
"box[Valid recipients]:body"
]
}
]
},
"box[Spam filter]:body"
]
}
]
}
],
"in-domains": [
"head",
"box[Add a domain]:body",
"box[Domains]:table"
],
"in-filter-lists": [
"head",
"box[Add an entry]:body",
{
"columns": [
{
"is-6": [
"box[Deny]:table"
]
},
{
"is-6": [
"box[Allow]:table"
]
}
]
},
"box[Where the lists go]:body"
],
"in-log": [
"head",
"box[Messages]:table"
],
"in-quarantine-message": [
"head",
{
"columns": [
{
"is-7": [
"box[Message]:body"
]
},
{
"is-5": [
"box[Why it was held]:table"
]
}
]
},
"box[Headers]:log",
{
"columns": [
{
"is-6": [
"box[Release]:body"
]
},
{
"is-6": [
"box.sp-danger-zone[Discard]:body"
]
}
]
}
],
"in-quarantine": [
"head",
"box[Held messages]:table"
],
"in-queue": [
"head",
"box[Waiting]:empty",
"box[How retries work]:body"
],
"out-app-created": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-credential[Shown once]:record"
]
},
{
"is-5": [
"box[Connect with]:body"
]
}
]
}
],
"out-app": [
"head+actions",
{
"form": [
{
"columns": [
{
"is-4": [
"box[Sender]:body"
]
},
{
"is-4": [
"box[Client IP allow-list]:body"
]
},
{
"is-4": [
"box[Rate limit]:body"
]
}
]
},
"submit_row"
]
}
],
"out-domain-delete": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-danger-zone[This cannot be undone]:body"
]
},
{
"is-5": [
"box[Moving it instead?]:body"
]
}
]
}
],
"out-domain-settings": [
"head",
{
"columns": [
{
"is-6": [
"box[DMARC report address]:body",
"box[Export domain]:body"
]
},
{
"is-6": [
"box[Rate limit]:body",
"box.sp-danger-zone[Delete domain]:body"
]
}
]
}
],
"out-domain": [
"head+postmark+actions",
{
"columns": [
{
"is-3": [
"side_menu"
]
},
{
"is-9": [
"box[DNS records]:record×3",
"box[Applications]:table",
"box[Connection]:body"
]
}
]
}
],
"out-domains": [
"head",
"box[Add a domain]:body",
"box[Domains]:table"
],
"out-log": [
"head",
"box[Messages]:table"
],
"out-message": [
"head",
{
"columns": [
{
"is-7": [
"box[Message]:body"
]
},
{
"is-5": [
"box[History]:body"
]
}
]
},
"box[Delivery log]:log"
],
"out-queue": [
"head",
"box[Waiting]:table",
"box[How retries work]:body"
],
"overview": [
"head+postmark",
"box[Server health]:health",
{
"columns": [
{
"is-7": [
"box[Outbound domains]:table"
]
},
{
"is-5": [
"box[Inbound domains]:table"
]
}
]
}
],
"preflight": [
"head+postmark+actions",
"box[Checks]:table",
"box[Send a test e-mail]:body"
],
"settings": [
"head",
{
"form": [
{
"columns": [
{
"is-6": [
"box[Log retention]:body",
"box[Notifications]:body"
]
},
{
"is-6": [
"box[Sending rate limits]:body"
]
}
]
},
"submit_row"
]
}
],
"system-log": [
"head",
"box[Recent lines]:log"
],
"user-delete": [
"head",
{
"columns": [
{
"is-7": [
"box.sp-danger-zone[This cannot be undone]:body"
]
}
]
}
],
"user": [
"head+actions",
{
"form": [
{
"columns": [
{
"is-6": [
"box[Account]:body"
]
},
{
"is-6": [
"box[Outbound domains]:body",
"box[Inbound domains]:body"
]
}
]
},
"submit_row"
]
}
],
"users": [
"head+actions",
"box[Panel users]:table"
]
}
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item sp-current" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-grow">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
<p class="sp-lead">The TLS certificate expires in 12 days and has not renewed yet.</p>
</div>
<div class="sp-muted sp-small">Updated 14:02:31 UTC<br>refreshes every 10 s</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html#certificate" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html#sockets"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html#hostname"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound domains</h2><div class="sp-end"><a href="out-domains.html">Manage</a></div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Apps</th><th>30 days</th></tr></thead>
<tbody>
<tr><td><a href="out-domain.html"><strong>example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-danger is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>2</td><td class="sp-muted">1 284 msg</td></tr>
<tr><td><a href="out-domain.html"><strong>shop.example.org</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-success is-light">DMARC</span></td><td>1</td><td class="sp-muted">18 920 msg</td></tr>
<tr><td><a href="out-domain.html"><strong>notify.acme.io</strong></a></td><td><span class="tag is-success is-light">DKIM</span> <span class="tag is-success is-light">SPF</span> <span class="tag is-warning is-light">DMARC</span></td><td>3</td><td class="sp-muted">402 msg</td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound domains</h2><div class="sp-end"><a href="in-domains.html">Manage</a></div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>MX</th><th>Upstream</th></tr></thead>
<tbody>
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono sp-small">mx.internal:25</td></tr>
<tr><td><a href="in-domain.html"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-muted sp-mono sp-small">10.0.4.12:2525</td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4" viewBox="0 0 8 4">
<!-- One tooth and one hole of the stamp's edge. Hole radius 2.5 on a pitch of 8:
the mark's 2.2 / 7.03. The hairline is the mark's 0.7 stroke at this scale. -->
<path d="M0 0H8V3H6.5A2.5 2.5 0 0 0 1.5 3H0Z" fill="#F3EDE1"/>
<path d="M0 3H1.5A2.5 2.5 0 0 1 6.5 3H8" fill="none" stroke="#12161C" stroke-width="0.75"/>
</svg>

After

Width:  |  Height:  |  Size: 409 B

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Preflight · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item is-active" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html" aria-current="page"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html"><i class="ti ti-settings"></i>Settings</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-postmark sp-warn"><span>PREFLIGHT</span><b>WARN</b><span>1 OF 7 CHECKS</span></div>
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Preflight</h1>
<p class="sp-lead">Is this installation ready to deliver mail? Run it after the first start, after moving the server, or when receivers start refusing you. Health watches what runs; Preflight checks what the outside world sees.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Run again</button></form>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 UTC · 3.1 s</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Check</th><th>Result</th><th>Found</th><th>What to do</th></tr></thead>
<tbody>
<tr><td class="sp-nowrap"><strong>Reverse DNS</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">203.0.113.25 ⇄ mail.example.org</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>TLS certificate</strong></td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-small">Valid for <span class="sp-mono">mail.example.org</span>, expires in 12 days</td><td>Renewal has not run. Check that port 80 reaches the ACME client and look for <code>acme</code> in the <a href="system-log.html">system log</a>.</td></tr>
<tr><td class="sp-nowrap"><strong>Port 25</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">220 mail.example.org ESMTP</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>Ports 465, 587</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">TLS handshake, 220 banner on both</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>HELO name</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">mail.example.org</td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>Proxy headers</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-small">Peer <span class="sp-mono">172.18.0.2</span> is trusted; <span class="sp-mono">X-Forwarded-Proto: https</span></td><td class="sp-muted">—</td></tr>
<tr><td class="sp-nowrap"><strong>DKIM signing</strong></td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">inet:127.0.0.1:8891 answers</td><td class="sp-muted">—</td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>Ports are dialled from inside the container at the public address; a firewall that treats inside and outside differently can make this pass while the world is still blocked. SPF, DKIM and DMARC are per domain — each <a href=\"out-domains.html\">outbound domain</a> shows its own.</span></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Send a test e-mail</h2><div class="sp-end">Confirms the relay accepted it, not that it arrived</div></div>
<div class="sp-box-body">
<div class="columns">
<div class="column is-6">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rcpt">Recipient</label>
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" id="rcpt" name="recipient" type="email" placeholder="you@elsewhere.example" required><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-send"></i>Send</button></div>
</div>
<p class="help">Sent from <span class="sp-mono">postmaster@mail.example.org</span> through the local Postfix.</p></div>
</form>
</div>
<div class="column is-6">
<dl class="sp-facts">
<div><dt>Last test</dt><dd>14:03 UTC to <span class="sp-mono">mix@elsewhere.example</span></dd></div>
<div><dt>Result</dt><dd><span class="tag is-success is-light">queued</span> <span class="sp-mono sp-small">4XcC1d8Pq2z1</span></dd></div>
<div><dt>Then</dt><dd><a href="out-message.html">Follow it in the outbound log</a></dd></div>
</dl>
</div>
</div>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<nav class="navbar is-primary" aria-label="main navigation">
<div class="container">
<div class="navbar-brand"><a class="navbar-item" href="overview.html"><img src="../../selfpost-wordmark.svg" alt="SelfPost"></a></div>
<div class="navbar-menu is-active">
<div class="navbar-start">
<a class="navbar-item" href="overview.html">Overview</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="out-domains.html">Outbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="out-domains.html"><i class="ti ti-world-upload"></i>Domains</a>
<a class="navbar-item" href="out-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="out-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="dmarc.html"><i class="ti ti-report-analytics"></i>DMARC reports</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="in-domains.html">Inbound</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="in-domains.html"><i class="ti ti-world-download"></i>Domains</a>
<a class="navbar-item" href="in-log.html"><i class="ti ti-list-details"></i>Log</a>
<a class="navbar-item" href="in-queue.html"><i class="ti ti-stack-2"></i>Queue</a>
<a class="navbar-item" href="in-quarantine.html"><i class="ti ti-mail-pause"></i>Quarantine</a>
<a class="navbar-item" href="in-filter-lists.html"><i class="ti ti-filter"></i>Filter lists</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable sp-current">
<a class="navbar-link" href="health.html">Server</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="health.html"><i class="ti ti-heartbeat"></i>Health</a>
<a class="navbar-item" href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a>
<a class="navbar-item" href="system-log.html"><i class="ti ti-file-text"></i>System log</a>
<a class="navbar-item" href="backup.html"><i class="ti ti-archive"></i>Backup</a>
<a class="navbar-item" href="users.html"><i class="ti ti-users"></i>Users</a>
<a class="navbar-item is-active" href="settings.html"><i class="ti ti-settings"></i>Settings</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="account.html"><i class="ti ti-user-circle"></i>admin</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>
<a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider">
<a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="sp-perf" aria-hidden="true"></div>
<div class="sp-subnav"><div class="container"><a href="health.html"><i class="ti ti-heartbeat"></i>Health</a><a href="preflight.html"><i class="ti ti-checklist"></i>Preflight</a><a href="system-log.html"><i class="ti ti-file-text"></i>System log</a><a href="backup.html"><i class="ti ti-archive"></i>Backup</a><a href="users.html"><i class="ti ti-users"></i>Users</a><a href="settings.html" aria-current="page"><i class="ti ti-settings"></i>Settings</a></div></div>
<main>
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Settings</h1>
<p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail, notifications and DMARC report address are under <a href="account.html">Account</a>.</p>
</div>
</div>
<form class="sp-form" method="post" action="#">
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2></div>
<div class="sp-box-body">
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label>
<div class="control"><input class="input" id="ret" name="send_log_retention_days" type="number" min="7" max="365" value="30" required></div>
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Notifications</h2></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label></div>
<p class="help">Sent through this relay from <span class="sp-mono">postmaster@mail.example.org</span>. Each user picks the events under <a href="account.html">Account</a>; a user without an e-mail gets none.</p>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="rate-limits">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html#limit">domain</a> and <a href="out-app.html">application</a>.</dd></div>
</dl>
</div>
</div>
</div>
</div>
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
</form>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
</body>
</html>
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create administrator · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
<link rel="stylesheet" href="theme.css">
</head>
<body>
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="overview.html">
<p class="sp-kicker">First run · one-time link</p>
<h1 class="title is-3">Create administrator</h1>
<p class="sp-muted mb-4">This link creates the single global administrator. After you submit, it stops working for good.</p>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field">
<label class="label" for="password_confirm">Confirm password</label>
<div class="control has-icons-left"><input class="input" id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Create administrator</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,88 @@
<!-- title: Account | section: user -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel, what it should tell you about, and where reports about your domains go. Applications keep their own logins and passwords and are not affected.</p>
</div>
</div>
<div class="columns">
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you: the notifications on the right and a password-reset link. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div>
<button type="submit" class="button is-primary">Save profile</button>
</form>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="cur">Current password</label><div class="control"><input class="input" id="cur" name="current_password" type="password" autocomplete="current-password" required></div></div>
<div class="sp-pair">
<div class="field"><label class="label" for="new">New password</label><div class="control"><input class="input" id="new" name="new_password" type="password" autocomplete="new-password" required></div></div>
<div class="field"><label class="label" for="new2">Repeat</label><div class="control"><input class="input" id="new2" name="new_password_confirm" type="password" autocomplete="new-password" required></div></div>
</div>
<p class="help mb-3">Changing it signs out every other session of this account; this one stays signed in.</p>
<button type="submit" class="button">Change password</button>
</form>
</div>
</div>
</div>
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><div class="sp-end">Sent to mix@example.org</div></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<p class="sp-kicker mb-2">Your domains</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A DNS record stops matching — DKIM, SPF, DMARC or an inbound MX</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A domain or an application reaches its rate limit and mail is being refused</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> An inbound domain's upstream cannot be reached and mail is piling up</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> DMARC reports start showing failures for a domain</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Mail is held in quarantine</label></div>
<p class="sp-kicker mt-4 mb-2">The server · global role</p>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> The TLS certificate is within 14 days of expiry</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev" checked> A process or a milter socket stops answering</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="ev"> Messages sit in a queue for more than a day</label></div>
<p class="help mb-3">One message when a problem starts and one when it clears — never a stream. A domain administrator is told only about the domains assigned to them.</p>
<button type="submit" class="button is-primary">Save notifications</button>
</form>
</div>
</div>
<div class="box" id="dmarc">
<div class="sp-box-head"><span class="sp-no">04</span><h2>DMARC reports</h2><div class="sp-end"><span class="tag is-success is-light">authorized</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_default">
<option>SelfPost hosted — read them under DMARC reports</option>
<option selected>My account e-mail — mix@example.org</option>
<option>Another address…</option>
<option>No reports</option>
</select></div></div>
<p class="help">Used by every outbound domain whose report address is set to <em>admin's default</em> — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.</p></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Report authorization</h3><span class="tag is-success is-light">published</span><small>Needed because the address is on another domain than the senders</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="*._report._dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,44 @@
<!-- title: Backup | section: server | page: backup -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Backup</h1>
<p class="sp-lead">Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets.</p>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2></div>
<div class="sp-box-body">
<p class="mb-3"><code>data/</code> (database, DKIM keys, credentials, the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code> in one archive. Restore into an empty project directory and start the <strong>same SelfPost version</strong>. The reverse-proxy vhost is not included.</p>
<form class="sp-form" method="post" action="#">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" checked> Encrypt with a password (<code>.spbk</code>)</label></div>
<div class="sp-pair">
<div class="field"><label class="label" for="pw">Password</label><div class="control"><input class="input" id="pw" type="password" placeholder="at least 12 characters"></div></div>
<div class="field"><label class="label" for="pw2">Repeat password</label><div class="control"><input class="input" id="pw2" type="password"></div></div>
</div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
<button type="submit" class="button is-primary"><i class="ti ti-download"></i>Download full backup</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2></div>
<div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p>
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
<div class="field"><label class="label">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
<div class="field"><label class="label" for="ipw">Password</label><div class="control"><input class="input" id="ipw" name="import_password" type="password" autocomplete="off"></div>
<p class="help">Asked only for an encrypted <code>.spde</code> file.</p></div>
<button type="submit" class="button is-primary"><i class="ti ti-file-import"></i>Import domain</button>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,222 @@
<!-- title: Components | section: reference -->
<div class="sp-head">
<div class="sp-postmark"><span>SELFPOST</span><b>REF</b><span>DESIGN CONTRACT</span></div>
<div class="sp-grow">
<p class="sp-kicker">Reference · page_head</p>
<h1 class="title is-3">Components</h1>
<p class="sp-lead">Every building block of the panel, in every state it has. A screen is assembled from these and from plain Bulma — nothing else. The title of each box is the name of its template partial.</p>
</div>
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
</div>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i><strong>flash</strong> — the result of the last action, between page_head and the first box. Success, or…</div>
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>…<strong>flash, error</strong> — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's <code>help is-danger</code>.</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>list</dt><dd>page_head, then full-width boxes: the add form first, the table second. <a href="out-domains.html">Example</a></dd></div>
<div><dt>detail</dt><dd>page_head, then <code>columns</code>: <code>is-3</code> side menu + <code>is-9</code> boxes. <a href="out-domain.html">Example</a></dd></div>
<div><dt>form</dt><dd>page_head, then boxes in equal <code>columns</code>, one submit row under them. <a href="out-app.html">Example</a></dd></div>
<div><dt>signed out</dt><dd>The split brand / form screen, no navbar. <a href="login.html">Example</a></dd></div>
</dl>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>postmark</h2><div class="sp-end">Worst status of the page's checks</div></div>
<div class="sp-box-body">
<div class="buttons">
<div class="sp-postmark"><span>SELFPOST</span><b>OK</b><span>6 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-warn"><span>SELFPOST</span><b>WARN</b><span>1 OF 6 CHECKS</span></div>
<div class="sp-postmark sp-fail"><span>DNS</span><b>FAIL</b><span>1 OF 3 RECORDS</span></div>
</div>
<p class="sp-muted">Only inside page_head, at most one per page, and only where the page has a verdict to give.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>status_tag</h2><div class="sp-end">One mapping, one function</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Status</th><th>Tag</th><th>Bulma classes</th></tr></thead>
<tbody>
<tr><td>ok, published, running, sent</td><td><span class="tag is-success is-light">ok</span></td><td class="sp-mono sp-small">tag is-success is-light</td></tr>
<tr><td>warn, deferred, quiet</td><td><span class="tag is-warning is-light">warn</span></td><td class="sp-mono sp-small">tag is-warning is-light</td></tr>
<tr><td>error, fail, mismatch, bounced</td><td><span class="tag is-danger is-light">fail</span></td><td class="sp-mono sp-small">tag is-danger is-light</td></tr>
<tr><td>unknown, off, neutral counts</td><td><span class="tag is-light">off</span></td><td class="sp-mono sp-small">tag is-light</td></tr>
<tr><td>held: quarantine · role: global</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-mono sp-small">tag is-primary is-light</td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
</div>
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>box · sp-danger-zone</h2></div>
<div class="sp-box-body"><p>For destructive confirmations and the "delete" entry of a settings page. The number may be an icon when the box is not part of a sequence.</p></div>
</div>
<div class="box sp-credential">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-eye"></i></span><h2>box · sp-credential</h2><div class="sp-end">Shown once</div></div>
<div class="sp-box-body"><p>Only for a secret the panel will never show again.</p></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>health_card</h2><div class="sp-end">Overview only · each card links to its detail</div></div>
<div class="sp-health">
<a href="#"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Verdict in words</span><span class="sp-h-sub">ok · one supporting fact</span></span></a>
<a href="#" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">sp-warn</span></span></a>
<a href="#" class="sp-fail"><span class="sp-h-icon"><i class="ti ti-plug-connected-x"></i></span><span><span class="sp-h-name">Name</span><span class="sp-h-value">1 of 2 answering</span><span class="sp-h-sub">sp-fail</span></span></a>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>dns_record · copy_field</h2><div class="sp-end">One block per record</div></div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-success is-light">published</span><small>A one-line note</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="copy_field — single line"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="2" readonly>copy_field — multi-line, for a DKIM key</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Record</h3><span class="tag is-danger is-light">mismatch</span><small>"In DNS now" appears only when it differs</small></div>
<div class="field"><label class="label">In DNS now</label>
<div class="control"><input class="input is-danger" readonly value="what the resolver returned"></div>
<p class="help is-danger">What is wrong and the exact fix.</p></div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">09</span><h2>log_pane</h2><div class="sp-end">sp-d time · sp-w warning · sp-e error</div></div>
<pre class="sp-log"><span class="sp-d">13:58:07</span> an ordinary line
<span class="sp-d">13:58:09</span> <span class="sp-w">a deferred delivery — sp-w</span>
<span class="sp-d">13:57:41</span> <span class="sp-e">a reject or a bounce — sp-e</span></pre>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">06</span><h2>facts</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Label</dt><dd>Value</dd></div>
<div><dt>Mono value</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
<div><dt>sp-big</dt><dd class="sp-big">5 days</dd><dd class="sp-muted">a supporting line</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
<div class="sp-box-body">
<ol class="sp-timeline">
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
</ol>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">10</span><h2>empty_state</h2></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>One sentence: what is absent and why that is fine.</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">11</span><h2>confirm_list</h2></div>
<div class="sp-box-body">
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>each thing that will be lost, one per line;</li>
<li><i class="ti ti-x"></i>inside an sp-danger-zone box, above the danger button.</li>
</ul>
</div>
</div>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">side_menu</p>
<ul class="menu-list">
<li><a class="is-active" href="#"><i class="ti ti-world-check"></i>Current <span class="tag is-danger is-light">1</span></a></li>
<li><a href="#"><i class="ti ti-apps"></i>Section <span class="tag is-light">2</span></a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a class="has-text-danger" href="#"><i class="ti ti-trash has-text-danger"></i>Delete</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">12</span><h2>Form rows · route · help_topic</h2></div>
<div class="sp-box-body">
<div class="sp-form">
<div class="sp-row">
<div class="field"><label class="label">sp-row — a wide field</label><div class="control"><input class="input" value="host"></div></div>
<div class="field"><label class="label">and a short one</label><div class="control"><input class="input" value="25"></div></div>
</div>
<div class="sp-pair">
<div class="field"><label class="label">sp-pair — two equal fields</label><div class="control"><input class="input" type="password"></div></div>
<div class="field"><label class="label">Repeat</label><div class="control"><input class="input" type="password"></div><p class="help">Help text lives in Bulma's <code>help</code>.</p></div>
</div>
</div>
<p class="sp-route"><span>from@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>to@example.net</span><span class="tag is-warning is-light">deferred</span></p>
</div>
<div class="sp-help-topic"><h3>help_topic</h3><p>A heading and a paragraph; topics stack inside one box on the Help page.</p></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">13</span><h2>shell</h2><div class="sp-end">Owned by the layout — a screen never writes it</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Name</dt><dd>The wordmark, not the stamp: at nav size the stamp's perforation is under a pixel and its brick field dissolves into the bar. The stamp lives on sign-in, setup and the favicon.</dd></div>
<div><dt>Edge</dt><dd>Built like the mark: the brick field, then the stamp's paper margin, and the perforation along the outer edge of that margin — round holes, flat teeth, the ink hairline. One tile, <code>perf-edge.svg</code>.</dd></div>
<div><dt>Sibling strip</dt><dd>The pages of the current group, under the edge. Five or six entries; past that the group is split.</dd></div>
<div><dt>Menus</dt><dd>Bulma hover dropdowns, no JavaScript. The user menu holds Account, Help and Sign out.</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">14</span><h2>Type scale</h2><div class="sp-end">Three sizes · nothing to be read is under 13px</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Size</th><th>Looks like</th><th>Used for</th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">body · 16px</td><td>The quick brown fox, 203.0.113.25</td><td class="sp-muted">Text, table cells, inputs, selects, buttons, menus and their dropdowns</td></tr>
<tr><td class="sp-mono sp-small">--sp-fs-s · 14px</td><td class="sp-small">The quick brown fox, <span class="sp-mono">203.0.113.25</span></td><td class="sp-muted">Supporting text: labels, <code>help</code>, notes in a box head or foot, mono values (<code>sp-small</code>), logs, the footer</td></tr>
<tr><td class="sp-mono sp-small">--sp-fs-xs · 13px</td><td><span class="sp-kicker">Upper-case mono label</span> <span class="tag is-light">tag</span></td><td class="sp-muted">Upper-case mono micro-labels (kicker, table head, <code>dt</code>, box title) and tags — nothing else</td></tr>
</tbody>
</table></div>
<div class="sp-box-foot">Bulma's <code>is-small</code> and <code>is-size-7</code> are not used: a control is never small, and <code>sp-small</code> replaces the helper. The postmark's fine print is an imprint, not text.</div>
</div>
@@ -0,0 +1,38 @@
<!-- title: shop.example.org · DMARC reports | section: outbound | page: dmarc -->
<div class="sp-head">
<div class="sp-postmark sp-warn"><span>DMARC</span><b>99.6%</b><span>PASS · 7 DAYS</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> /</p>
<h1 class="title is-3">shop.example.org</h1>
<p class="sp-lead">11 204 pass · 41 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening <code>p=</code>.</p>
</div>
<a class="button" href="out-domain.html"><i class="ti ti-world-upload"></i>Domain page</a>
</div>
<div class="columns">
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>11 204</td><td>0</td><td><span class="tag is-success is-light">none</span></td></tr>
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>0</td><td>41</td><td><span class="tag is-warning is-light">none</span></td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 04:38</td><td class="sp-muted">Outlook.com</td><td>19 Sep</td><td>1 877</td><td><span class="tag is-warning is-light">34</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
@@ -0,0 +1,47 @@
<!-- title: Outlook.com · 20 Sep · DMARC | section: outbound | page: dmarc -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="dmarc.html">DMARC reports</a> / <a href="dmarc-domain.html">shop.example.org</a> /</p>
<h1 class="title is-4">Outlook.com · 20 Sep 2026</h1>
<p class="sp-route"><span>shop.example.org</span><span class="tag is-success is-light">1 903 pass</span><span class="tag is-warning is-light">7 fail</span></p>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
</dl>
</div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Published policy</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>p / sp / pct</dt><dd class="sp-mono">none / none / 100</dd></div>
<div><dt>adkim / aspf</dt><dd class="sp-mono">r / r</dd></div>
<div><dt>rua</dt><dd class="sp-mono sp-small">dmarc@mail.example.org</dd></div>
</dl>
</div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Records</h2><div class="sp-end">Parsed from the aggregate XML</div></div>
<div class="table-container"><table class="table is-fullwidth">
<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="sp-mono sp-small">203.0.113.25 <span class="tag is-light">this relay</span></td><td>1 903</td><td><span class="tag is-success is-light">none</span></td><td><span class="tag is-success is-light">pass</span></td><td><span class="tag is-success is-light">pass</span></td><td>shop.example.org</td></tr>
<tr><td class="sp-mono sp-small">198.51.100.44</td><td>7</td><td><span class="tag is-warning is-light">none</span></td><td><span class="tag is-danger is-light">fail</span></td><td><span class="tag is-danger is-light">fail</span></td><td>shop.example.org</td></tr>
</tbody>
</table></div>
</div>
@@ -0,0 +1,33 @@
<!-- title: DMARC reports | section: outbound | page: dmarc -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Outbound</p>
<h1 class="title is-3">DMARC reports</h1>
<p class="sp-lead">How receivers saw mail claiming to be from your domains. Aggregate (<code>rua=</code>) only; open a report for the parsed XML.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted address</dt><dd class="sp-mono">dmarc@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 23:58</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">Yahoo</td><td>19 Sep</td><td>38</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 06:09</td><td><a href="dmarc-domain.html">notify.acme.io</a></td><td class="sp-muted">google.com</td><td>19 Sep</td><td>61</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
</tbody>
</table></div>
</div>
@@ -0,0 +1,88 @@
<!-- title: Health | section: server | page: health -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Health</h1>
<p class="sp-lead">Everything behind the six cards on Overview. Refreshes every 10 s.</p>
</div>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
<div class="columns">
<div class="column is-6">
<div class="box" id="machine">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Machine</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>CPU</td><td><progress class="progress is-small is-success mb-1" value="12" max="100"></progress>12 %</td><td class="sp-muted">2 cores, load 0.21</td></tr>
<tr><td>Memory</td><td><progress class="progress is-small is-success mb-1" value="41" max="100"></progress>41 %</td><td class="sp-muted">812 MB of 1.9 GB</td></tr>
<tr><td>Network</td><td class="sp-nowrap">↓ 18 kB/s · ↑ 44 kB/s</td><td class="sp-muted">eth0: 2.1 GB in, 6.4 GB out</td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-6">
<div class="box" id="processes">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Processes</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>postfix</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 41, uptime 18 days</td></tr>
<tr><td>opendkim</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 39, uptime 18 days</td></tr>
<tr><td>journal-milter</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 40, uptime 18 days</td></tr>
<tr><td>panel</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 44, uptime 18 days</td></tr>
<tr><td>dmarc-ingest</td><td><span class="tag is-success is-light">running</span></td><td class="sp-muted">pid 47, uptime 18 days</td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<div class="columns">
<div class="column is-4">
<div class="box" id="certificate">
<div class="sp-box-head"><span class="sp-no">03</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
<div><dt>Names</dt><dd class="sp-mono">mail.example.org</dd></div>
</dl>
<p class="mt-4 has-text-danger">Expires in 12 days — renewal has not run.</p>
<p class="sp-muted">Renewal normally runs 30 days before expiry. Check that port 80 is reachable and look for <code>acme</code> lines in the <a href="system-log.html">system log</a>.</p>
</div>
</div>
</div>
<div class="column is-4">
<div class="box" id="sockets">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Milter sockets</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Milter</th><th>State</th></tr></thead>
<tbody>
<tr><td>opendkim<br><span class="sp-muted sp-mono sp-small">inet:127.0.0.1:8891</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>journal-milter<br><span class="sp-muted sp-mono sp-small">unix:/run/selfpost/journal.sock</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
<tr><td>rspamd <span class="sp-muted">· inbound spam filter</span><br><span class="sp-muted sp-mono sp-small">inet:antispam:11332</span></td><td><span class="tag is-success is-light">ok</span></td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-4">
<div class="box" id="hostname">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Hostname and reverse DNS</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
<div><dt>Lookup</dt><dd class="sp-mono">→ 203.0.113.25<br>← mail.example.org</dd></div>
</dl>
</div>
</div>
</div>
</div>
<div class="box" id="configuration">
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
<div class="sp-box-body">
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
</div>
</div>
@@ -0,0 +1,47 @@
<!-- title: Help | section: user -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Reference</p>
<h1 class="title is-3">Help</h1>
<p class="sp-lead">What each screen checks and what to do when it is not green. Every numbered section on a page links to its topic here.</p>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">Overview</p>
<ul class="menu-list">
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
<li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li>
</ul>
<p class="menu-label">Outbound</p>
<ul class="menu-list">
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li>
<li><a href="#apps"><i class="ti ti-apps"></i>Applications</a></li>
<li><a href="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</a></li>
<li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
</ul>
<p class="menu-label">Inbound</p>
<ul class="menu-list">
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now.</p></div>
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. It warns 14 days before expiry and fails once expired — clients that verify certificates then refuse to connect.</p></div>
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div>
<div class="sp-help-topic" id="dns"><h3>DNS records</h3><p>DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one.</p></div>
<div class="sp-help-topic" id="apps"><h3>Applications</h3><p>An application is one SASL login. It may send as any address of its domain or only as listed addresses. The password is shown once at creation or regeneration and is not stored in readable form.</p></div>
<div class="sp-help-topic" id="limits"><h3>Rate limits</h3><p>Level 1 is a per-IP ceiling set in <code>.env</code>. Level 2 is optional, per domain and per application, manual or computed from 30-day statistics, and can never exceed level 1.</p></div>
</div>
</div>
</div>
@@ -0,0 +1,29 @@
<!-- title: Delete lists.example.org | section: inbound | page: domains -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> / <a href="in-domain.html">lists.example.org</a> /</p>
<h1 class="title is-3">Delete lists.example.org</h1>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the inbound domain <strong>lists.example.org</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>stop accepting mail for it on port 25 — senders get a relay-denied reply;</li>
<li><i class="ti ti-x"></i>remove its upstream and its 4 listed recipients.</li>
</ul>
<p class="sp-muted mb-4">Remove or repoint the MX record first, or mail for the domain will bounce. Outbound domains are not touched.</p>
<form method="post" action="in-domains.html">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete lists.example.org</button>
<a class="button is-text" href="in-domain.html">Keep it</a>
</div>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,99 @@
<!-- title: lists.example.org · Inbound domains | section: inbound | page: domains -->
<div class="sp-head">
<div class="sp-postmark"><span>DNS</span><b>OK</b><span>MX POINTS HERE</span></div>
<div class="sp-grow">
<p class="sp-kicker"><a href="in-domains.html">Inbound domains</a> /</p>
<h1 class="title is-3">lists.example.org</h1>
<p class="sp-lead">Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over required TLS · 4 recipients</p>
</div>
<div class="buttons">
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>
</div>
</div>
<div class="columns">
<div class="column is-3">
<aside class="menu sp-side">
<p class="menu-label">This domain</p>
<ul class="menu-list">
<li><a class="is-active" href="#dns"><i class="ti ti-world-check"></i>MX record <span class="tag is-success is-light">ok</span></a></li>
<li><a href="#upstream"><i class="ti ti-arrow-forward-up"></i>Upstream</a></li>
<li><a href="#recipients"><i class="ti ti-address-book"></i>Recipients <span class="tag is-light">4</span></a></li>
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter <span class="tag is-success is-light">on</span></a></li>
</ul>
<p class="menu-label">See also</p>
<ul class="menu-list">
<li><a href="in-log.html"><i class="ti ti-list-details"></i>Log for this domain</a></li>
</ul>
<p class="menu-label">Rarely changed</p>
<ul class="menu-list">
<li><a class="has-text-danger" href="in-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
</ul>
</aside>
</div>
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="lists.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="MX"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="10 mail.example.org."></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box" id="upstream">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Upstream</h2><div class="sp-end">Not a mailbox</div></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="sp-row">
<div class="field"><label class="label" for="host">Host</label><div class="control"><input class="input sp-mono" id="host" name="host" value="mx.internal" autocomplete="off" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="port">Port</label><div class="control"><input class="input sp-mono" id="port" name="port" inputmode="numeric" value="25" required></div></div>
</div>
<div class="field"><label class="label" for="tls">TLS to upstream</label>
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode"><option>Opportunistic</option><option selected>Required</option><option>Off</option></select></div></div></div>
<button type="submit" class="button is-primary">Save upstream</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box" id="recipients">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rmode">Who to accept</label>
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="recipient_mode"><option selected>Listed addresses only</option><option>Any recipient at this domain</option></select></div></div></div>
<div class="field"><label class="label" for="addresses">Addresses</label>
<div class="control"><textarea class="textarea sp-mono" id="addresses" name="addresses" rows="4">announce@lists.example.org
dev@lists.example.org
owner@lists.example.org
security@lists.example.org</textarea></div>
<p class="help">Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns.</p></div>
<button type="submit" class="button is-primary">Save recipients</button>
</form>
</div>
</div>
</div>
</div>
<div class="box" id="filter">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Spam filter</h2><div class="sp-end"><span class="tag is-success is-light">on</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
<div><dt>Suspicious mail</dt><dd>Held — <a href="in-quarantine.html">1 message in quarantine</a> for this domain</dd></div>
<div><dt>Spam</dt><dd>Refused at the door; see <em>reject</em> in the <a href="in-log.html">log</a></dd></div>
<div><dt>Allow and deny lists</dt><dd class="sp-muted">Kept by the global administrator, one pair for the whole server</dd></div>
</dl>
</div>
</div>
</div>
</div>
@@ -0,0 +1,33 @@
<!-- title: Inbound domains | section: inbound | page: domains -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound domains</h1>
<p class="sp-lead">Backup-MX / forwarder. Port 25 accepts mail only for the domains listed here and hands it to their upstream. No mailboxes.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add a domain</h2></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="in-domain.html">
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" placeholder="lists.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
</div>
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.</p>
</form>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Domains</h2><div class="sp-end">Spam filter <span class="tag is-success is-light">on</span> · 2 domains</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody>
<tr><td><a href="in-domain.html"><strong>lists.example.org</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">mx.internal:25</td><td>4 addresses</td><td><span class="tag is-success is-light">required</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
<tr><td><a href="in-domain.html"><strong>acme.io</strong></a></td><td><span class="tag is-success is-light">MX</span></td><td class="sp-mono sp-muted">10.0.4.12:2525</td><td>any address</td><td><span class="tag is-light">opportunistic</span></td><td class="sp-actions"><a href="in-domain.html">Open</a><a class="has-text-danger" href="in-domain-delete.html">Delete</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot">The MX tag is green when at least one MX of the domain points at this server.</div>
</div>
@@ -0,0 +1,73 @@
<!-- title: Filter lists | section: inbound | page: filter-lists -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound · global role</p>
<h1 class="title is-3">Filter lists</h1>
<p class="sp-lead">Correct the spam filter by hand: senders it must always let through, and senders it must always turn away. One pair of lists for the whole instance.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Add an entry</h2><a class="sp-help" href="help.html#inbound" title="How the lists are applied"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="columns">
<div class="column is-3">
<div class="field"><label class="label" for="list">List</label>
<div class="control"><div class="select is-fullwidth"><select id="list" name="list_type"><option>Deny — always turn away</option><option>Allow — always let through</option></select></div></div></div>
</div>
<div class="column is-3">
<div class="field"><label class="label" for="etype">Entry is</label>
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type"><option>a sender address</option><option>a sender domain</option><option>a client IP or CIDR</option></select></div></div></div>
</div>
<div class="column is-6">
<div class="field"><label class="label" for="value">Value</label>
<div class="control"><input class="input sp-mono" id="value" name="value" placeholder="promo@bulk.example" autocomplete="off" autocapitalize="none" spellcheck="false" required></div></div>
</div>
</div>
<div class="field"><label class="label" for="note">Note</label>
<div class="control"><input class="input" id="note" name="note" placeholder="Why — for whoever reads this list next year"></div></div>
<button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add entry</button>
</form>
</div>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">@bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
<tr><td class="sp-mono sp-small">198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">dictionary attack on lists.example.org</td><td class="sp-muted sp-nowrap sp-small">2026-08-19</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
<tr><td class="sp-mono sp-small">win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td><td class="sp-muted sp-nowrap sp-small">2026-07-30</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
</tbody>
</table></div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Allow</h2><div class="sp-end">2 entries</div></div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">@partner.example <span class="tag is-light">domain</span></td><td class="sp-muted">invoices were scored as spam</td><td class="sp-muted sp-nowrap sp-small">2026-09-11</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
<tr><td class="sp-mono sp-small">noreply@github.example <span class="tag is-light">address</span></td><td class="sp-muted">security advisories</td><td class="sp-muted sp-nowrap sp-small">2026-06-04</td><td class="sp-actions"><a class="has-text-danger" href="#">Remove</a></td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Applied by</dt><dd>rspamd, from map files</dd></div>
<div><dt>Files</dt><dd class="sp-mono sp-small">/data/antispam/allow.map<br>/data/antispam/deny.map</dd></div>
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
<div><dt>When the filter is off</dt><dd class="sp-muted">The lists are kept and editable, but nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.</dd></div>
</dl>
</div>
</div>
@@ -0,0 +1,33 @@
<!-- title: Inbound log | section: inbound | page: log -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound log</h1>
<p class="sp-lead">What port 25 decided about each message, one row per recipient. Accepts come from the inbound journal, rejects from <code>mail.log</code>; the filter's reasons appear when the spam filter is on.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head">
<span class="sp-no">01</span><h2>Messages</h2>
<form class="sp-end" method="get" action="#">
<div class="select"><select name="domain"><option>All domains</option><option>lists.example.org</option><option>acme.io</option></select></div>
<div class="select"><select name="decision"><option>Any decision</option><option>accept</option><option>reject</option><option>tempfail</option><option>quarantine</option></select></div>
<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">
<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form>
</div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · score 0.4</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example<br><span class="sp-mono sp-muted sp-small">203.0.113.140</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Outstanding invoice — action required</td><td><span class="tag is-primary is-light">quarantine</span></td><td class="sp-muted sp-small">rspamd · score 9.8 · PHISHING, FORGED_SENDER</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:57:41</td><td>promo@<wbr>bulk.example<br><span class="sp-mono sp-muted sp-small">198.51.100.90</span></td><td>sales@<wbr>lists.example.org</td><td class="sp-clip sp-muted">—</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">postfix · 550 5.1.1 unknown recipient</td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:51:30</td><td>billing@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">198.51.100.12</span></td><td>owner@<wbr>lists.example.org</td><td class="sp-clip">Invoice 2026-0917</td><td><span class="tag is-danger is-light">reject</span></td><td class="sp-muted sp-small">rspamd · score 16.2 · BAYES_SPAM</td><td class="sp-actions"><a href="in-filter-lists.html">Allow sender</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:44:02</td><td>hr@<wbr>partner.example<br><span class="sp-mono sp-muted sp-small">192.0.2.31</span></td><td>jobs@<wbr>acme.io</td><td class="sp-clip">Application: backend engineer</td><td><span class="tag is-warning is-light">tempfail</span></td><td class="sp-muted sp-small">selfpost · upstream 10.0.4.12 timed out</td><td class="sp-actions"></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:30:55</td><td>noreply@<wbr>github.example<br><span class="sp-mono sp-muted sp-small">192.0.2.200</span></td><td>security@<wbr>lists.example.org</td><td class="sp-clip">[advisory] new report</td><td><span class="tag is-success is-light">accept</span></td><td class="sp-muted sp-small">rspamd · allow list</td><td class="sp-actions"></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>Page 1 of 38</span><a href="#">Older →</a><span class="sp-end">Allow / Deny sender opens <a href="in-filter-lists.html">Filter lists</a> with the entry filled in · global administrator only</span></div>
</div>
@@ -0,0 +1,77 @@
<!-- title: Held message · Quarantine | section: inbound | page: quarantine -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="in-quarantine.html">Quarantine</a> /</p>
<h1 class="title is-4">Outstanding invoice — action required</h1>
<p class="sp-route"><span>invoice@partner-billing.example</span><i class="ti ti-arrow-right sp-muted"></i><span>owner@lists.example.org</span><span class="tag is-primary is-light">quarantine</span></p>
</div>
</div>
<div class="columns">
<div class="column is-7">
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Message</h2></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div>
<div><dt>Deleted on</dt><dd>2026-10-05 · in 14 days</dd></div>
<div><dt>Size</dt><dd>48 kB · 1 attachment (<span class="sp-mono">invoice.html</span>)</dd></div>
<div><dt>Would go to</dt><dd class="sp-mono">mx.internal:25</dd></div>
</dl>
</div>
</div>
</div>
<div class="column is-5">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Why it was held</h2><div class="sp-end">rspamd · score 9.8</div></div>
<div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Symbol</th><th>Score</th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-small">PHISHING</td><td>5.0</td></tr>
<tr><td class="sp-mono sp-small">FORGED_SENDER</td><td>3.0</td></tr>
<tr><td class="sp-mono sp-small">HTML_ATTACHMENT</td><td>1.8</td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Headers</h2><div class="sp-end">The body and attachments are never rendered in the panel</div></div>
<pre class="sp-log"><span class="sp-d">Received:</span> from mail.partner-billing.example (unknown [203.0.113.140]) by mail.example.org
<span class="sp-d">From:</span> "Partner Billing" &lt;invoice@partner-billing.example&gt;
<span class="sp-d">Reply-To:</span> <span class="sp-w">payments.dept@freemail.example</span>
<span class="sp-d">To:</span> owner@lists.example.org
<span class="sp-d">Subject:</span> Outstanding invoice — action required
<span class="sp-d">Date:</span> Mon, 21 Sep 2026 13:58:57 +0000
<span class="sp-d">Authentication-Results:</span> <span class="sp-e">spf=fail dkim=none dmarc=fail</span></pre>
</div>
<div class="columns">
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Release</h2></div>
<div class="sp-box-body">
<p class="mb-3">Hands the message to <span class="sp-mono">mx.internal:25</span> unchanged, as if it had just arrived. It leaves the quarantine.</p>
<form class="sp-form" method="post" action="in-quarantine.html">
<div class="field"><label class="checkbox"><input type="checkbox" name="allow_sender"> Also add <span class="sp-mono">invoice@partner-billing.example</span> to the Allow list</label></div>
<button type="submit" class="button is-primary"><i class="ti ti-send"></i>Release to upstream</button>
</form>
</div>
</div>
</div>
<div class="column is-6">
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>Discard</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deletes the message now instead of in 14 days. The sender is not told. This cannot be undone.</p>
<form class="sp-form" method="post" action="in-quarantine.html">
<div class="field"><label class="checkbox"><input type="checkbox" name="deny_sender" checked> Also add <span class="sp-mono">@partner-billing.example</span> to the Deny list</label></div>
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Discard message</button>
</form>
</div>
</div>
</div>
</div>
@@ -0,0 +1,27 @@
<!-- title: Quarantine | section: inbound | page: quarantine -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Quarantine</h1>
<p class="sp-lead">Mail the filter found suspicious enough to hold, but not to refuse. Release hands a message to its upstream as if it had just arrived; Discard deletes it. Anything left is deleted after 14 days.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head">
<span class="sp-no">01</span><h2>Held messages</h2>
<form class="sp-end" method="get" action="#">
<div class="select"><select name="domain"><option>All domains</option><option>lists.example.org</option><option>acme.io</option></select></div>
<button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form>
</div>
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
<tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Outstanding invoice — action required</strong></a></td><td class="sp-muted sp-small">score 9.8 · PHISHING</td><td class="sp-muted sp-nowrap">48 kB</td><td class="sp-nowrap">14 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 22:14:40</td><td>no-reply@<wbr>shipping-update.example</td><td>jobs@<wbr>acme.io</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Your parcel could not be delivered</strong></a></td><td class="sp-muted sp-small">score 8.1 · URL_SHORTENER</td><td class="sp-muted sp-nowrap">12 kB</td><td class="sp-nowrap">13 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-09 07:02:11</td><td>m.ortega@<wbr>supplier.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Fwd: scanned contract (3 attachments)</strong></a></td><td class="sp-muted sp-small">score 7.4 · MIME_ARCHIVE</td><td class="sp-muted sp-nowrap">2.9 MB</td><td class="sp-nowrap"><span class="tag is-warning is-light">2 days</span></td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
</tbody>
</table></div>
<div class="sp-box-foot"><span>3 messages · 3.0 MB of 500 MB</span><span class="sp-end">A domain administrator sees only their assigned inbound domains</span></div>
</div>
@@ -0,0 +1,25 @@
<!-- title: Inbound queue | section: inbound | page: queue -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker">Inbound</p>
<h1 class="title is-3">Inbound queue</h1>
<p class="sp-lead">Mail accepted on port 25 and still waiting for its upstream. The same Postfix queue as <a href="out-queue.html">outbound</a>, filtered by recipient domain.</p>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">Refreshes every 10 s</div></div>
<div class="sp-empty"><i class="ti ti-stack-2"></i>Nothing is waiting. Every accepted message has reached its upstream.</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>How retries work</h2><div class="sp-end">This Postfix's policy, read at panel start</div></div>
<div class="sp-box-body">
<dl class="sp-facts">
<div><dt>First retry</dt><dd class="sp-big">5 min</dd></div>
<div><dt>Later retries</dt><dd class="sp-big">doubling</dd><dd class="sp-muted">capped at 66 min</dd></div>
<div><dt>Kept in queue</dt><dd class="sp-big">5 days</dd></div>
<div><dt>Then</dt><dd class="sp-big">bounced</dd><dd class="sp-muted">back to the original sender</dd></div>
</dl>
</div>
</div>
@@ -0,0 +1,25 @@
<!-- title: Sign in | shell: bare -->
<div class="columns is-gapless sp-signin">
<div class="column is-half sp-signin-brand">
<div>
<img src="../../selfpost-stamp.svg" alt="SelfPost">
<p>YOUR SMTP. YOUR RULES.</p>
</div>
</div>
<div class="column is-half sp-signin-form">
<form class="sp-form" method="post" action="overview.html">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">Sign in</h1>
<div class="field">
<label class="label" for="username">Username</label>
<div class="control has-icons-left"><input class="input" id="username" name="username" autocomplete="username" required><span class="icon is-left"><i class="ti ti-user"></i></span></div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control has-icons-left"><input class="input" id="password" name="password" type="password" autocomplete="current-password" required><span class="icon is-left"><i class="ti ti-lock"></i></span></div>
</div>
<div class="field mt-5"><button type="submit" class="button is-primary is-fullwidth">Sign in</button></div>
</form>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>

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