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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
- .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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Global administrators manage every domain; the assignment checkboxes are
now hidden when that role is selected.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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>
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>
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
/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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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>
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).
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>
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>
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>
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>
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".
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>