279 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
mixandCursor 2f888dc7be ci: drop per-arch GHCR tags via GitHub Packages API
test / test (push) Has been cancelled
imagetools has no rm subcommand; the merge cleanup had been failing silently. Document Gitea-to-GitHub tag mirror pitfalls in development.md. CHANGELOG [1.3.0] updated.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Corrections to the section while cutting it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Decision recorded, implementation deferred to its own step.

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

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

Decision only; no code yet.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:44:24 +03:00
mix 75d0329569 Update specification.md 2026-07-11 13:11:40 +03:00
mix 68c710e99a Create .gitignore 2026-07-11 12:58:57 +03:00
mix 8ad56ed739 Initial commit with project specification 2026-07-10 15:54:35 +03:00
306 changed files with 6392 additions and 21002 deletions
-129
View File
@@ -1,129 +0,0 @@
#!/usr/bin/env bash
# GUARD FILE — docs/plans/panel-redesign.md § Enforcement, "design-first".
#
# Checks every commit of a push or pull request, one by one:
#
# 1. Design first. A commit that touches the accepted design — the mockups
# under docs/assets/panel-redesign/panel/, the guard tests, this script —
# touches nothing else under internal/web/. The guards are not adjusted
# in the commit they are supposed to judge.
# 2. The ratchet. internal/web/view/legacy_pages.txt only shrinks: a commit's
# list is a subset of its parent's. A page that was restyled stays held
# to its mockup; its name cannot be put back.
# 3. No blob from the past. A template a commit adds or changes is not,
# whitespace aside, a template that already existed anywhere in the
# history before it. Taking an old page out of git is not a redesign.
#
# Usage: design-first.sh <base> <head> (commits base..head are checked)
# Needs the full history: actions/checkout with fetch-depth: 0.
set -euo pipefail
base="${1:?usage: design-first.sh <base> <head>}"
head="${2:?usage: design-first.sh <base> <head>}"
RATCHET=internal/web/view/legacy_pages.txt
TEMPLATES=internal/web/view/templates/
ZERO=0000000000000000000000000000000000000000
# A new branch, or a force push whose old tip is gone, has no usable base:
# check the tip commit alone rather than nothing.
if [ "$base" = "$ZERO" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base="$(git rev-parse "$head^" 2>/dev/null || true)"
fi
if [ -n "$base" ]; then
commits="$(git rev-list --reverse --no-merges "$base..$head")"
else
commits="$(git rev-list --reverse --no-merges "$head")"
fi
is_guard() {
case "$1" in
docs/assets/panel-redesign/panel/*) return 0 ;;
.github/scripts/design-first.sh) return 0 ;;
internal/web/guard_*_test.go | internal/web/*/guard_*_test.go) return 0 ;;
esac
return 1
}
entries() { # the ratchet's entries at a commit, sorted; nothing if the file is absent
git show "$1:$RATCHET" 2>/dev/null | tr -d '\r' | sed -e 's/#.*//' -e 's/[[:space:]]//g' -e '/^$/d' | sort -u
}
normalised() { # a blob's hash with all whitespace removed
git cat-file blob "$1" | tr -d ' \t\r\n' | git hash-object --stdin
}
cache="$(mktemp -d)"
trap 'rm -rf "$cache"' EXIT
past_hash() { # normalised hash of a historical blob, computed once
if [ ! -f "$cache/$1" ]; then
normalised "$1" > "$cache/$1"
fi
cat "$cache/$1"
}
fail=0
say() {
echo "::error::design-first: $*"
fail=1
}
for c in $commits; do
short="$(git rev-parse --short "$c")"
parent="$(git rev-parse --verify --quiet "$c^" || true)"
if [ -n "$parent" ]; then
changed="$(git diff --name-only --no-renames "$parent" "$c")"
else
changed="$(git ls-tree -r --name-only "$c")"
fi
# 1. design first
guards="" impl=""
while IFS= read -r f; do
[ -n "$f" ] || continue
if is_guard "$f"; then
guards="$guards $f"
elif [ "${f#internal/web/}" != "$f" ]; then
impl="$impl $f"
fi
done <<< "$changed"
if [ -n "$guards" ] && [ -n "$impl" ]; then
say "$short changes the design or its guards ($(echo $guards | cut -c1-200)) together with the implementation ($(echo $impl | cut -c1-200)). A design change is its own 'design:' commit, made and accepted first."
fi
# 2. the ratchet
if [ -n "$parent" ] && grep -qx "$RATCHET" <<< "$changed"; then
# Once the file has existed, a parent without it means an empty list.
if git cat-file -e "$parent:$RATCHET" 2>/dev/null || [ -n "$(git log -1 --format=%h "$parent" -- "$RATCHET")" ]; then
added="$(comm -13 <(entries "$parent") <(entries "$c"))"
if [ -n "$added" ]; then
say "$short adds to $RATCHET: $(echo $added). The list only shrinks — what was held to the design stays held."
fi
fi
fi
# 3. no blob from the past
if [ -n "$parent" ]; then
touched="$(git diff --name-only --no-renames --diff-filter=AM "$parent" "$c" -- "$TEMPLATES")"
if [ -n "$touched" ]; then
past="$(git log --format= --raw --no-abbrev --no-renames "$parent" -- "$TEMPLATES" | awk '$4 != "'$ZERO'" {print $4}' | sort -u)"
while IFS= read -r f; do
[ -n "$f" ] || continue
now="$(normalised "$(git rev-parse "$c:$f")")"
for blob in $past; do
if [ "$(past_hash "$blob")" = "$now" ]; then
say "$short: $f is, whitespace aside, a template that already existed in history (blob ${blob:0:12}). An old template is not a redesigned page."
break
fi
done
done <<< "$touched"
fi
fi
done
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "design-first: $(echo "$commits" | grep -c . || true) commit(s) checked, nothing to report"
+19 -26
View File
@@ -11,9 +11,9 @@ name: release
# own native runner; a merge job then combines them into the one manifest tag # 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 # 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 # then test) is deliberate: it means the bytes that get tagged are exactly the
# bytes that passed e2e. The per-arch tags stay in GHCR next to the version # bytes that passed e2e. Per-arch tags are pushed only so imagetools can
# tag: they are the images the multi-arch manifest points at, and GHCR cannot # assemble the multi-arch manifest; merge removes them from GHCR so operators
# drop a tag without deleting the image behind it. # see a single version tag.
on: on:
release: release:
types: [published] types: [published]
@@ -177,35 +177,28 @@ jobs:
backoff=$((backoff * 2)) backoff=$((backoff * 2))
done done
- name: Remove orphaned untagged versions from GHCR - name: Remove per-arch tags from GHCR
# Re-publishing a version moves its tags to new manifests and leaves the # Side-effect tags for imagetools assembly only — not part of the public
# old ones behind as untagged "sha256:..." package versions. Delete only # version surface (deploy/docker-compose.yml pins X.Y.Z, not X.Y.Z-amd64).
# those that no tagged manifest references: the images behind a # imagetools has no "rm" subcommand; delete via the GitHub Packages API.
# multi-arch tag are package versions too, and deleting one breaks pulls
# of that tag. Any failed lookup aborts the step before anything is
# deleted.
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
image="ghcr.io/${{ github.repository }}" version="${{ needs.prepare.outputs.version }}"
owner="${{ github.repository_owner }}" owner="${{ github.repository_owner }}"
pkg="${{ github.event.repository.name }}" pkg="${{ github.event.repository.name }}"
api="/users/${owner}/packages/container/${pkg}/versions" api="/users/${owner}/packages/container/${pkg}/versions"
versions=$(gh api "${api}?per_page=100" --paginate \ for suffix in amd64 arm64; do
--jq '.[] | [.id, .name, (.metadata.container.tags | length)] | @tsv') tag="${version}-${suffix}"
keep=$(mktemp) mapfile -t ids < <(gh api "$api" --paginate \
while IFS=$'\t' read -r id digest ntags; do --jq ".[] | select([.metadata.container.tags[]] | index(\"${tag}\")) | .id")
[ "$ntags" -gt 0 ] || continue if [ "${#ids[@]}" -eq 0 ]; then
echo "$digest" >> "$keep" echo "no GHCR package version for tag ${tag}"
raw=$(docker buildx imagetools inspect --raw "${image}@${digest}")
jq -r '.manifests[]?.digest' <<<"$raw" >> "$keep"
done <<<"$versions"
while IFS=$'\t' read -r id digest ntags; do
[ "$ntags" -eq 0 ] || continue
if grep -qxF "$digest" "$keep"; then
continue continue
fi fi
echo "deleting orphaned GHCR package version ${id} (${digest})" for id in "${ids[@]}"; do
gh api -X DELETE "${api}/${id}" echo "deleting GHCR package version ${id} (tag ${tag})"
done <<<"$versions" gh api -X DELETE "${api}/${id}"
done
done
-9
View File
@@ -10,15 +10,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# design-first.sh reads each commit's parent and the template history.
fetch-depth: 0
- name: Design first
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash .github/scripts/design-first.sh "$BASE" "$HEAD"
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
-328
View File
@@ -5,334 +5,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [2.0.0] - 2026-10-11
The panel redesign. A breaking release: every panel URL changed and the
database starts from zero - a 1.x `/data` is not upgraded (see Changed and
Removed).
### Added
- panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons
3.49.0 are vendored under `/static` (MIT, listed in NOTICE). Nothing links
them yet; the old panel is unchanged.
- panel-redesign (stage 0): the design contract as guard tests
(`guard_*_test.go` under `internal/web`) and a `design-first` CI step. The
tests read their rules from the accepted mockups — class vocabulary,
stylesheet, page structure, component outlines, the 2.0 route table — and
pin the vendored assets. `internal/web/view/legacy_pages.txt` lists what is
still pre-2.0 and may only shrink; CI also rejects a commit that changes a
guard together with the implementation, or brings back a template from
history. The kit and route tests stay skipped until their stages land.
- panel-redesign (stage 0): the component kit. `panel.css` is the mockups'
stylesheet rule for rule; `components.html` holds every partial with its
typed input in `components.go`; the new `layout.html` is the shell (brick
navbar with the wordmark, perforated edge, sibling strip, user menu,
footer) and the signed-out screen; `GET /server/components` (global role)
renders every partial in every state. `@kit` is off the ratchet: the kit
is held to the design contract from here on.
- panel-redesign (design): Sign out in the user menu is a POST form in the
mockups too, and its button takes the same hover, focus and press tint as
Account and Help (owner, 2026-10-09).
- panel-redesign (stage 1): the 2.0 schema. `users.email` is the user's own
address; the default DMARC report address is a user's setting
(`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an
address of its own or follows a named user's default
(`domains.dmarc_rua_user_id`) — a new domain follows whoever created it,
and deleting that user leaves it with no report address (replaced before
release — see the entry of 2026-10-10 below).
`user_inbound_domains` and the per-user `all_domains` /
`all_inbound_domains` flags are in the schema for the inbound delegation
that follows. Details: `docs/schema-migrations.md`.
- panel-redesign (stage 1): inbound domains are delegated to domain
administrators, separately from outbound. The user form has two lists, each
with *All* (which includes domains added later; with it ticked the rows
under it are ignored); a domain administrator needs at least one assignment
on either. Such a user sees the inbound domains assigned to them — the list
is filtered in the query — and can change their upstream and recipients;
adding and deleting an inbound domain stays with the global role. Another
tenant's domain, a missing one and a user with no inbound reach all get the
same 404. A menu group with nothing assigned is absent, and `/` leads an
inbound-only administrator to Inbound.
- panel-redesign (stage 1): **Account** (`/account`, every role) and
**Server › Settings** (`/server/settings`, global role) replace the one
Settings page. Account has three forms, each its own POST: profile
(username, e-mail), password, and the default DMARC report address
(SelfPost hosted, my account e-mail, another address, none) (replaced before
release — see the entry of 2026-10-10 below). Saving the profile no longer
asks for the password; changing the password still does.
- panel-redesign (stage 1): the application form is one POST. Who the
application may send as, its client-IP allow-list and its rate limit
("use the domain limit", manual or auto) are validated together and saved
together or not at all — also at creation, so an application can be added
with its limit already in place. The page that shows a generated password
is the response to its POST only and is sent with `Cache-Control: no-store`.
- panel-redesign (stage 1): **Server › Health** (`/server/health`) holds the
tables of the old Status page and its two actions, *Re-check DNS* and
*Reload configuration*. **Overview** (`/overview`) is reduced to one
verdict per check — machine, processes, TLS certificate, queue, milter
sockets, reverse DNS — each leading to its table on Health; a check that
could not run shows as a warning, never as fine. Both read the same checks.
- panel-redesign (stage 2): **Sign in** and **Create administrator** are the
first pages in the new design - the split brand / form screen, built from
the component kit. Sign in names the mail host above its title; a refused
attempt is shown as a notice above the fields. `login` and `setup` are off
the ratchet.
- panel-redesign (design): the sign-in page before any administrator exists
is a drawn screen (`login-no-admin`): the heading *No administrator yet* and
the pointer to the one-time setup link, in the same column as the form. The
signed-out column is sized through `.sp-form`, not through `form` (owner,
2026-10-09).
- panel-redesign (design): the Notifications boxes are out of the Account
and Server › Settings mockups. Event notifications are not built
(panel-notifications is a candidate), and 2.0 carries no box for a feature
that does not exist; they are drawn again as the first step of that
feature (owner, 2026-10-10).
- panel-redesign (stage 2): **Overview**, **Server › Health**, **Account**
and **Server › Settings** are in the new design. Overview shows the verdict
as a stamp with one sentence, the six checks as cards and both domain
lists; Health holds the tables behind the cards. Both refresh themselves.
The Queue card reads "Empty" or "N queued". `status` and `settings` are
off the ratchet.
- panel-redesign (stage 2): the **Outbound** domain pages are in the new
design: the domain list, the domain page (DNS records with what to
publish and what is in DNS now, applications, connection), **Domain
settings** as a page of its own (report address, rate limit, export,
delete), the application form, the password shown once and the delete
confirmation. `dashboard`, `domain_detail` and `domain_delete` are off
the ratchet.
- panel-redesign (stage 2): the **Outbound log**, the message page (facts,
history, the lines of `mail.log`) and the three **DMARC report** pages are
in the new design. The log refreshes its rows without touching the filter
being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and
`dmarc_report` are off the ratchet.
- panel-redesign (stage 2): the **Inbound** pages are in the new design: the
domain list, the domain page (MX record, upstream, valid recipients) and
the delete confirmation. Every inbound domain page has a **Spam filter**
box and the list names the filter's state: the panel reads
`INBOUND_ANTISPAM_MILTER` and `INBOUND_ANTISPAM_MILTER_ACTION` and says
whether inbound mail is filtered and what happens while the filter is
down. The filter's address is shown to the global role only. `inbound`,
`inbound_domain` and `inbound_delete` are off the ratchet.
- panel-redesign (stage 2): the **Server** pages - System log, Backup,
Users, the user form and its delete confirmation - and **Help** are in the
new design. Help is a page of topics that the `?` in a box's head opens;
the help drawer is gone. The user form sets the user's e-mail. The backup
download is encrypted unless the box is unticked. Only the queue page is
left on the ratchet.
- panel-redesign (design, stage 3): **Help** covers every section -
seventeen topics for Overview, Outbound, Inbound, Server and Account - and
the boxes of nearly every page carry a `?` that opens their topic (owner,
2026-10-10). A reader sees the topics of the sections their menu shows.
- panel-redesign (design, stage 3): **a domain's DMARC report address is one
of three choices** - no reports (a new domain), the SelfPost hosted
address, or an address typed for the domain - set in Domain settings, with
a button that fills in one's own profile e-mail. The per-user default
report address, a domain "following" a user and the DMARC box on Account
are gone, and with them `users.dmarc_default_mode`,
`users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner,
2026-10-10; replaces the design of 2026-09-21 described above).
- panel-redesign (stage 3): with the inbound spam filter on, its socket is one
of the milter sockets on **Server › Health** and counts in Overview's card.
The panel connects to the configured address (`inet:host:port` or
`unix:/path`, one second at most, nothing sent). A filter that does not
answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound
mail is deferred - and a warning otherwise - mail goes through unfiltered.
- **Port 25 no longer accepts `dmarc-reports@<hostname>`**, the report
mailbox of the server as a whole. Only an address a domain has chosen is
accepted: its hosted address `dmarc-reports+<domain>@<hostname>`, or a
typed address that is on this host. A domain whose published `rua=` still
names the general mailbox must be set to *SelfPost hosted* and its DMARC
record republished (owner, 2026-10-10).
- panel-redesign (design, stage 3): **New password** and **Delete** are in
each row of a domain's Applications table again, as POST forms that look
like the links beside them.
- panel-redesign (stage 2): the **Outbound queue** page is in the new shell
with its content unchanged - the listing of `postqueue -p` and how
retries work. With it every page is built from the component kit: the old
stylesheet, the old layout and the engine's second rendering path are
deleted, and the ratchet is empty.
### Changed
- **Breaking — every panel URL changed.** A path now reads like the menu
(`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`, `/account`); the
old paths are removed, not redirected — see *Removed*. Polled fragments all
answer `<page>/fragment`. Everything under `/server/` is guarded for the
global role where the routes are registered, in addition to each handler's
own check. An application is addressed under its domain
(`/outbound/domains/{id}/applications/{aid}`), and an application id that
does not belong to the domain in the path is a 404.
- **Breaking — the database starts from zero.** The 1.x migration chain
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
`/data/selfpost.db` is refused at start-up with an explicit error and left
untouched; nothing is migrated. A domain can be carried over with its
export file.
- The panel role `domain_admin` is stored as `domain`.
- On the old domain page, "Same as Settings" makes the domain follow the
default report address of the user who chose it (replaced before release —
see the entry of 2026-10-10 below).
- The old panel is untouched on screen but steps aside in the tree: its
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
last old page is restyled. The 2.0 menu paths in the new shell do not
exist before stage 1; only the kit page uses that shell so far (a stage-0
state: both files are deleted and every page is on the shell — see
*Removed*).
- panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0,
the component kit.
- panel-redesign (stage 3): the operator guide, `architecture.md` (Panel HTTP
surface, Persistence) and `security.md` (roles, sessions, errors shown to
users) describe the 2.0 panel. `security.md` records two accepted risks from
the 2.0 review: a delegated inbound upstream may be any host and port, and
Account's profile is saved without the password.
### Security
- A panel session belongs to a user by id and ends with them. It used to be
keyed by user name: the cookie of a deleted or renamed user stayed valid
and became a session of whoever was next created under that name. Signing
in again is required after upgrading (there is no upgrade from 1.x; this
concerns development builds of 2.0).
- Changing one's password ends one's own other sessions only; it used to
sign every other user out. A password set by an administrator on the user
form ends that user's sessions.
- Internal errors (database, `saslpasswd2`, Postfix reload) are no longer
shown on the application, inbound upstream / recipients and limit forms;
they are logged and the page says so.
- `/static/` no longer answers with a listing of the embedded files.
- The DMARC reports hub is open to a domain administrator for the domains
assigned to them, without the server's ingest statistics; the list of
reports no longer treats "no domains" as "all domains".
### Fixed
- Saving a domain's report-address form untouched no longer moves a domain
that follows another user's default to whoever saved it: that user's
default is its own choice, and "My default instead" is the change
(replaced before release — see the entry of 2026-10-10 below).
- The auto rate-limit fields follow the limit mode on the domain and
application forms (the script applied only the first matching rule).
- The DMARC page of a domain no longer answers 500 once the domain has a
reported source.
- **The panel can read the mail queue.** The container's entrypoint left the
queue's `public/` and `maildrop/` directories in group `postfix` on every
start; Postfix keeps them in `postdrop`, the group `postqueue` works
through, so the panel's `postqueue -p` was refused ("Connect to the Postfix
showq service: Permission denied"), the Queue card said "Could not be read"
and the queue page had no listing. Present since the persistent queue of
1.3.0; mail flow was not affected. The e2e suite opens the queue page now.
- **Port 25 answers again with DMARC report ingest on.** The generated
`master.cf` gave the port-25 service `-o smtpd_recipient_restrictions=
check_recipient_access texthash:…,…` with a space in the value; Postfix
cuts a service line at whitespace, so `smtpd` was started with a stray
argument and died with "fatal: unexpected command-line argument" on every
connection - no inbound relay and no DMARC reports were accepted while
`DMARC_REPORTS_ENABLE=true`, although `postfix check` and the suite were
silent. The list is written with commas only, the generator refuses a
value with whitespace, and the e2e check asks Postfix what it reads.
Present since DMARC ingest was fitted into the 1.9.1 fix of the same
stanza.
- **DMARC reports are accepted with the inbound relay on.** With both
features enabled every report address was refused as "User unknown in
relay recipient table": the relay's unlisted-recipient check runs after the
restriction list, and the report addresses - on the server's own name, a
relay domain - were not in `relay_recipient_maps`. Their table is a
recipient table now. Hidden until now by the port-25 failure above.
- **DMARC reports reach the panel.** A report accepted on port 25 stayed in
the queue: the generated delivery service was a bare `pipe` ("fatal:
missing user= command-line attribute"), because the generator addressed it
under a type Postfix does not have and set `flags=`, `user=` and `argv=`
as `-o` parameters, which they are not. Behind that, the command would
have refused to run (Postfix starts it without the container's
environment) and was never told the recipient, so the check that a report
came to the address of its own domain was skipped. The service line
carries its arguments, the recipient is one of them, and
`export_environment` names what the command reads. No report had been
delivered since the feature shipped in 1.7.0; the e2e check asks Postfix
how it reads the service.
- Postfix no longer logs "warning: not owned by root" for the queue
directory and its `pid/` at every start: the entrypoint hands the two back
to root after its recursive change of owner.
- Overview's Processes card counts the programs that are meant to stay up
("6 of 6 running"); the idle one-shot `postfix-reload` was counted as
running. The Milter sockets card no longer clips its second line with three
sockets, and a DKIM value is shown whole instead of scrolling inside its
field.
- Server › Health shows the idle reload helper (`postfix-reload`) as
**on demand**, with when it last ran, instead of `stopped` beside the
running programs (owner, 2026-10-10).
- The confirmation before deleting a user names the domains that follow
that user's default report address and will be left without one; it used
to say the domains were not affected (replaced before release — see the
entry of 2026-10-10 below).
### Removed
- **2.0 starts from an empty data directory.** A 1.x `/data` is not upgraded:
its database is refused at start and left untouched, and a 1.x backup is not
restored; a domain is carried over with its export file. Gone with it, as
built: the 1.x migration chain, every pre-2.0 panel path (listed next), the
help drawer (Help is a page of topics), the default DMARC report address —
the panel-wide one of 1.x (`dmarc_report_email`, "Same as Settings") and the
per-user one with a domain following a user that earlier entries of this
release describe (`users.dmarc_default_*`, `domains.dmarc_rua_user_id`) —
together with the DMARC box on Account, the old stylesheet `legacy.css`, the
old layout `layout_legacy.html`, and the unused `rate_limits.allowed_ips`
column.
- Old panel paths, GET and POST alike, without redirects: `/status`,
`/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`,
`…/ratelimit`, `…/ratelimit/recalc`, `…/dmarc`, `…/export`), `/inbound` and
`/inbound/{id}/…`, `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}`,
`/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`,
`/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users`
and everything under it, `/settings`, and the `/account` → `/settings`
redirect; `POST /domains/{id}/applications` and `/applications/{aid}/mode`,
`/authips`, `/ratelimit`, `/ratelimit/recalc`, `/password`, `/delete` — the
three separate saves of an application are one form now.
- The instance setting `dmarc_report_email` (a mirror of the global user's
profile field) and the unused `rate_limits.allowed_ips` column.
## [1.9.5] - 2026-10-09
CI-only cut: the release workflow no longer breaks the multi-arch image it
just published. No product code changes.
### Fixed
- ci (GHCR): the release no longer deletes the `X.Y.Z-amd64` / `X.Y.Z-arm64`
package versions after the manifest merge. Those versions are the images the
`X.Y.Z` manifest points at, so `docker pull ghcr.io/mixeme/selfpost:1.3.0`
failed with a missing manifest. The per-arch tags now stay; the merge job
instead removes untagged versions that no tagged manifest references.
## [1.9.4] - 2026-10-09
Plan-only cut, owner-requested: the redesign goes first as `2.0.0` with a
component-kit stage and guards against resurrected pages; antispam moves to
`2.1.0`; a new candidate explains the empty delivery log. No product code
changes.
### Changed
- Roadmap order (owner, 2026-10-09): the panel redesign ships first as
`2.0.0`; inbound-antispam-panel follows as `2.1.0` on the 2.0 baseline
(its migration becomes `0002_inbound_spam_log.sql`), built from the
redesign's component kit so its two screens are styled once. The redesign
plan gains a **Component kit** section and stage (partials with typed
inputs, one `panel.css`, a `/server/components` kit page, acceptance before
any page is restyled), a staged checklist (5/27), Health decided inside 2.0
and the queue pages left as old content in the new shell, and an **Against
resurrected pages** section: a shrink-only `legacyPages` ratchet in the
guard tests, the outline golden, the old-path test and a history-blob
check in CI, so an old template from git history cannot pass as a
redesigned page.
- New roadmap candidate `delivery-log-storage`: the *Delivery log* box on a
message's page is empty for anything older than the last daily rotation,
because the lines are grepped from the current `mail.log` on each view and
never stored; the item proposes keeping them with the send-log row.
## [1.9.3] - 2026-09-22 ## [1.9.3] - 2026-09-22
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the
-16
View File
@@ -21,22 +21,6 @@ htmx 2.0.4 (internal/web/view/static/htmx.min.js)
Licence: Zero-Clause BSD (0BSD) Licence: Zero-Clause BSD (0BSD)
https://github.com/bigskysoftware/htmx https://github.com/bigskysoftware/htmx
Bulma 1.0.4 (internal/web/view/static/bulma.min.css)
Copyright (c) 2023 Jeremy Thomas
Licence: MIT
https://github.com/jgthms/bulma
Distributed unmodified.
Tabler Icons 3.49.0
Copyright (c) 2020-2026 Paweł Kuna
Licence: MIT
https://github.com/tabler/tabler-icons
Distributed as two files:
internal/web/view/static/tabler-icons.css
internal/web/view/static/tabler-icons.woff2
Webfont subset to the 57 icons the panel uses, made with fontTools
pyftsubset.
IBM Plex Sans / IBM Plex Mono IBM Plex Sans / IBM Plex Mono
Copyright © 2017 IBM Corp., with Reserved Font Name "Plex" Copyright © 2017 IBM Corp., with Reserved Font Name "Plex"
Licence: SIL Open Font License 1.1 Licence: SIL Open Font License 1.1
+3 -3
View File
@@ -26,9 +26,9 @@ send log and DNS checks in the panel, encrypted backups.
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing - Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default) - Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Optional DMARC aggregate report ingest on port 25 (off by default) - Optional DMARC aggregate report ingest on port 25 (off by default)
- In-panel Help: a page of topics, opened from the `?` in the head of a box - In-panel Help drawer and `/help` page for Status and domain controls
- Per-application client IP allow-list for SMTP AUTH (optional) - Per-application client IP allow-list for SMTP AUTH (optional)
- Web panel grouped by mail direction — Overview, Outbound (domains, log, queue, DMARC reports), Inbound, Server (health, system log, backup, users, settings) - Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain - Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check - DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
- Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides - Two-level rate limiting — IP backstop (Postfix), per-domain ceilings, and trusted-IP app overrides
@@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:2.0.0 ghcr.io/mixeme/selfpost:1.9.3
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
-11
View File
@@ -157,16 +157,5 @@ if [ ! -d /data/postfix/queue/active ]; then
postfix set-permissions postfix set-permissions
fi fi
chown -R postfix:postfix /data/postfix/queue chown -R postfix:postfix /data/postfix/queue
# The recursive chown above would leave public/ and maildrop/ in group postfix.
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
# postdrop and reach the showq socket and the maildrop directory through that
# group alone. With the wrong group `postqueue -p` run by the panel answers
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
# read the queue (the Queue card and the Outbound queue page).
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start.
chown root /data/postfix/queue /data/postfix/queue/pid
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+11 -51
View File
@@ -13,25 +13,12 @@
# a fixed literal or a container environment variable. # a fixed literal or a container environment variable.
set -eu set -eu
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza. # postconf -P refuses master.cf -o values that contain whitespace; inbound port
# # 25 needs restriction lists and milter specs that include spaces. Patch the
# A master.cf -o value must not contain whitespace: master splits the line on # smtp/inet service stanza directly instead.
# it, so "-o name=check_recipient_access texthash:/x,reject" reaches smtpd as
# the option "name=check_recipient_access" plus a stray argument, and smtpd dies
# at start with "fatal: unexpected command-line argument" - port 25 then
# answers nobody. (postconf -P refuses such values for the same reason.) Postfix
# splits a restriction list on commas as well as on whitespace, so every value
# passed here is written with commas only; a value with whitespace is a bug in
# this script and stops the container.
master_cf_set_smtp_inet_o() { master_cf_set_smtp_inet_o() {
param="$1" param="$1"
value="$2" value="$2"
case "$value" in
*[[:space:]]*)
echo "FATAL: master.cf -o ${param} would contain whitespace: ${value}" >&2
exit 1
;;
esac
awk -v p="$param" -v v="$value" ' awk -v p="$param" -v v="$value" '
/^smtp[ \t]+inet[ \t]/ && !done { /^smtp[ \t]+inet[ \t]/ && !done {
print print
@@ -285,21 +272,11 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
[ -e "$f" ] || : > "$f" [ -e "$f" ] || : > "$f"
done done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
# flags=, user= and argv= are arguments of pipe(8), not -o parameters: postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe"
# postconf -P refuses them ("unmatched service_name/type") and leaves a postconf -P \
# bare "pipe", which dies with "fatal: missing user= command-line "dmarc-ingest/unix-pipe/flags=Rq" \
# attribute" on every report. They go into the service line itself. "dmarc-ingest/unix-pipe/user=panel" \
# ${recipient} is expanded by pipe(8), not by the shell: the command "dmarc-ingest/unix-pipe/argv=/usr/local/bin/dmarc-ingest"
# learns which report address the message came to from its argument.
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
# One recipient per run, so ${recipient} is one argument.
postconf -e "dmarc-ingest_destination_recipient_limit=1"
# pipe(8) starts the command with export_environment only, so what the
# command reads from the container's environment is named there.
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
postconf -e "export_environment=TZ MAIL_CONFIG LANG ${DMARC_INGEST_ENV}"
if [ -n "$RELAY_DOMAINS_SETTING" ]; then if [ -n "$RELAY_DOMAINS_SETTING" ]; then
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
@@ -307,31 +284,15 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
fi fi
# Comma, not a space, between check_recipient_access and its table: see RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
# master_cf_set_smtp_inet_o.
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
fi fi
postconf -e \ postconf -e \
"relay_domains=${RELAY_DOMAINS_SETTING}" \ "relay_domains=${RELAY_DOMAINS_SETTING}" \
"transport_maps=${TRANSPORT_SETTING}" \ "transport_maps=${TRANSPORT_SETTING}" \
"smtpd_reject_unlisted_recipient=yes" "smtpd_reject_unlisted_recipient=yes"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
# With the relay on, Postfix refuses every recipient of a relay domain
# that relay_recipient_maps does not list (smtpd_reject_unlisted_recipient
# above) - and it does so after the restriction list, whatever
# check_recipient_access answered. The DMARC report addresses live on a
# relay domain too (the server's own name), so their table is a recipient
# table as well; without it they are "User unknown in relay recipient
# table" and no report is ever accepted while both features are on.
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
fi
postconf -e \ postconf -e \
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \ "relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
else else
postconf -e \ postconf -e \
@@ -394,8 +355,7 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}" master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
else else
postconf -MX "smtp/inet" 2>/dev/null || true postconf -MX "smtp/inet" 2>/dev/null || true
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
postconf -e \ postconf -e \
"relay_domains=" \ "relay_domains=" \
"transport_maps=" \ "transport_maps=" \
-34
View File
@@ -1,34 +0,0 @@
package main
import "testing"
// The panel reads the inbound filter's two variables with the semantics of
// build/postfix-config.sh: the action is accept unless it says tempfail, and a
// value the script would refuse is not guessed at.
func TestAntispamActionMirrorsTheBuildScript(t *testing.T) {
for raw, want := range map[string]string{
"": "accept", "accept": "accept", "tempfail": "tempfail",
"reject": "", "Accept": "", "TEMPFAIL": "", " accept": "",
} {
if got := antispamAction(raw); got != want {
t.Errorf("antispamAction(%q) = %q, want %q", raw, got, want)
}
}
}
func TestLoadConfigReadsTheInboundFilter(t *testing.T) {
t.Setenv("INBOUND_RELAY_ENABLE", "true")
t.Setenv("INBOUND_ANTISPAM_MILTER", "inet:antispam:11332")
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "tempfail")
cfg := loadConfig()
if !cfg.inboundEnabled || cfg.inboundAntispamMilter != "inet:antispam:11332" || cfg.inboundAntispamAction != "tempfail" {
t.Errorf("config = %+v", cfg)
}
t.Setenv("INBOUND_ANTISPAM_MILTER", "")
t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "")
cfg = loadConfig()
if cfg.inboundAntispamMilter != "" || cfg.inboundAntispamAction != "accept" {
t.Errorf("unset filter: milter %q, action %q", cfg.inboundAntispamMilter, cfg.inboundAntispamAction)
}
}
+4 -16
View File
@@ -26,7 +26,10 @@ func runDMARCIngestMode() error {
} }
defer st.Close() defer st.Close()
recipient := ingestRecipient(os.Args[1:]) 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 err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil { if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr) log.Printf("dmarc ingest: record failure: %v", incrErr)
@@ -36,21 +39,6 @@ func runDMARCIngestMode() error {
return nil return nil
} }
// ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Without it the check that a report came to its own domain's address is
// skipped.
func ingestRecipient(args []string) string {
for _, a := range args {
if a == "-dmarc-ingest" {
continue
}
return strings.ToLower(strings.TrimSpace(a))
}
return ""
}
func isDMARCIngestInvocation() bool { func isDMARCIngestInvocation() bool {
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" { if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
return true return true
-20
View File
@@ -1,20 +0,0 @@
package main
import "testing"
func TestIngestRecipient(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
// As pipe(8) starts it: the symlink name, then ${recipient}.
{[]string{"dmarc-reports+Example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest", "dmarc-reports+example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest"}, ""},
{nil, ""},
} {
if got := ingestRecipient(tc.args); got != tc.want {
t.Errorf("ingestRecipient(%q) = %q, want %q", tc.args, got, tc.want)
}
}
}
-2
View File
@@ -89,8 +89,6 @@ var loadConfigKeys = []string{
"POSTFIX_DIR", "POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT", "SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE", "INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"DMARC_REPORTS_ENABLE", "DMARC_REPORTS_ENABLE",
} }
-2
View File
@@ -110,8 +110,6 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy, RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled, InboundEnabled: cfg.inboundEnabled,
InboundAntispamMilter: cfg.inboundAntispamMilter,
InboundAntispamAction: cfg.inboundAntispamAction,
DMARCEnabled: cfg.dmarcEnabled, DMARCEnabled: cfg.dmarcEnabled,
SendLogRetentionEnvDefault: cfg.retentionDays, SendLogRetentionEnvDefault: cfg.retentionDays,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
+2 -23
View File
@@ -91,12 +91,7 @@ type config struct {
deployRoot string deployRoot string
inboundEnabled bool inboundEnabled bool
// The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and dmarcEnabled bool
// what Postfix does when it is down. Read-only: build/postfix-config.sh is
// what attaches it; the panel only says so. See antispamAction.
inboundAntispamMilter string
inboundAntispamAction string
dmarcEnabled bool
} }
func loadConfig() config { func loadConfig() config {
@@ -159,28 +154,12 @@ func loadConfig() config {
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true". // Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true", inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"),
inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")),
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true". // Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true", dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
} }
} }
// antispamAction reads INBOUND_ANTISPAM_MILTER_ACTION the way
// build/postfix-config.sh does: empty means accept, and accept and tempfail are
// the only other values it lets the container start with. Anything else is
// returned as "" — the panel says nothing about the action rather than guess.
func antispamAction(raw string) string {
switch raw {
case "", "accept":
return "accept"
case "tempfail":
return "tempfail"
}
return ""
}
// saslRealm chooses the realm new SASL accounts live under. It mirrors the // saslRealm chooses the realm new SASL accounts live under. It mirrors the
// hostname Postfix's SASL layer uses so a client authenticating with a bare // hostname Postfix's SASL layer uses so a client authenticating with a bare
// login resolves to the right account. // login resolves to the right account.
+4 -4
View File
@@ -74,7 +74,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) { func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "") r := restoreFromOwnBackup(t, "")
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel)) body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} { for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body) t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
@@ -128,7 +128,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) { func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "") r := restoreFromOwnBackup(t, "")
rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session) rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code) t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
} }
@@ -141,7 +141,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) { func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password") r := restoreFromOwnBackup(t, "a-long-enough-password")
body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel)) body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) { if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body) t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
} }
@@ -384,7 +384,7 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
form.Set("password", password) form.Set("password", password)
form.Set("password_confirm", password) form.Set("password_confirm", password)
} }
rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session) rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String()) t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
} }
+1 -1
View File
@@ -21,7 +21,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:2.0.0 image: ghcr.io/mixeme/selfpost:1.9.3
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+59 -272
View File
@@ -114,10 +114,10 @@ One process, four roles:
parameters (`queue_run_delay`, `minimal_backoff_time`, parameters (`queue_run_delay`, `minimal_backoff_time`,
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`, `maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
`delay_warning_time`) and caches the snapshot on the handlers config. The `delay_warning_time`) and caches the snapshot on the handlers config. The
Outbound queue box and a delivery's `deferred` / `bounced` history print those Mail queue card and a delivery's `deferred` / `bounced` history print those
numbers; they never call `postconf` per request. If `postconf` is missing, numbers; they never call `postconf` per request. If `postconf` is missing,
the panel logs a warning and uses Postfix 3.x compiled-in defaults the panel logs a warning and uses Postfix 3.x compiled-in defaults
(`300s` / `4000s` / `5d` / `0`) with a muted note on the box. (`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/ 2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open** Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
(`default_action=accept`) so milter failure does not stop mail. Domain (`default_action=accept`) so milter failure does not stop mail. Domain
@@ -133,8 +133,8 @@ One process, four roles:
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by 3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)). (`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and 4. **rate-limit recalc** — every six hours (and on demand from the domain
the application form), recomputes level-2 **Auto** rate limits from send-log statistics. page), recomputes level-2 **Auto** rate limits from send-log statistics.
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure). Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
@@ -159,7 +159,7 @@ panel user cannot read). `follow()` drains the old inode once more before
switching descriptors; the panel treats a missing log file as an empty tail, not switching descriptors; the panel treats a missing log file as an empty tail, not
an error. an error.
**Read offset is persisted** (`logtail_state` table, in the baseline schema): the **Read offset is persisted** (`logtail_state` table, migration `0003`): the
tailer stores its position plus a fingerprint of the log's first 512 bytes, and tailer stores its position plus a fingerprint of the log's first 512 bytes, and
on start resumes from it, parsing the tail written while the panel was down. If on start resumes from it, parsing the tail written while the panel was down. If
the fingerprint no longer matches (rotated or recreated in the meantime) it reads the fingerprint no longer matches (rotated or recreated in the meantime) it reads
@@ -185,8 +185,8 @@ rotated past fourteen files while the panel was down, or deleted.
**Two one-shot reads** sit beside the follow loop and are unrelated to it, both **Two one-shot reads** sit beside the follow loop and are unrelated to it, both
serving panel pages on request: `TailLines` (the last *n* lines, for serving panel pages on request: `TailLines` (the last *n* lines, for
`/server/log`) and `QueueLines` (the lines carrying one queue-id, for `/system-log`) and `QueueLines` (the lines carrying one queue-id, for
`/outbound/log/{id}`). `QueueLines` scans a bounded tail of the current file — `/deliveries/{id}`). `QueueLines` scans a bounded tail of the current file —
finding a message's lines means reading rather than seeking — and matches the id finding a message's lines means reading rather than seeking — and matches the id
anchored on the character before it, since queue ids are hexadecimal runs and a anchored on the character before it, since queue ids are hexadecimal runs and a
shorter one is regularly the tail of a longer one. Send-log rows outlive the log shorter one is regularly the tail of a longer one. Send-log rows outlive the log
@@ -197,227 +197,61 @@ state for an older message and the page reports it as such, not as a failure.
## Panel HTTP surface ## Panel HTTP surface
Canonical routes: [internal/web/web.go](../internal/web/web.go). The router is Canonical routes: [internal/web/web.go](../internal/web/web.go). Authenticated
two flat `http.ServeMux`es built by `muxes()` — one pattern per route, method unless noted. Routes marked **global** return **404** for domain administrators
included (`GET /outbound/log/{id}`), nothing mounted as a sub-router, and (`requireGlobal()` in
building them only takes method values, so the route guard test can run it on [internal/web/handlers/authz.go](../internal/web/handlers/authz.go)). The table
a bare `Server` and ask which pattern answers a path: below is a summary — HTMX fragment endpoints
(`/status/fragment`, `/deliveries/rows`, `/mail-queue/body`,
`/system-log/body`, …) and every POST variant live in `web.go`.
- **public** — `/healthz` (liveness), `/license` (the embedded `LICENSE`), | Route | Purpose |
`/static/`, `/setup/`, `/login`, `/logout`;
- **authenticated** — everything else, behind `RequireAuth`: a request without
a live session is redirected to `/login`.
Both sit inside `secure()`, which sets the security headers and runs the origin
check on every state-changing request (see below).
### Routes
A path reads like the menu: `/<group>/<page>`, an entity under its list, an
action under the thing it changes. Every state change is a `POST`; a `GET`
only reads (the delete confirmation at `GET …/delete` renders a form).
| Group | Routes |
|---|---| |---|---|
| Home | `GET /` → `/overview`, or for a user with the *domain* role to the first list they reach (`/outbound/domains`, or `/inbound/domains` when that is all) | | `/healthz` | Liveness (no auth) |
| Overview | `GET /overview`, `GET /overview/fragment` | | `/license` | Embedded `LICENSE` text (no auth) |
| Outbound › Domains | `GET, POST /outbound/domains` · `/outbound/domains/{id}` · `POST …/dns-recheck` · `GET, POST …/delete` · `GET …/settings` · `POST …/settings/reports`, `…/ratelimit`, `…/ratelimit/recalc`, `…/export` | | `/setup/*` | One-time admin bootstrap |
| Outbound › Applications | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/applications/{aid}/password`, `…/{aid}/ratelimit/recalc`, `…/{aid}/delete` | | `/login`, `/logout` | Session auth |
| Outbound › Log, Queue | `GET /outbound/log` · `/outbound/log/fragment` · `/outbound/log/{id}` · `GET /outbound/queue` · `/outbound/queue/fragment` | | `/account` | 308 redirect to `/settings` (pre-1.2.3 route, kept as a compat shim) |
| Outbound › DMARC reports | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` — registered only with `DMARC_REPORTS_ENABLE=true` | | `/status`, `/status/*` | **Global.** Process, cert, socket, PTR checks; machine CPU/memory/network |
| Inbound › Domains | `GET, POST /inbound/domains` · `/inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` · `GET, POST …/delete` — registered only with `INBOUND_RELAY_ENABLE=true` | | `/domains` | Domain list; `POST /domains` (add domain) is **global** |
| Server | `GET /server/health` (+ `/fragment`) · `POST /server/health/recheck`, `/server/health/reload` · `GET /server/log` (+ `/fragment`) · `GET, POST /server/backup` · `POST /server/backup/import` · `/server/users`, `…/new`, `…/{uid}`, `…/{uid}/delete` · `GET, POST /server/settings` · `GET /server/components` | | `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
| Account | `GET /account` · `POST /account/profile`, `/account/password` | | `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
| User menu | `GET /help` · `POST /logout` (public mux) | | `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
| `/reload` | **Global.** `POST` — reload OpenDKIM 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. |
An application is addressed under its domain, and an application id that does HTMX polling refreshes monitoring fragments (5 s while the operator is active on
not belong to the domain in the path is a 404. The application form is one the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
`POST`: sender, client IPs and rate limit are validated and saved together or `panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
not at all, also at creation. The page that shows a new password is the session idle timeout (only non-`HX-Request` GET and mutating requests count as
response to the `POST` that created or regenerated it; it has no `GET` path and activity). The Mail queue retry-policy card is outside that fragment: it is the
carries `Cache-Control: no-store`. start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
not a live re-read.
The pre-2.0 paths are not served and not redirected (`/status`, `/domains/…`,
`/inbound/{id}/…`, `/deliveries`, `/mail-queue`, `/system-log`, `/backup`,
`/users/…`, `/settings`, `/reload`, `/dmarc/…`, and `/account` as a redirect):
each answers 404 once signed in. `TestRoutesFollowNavigation`
([internal/web/guard_routes_test.go](../internal/web/guard_routes_test.go))
holds the table: every path is registered with its methods, every menu entry
points at a registered path, every old path answers 404, and no template
contains an old path.
### Who may open what
Authorization is by tree where the tree is the boundary, and by object where it
is not.
- **`/server/` is guarded once.** Routes under it are registered through
`server()` in `web.go`, which wraps the handler in `globalOnly` (404 for
anyone but the global role — the answer a missing page gets, so the panel does
not confirm the page exists) and panics at start-up for a pattern outside
`/server/`. The handlers also call `requireGlobal()` themselves.
- **Overview and the Outbound queue** are global-only but not under `/server/`,
so their handlers call `requireGlobal()`.
- **Outbound is checked per domain.** `lookupDomain` resolves `{id}` and answers
404 for a missing domain and for one the principal may not reach alike
([handlers/handlers_domains.go](../internal/web/handlers/handlers_domains.go));
application routes go on to check the application belongs to that domain.
Lists and the log are filtered by assignment in the query
(`ListDomainsForUser`), not in the template. Adding and deleting a domain is
`requireGlobal()`.
- **Inbound is checked per inbound domain**, separately from outbound
([handlers/handlers_inbound.go](../internal/web/handlers/handlers_inbound.go)):
`requireInbound` (the list), `requireInboundDomain` (one domain — page,
upstream, recipients, DNS re-check) and `requireInboundGlobal` (add, delete).
All three answer 404 alike.
- **The DMARC hub** is open to anyone with outbound reach for the reports of
their own domains; the *Ingest* box (server-wide statistics) is rendered for
the global role only.
- **Account and Help** are open to every signed-in user.
- The principal ([web/auth/principal.go](../internal/web/auth/principal.go))
carries the role, the assigned outbound and inbound domain ids and the two
*All* flags; `CanAccessDomain` and `CanAccessInboundDomain` are the only
predicates. The reach, not the role, decides which menu groups exist.
Routes marked global are tested per path for a user with the *domain* role,
including another tenant's domain id
([handlers/handlers_delegation_test.go](../internal/web/handlers/handlers_delegation_test.go),
[authz_test.go](../internal/web/handlers/authz_test.go)).
### Polled fragments
A polled page answers its refresh at `<page>/fragment` (`/overview/fragment`,
`/outbound/log/fragment`, `/server/health/fragment`, …). The fragment is the
same `{{define}}` block that renders the page's own box, so the first paint and
a refresh cannot differ. HTMX refreshes them every 5 s while the operator is
active on the page, every 30 s when the tab is visible but idle, and not at all
when it is hidden — scheduled in `panel.js` via `data-poll`, not
`hx-trigger="every …"`, because a trigger filter is evaluated with `new
Function`, which the CSP forbids. Polling does not extend the session idle
timeout (only non-`HX-Request` `GET`s and mutating requests count as
activity). The Outbound queue's retry-policy box is outside the polled region:
it is the start-up `postconf -h` snapshot (see [Panel
binary](#panel-binary-cmdpanel)), not a live re-read.
### View engine and component kit
Pages are rendered by [internal/web/view](../internal/web/view/view.go). There
is one layout and one way to render.
- **One layout.** Every page is parsed with `layout.html` (the shell: brick
navbar with the wordmark, the perforated edge, the strip of sibling pages,
user menu, footer) and `components.html` (the partials), then its own file.
`layout_signed_out` is the same document without navigation, used for sign-in
and setup; `Engine.Render` picks it when the page data names no user. The
page's file defines `content`; `pageFiles` in `view.go` lists the files of
each page, and fragments are listed in `fragmentFiles` and rendered by
`RenderFragment` with no layout.
- **Typed page data.** A page's data is a struct that embeds `Meta` (title,
user, the viewer's reach, the menu group and page it sits in) and carries
the page's boxes and rows as fields, built by a constructor in the `view`
package (`NewOutDomain`, `NewHelp`, …) that the handler fills. The shell is
derived from `Meta` and the feature flags (`SetInboundEnabled`,
`SetDMARCEnabled`) in one place, `Engine.shell`, so which entries the menu
has is decided once. No template reads a `map[string]any`; the one map left
in the handlers is the health sampler's, which is turned into a typed
`Health` before rendering.
- **The component kit.** [components.html](../internal/web/view/templates/components.html)
holds one `{{define}}` per partial (page head, postmark, box and its head and
foot, flash, health card, DNS record, facts, side menu, timeline, log pane,
empty state, confirm list, help topic, form rows) and is the only file that
writes their `sp-` markup. [components.go](../internal/web/view/components.go)
is the typed input of each (`Head`, `Box`, `Record`, `Fact`, `SideMenu`, …),
so a page passes a struct, never a `dict`. Template functions: `status_tag`
(a status → Bulma's tag classes, once), `wbr_at` (a break after the `@` in a
table cell). A page that needs a component the kit lacks is a design change
made in the mockups first
([plans/panel-redesign.md](plans/panel-redesign.md) § The contract).
- **Help.** [help.go](../internal/web/view/help.go) is the Help page's data:
topics grouped by section of the panel. A box's head links to its topic with
`Box.Help` (`/help#<topic>`), and the page lists only the
sections the viewer's menu shows.
- **Assets.** [static/](../internal/web/view/static) is embedded
(`//go:embed`): vendored `bulma.min.css` and a Tabler icon subset (checksummed),
`panel.css` (the mockups' stylesheet, rule for rule), `htmx.min.js`, `panel.js`,
the IBM Plex subset and the logo files. Served with a content-derived `ETag`,
and `/static/` does not list its files. The kit page
`GET /server/components` renders every partial in every state from fixtures
(global role only).
**Guard tests** hold the design in place; they run in `go test ./...` and CI.
They read their rules from the accepted mockups
([docs/assets/panel-redesign/panel/](assets/panel-redesign/index.html)) rather
than keep a copy: `TestPanelClassVocabulary` (only the Bulma subset and the
`sp-` classes of `panel.css`; every `sp-` class defined, used and shown on the
components page), `TestPanelCSSIsTheMockupStylesheet`, `TestPanelCSSContract`
(no `!important`, no id or page selector, type floor, widths),
`TestTemplatesUseComponents` (no hand-written component markup in a page),
`TestPanelOutlinesMatchMockups` and `TestPanelPageStructure` (each rendered page
has the component skeleton of its mockup), `TestRedesignedPagesLoadOnlyTheKit`,
`TestVendoredAssetsArePinned`, `TestStaticHoldsOnlyTheKnownAssets`,
`TestRoutesFollowNavigation`, `TestNoTemplateUsesInlineScriptOrStyle`. The
ratchet list `internal/web/view/legacy_pages.txt` is empty and can only shrink;
the CI step
[design-first](../.github/scripts/design-first.sh) rejects a commit that
changes a guard together with the code it judges, and a template that is a
byte-for-byte return of one from history. The design contract and the process
are in [plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement.
### Security headers, CSP and scripts
`secure()` ([internal/web/security.go](../internal/web/security.go)) puts these
on every response: `Content-Security-Policy: default-src 'self'; object-src
'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`, and `Strict-Transport-Security` when the cookie
is `Secure`. Because the policy has no exemptions, no template carries an
inline `<script>`, an event-handler attribute or `style="…"` — the page's
behaviour is `htmx.min.js` plus `panel.js` (copy and *fill* buttons, the
`data-confirm` prompt on destructive forms, conditional field visibility,
adaptive polling), both optional: every form works as a native HTML `POST`
with scripts off. htmx is told not to inject its indicator stylesheet
(`htmx-config` meta), which the CSP would otherwise have to allow.
State-changing requests (every non-`GET`/`HEAD`/`OPTIONS`) pass `originAllowed`
first: `Sec-Fetch-Site` must say `same-origin`, or `Origin` must name the
panel's host; a request with neither header is let through (an accepted risk).
There are no CSRF tokens — see [security.md](security.md).
### Errors shown to users
A validation refusal is shown as the text the validator wrote (on the form,
next to the field, or in a notice above the first box). Anything else — a
database error, `saslpasswd2`, a Postfix reload — is logged
and the page gets a fixed sentence saying so; the error text never reaches the
browser. A template error renders a bare `500` rather than a half-written page
(`Engine.Render` buffers).
### Sessions ### Sessions
Stored in SQLite (`sessions` table of the baseline, `0001_init.sql`): the cookie Stored in SQLite (`sessions` table, migration `0002`): cookie holds a random
holds a random token; the database stores **SHA-256 of the token**, not the token; the database stores **SHA-256 of the token**, not the token itself — a
token itself — a stolen DB or backup archive does not alone grant login, but a stolen DB or backup archive does not alone grant login, but a browser that still
browser that still holds the cookie works after process restart, redeploy, or holds the cookie works after process restart, redeploy, or full backup restore.
full backup restore.
- **A session belongs to a user by id** (`sessions.user_id`, `ON DELETE
CASCADE`). Deleting a user ends their sessions; renaming one keeps them;
`RequireAuth` loads the user on every request, so a session whose user is
gone is a redirect to `/login`.
- **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no - **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no
absolute cap (regular use keeps the session alive indefinitely). absolute cap (regular use keeps the session alive indefinitely).
- **Renewal** — the DB `expires_at` and the cookie `Max-Age` update at most once per - **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour
hour (`renewThreshold` in (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
[internal/web/auth/session.go](../internal/web/auth/session.go)). - **Password change on `/settings`** — changing your own password deletes
- **Password change on Account** — deletes every other session of that user; every other session for that user; the current session stays active
the current one stays active
([internal/store/sessions.go](../internal/store/sessions.go), ([internal/store/sessions.go](../internal/store/sessions.go),
[handlers_account.go](../internal/web/handlers/handlers_account.go)). [handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
- **A password set by a global user on the user form** deletes all of that A global administrator resetting another user's password on `/users` updates
user's sessions (`DestroyUserSessions`); set for oneself, it keeps the the hash but does not delete that user's existing sessions.
current one
([handlers_users.go](../internal/web/handlers/handlers_users.go),
`endSessionsAfterPasswordSet`).
Restoring an **older** backup also restores session rows: a session removed Restoring an **older** backup also restores session rows: a session removed
after that backup was taken can become valid again if the browser still holds after that backup was taken can become valid again if the browser still holds
@@ -430,7 +264,7 @@ the cookie and the restored row's `expires_at` has not passed.
Multi-store writes that must land in more than one place (SQLite row, Multi-store writes that must land in more than one place (SQLite row,
`sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is `sasldb2` entry, Postfix map, OpenDKIM table) go through a service, which is
also where the rollback of a partial failure lives. Handlers may call also where the rollback of a partial failure lives. Handlers may call
`store` directly for single-table reads and simple writes (sessions, users, `store` directly for single-table reads and simple writes (sessions, admin,
send-log queries); the first-run setup-token file is read and written in send-log queries); the first-run setup-token file is read and written in
`web` itself. The adapters below the services are the only code that knows `web` itself. The adapters below the services are the only code that knows
about Postfix, OpenDKIM, DNS or the log file, which is what makes them about Postfix, OpenDKIM, DNS or the log file, which is what makes them
@@ -461,7 +295,7 @@ flowchart TB
dmarcSvc["internal/dmarc"] dmarcSvc["internal/dmarc"]
end end
subgraph persistence ["Persistence"] subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded schema"] store["internal/store — SQLite, embedded migrations"]
end end
subgraph adapters ["Adapters — the only infrastructure-aware code"] subgraph adapters ["Adapters — the only infrastructure-aware code"]
postfix["internal/postfix"] postfix["internal/postfix"]
@@ -510,7 +344,7 @@ single-connection trade-off that follows from it.
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `selfpost.db` | SQLite panel state (users, sessions, domains, apps, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [Database](#database) and [schema-migrations.md](schema-migrations.md) | | `selfpost.db` | SQLite panel state (domains, apps, users, sessions, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [schema-migrations.md](schema-migrations.md) |
| `setup-token` | First-run setup token file | | `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables | | `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials | | `sasl/sasldb2` | Application SASL credentials |
@@ -529,49 +363,6 @@ single-connection trade-off that follows from it.
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
full backups also archive the operator's `./certs` PEM files when present. full backups also archive the operator's `./certs` PEM files when present.
### Database
The schema is one embedded baseline, `internal/store/migrations/0001_init.sql`,
applied by `store.Open` into an empty file; `PRAGMA user_version` is `1` and
`PRAGMA application_id` is `0x53503230` ("SP20"). There is no 1.x migration
chain. **2.0 starts from an empty data directory:** a database with a schema
version but without the 2.x `application_id` — what every 1.x file looks like —
is refused at start with `ErrForeignSchema` and left untouched, as is one whose
version is newer than the build knows. A restored 1.x backup fails the same
way. Domains are carried across with a domain export, whose format is not the
database. Later schema changes are new files (`0002_*.sql`, …); once `2.0.0` is
released the baseline is never edited. History and the per-table list:
[schema-migrations.md](schema-migrations.md).
The tables that carry the panel's model:
- **`users`** — `role` is `global` or `domain` (a reach, not a rank); `email` is
the user's own address, edited on Account and set by the global role on the
user form; `all_domains` / `all_inbound_domains` widen a *domain* user to
every domain of that direction, including ones added later (with a flag set
the assignment rows are ignored).
- **`user_domains`** and **`user_inbound_domains`** — the outbound and inbound
domains assigned to a *domain* user. Separate tables: the same name on both
is two assignments, and a sending domain gives no inbound access or the other
way round.
- **`sessions`** — `token_hash`, `user_id` (`ON DELETE CASCADE`), `expires_at`.
Keyed by user id, not by name.
- **`domains`** — `dmarc_rua` is the whole of a domain's report-address choice:
`''` is no reports (a new domain), the address SelfPost derives for the
domain and the server's hostname is the hosted choice, anything else is an
address typed for the domain. Nothing refers to a user, and deleting one
changes no domain.
- **`settings`** — key/value, what holds for the instance whoever is signed in
(send-log retention). Nothing that belongs to a user.
- `applications` (with `auth_ip_restrict` / `auth_allowed_ips`),
`application_addresses`, `rate_limits` (no `allowed_ips` column), `send_log`,
`logtail_state`, `inbound_domains`, `inbound_transports`,
`inbound_recipients`, `dmarc_reports`, `dmarc_report_records`.
The panel is the only writer; SQLite runs in WAL mode on one connection (see
[Panel binary](#panel-binary-cmdpanel)), which is why a full backup snapshots
with `VACUUM INTO` instead of copying the file.
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90); **Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
`mail.log` via logrotate (14 rotated files, check every 6h, rename + `mail.log` via logrotate (14 rotated files, check every 6h, rename +
`postfix reload` in `postrotate` — see § Log tailer above). `postfix reload` in `postrotate` — see § Log tailer above).
@@ -586,8 +377,7 @@ tables, Postfix's sender map, inbound relay maps (when
`INBOUND_RELAY_ENABLE=true`), and DMARC maps (when `DMARC_REPORTS_ENABLE=true`) `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 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 extracted archive and the database is healed before mail flows. Manual
`POST /server/health/reload` (the *Reload configuration* button on Server › `POST /reload` on the Status page resyncs OpenDKIM, the sender map, and
Health) resyncs OpenDKIM, the sender map, and
inbound maps only — not DMARC maps. Stopped-container inbound maps only — not DMARC maps. Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide). `tar` of `./data` alone remains possible for state-only copies (see guide).
@@ -596,12 +386,9 @@ inbound maps only — not DMARC maps. Stopped-container
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header, scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
its counter and an end-of-stream flag (so truncation and reordering fail to its counter and an end-of-stream flag (so truncation and reordering fail to
open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export open). Full backup `.tar.gz` → `.spbk` (SelfPost backup), domain export
`.json` → `.spde` (SelfPost domain export). In the panel the *Encrypt with a `.json` → `.spde` (SelfPost domain export); the plain forms remain the
password* box is ticked by default on both downloads and the plain form is default. Domain import detects the envelope by magic bytes; an encrypted full
what an unticked box gives; the `selfpost-backup` CLI stays plain unless it is backup is converted back with `selfpost-backup -decrypt` before restore.
given a password. Domain import detects the envelope by magic bytes; an
encrypted full backup is converted back with `selfpost-backup -decrypt` before
restore.
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

+2 -2
View File
@@ -13,7 +13,7 @@
<h1>The accepted direction, every screen</h1> <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 <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 or two clicks away. It takes the postal signature of <strong>A</strong> — the postmark verdict, numbered mono section
heads, the stamp's perforated paper edge under a brick bar that carries the wordmark — and the icons of <strong>B</strong>. Thirty-four screens and a components page live in 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 <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> fonts and mark by relative path.</p>
@@ -21,7 +21,7 @@
<div class="scroll"> <div class="scroll">
<table> <table>
<tbody> <tbody>
<tr><th>Signed out</th><td><a href="panel/login.html">Sign in</a> · <a href="panel/login-no-admin.html">sign in before an administrator exists</a> · <a href="panel/setup.html">Create administrator</a></td></tr> <tr><th>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>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> · <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-domain-settings.html">domain settings</a> · <a href="panel/out-app.html">application form</a> ·
+53 -6
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -69,26 +69,24 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p> <p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1> <h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p> <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> </div>
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <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="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> <div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div> <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> <button type="submit" class="button is-primary">Save profile</button>
</form> </form>
</div> </div>
</div> </div>
</div>
<div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
@@ -104,6 +102,55 @@
</div> </div>
</div> </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> </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> <footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
+4 -4
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -76,7 +76,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2></div>
<div class="sp-box-body"> <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> <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="#"> <form class="sp-form" method="post" action="#">
@@ -94,9 +94,9 @@
<div class="column is-6"> <div class="column is-6">
<div class="box" id="import"> <div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p> <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"> <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="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="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>
+1 -3
View File
@@ -81,9 +81,7 @@ def navbar(section, page):
' <a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a>', ' <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>', ' <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>',
' <hr class="navbar-divider">', ' <hr class="navbar-divider">',
# Signing out changes state, so it is a POST: a form with a button, not a link. ' <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>',
' <form method="post" action="login.html"><button type="submit" class="navbar-item">'
'<i class="ti ti-logout"></i>Sign out</button></form>',
' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>', ' </div>', ' </div>', ' </div>', ' </div>', ' </div>', '</nav>',
'<div class="sp-perf" aria-hidden="true"></div>'] '<div class="sp-perf" aria-hidden="true"></div>']
return "\n".join(out) return "\n".join(out)
+1 -1
View File
@@ -27,7 +27,7 @@ box button buttons checkbox column columns control field file file-cta file-inpu
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-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 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-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
is-hidden is-hoverable is-left is-light is-primary is-small is-success is-text is-warning 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 mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
""".split()) """.split())
# Classes the shell (build.py, later layout.html) owns; screens never write them. # Classes the shell (build.py, later layout.html) owns; screens never write them.
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -172,7 +172,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <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> <thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <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><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr> <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> </tbody>
</table></div> </table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div> <div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -78,7 +78,7 @@
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2><a class="sp-help" href="help.html#dmarc" title="How to read pass, fail and sources"><i class="ti ti-help-circle"></i></a></div> <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"> <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> <thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody> <tbody>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -76,7 +76,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2><a class="sp-help" href="help.html#dmarc" title="What an aggregate report is"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div> <div><dt>Reporter</dt><dd>Outlook.com</dd></div>
+3 -3
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -74,12 +74,12 @@
</div> </div>
<div class="box"> <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><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-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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <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>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</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> <div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl> </dl>
</div> </div>
+4 -4
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -77,7 +77,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="machine"> <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><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead> <thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody> <tbody>
@@ -108,7 +108,7 @@
<div class="columns"> <div class="columns">
<div class="column is-4"> <div class="column is-4">
<div class="box" id="certificate"> <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><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div> <div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
@@ -134,7 +134,7 @@
</div> </div>
<div class="column is-4"> <div class="column is-4">
<div class="box" id="hostname"> <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><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div> <div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
+8 -42
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -69,7 +69,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Reference</p> <p class="sp-kicker">Reference</p>
<h1 class="title is-3">Help</h1> <h1 class="title is-3">Help</h1>
<p class="sp-lead">What each screen checks or does, and what to do when it is not green. The ? in a box's head opens its topic here.</p> <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> </div>
@@ -87,61 +87,27 @@
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li> <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="#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="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</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> <li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
</ul> </ul>
<p class="menu-label">Inbound</p> <p class="menu-label">Inbound</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li> <li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</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>Valid recipients</a></li>
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
</ul>
<p class="menu-label">Server</p>
<ul class="menu-list">
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
</ul>
<p class="menu-label">Account</p>
<ul class="menu-list">
<li><a href="#account"><i class="ti ti-user-circle"></i>Your account</a></li>
</ul> </ul>
</aside> </aside>
</div> </div>
<div class="column is-9"> <div class="column is-9">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div> <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, and 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. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></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. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</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 (<code>SELFPOST_HOSTNAME</code>) 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 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>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div> <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. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></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, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those 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="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, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</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 class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -116,7 +116,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="upstream"> <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><a class="sp-help" href="help.html#upstream" title="Host, port and TLS"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="sp-row"> <div class="sp-row">
@@ -132,7 +132,7 @@
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="recipients"> <div class="box" id="recipients">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2><a class="sp-help" href="help.html#recipients" title="Listed addresses or any address"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rmode">Who to accept</label> <div class="field"><label class="label" for="rmode">Who to accept</label>
@@ -151,7 +151,7 @@ security@lists.example.org</textarea></div>
</div> </div>
<div class="box" id="filter"> <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><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div> <div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -87,7 +87,7 @@
</div> </div>
<div class="box"> <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><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-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"> <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> <thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody> <tbody>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="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">
<div class="sp-form">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">No administrator yet</h1>
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
</div>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
</body>
</html>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -97,7 +97,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <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><a class="sp-help" href="help.html#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div> <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="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="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="field"><label class="label" for="ips">Allowed client IPs</label>
@@ -109,7 +109,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <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><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-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="sp-box-body">
<div class="field"><label class="label" for="rmode">Limit mode</label> <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="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>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -97,7 +97,7 @@
<div class="box"> <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-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p> <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>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -76,25 +76,25 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="reports"> <div class="box" id="reports">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2><a class="sp-help" href="help.html#dmarc" title="Where DMARC reports go"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label> <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"> <div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode">
<option>No reports</option> <option selected>admin's default — mix@example.org</option>
<option>SelfPost hosted — example.org@dmarc.mail.example.org</option> <option>SelfPost hosted (example.org@dmarc.mail.example.org)</option>
<option selected>A specific address</option> <option>My default instead — (you are admin)</option>
<option>No aggregate reports</option>
<option>Custom address</option>
</select></div></div> </select></div></div>
<p class="help">No reports leaves <code>rua=</code> out of the DMARC record. SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish.</p></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>
<div class="field"><label class="label" for="rua_email">Report address</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="reports@acme.io" placeholder="reports@your-mail-domain.com" autocomplete="off" spellcheck="false"></div><div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button></div></div></div>
<button type="submit" class="button is-primary">Save report address</button> <button type="submit" class="button is-primary">Save report address</button>
</form> </form>
</div> </div>
</div> </div>
<div class="box" id="export"> <div class="box" id="export">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2></div>
<div class="sp-box-body"> <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> <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="#"> <form class="sp-form" method="post" action="#">
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -94,9 +94,9 @@
</ul> </ul>
<p class="menu-label">Rarely changed</p> <p class="menu-label">Rarely changed</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li> <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"><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#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"><i class="ti ti-file-export"></i>Export</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> <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> </ul>
</aside> </aside>
@@ -132,14 +132,14 @@
</div> </div>
<div class="sp-record"> <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">example.org@dmarc.mail.example.org</a></small></div> <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="sp-row">
<div class="field"><label class="label">Host / name</label> <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 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 class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div> </div>
<div class="field"><label class="label">Value to publish</label> <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:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div> <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> <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> </div>
@@ -149,10 +149,10 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <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> <thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-nowrap"><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> <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><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr> <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 sp-nowrap"><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> <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><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr> <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> </tbody>
</table></div> </table></div>
<div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div> <div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -82,7 +82,6 @@
<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> <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> <button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form> </form>
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
</div> </div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <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> <thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -91,7 +91,7 @@
</div> </div>
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<ol class="sp-timeline"> <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><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -74,7 +74,7 @@
</div> </div>
<div class="box"> <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><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div> <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"> <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> <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> <tbody>
@@ -4,13 +4,15 @@
{ {
"columns": [ "columns": [
{ {
"is-6": [ "is-5": [
"box[Profile]:body" "box[Profile]:body",
"box[Password]:body"
] ]
}, },
{ {
"is-6": [ "is-7": [
"box[Password]:body" "box[Notifications]:body",
"box[DMARC reports]:record×2"
] ]
} }
] ]
@@ -122,10 +124,7 @@
{ {
"is-9": [ "is-9": [
"box[Overview]:help×3", "box[Overview]:help×3",
"box[Outbound]:help×6", "box[Outbound]:help×3"
"box[Inbound]:help×4",
"box[Server]:help×3",
"box[Account]:help"
] ]
} }
] ]
@@ -406,7 +405,8 @@
"columns": [ "columns": [
{ {
"is-6": [ "is-6": [
"box[Log retention]:body" "box[Log retention]:body",
"box[Notifications]:body"
] ]
}, },
{ {
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -78,12 +78,12 @@
<div class="box"> <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-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"> <div class="sp-health">
<a href="health.html"><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#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"><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#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" 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="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="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"><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#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"><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> <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> </div>
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
+13 -5
View File
@@ -54,7 +54,7 @@
<a class="navbar-item" href="account.html"><i class="ti ti-key"></i>Account</a> <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> <a class="navbar-item" href="help.html"><i class="ti ti-help"></i>Help</a>
<hr class="navbar-divider"> <hr class="navbar-divider">
<form method="post" action="login.html"><button type="submit" class="navbar-item"><i class="ti ti-logout"></i>Sign out</button></form> <a class="navbar-item" href="login.html"><i class="ti ti-logout"></i>Sign out</a>
</div> </div>
</div> </div>
</div> </div>
@@ -69,7 +69,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Server</p> <p class="sp-kicker">Server</p>
<h1 class="title is-3">Settings</h1> <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 and password are under <a href="account.html">Account</a>.</p> <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>
</div> </div>
@@ -77,22 +77,30 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2><a class="sp-help" href="help.html#settings" title="How long rows are kept"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label> <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> <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> <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> </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>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="rate-limits"> <div class="box" id="rate-limits">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</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-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"> <div class="sp-box-body">
<dl class="sp-facts"> <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 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">domain</a> and <a href="out-app.html">application</a>.</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> </dl>
</div> </div>
</div> </div>
@@ -3,26 +3,24 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p> <p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1> <h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p> <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> </div>
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <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="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> <div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div> <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> <button type="submit" class="button is-primary">Save profile</button>
</form> </form>
</div> </div>
</div> </div>
</div>
<div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
@@ -38,4 +36,53 @@
</div> </div>
</div> </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> </div>
@@ -10,7 +10,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2><a class="sp-help" href="help.html#backup" title="Backups, encryption and restore"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Full backup</h2></div>
<div class="sp-box-body"> <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> <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="#"> <form class="sp-form" method="post" action="#">
@@ -28,9 +28,9 @@
<div class="column is-6"> <div class="column is-6">
<div class="box" id="import"> <div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p> <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"> <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="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="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>
@@ -106,7 +106,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <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> <thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <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><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr> <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> </tbody>
</table></div> </table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div> <div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
@@ -12,7 +12,7 @@
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sources · 7 days</h2><a class="sp-help" href="help.html#dmarc" title="How to read pass, fail and sources"><i class="ti ti-help-circle"></i></a></div> <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"> <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> <thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
<tbody> <tbody>
@@ -10,7 +10,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2><a class="sp-help" href="help.html#dmarc" title="What an aggregate report is"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Report</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Reporter</dt><dd>Outlook.com</dd></div> <div><dt>Reporter</dt><dd>Outlook.com</dd></div>
@@ -8,12 +8,12 @@
</div> </div>
<div class="box"> <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><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-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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <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>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</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> <div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl> </dl>
</div> </div>
@@ -11,7 +11,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="machine"> <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><a class="sp-help" href="help.html#checks" title="What these readings mean"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="table-container"><table class="table is-fullwidth">
<thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead> <thead><tr><th>Resource</th><th>Usage</th><th>Detail</th></tr></thead>
<tbody> <tbody>
@@ -42,7 +42,7 @@
<div class="columns"> <div class="columns">
<div class="column is-4"> <div class="column is-4">
<div class="box" id="certificate"> <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><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div> <div><dt>Expires</dt><dd class="sp-mono">2026-10-03 09:14 UTC</dd></div>
@@ -68,7 +68,7 @@
</div> </div>
<div class="column is-4"> <div class="column is-4">
<div class="box" id="hostname"> <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><a class="sp-help" href="help.html#rdns" title="Forward and reverse DNS"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div> <div><dt>Hostname</dt><dd class="sp-mono">mail.example.org</dd></div>
+7 -41
View File
@@ -3,7 +3,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Reference</p> <p class="sp-kicker">Reference</p>
<h1 class="title is-3">Help</h1> <h1 class="title is-3">Help</h1>
<p class="sp-lead">What each screen checks or does, and what to do when it is not green. The ? in a box's head opens its topic here.</p> <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> </div>
@@ -21,61 +21,27 @@
<li><a href="#dns"><i class="ti ti-world-check"></i>DNS records</a></li> <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="#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="#limits"><i class="ti ti-gauge"></i>Rate limits</a></li>
<li><a href="#log"><i class="ti ti-list-details"></i>Outbound log</a></li>
<li><a href="#queue"><i class="ti ti-stack-2"></i>Queue and retries</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> <li><a href="#dmarc"><i class="ti ti-report-analytics"></i>DMARC reports</a></li>
</ul> </ul>
<p class="menu-label">Inbound</p> <p class="menu-label">Inbound</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</a></li> <li><a href="#inbound"><i class="ti ti-inbox"></i>Backup-MX and forwarding</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>Valid recipients</a></li>
<li><a href="#filter"><i class="ti ti-shield-check"></i>Spam filter</a></li>
</ul>
<p class="menu-label">Server</p>
<ul class="menu-list">
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
</ul>
<p class="menu-label">Account</p>
<ul class="menu-list">
<li><a href="#account"><i class="ti ti-user-circle"></i>Your account</a></li>
</ul> </ul>
</aside> </aside>
</div> </div>
<div class="column is-9"> <div class="column is-9">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div> <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, and 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. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total.</p></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. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect.</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 (<code>SELFPOST_HOSTNAME</code>) 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 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>
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound</h2></div> <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. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow <code>include:</code> or <code>redirect=</code>, so a record that relies on them is reported as a warning, not a failure. <code>p=none</code> does not affect delivery; tighten to <code>quarantine</code>, then <code>reject</code>, once the reports look clean. A domain's report address is set in its Domain settings.</p></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, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those 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="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, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's.</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 class="sp-help-topic" id="log"><h3>Outbound log</h3><p>One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in <code>mail.log</code>, found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains.</p></div>
<div class="sp-help-topic" id="queue"><h3>Queue and retries</h3><p>The page lists, as <code>postqueue -p</code> prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged.</p></div>
<div class="sp-help-topic" id="dmarc"><h3>DMARC reports</h3><p>An aggregate report is the summary a receiving provider sends, usually daily, to the <code>rua=</code> address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as <em>this relay</em>. A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten <code>p=</code> until that sender is fixed or removed. A domain administrator reads the reports of their own domains.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound</h2></div>
<div class="sp-help-topic" id="inbound"><h3>Backup-MX and forwarding</h3><p>An optional backup-MX or forwarder, switched on in the server's <code>.env</code>; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role.</p></div>
<div class="sp-help-topic" id="upstream"><h3>Upstream</h3><p>The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail.</p></div>
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys, application credentials and the queue), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
</div> </div>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="upstream"> <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><a class="sp-help" href="help.html#upstream" title="Host, port and TLS"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="sp-row"> <div class="sp-row">
@@ -66,7 +66,7 @@
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="recipients"> <div class="box" id="recipients">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2><a class="sp-help" href="help.html#recipients" title="Listed addresses or any address"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Valid recipients</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rmode">Who to accept</label> <div class="field"><label class="label" for="rmode">Who to accept</label>
@@ -85,7 +85,7 @@ security@lists.example.org</textarea></div>
</div> </div>
<div class="box" id="filter"> <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><a class="sp-help" href="help.html#filter" title="A server setting"><i class="ti ti-help-circle"></i></a></div> <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"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div> <div><dt>Engine</dt><dd>rspamd, for every inbound domain of this server</dd></div>
@@ -21,7 +21,7 @@
</div> </div>
<div class="box"> <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><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-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"> <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> <thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
<tbody> <tbody>
@@ -1,17 +0,0 @@
<!-- 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">
<div class="sp-form">
<p class="sp-kicker">mail.example.org</p>
<h1 class="title is-3">No administrator yet</h1>
<p class="sp-muted">Open the one-time setup link printed in the container log to create one.</p>
</div>
<p class="sp-foot">© 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</p>
</div>
</div>
@@ -31,7 +31,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <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><a class="sp-help" href="help.html#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div> <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="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="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="field"><label class="label" for="ips">Allowed client IPs</label>
@@ -43,7 +43,7 @@
<div class="column is-4"> <div class="column is-4">
<div class="box"> <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><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-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="sp-box-body">
<div class="field"><label class="label" for="rmode">Limit mode</label> <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="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>
@@ -31,7 +31,7 @@
<div class="box"> <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-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p> <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>
</div> </div>
@@ -10,25 +10,25 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box" id="reports"> <div class="box" id="reports">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2><a class="sp-help" href="help.html#dmarc" title="Where DMARC reports go"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>DMARC report address</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label> <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"> <div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode">
<option>No reports</option> <option selected>admin's default — mix@example.org</option>
<option>SelfPost hosted — example.org@dmarc.mail.example.org</option> <option>SelfPost hosted (example.org@dmarc.mail.example.org)</option>
<option selected>A specific address</option> <option>My default instead — (you are admin)</option>
<option>No aggregate reports</option>
<option>Custom address</option>
</select></div></div> </select></div></div>
<p class="help">No reports leaves <code>rua=</code> out of the DMARC record. SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish.</p></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>
<div class="field"><label class="label" for="rua_email">Report address</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="reports@acme.io" placeholder="reports@your-mail-domain.com" autocomplete="off" spellcheck="false"></div><div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button></div></div></div>
<button type="submit" class="button is-primary">Save report address</button> <button type="submit" class="button is-primary">Save report address</button>
</form> </form>
</div> </div>
</div> </div>
<div class="box" id="export"> <div class="box" id="export">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">03</span><h2>Export domain</h2></div>
<div class="sp-box-body"> <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> <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="#"> <form class="sp-form" method="post" action="#">
@@ -28,9 +28,9 @@
</ul> </ul>
<p class="menu-label">Rarely changed</p> <p class="menu-label">Rarely changed</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li> <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"><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#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"><i class="ti ti-file-export"></i>Export</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> <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> </ul>
</aside> </aside>
@@ -66,14 +66,14 @@
</div> </div>
<div class="sp-record"> <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">example.org@dmarc.mail.example.org</a></small></div> <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="sp-row">
<div class="field"><label class="label">Host / name</label> <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 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 class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div> </div>
<div class="field"><label class="label">Value to publish</label> <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:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div> <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> <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> </div>
@@ -83,10 +83,10 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <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> <thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-nowrap"><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> <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><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr> <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 sp-nowrap"><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> <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><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr> <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> </tbody>
</table></div> </table></div>
<div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div> <div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div>
@@ -16,7 +16,6 @@
<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> <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> <button type="submit" class="button"><i class="ti ti-filter"></i>Filter</button>
</form> </form>
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
</div> </div>
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <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> <thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
@@ -25,7 +25,7 @@
</div> </div>
<div class="column is-5"> <div class="column is-5">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<ol class="sp-timeline"> <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><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
@@ -8,7 +8,7 @@
</div> </div>
<div class="box"> <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><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div> <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"> <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> <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> <tbody>
@@ -12,12 +12,12 @@
<div class="box"> <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-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"> <div class="sp-health">
<a href="health.html"><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#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"><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#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" 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="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="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"><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#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"><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> <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> </div>
@@ -3,7 +3,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Server</p> <p class="sp-kicker">Server</p>
<h1 class="title is-3">Settings</h1> <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 and password are under <a href="account.html">Account</a>.</p> <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>
</div> </div>
@@ -11,22 +11,30 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2><a class="sp-help" href="help.html#settings" title="How long rows are kept"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Log retention</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="label" for="ret">Keep outbound log rows, days</label> <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> <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> <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> </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>
<div class="column is-6"> <div class="column is-6">
<div class="box" id="rate-limits"> <div class="box" id="rate-limits">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</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-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"> <div class="sp-box-body">
<dl class="sp-facts"> <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 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">domain</a> and <a href="out-app.html">application</a>.</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> </dl>
</div> </div>
</div> </div>
@@ -12,7 +12,7 @@
<div class="columns"> <div class="columns">
<div class="column is-6"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2><a class="sp-help" href="help.html#users" title="Roles and domain assignments"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Account</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div> <div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div> <div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="ops@acme.io" autocomplete="off"></div>

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