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>
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>
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>
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>
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>
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>
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>
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>
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 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>
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>
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>
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>
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>
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>
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>