- test.yml: after go test, `go vet ./...` and `go test -c -o /dev/null .` in test/e2e - compiled and linked, not run. Not `go test -run xxx` as the plan said: TestMain would still run and bring up the stand. The Go cache now keys on test/e2e/go.sum too. - .dockerignore: test (e2e leftovers in test/e2e/.stage - DKIM keys, sasldb2 - no longer reach the builder; the sink image is built from test/e2e/sink as its own context, so it is unaffected). - development.md (test.yml line), CHANGELOG [Unreleased], audit plan lines ticked, roadmap 3/61. Evidence on the test server for this tree: make e2e (no SELFPOST_E2E_VERSION, so the "e2e" default path) exit 0, including image_stamped_with_version and the logrotate-mode build; in test/e2e go vet ./... and go test -c -o /dev/null . clean; docker build of build/Dockerfile with a file under test/e2e/.stage succeeds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 KiB
Changelog
All notable changes to this project are documented here. Format follows Keep a Changelog; versioning follows SemVer.
[Unreleased]
Fixed
- A release image says which release it is. The e2e gate in
release.ymlrebuilt the image it was about to push with its ownVERSION=e2e, so every published image printede2efor-versionand in the panel's footer, and the backup/restore version check compared a backup's version withe2e- any backup restored into any release (audit 2026-10-12, H1). The gate now builds with the release's version (SELFPOST_E2E_VERSION), the e2e checkspanel -versionandselfpost-backup -versionagainst it, andrelease.ymlchecks the stamp again on the image right before pushing it. Images already published are left as they are. - The e2e module is compiled on every push (
test.yml:go vetandgo test -cintest/e2e), so a type error there no longer first shows up inrelease.ymlafter a release is published (audit 2026-10-12, M20). test/is out of the image's build context (.dockerignore): a developer build after an e2e run sent the stand's leftovers - DKIM keys,sasldb2- to the builder (audit 2026-10-12, L33).
[2.4.1] - 2026-10-12
Documentation only; no product code changes.
Added
- A full-tree code audit at
2.4.0, with its plan of work: docs/audit-2026-10-12.md. Six zones read file by file against whatarchitecture.mdpromises; what the readers could not settle from source was settled on the test server. Four high findings - the published release images are stampedVERSION=e2ebecause the e2e gate rebuilds the image it is about to push, so the backup version check has never checked anything; a renewed TLS certificate is not picked up while the container runs; the queue-reconcile sweep can mark delivered mailbouncedwhile the log tailer is blind; application logins are case-sensitive to the panel and case-insensitive to Postfix - twenty-one medium, thirty-four low, the doc drift and the test gaps, and what was checked and found sound. The plan is six stages, each a cut of its own; nothing in it is agreed yet. The published1.xtags and images stay as they are (owner, 2026-10-12): fixes go forward only.
[2.4.0] - 2026-10-12
Security
- An application signs only as itself. OpenDKIM chooses the key by the
From:header, and Postfix held only the envelope sender against the login: an application of one sending domain could write another registered domain, or the server's own name, intoFrom:and have the message signed with that name's key (security review of2.3.0, where it was recorded as an accepted risk). The panel now holds the header to what the envelope is held to - any address of the application's domain, or the addresses of its list, with or without a+extension- and refuses anything else with550 5.7.1, a domain this server does not sign for included (plans/from-header-binding.md). - The check is strict. It is the first milter of the chain, on a socket
of its own (
SENDER_CHECK_SOCKET), attached withdefault_action=tempfail: while the panel does not answer, Postfix defers mail and accepts nothing unchecked. The journal milter keeps its socket and stays fail-open.
Added
- Server › Health counts what the strict check costs in a card of its own, Sender check: for how long the panel did not answer while Postfix was running, and how many submissions Postfix turned away because of it - today, in the last 30 days and in the last 12 months (UTC days, kept a year). The card warns when mail was deferred in the last 24 hours. The numbers are there to decide whether the check needs a process of its own.
- A refused row of the Outbound log says why, in its tag:
wrong From,wrong client(the application's client IP list) orrate-limited; a row refused before this version saysrejected. A message refused for itsFrom:line shows on its page what the line said and what the application may send as. Help has a Sender check topic. - Migration
0006_sender_check.sql(DDL only):send_log.reason,send_log.header_from,sender_check_days.
Changed
- Mail with a
From:the application is not bound to is refused. What stops working on upgrade: an application sending with theFrom:of another of your domains (give that domain an application); an application with a list whose envelope andFrom:addresses differ (add the second to the list); a form that puts its visitor's address intoFrom:(useReply-To:). The line is read strictly: oneFrom:field, one address, one@outside a quoted display name; a name with a comma or brackets has to be quoted. Guide § Who an application may send as. - With the panel down, mail is deferred, not accepted. Until now a stopped panel cost the send log and the level-2 limits and nothing else.
- Server › Health is laid out as two stacks of boxes numbered by importance, read left to right and top to bottom, each stack closed up: Processes, Milter sockets, Sender check, TLS certificate, Hostname and reverse DNS, Notification mail, Machine, Configuration. On a phone they come in that order.
- A refused message is one row of the log, naming its first recipient and how many more there were.
recipient_delimiteris set to+by the image instead of being left to the distribution's default; the start removes milter sockets a killed container left behind.
[2.3.0] - 2026-10-12
Added
- Schema for notifications (migration
0004_notifications.sql, DDL only): a user's own switch,users.notify, on for everyone;notify_state, what the panel has already written to its users about. The instance switch is a settings key, on unless turned off (plans/panel-notifications.md). - The panel writes to its users when something needs them. Four things,
each said once: a sending domain's DKIM, SPF or DMARC record stops matching
(after two hourly checks in a row, and once more when it matches again); a
level-2 rate limit refuses mail (at most once a day per sending domain); the TLS
certificate is 14 days from expiry, and 3; a message has waited in the
queue for more than three days (and once more when none has). Global users
are told all of it, domain users about their own domains. Sent from
postmaster@<SELFPOST_HOSTNAME>by local submission, one message per recipient; not rows of the Outbound log and counted against no limit. - The server's own name is signed. The panel generates a DKIM key for
SELFPOST_HOSTNAMEand OpenDKIM signs mail submitted locally as that name (non_smtpd_milters), so that notifications arrive. The name is not a sending domain: no application can use it as an envelope sender. - Notifications in the panel. Account has Send me notifications beside the e-mail; Server › Settings has the switch for everyone and the two DNS records of the server's own name, DKIM and SPF, with the values to publish; Server › Health has a Notification mail card with the state of each. Help has a Notifications topic. Every user starts with notifications on, and so does the instance.
Changed
- An account e-mail may be at a public mail host. Account and Server › Users refused addresses at Gmail, Outlook, Proton and the like, a rule that belongs to DMARC report addresses. A user's own address is now where the panel writes to them.
- A lookup that does not answer is not an SPF mismatch. When the address
lookup behind an
aormxmechanism timed out, a domain's SPF record was shown as not authorising this server. It is now a warning that the check could not tell.
Security
- Technical and security reviews of 2026-10-12 of the notifications, before
release: no Critical finding. The notification about refused mail was built
on the domain a client writes in
MAIL FROM; it is now built on the registry. Recorded in security.md as accepted, with a roadmap candidate (from-header-binding): an application can have a message signed as another registered domain, or as the server's own name, by writing it intoFrom:- the first part of which has always been so.
[2.2.0] - 2026-10-12
Added
- Schema:
send_log_lines(migration0003_send_log_lines.sql, DDL only). The table that will hold what Postfix wrote about a message for as long as its send-log row lives, with the store methods to write a line, to write it once, to read the lines of a row and to prune (plans/delivery-log-storage.md). - The log tailer keeps what Postfix writes about queued messages. A
mail.logline headed by a queue id is stored with the line's own time, one transaction per pass over the log; after an unclean stop a line read twice is stored once. Mail that came in on port 25 is left out: a stranger's message never has a send-log row, and what he can make Postfix log is not written to the database. Lines whose queue id has no send-log row - the row was pruned, or the message never had one (bounces) - are deleted once they are an hour old, at start and with every six-hourly send-log sweep.
Changed
- A message's page keeps its delivery log after
mail.logrotates. The Delivery log box on Outbound › Log › a message was filled by searching the currentmail.log, which is rotated daily, so it was empty for anything not sent today while the row itself lives for ninety days. The page now reads the lines the tailer stored: they are there for as long as the row is. Postfix reuses queue ids, so a row is given only the lines dated within its own queue lifetime. A message accepted before this version shows its lines only while they are still in the current file, as before; the note shown when there are none and the Outbound log help topic say so.
Security
- A full backup now carries the log lines of messages in the send log -
client addresses, SASL logins, message ids, receivers' replies - where
mail.logitself is not in a backup. Recorded in security.md with the accepted risk of a queue-id collision showing one message's lines on another's page. Technical and security reviews of 2026-10-12: no Critical or High finding.
[2.1.0] - 2026-10-12
Security
- The level-2 rate limit and an application's client IP list held only for
the first message of a connection. After a refused
MAIL FROM, a second one on the same connection was accepted, and so was every message after the first that a connection carried: an application over its limit could go on sending, and one restricted to certain client addresses could send from any other with a retry. In the code since1.9.0(the client IP list) and earlier (the limit); found by the technical review of 2026-10-12 and reproduced on the test stand. Postfix now gives the panel's milters the client address with every message, the limit no longer depends on it, and an application with a client IP list is refused when no address is known. - Reviews of the inbound antispam feature before its release (technical and security, 2026-10-12; nothing Critical or High in the feature itself). Fixed: a client's HELO name could supply the sender, recipient and reply of an inbound log row; the log's row cap was applied only every six hours; rows of a connection's later messages had no client address; two changes of the filter lists at once could leave the filter's files behind the page; a held message was logged twice; after an unclean stop of the panel the refusals of its last seconds were logged twice; with the spam filter down, a sender's own verdict headers were recorded as the filter's. Recorded as accepted in docs/security.md: an allow entry for a sender address trusts an address anyone can claim; the list files are readable by anyone on the host.
INBOUND_ANTISPAM_MILTERandINBOUND_JOURNAL_MILTER_SOCKETare checked character by character at start; the check looked at their first characters only.
Changed
- Port 25 logs as
postfix/inbound. Its lines in the mail log (Server › System log) now readpostfix/inbound/smtpd[…]where they readpostfix/smtpd[…]; the submission ports keeppostfix/smtpd. This is how a refusal on port 25 is told from one on 465 / 587.
Added
- Schema: migration
0002_inbound_spam_log.sql, the first after the 2.0 baseline - two empty tables for the inbound antispam journal and the filter lists (inbound-antispam-panel). A 2.0.x database is migrated when the panel starts. - Inbound mail is recorded. With the inbound relay on, the panel runs a second milter, the last one on port 25, and writes a row for every recipient of a message that was accepted for an inbound domain: client address, envelope, subject and - with a spam filter attached - the filter's action, score and heaviest symbols. It only watches: it refuses nothing and Postfix accepts mail when it is down.
- Refused inbound mail is recorded too, from the mail log: a recipient Postfix refused at an inbound domain (unknown user, sender domain not found), and a message the spam filter rejected, deferred, held or discarded. The row is dated by its log line and carries the reply text. A refusal to relay for a domain that is not an inbound one is not recorded.
- The inbound journal is pruned every six hours: rows older than 90 days
(
INBOUND_SPAM_LOG_RETENTION_DAYS, 7-365) and anything beyond 10 000 rows. - The antispam compose fragment mounts
antispam/rspamd/milter_headers.confinto the rspamd sidecar, which makes it write its verdict intoX-Rspamd-ActionandX-Spamd-Result. Copydeploy/antispam/rspamd/next to the fragment before starting it - Docker creates a directory in place of a missing file and the sidecar then fails to read its configuration. - Allow and deny lists for the spam filter, kept by SelfPost and applied
by rspamd: sender addresses, sender domains, client addresses and networks.
They are written as six files under
data/antispam/, which the fragment mounts into the sidecar together withantispam/rspamd/multimap.conf; rspamd rereads a changed file by itself. Deny wins over allow. The files are there from the first start with the inbound relay on. - Inbound › Log: the journal as a page - time, sender and client address, recipient, subject and decision, filtered by domain, decision and day. The decision opens a sheet over the page with who decided and why: the filter's verdict and symbols, or the reply a refusal was given. A user with the domain role sees the rows of the inbound domains assigned to them. For the global role a row links to the filter lists with the sender filled in: Deny sender on accepted mail, Allow sender on mail the filter refused - unless the sender is spelt in a way the lists do not take.
- Inbound › Filter lists (global role): add and remove allow and deny
entries; the page says where the lists go and whether the filter is on to
read them. Each entry shows how often it decided a message - Matched, or
never - and its kind opens a sheet with the note, the day it was added and
the last match, so that entries nothing matches any more can be found and
removed. The count is read from the inbound log and reaches back as far as
the log does. A network of one address (
192.0.2.7/32) is stored as the address, so it cannot be on a list twice. - Server › Settings: Keep inbound log rows, days, shown with the inbound relay on.
- Help: Inbound log and Filter lists.
- New setting
ANTISPAM_DIR(default/data/antispam). - The spam filter signs what it judged. rspamd adds
X-SelfPost-Filterto every message it lets through - an HMAC of the message's queue id under a key SelfPost creates indata/antispam/filter.key- and the inbound log shows a verdict only next to a signature that fits. Nothing to configure, but the sidecar needs the newantispam/rspamd/milter_headers.confand a restart to read it; until then accepted mail is logged as selfpost, with no verdict. - A full backup carries the inbound log, the filter lists and their files
under
data/antispam/. It does not carry theantispam/directory with the compose fragment and the rspamd configuration - copy that fromdeploy/antispam/when restoring. - New setting
INBOUND_JOURNAL_MILTER_SOCKET(default/run/selfpost/inbound-journal.sock).
[2.0.1] - 2026-10-11
Changed
- A message that is not a report this relay keeps is dropped at once.
One that cannot be read as an aggregate report, reports on a domain not
sent from here, or came to another domain's report address used to be put
off and offered again by Postfix for days, counted as a parse failure each
time. It is now accepted and discarded - no retry, and no bounce, since the
envelope sender of such a message is whatever its author wrote. It counts
as one parse failure and the reason is in the mail log, on the message's
own delivery line (Server › System log,
dropped: …). Only a failure of the database is still retried. - A full backup no longer holds the Postfix queue, from the panel or from
selfpost-backup. It is taken while the instance runs, and a copy of a queue Postfix is working on can hold a message twice or not at all. Mail still waiting for delivery is not carried over; a restored instance starts with an empty queue. To keep the queue, stop the instance andtarthe project directory. - DMARC reports are kept 500 for each domain (and 90 days), not 500 for the whole server - see Security.
Fixed
- Full backup from the panel works. Since the Postfix queue moved under
/datain 1.3.0 the button answered with a 200 and ten bytes: the archive includes the queue, Postfix keeps the queue readable by its own user alone, and the panel is not that user ("open /data/postfix/queue/active: permission denied" in the log).selfpost-backuprun withdocker execwas not affected - it runs as root. The queue is no longer part of a backup (see Changed), so the two archives are the same again. Everything is read through before the download starts, so a failure is a message on the page and not a file cut short. One thing the panel may still be unable to read is the private key incerts/: it then refuses and says what to do -chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pemon the host, 998 being the container'sselfpostgroup, whose id is now fixed in the image.
Security
Review of the DMARC report path (Fable, 2026-10-11), which started to run for the first time in 2.0.0. Nothing Critical or High.
- A report is bounded before it is stored. The XML comes from anyone who knows a report address, and its 8 MiB limit left room for some 160 000 records or a single name of megabytes - rows in the database and cells of the report page. A report holds at most 5 000 records (a larger one is dropped), every text field is cut to 255 bytes, and a record's count is capped so the sums cannot wrap.
- One domain's reports cannot push out another's. The cap of 500 was for the whole server, so a stranger filing reports under one domain's address could evict every other domain's history. It is counted per domain now.
- The delivery command runs one at a time, not up to a hundred in parallel.
- The reason written to the mail log for a dropped message is cut to 200 bytes and anything outside printable ASCII is replaced: parts of it are the sender's own bytes (a header line, a file name inside an archive).
SELFPOST_DATA_DIR/SELFPOST_DB_PATHwith whitespace or$are refused at start when DMARC ingest is on, instead of handing the delivery command another path and so an empty database of its own.
Added
- e2e: the run now ends on port 25 with both optional features on. A DMARC report is mailed to a domain's hosted address and has to appear under Outbound › DMARC reports; a report on another domain and a message with no report are accepted and dropped; mail for an inbound domain reaches its upstream and other recipients are refused. Until now the suite only read back the generated configuration of these features.
- e2e: full backup and restore. The run takes a backup from the panel, throws the installation away, unpacks the archive into the empty project directory and starts it: the administrator signs in, the domain has the same DKIM key, and mail sent by the application verifies against the record published before the backup.
[2.0.0] - 2026-10-11
The panel redesign. A breaking release: every panel URL changed and the
database starts from zero - a 1.x /data is not upgraded (see Changed and
Removed).
Added
-
panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons 3.49.0 are vendored under
/static(MIT, listed in NOTICE). Nothing links them yet; the old panel is unchanged. -
panel-redesign (stage 0): the design contract as guard tests (
guard_*_test.gounderinternal/web) and adesign-firstCI step. The tests read their rules from the accepted mockups — class vocabulary, stylesheet, page structure, component outlines, the 2.0 route table — and pin the vendored assets.internal/web/view/legacy_pages.txtlists what is still pre-2.0 and may only shrink; CI also rejects a commit that changes a guard together with the implementation, or brings back a template from history. The kit and route tests stay skipped until their stages land. -
panel-redesign (stage 0): the component kit.
panel.cssis the mockups' stylesheet rule for rule;components.htmlholds every partial with its typed input incomponents.go; the newlayout.htmlis the shell (brick navbar with the wordmark, perforated edge, sibling strip, user menu, footer) and the signed-out screen;GET /server/components(global role) renders every partial in every state.@kitis off the ratchet: the kit is held to the design contract from here on. -
panel-redesign (design): Sign out in the user menu is a POST form in the mockups too, and its button takes the same hover, focus and press tint as Account and Help (owner, 2026-10-09).
-
panel-redesign (stage 1): the 2.0 schema.
users.emailis the user's own address; the default DMARC report address is a user's setting (dmarc_default_mode/dmarc_default_address) and a domain either has an address of its own or follows a named user's default (domains.dmarc_rua_user_id) — a new domain follows whoever created it, and deleting that user leaves it with no report address (replaced before release — see the entry of 2026-10-10 below).user_inbound_domainsand the per-userall_domains/all_inbound_domainsflags are in the schema for the inbound delegation that follows. Details:docs/schema-migrations.md. -
panel-redesign (stage 1): inbound domains are delegated to domain administrators, separately from outbound. The user form has two lists, each with All (which includes domains added later; with it ticked the rows under it are ignored); a domain administrator needs at least one assignment on either. Such a user sees the inbound domains assigned to them — the list is filtered in the query — and can change their upstream and recipients; adding and deleting an inbound domain stays with the global role. Another tenant's domain, a missing one and a user with no inbound reach all get the same 404. A menu group with nothing assigned is absent, and
/leads an inbound-only administrator to Inbound. -
panel-redesign (stage 1): Account (
/account, every role) and Server › Settings (/server/settings, global role) replace the one Settings page. Account has three forms, each its own POST: profile (username, e-mail), password, and the default DMARC report address (SelfPost hosted, my account e-mail, another address, none) (replaced before release — see the entry of 2026-10-10 below). Saving the profile no longer asks for the password; changing the password still does. -
panel-redesign (stage 1): the application form is one POST. Who the application may send as, its client-IP allow-list and its rate limit ("use the domain limit", manual or auto) are validated together and saved together or not at all — also at creation, so an application can be added with its limit already in place. The page that shows a generated password is the response to its POST only and is sent with
Cache-Control: no-store. -
panel-redesign (stage 1): Server › Health (
/server/health) holds the tables of the old Status page and its two actions, Re-check DNS and Reload configuration. Overview (/overview) is reduced to one verdict per check — machine, processes, TLS certificate, queue, milter sockets, reverse DNS — each leading to its table on Health; a check that could not run shows as a warning, never as fine. Both read the same checks. -
panel-redesign (stage 2): Sign in and Create administrator are the first pages in the new design - the split brand / form screen, built from the component kit. Sign in names the mail host above its title; a refused attempt is shown as a notice above the fields.
loginandsetupare off the ratchet. -
panel-redesign (design): the sign-in page before any administrator exists is a drawn screen (
login-no-admin): the heading No administrator yet and the pointer to the one-time setup link, in the same column as the form. The signed-out column is sized through.sp-form, not throughform(owner, 2026-10-09). -
panel-redesign (design): the Notifications boxes are out of the Account and Server › Settings mockups. Event notifications are not built (panel-notifications is a candidate), and 2.0 carries no box for a feature that does not exist; they are drawn again as the first step of that feature (owner, 2026-10-10).
-
panel-redesign (stage 2): Overview, Server › Health, Account and Server › Settings are in the new design. Overview shows the verdict as a stamp with one sentence, the six checks as cards and both domain lists; Health holds the tables behind the cards. Both refresh themselves. The Queue card reads "Empty" or "N queued".
statusandsettingsare off the ratchet. -
panel-redesign (stage 2): the Outbound domain pages are in the new design: the domain list, the domain page (DNS records with what to publish and what is in DNS now, applications, connection), Domain settings as a page of its own (report address, rate limit, export, delete), the application form, the password shown once and the delete confirmation.
dashboard,domain_detailanddomain_deleteare off the ratchet. -
panel-redesign (stage 2): the Outbound log, the message page (facts, history, the lines of
mail.log) and the three DMARC report pages are in the new design. The log refreshes its rows without touching the filter being chosen.deliveries,delivery,dmarc,dmarc_domainanddmarc_reportare off the ratchet. -
panel-redesign (stage 2): the Inbound pages are in the new design: the domain list, the domain page (MX record, upstream, valid recipients) and the delete confirmation. Every inbound domain page has a Spam filter box and the list names the filter's state: the panel reads
INBOUND_ANTISPAM_MILTERandINBOUND_ANTISPAM_MILTER_ACTIONand says whether inbound mail is filtered and what happens while the filter is down. The filter's address is shown to the global role only.inbound,inbound_domainandinbound_deleteare off the ratchet. -
panel-redesign (stage 2): the Server pages - System log, Backup, Users, the user form and its delete confirmation - and Help are in the new design. Help is a page of topics that the
?in a box's head opens; the help drawer is gone. The user form sets the user's e-mail. The backup download is encrypted unless the box is unticked. Only the queue page is left on the ratchet. -
panel-redesign (design, stage 3): Help covers every section - seventeen topics for Overview, Outbound, Inbound, Server and Account - and the boxes of nearly every page carry a
?that opens their topic (owner, 2026-10-10). A reader sees the topics of the sections their menu shows. -
panel-redesign (design, stage 3): a domain's DMARC report address is one of three choices - no reports (a new domain), the SelfPost hosted address, or an address typed for the domain - set in Domain settings, with a button that fills in one's own profile e-mail. The per-user default report address, a domain "following" a user and the DMARC box on Account are gone, and with them
users.dmarc_default_mode,users.dmarc_default_addressanddomains.dmarc_rua_user_id(owner, 2026-10-10; replaces the design of 2026-09-21 described above). -
panel-redesign (stage 3): with the inbound spam filter on, its socket is one of the milter sockets on Server › Health and counts in Overview's card. The panel connects to the configured address (
inet:host:portorunix:/path, one second at most, nothing sent). A filter that does not answer is an error withINBOUND_ANTISPAM_MILTER_ACTION=tempfail- inbound mail is deferred - and a warning otherwise - mail goes through unfiltered. -
Port 25 no longer accepts
dmarc-reports@<hostname>, the report mailbox of the server as a whole. Only an address a domain has chosen is accepted: its hosted addressdmarc-reports+<domain>@<hostname>, or a typed address that is on this host. A domain whose publishedrua=still names the general mailbox must be set to SelfPost hosted and its DMARC record republished (owner, 2026-10-10). -
panel-redesign (design, stage 3): New password and Delete are in each row of a domain's Applications table again, as POST forms that look like the links beside them.
-
panel-redesign (stage 2): the Outbound queue page is in the new shell with its content unchanged - the listing of
postqueue -pand how retries work. With it every page is built from the component kit: the old stylesheet, the old layout and the engine's second rendering path are deleted, and the ratchet is empty.
Changed
- Breaking — every panel URL changed. A path now reads like the menu
(
/overview,/outbound/…,/inbound/…,/server/…,/account); the old paths are removed, not redirected — see Removed. Polled fragments all answer<page>/fragment. Everything under/server/is guarded for the global role where the routes are registered, in addition to each handler's own check. An application is addressed under its domain (/outbound/domains/{id}/applications/{aid}), and an application id that does not belong to the domain in the path is a 404. - Breaking — the database starts from zero. The 1.x migration chain
(
0001–0010) is replaced by one baseline,0001_init.sql. A 1.x/data/selfpost.dbis refused at start-up with an explicit error and left untouched; nothing is migrated. A domain can be carried over with its export file. - The panel role
domain_adminis stored asdomain. - On the old domain page, "Same as Settings" makes the domain follow the default report address of the user who chose it (replaced before release — see the entry of 2026-10-10 below).
- The old panel is untouched on screen but steps aside in the tree: its
stylesheet is
legacy.cssand its layoutlayout_legacy.htmluntil the last old page is restyled. The 2.0 menu paths in the new shell do not exist before stage 1; only the kit page uses that shell so far (a stage-0 state: both files are deleted and every page is on the shell — see Removed). - panel-redesign is
agreed(owner, 2026-10-09); work starts with stage 0, the component kit. - panel-redesign (stage 3): the operator guide,
architecture.md(Panel HTTP surface, Persistence) andsecurity.md(roles, sessions, errors shown to users) describe the 2.0 panel.security.mdrecords two accepted risks from the 2.0 review: a delegated inbound upstream may be any host and port, and Account's profile is saved without the password.
Security
- A panel session belongs to a user by id and ends with them. It used to be keyed by user name: the cookie of a deleted or renamed user stayed valid and became a session of whoever was next created under that name. Signing in again is required after upgrading (there is no upgrade from 1.x; this concerns development builds of 2.0).
- Changing one's password ends one's own other sessions only; it used to sign every other user out. A password set by an administrator on the user form ends that user's sessions.
- Internal errors (database,
saslpasswd2, Postfix reload) are no longer shown on the application, inbound upstream / recipients and limit forms; they are logged and the page says so. /static/no longer answers with a listing of the embedded files.- The DMARC reports hub is open to a domain administrator for the domains assigned to them, without the server's ingest statistics; the list of reports no longer treats "no domains" as "all domains".
Fixed
- Saving a domain's report-address form untouched no longer moves a domain that follows another user's default to whoever saved it: that user's default is its own choice, and "My default instead" is the change (replaced before release — see the entry of 2026-10-10 below).
- The auto rate-limit fields follow the limit mode on the domain and application forms (the script applied only the first matching rule).
- The DMARC page of a domain no longer answers 500 once the domain has a reported source.
- The panel can read the mail queue. The container's entrypoint left the
queue's
public/andmaildrop/directories in grouppostfixon every start; Postfix keeps them inpostdrop, the grouppostqueueworks through, so the panel'spostqueue -pwas refused ("Connect to the Postfix showq service: Permission denied"), the Queue card said "Could not be read" and the queue page had no listing. Present since the persistent queue of 1.3.0; mail flow was not affected. The e2e suite opens the queue page now. - Port 25 answers again with DMARC report ingest on. The generated
master.cfgave the port-25 service-o smtpd_recipient_restrictions= check_recipient_access texthash:…,…with a space in the value; Postfix cuts a service line at whitespace, sosmtpdwas started with a stray argument and died with "fatal: unexpected command-line argument" on every connection - no inbound relay and no DMARC reports were accepted whileDMARC_REPORTS_ENABLE=true, althoughpostfix checkand the suite were silent. The list is written with commas only, the generator refuses a value with whitespace, and the e2e check asks Postfix what it reads. Present since DMARC ingest was fitted into the 1.9.1 fix of the same stanza. - DMARC reports are accepted with the inbound relay on. With both
features enabled every report address was refused as "User unknown in
relay recipient table": the relay's unlisted-recipient check runs after the
restriction list, and the report addresses - on the server's own name, a
relay domain - were not in
relay_recipient_maps. Their table is a recipient table now. Hidden until now by the port-25 failure above. - DMARC reports reach the panel. A report accepted on port 25 stayed in
the queue: the generated delivery service was a bare
pipe("fatal: missing user= command-line attribute"), because the generator addressed it under a type Postfix does not have and setflags=,user=andargv=as-oparameters, which they are not. Behind that, the command would have refused to run (Postfix starts it without the container's environment) and was never told the recipient, so the check that a report came to the address of its own domain was skipped. The service line carries its arguments, the recipient is one of them, andexport_environmentnames what the command reads. No report had been delivered since the feature shipped in 1.7.0; the e2e check asks Postfix how it reads the service. - Postfix no longer logs "warning: not owned by root" for the queue
directory and its
pid/at every start: the entrypoint hands the two back to root after its recursive change of owner. - Overview's Processes card counts the programs that are meant to stay up
("6 of 6 running"); the idle one-shot
postfix-reloadwas counted as running. The Milter sockets card no longer clips its second line with three sockets, and a DKIM value is shown whole instead of scrolling inside its field. - Server › Health shows the idle reload helper (
postfix-reload) as on demand, with when it last ran, instead ofstoppedbeside the running programs (owner, 2026-10-10). - The confirmation before deleting a user names the domains that follow that user's default report address and will be left without one; it used to say the domains were not affected (replaced before release — see the entry of 2026-10-10 below).
Removed
- 2.0 starts from an empty data directory. A 1.x
/datais not upgraded: its database is refused at start and left untouched, and a 1.x backup is not restored; a domain is carried over with its export file. Gone with it, as built: the 1.x migration chain, every pre-2.0 panel path (listed next), the help drawer (Help is a page of topics), the default DMARC report address — the panel-wide one of 1.x (dmarc_report_email, "Same as Settings") and the per-user one with a domain following a user that earlier entries of this release describe (users.dmarc_default_*,domains.dmarc_rua_user_id) — together with the DMARC box on Account, the old stylesheetlegacy.css, the old layoutlayout_legacy.html, and the unusedrate_limits.allowed_ipscolumn. - Old panel paths, GET and POST alike, without redirects:
/status,/status/fragment,/status/recheck,/reload,/domainsand everything under it (/domains/import,/domains/{id},…/dns-recheck,…/delete,…/ratelimit,…/ratelimit/recalc,…/dmarc,…/export),/inboundand/inbound/{id}/…,/dmarc,/dmarc/reports/{id},/dmarc/domains/{id},/deliveries,/deliveries/rows,/deliveries/{id},/mail-queue,/mail-queue/body,/system-log,/system-log/body,/backup,/usersand everything under it,/settings, and the/account→/settingsredirect;POST /domains/{id}/applicationsand/applications/{aid}/mode,/authips,/ratelimit,/ratelimit/recalc,/password,/delete— the three separate saves of an application are one form now. - The instance setting
dmarc_report_email(a mirror of the global user's profile field) and the unusedrate_limits.allowed_ipscolumn.
[1.9.5] - 2026-10-09
CI-only cut: the release workflow no longer breaks the multi-arch image it just published. No product code changes.
Fixed
- ci (GHCR): the release no longer deletes the
X.Y.Z-amd64/X.Y.Z-arm64package versions after the manifest merge. Those versions are the images theX.Y.Zmanifest points at, sodocker pull ghcr.io/mixeme/selfpost:1.3.0failed with a missing manifest. The per-arch tags now stay; the merge job instead removes untagged versions that no tagged manifest references.
[1.9.4] - 2026-10-09
Plan-only cut, owner-requested: the redesign goes first as 2.0.0 with a
component-kit stage and guards against resurrected pages; antispam moves to
2.1.0; a new candidate explains the empty delivery log. No product code
changes.
Changed
- Roadmap order (owner, 2026-10-09): the panel redesign ships first as
2.0.0; inbound-antispam-panel follows as2.1.0on the 2.0 baseline (its migration becomes0002_inbound_spam_log.sql), built from the redesign's component kit so its two screens are styled once. The redesign plan gains a Component kit section and stage (partials with typed inputs, onepanel.css, a/server/componentskit page, acceptance before any page is restyled), a staged checklist (5/27), Health decided inside 2.0 and the queue pages left as old content in the new shell, and an Against resurrected pages section: a shrink-onlylegacyPagesratchet in the guard tests, the outline golden, the old-path test and a history-blob check in CI, so an old template from git history cannot pass as a redesigned page. - New roadmap candidate
delivery-log-storage: the Delivery log box on a message's page is empty for anything older than the last daily rotation, because the lines are grepped from the currentmail.logon each view and never stored; the item proposes keeping them with the send-log row.
[1.9.3] - 2026-09-22
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the
accepted header/type-scale/contrast corrections, the account e-mail and
inbound-delegation decisions, and the routes/quarantine/preflight roadmap
screens. Nothing in internal/web changes; the design stays candidate
(3/21) until the redesign itself ships as 2.0.0.
Added
- Panel redesign, account e-mail and notifications: a user's e-mail is its own
field (2.0 baseline
users.email, todaydmarc_report_email) — edited under Account, seen and set by the global administrator on the user form and in the users list; DMARC may use it, as one choice beside the hosted address, another address or none (Settings and domain settings). Account gains a Notifications box and Settings a switch for the new roadmap candidatepanel-notifications;password-resetis added as a candidate too. The inbound spam filter becomes visible: a Spam filter box on every inbound domain, a tag on the inbound domains list, its socket among the milter sockets. Preflight loses a box that only filled space — the test form now shares its box with its result — and the plan's layout rule and contract say so: what stands beside a form must be real. The role is labelled by its reach,globalordomain— everyone in the panel is an administrator — and each assignment list on the user form gains All, a per-user flag that covers domains added later. The DMARC default report address is a user's setting, not the server's: it moves from Server › Settings to Account with its report-authorization record, and a domain follows the default of a named user, so two people sharing a domain never pull it two ways. - Panel redesign, contrast: a sweep of every text node against its real ground
found the solid red button at 3:1 (Bulma derives a solid button's text from
the fill's lightness; brick had been patched, red had not), the muted grey
at 4.1:1, the amber postmark at 4.0 and Bulma's menu labels at 4.2. Solid
buttons are white on colour, muted is
#5c6774, the postmark uses the warn token; all 33 screens now measure 4.5:1 or better. The sweep is kept aspanel/audit.js— text size, contrast, overflow, clipped values, missing icons — and its clean output joins the evidence a restyle step must show. - Panel redesign, header (owner's choice: variant A, with the perforation on a
light edge as on the mark): the navbar carries the wordmark instead of
the stamp —
docs/assets/selfpost-wordmark.svgand-ink, the stamp's own outlines, recorded indocs/product.md— and under the brick bar runs the stamp's paper margin with the perforation along its outer edge: round holes, flat teeth, the ink hairline (panel/perf-edge.svg). All 33 screens rebuilt; the components page documents the shell; the comparison sheet is removed. The six health cards now split into six, three or two columns, so one card is never left alone on a row. Three corrections after the owner's review: the paper edge is 8px under the 60px bar — it had been scaled from the hole size to 16px, where the mark's margin is a tenth of the brick field; the health row is compact again (71px: smaller icons, each line a block with its own line height, a verdict never wraps and stretches the row); and a read-only value is as tall as the Copy button joined to it.check.pyno longer countsmin-width: 0as setting a width. Text sizes: the filter controls in a box head and the navbar's dropdown entries are 15px like the bar itself (they had Bulma'sis-smalland its .875rem dropdown);check.pynow rejectsis-smallon anything but a progress bar. - Panel redesign, type scale: about 40 % of the panel's text measured under
13px (labels 11.7, table heads 10.5, fact labels 10.2, tags and help 11.3).
Body is now 16px with two tokens under it —
--sp-fs-s14px for supporting text and--sp-fs-xs13px for upper-case mono labels and tags; Bulma's small sizes are overridden or dropped (sp-smallreplacesis-size-7), andcheck.pyfails any font size under the floor. Every table now sits in atable-container(enforced), addresses in tables break after the@, the inbound log folds the client IP under the sender; all screens fit without scrolling at 1280px and up. Documented on the components page and in the plan (§ Type and tables). - Panel redesign, header comparison sheet
(docs/assets/panel-redesign/header/):
at nav size the stamp dissolves into the brick bar and the perforated edge
was drawn inverted (round bumps instead of round holes). Three replacements
beside the current header — A wordmark with the stamp's real hole-to-tooth
perforation bitten into the bar, B wordmark on a plain bar, C a
stamp-paper header with an ink hairline and brick only behind the name. The
wordmark is cut from the stamp's own outlines. Pending the owner's choice;
the mockups are unchanged.
.claude/launch.jsonserves the mockups locally (python -m http.server 8765from the repository root). - Panel redesign, the roadmap's screens: Inbound › Filter lists and the adjusted Inbound › Log (client, decision, engine and reason; Allow / Deny sender) for inbound-antispam-panel, Inbound › Quarantine (list and held message with Release / Discard) for inbound-quarantine, and Server › Preflight for preflight — all from existing components; 33 screens. Their paths join § Routes; the quarantine and preflight plans link their mockups. A mockup is the target look, not a decision to build: each item keeps its status, and quarantine's open questions stay open.
- Panel redesign, two owner decisions recorded in
docs/plans/panel-redesign.md: no compatibility — the redesign is2.0.0, old paths are removed without redirects, the SQLite migration chain is replaced by one baseline and 2.0 starts from an empty data directory (schema-squash ships as a step of this plan, without its upgrade gate; the only live installation is the owner's); and inbound delegation — a domain administrator manages assigned inbound domains, granted separately from outbound (user_inbound_domains, a second list on the user form, per-domain authorization under/inbound/). Mockupsusers,user,user-deleteredrawn for the two lists. Checklist3/21. - Panel redesign, routes: the owner decided that paths follow the new names
and structure.
docs/plans/panel-redesign.md§ Routes maps every screen to its new path (/overview,/outbound/…,/inbound/…,/server/…,/account) with the rules that come along — one<page>/fragmentname, the application form as a single POST, 301s from old GET paths for one MINOR, subtreerequireGlobal()under security review, andTestRoutesFollowNavigation. Mockups gained what the first pass missed: the Reload configuration box (now on Server › Health), theflashcomponent and thesp-helplink that replaces the help drawer. Checklist1/17. - Panel redesign, design contract:
docs/plans/panel-redesign.md rewritten for
the accepted direction — three layers (Bulma as shipped, adapters, own
sp-components), three page layouts, what an implementer must not do, the guard tests that land before any template, adesign-firstCI step and evidence-based acceptance. A components page shows every building block in every state;panel/check.pylints the mockups against the contract and writespanel/outlines.json, the per-screen component skeleton an implementation is compared with. Own modifier classes renamed to carry thesp-prefix so the vocabulary is machine-checkable. Agent rule.cursor/rules/panel-design.mdc; roadmap row updated (0/15). - Panel redesign, stage 2: the accepted direction drawn on every screen —
29 pages under docs/assets/panel-redesign/panel/.
Direction C (Bulma) is the base, with A's postal signature (postmark verdict,
numbered mono section heads, the stamp's perforated edge) and B's icons.
Overview keeps verdicts only and the tables move to a new Server › Health
page; the own password becomes Account; queues are drawn as tables
(needs
postqueue -j) and Inbound gets its own Log and Queue. Pages are built from fragments bypanel/build.py; the built HTML is committed. Mockups only; nothing ininternal/webchanges. - Panel redesign, stage 1: three directions drawn on the three key screens
(Overview, an outbound domain, sign-in) under
docs/assets/panel-redesign/ —
A Pico CSS ("Blank"), B Tabler ("Console"), C Bulma ("Counter"),
with a cover sheet comparing size, JavaScript, CSP fit and own CSS. All three
share a navigation grouped by mail direction (Overview · Outbound · Inbound ·
Server, with a symmetric Log and Queue on both sides), one block per DNS
record instead of separate "status" and "records to publish" cards, and
applications as a table. Mockups only; nothing in
internal/webchanges. The brief behind them supersedes the "no component library" decision indocs/plans/panel-redesign.md; that file is rewritten once a direction is accepted. - Roadmap item and givens for a panel visual redesign
(docs/plans/panel-redesign.md) — layout
grammar (
measure/fillplus Web Awesome layout CSS), no JS component library, new mockups rather thandocs/assets/panel-ui/. Status: candidate. - Roadmap item and plan for
/preflightinstallation check page (docs/plans/preflight.md) — instance-level infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers, DKIM socket, test email). Status: candidate.
[1.9.2] - 2026-09-22
Review pass over 1.4.0…1.9.1: port 25 no longer shares the submission
milter chain, DMARC reports are validated before they are stored, and the auto
rate limit is correct for a non-default level-1 window. Schema migration
0010 (index only). Upgrading is a tag bump.
A full-tree code review on 2026-08-19 (architecture, code quality, security, GUI, tests, documentation, AGPL compliance; 38 findings) produced most of what follows. Its four open items live in roadmap.md; its working document is not part of the release.
Added
- SQLite migration
0010_send_log_app_login_index.sql—idx_send_log_app_login_created_at. The journal-milter counts an application's messages on everyMAIL FROM, and the domain page computes per-application statistics on every render; both were scanning thecreated_atrange becauseapp_loginwas unindexed. - DMARC aggregate reports are accepted as zip as well as gzip or plain XML (several reporters ship a single-entry zip; those reports previously counted as parse failures).
- e2e:
checkPort25ConfigCombinationsregenerates the Postfix configuration inside the container for all four combinations ofINBOUND_RELAY_ENABLE/DMARC_REPORTS_ENABLE/INBOUND_ANTISPAM_MILTERand requirespostfix checkto pass. The1.9.1crash-loop shipped in1.4.0and survived three releases because the suite only ever exercised the flags-off path.
Changed
- README hero line and a Positioning block in
docs/product.mdrecord the tagline (YOUR SMTP. YOUR RULES.) and hero copy; the stamp subtitle staysSELF-HOSTED SMTP RELAY.docs/assets/selfpost-proof.htmlupdated to match.
Fixed
- The setup account's password is hashed at bcrypt cost
12like every other panel password; it was still being written atbcrypt.DefaultCost(10). The work factor now has one definition,auth.BcryptCost. bcrypt records the cost in the hash, so existing passwords keep verifying unchanged. - Login/setup rate limiter: protecting blocked buckets from eviction is now
bounded. Blocked buckets are still never evicted to make room, but past
maxBuckets × 4they are evicted too (nearest expiry first) — otherwise an attacker with many source addresses could grow the bucket map without limit by making each address fail its way to blocked. The overflow is logged at most once per window. postqueue -poutput is bounded to 4 MiB (the timeout alone left the size of the listing, and therefore the panel's allocation, up to the queue).- schema-migrations.md: the schema section was
still headed
v9while the chain head isv10, and three links pointed at plan files deleted when those features shipped. - postqueue reader is now time-bounded:
postqueue -pruns under a 5-second context timeout so a stuck queue command cannot hang the queue panel path indefinitely. - DMARC panel layout classes now exist in
internal/web/view/static/panel.css(.pair,.attn,.desk-only,.phone-only,.phone-list,.check-row), so desktop/mobile DMARC views and attention highlighting render as intended. dmarc-ingestnow bounds stdin withio.LimitReader(10 MiB) before parsing.- User-role update no longer contains a duplicate "last global admin" demotion guard; one check now returns a context-specific message for self-demotion.
- Settings flash text now reflects what actually changed and only mentions session sign-out when the password changed.
- Help page/drawer links now use
SourceURL({{.SourceURL}}/blob/main/docs/...) instead of a hardcoded repository URL. - Help drawer close controls include accessibility attributes (
role="button",aria-label) for the scrim and close action. - Test coverage expanded for DMARC map token injection rejection and email validation edge cases (ASCII/local-part/domain/freemail cases).
- Port 25 no longer runs the submission milter chain.
smtpd_miltersis now always overridden onsmtp/inet— to the optional antispam milter, or to nothing. Previously, withINBOUND_ANTISPAM_MILTERunset, inbound mail inherited main.cf's chain: OpenDKIM (strict,tempfail) could defer relayed mail during a signing outage, and the journal-milter filed inbound mail into the outbound send log under the sender's domain — so Deliveries showed other people's mail, and a forgedFrom:on an inbound message consumed that sending domain's level-2 rate-limit budget. architecture.md already described the intended behaviour; the code now matches it. - DMARC reports are validated before they are stored. A report is accepted
only when the domain in
policy_publishedis a sending domain configured here and, for a per-domain hosted address, matches that address's+tag. The rua address form is predictable, so anyone able to reach it could previously file reports under any domain, skew the panel's alignment figures and the tighten-p=hint, and evict genuine reports through the 500-report retention cap. - Auto level-2 rate limit: the ceiling is now scaled to the level-1 window
before the multiplier is applied.
ceil(avg msg/h × multiplier)was stored as the ceiling for a window ofRATE_LIMIT_WINDOW_SECONDS, so any window other than the default3600shifted the effective allowance bywindow/3600(a 1800s window allowed twice the intended rate). A domain that has sent anything now also gets a floor of 1. - Single-domain export no longer drops rate limits silently when the store
errors — the error is returned instead of being swallowed by
err == nil && ok.
Changed
- roadmap.md now carries everything the 2026-08-19 code
review left unimplemented, so no finding lives only in a plan file:
csrf-tokens(the recorded §4.4 decision that never became a task),template-data-typing,structured-logging, andreview-2026-08-followupsfor the small remainder (backup re-auth, the missing service/handler/error-page tests, and nine minor findings that were never carried into the review's own phase tables). Each roadmap row stands on its own — file, symptom, and what "done" means — and the declined finding (//go:build linuxfor the/procreader) is recorded as declined so it is not raised again. The review's working document is gone fromdocs/plans/now that nothing depends on it; git history keeps it. - Extracted
internal/configsafe— the empty-value and forbidden-character check every generated config file shares. Each writer keeps its own forbidden set next to the file it protects (an OpenDKIM table and a Postfix map break on different characters); only the check itself is shared. - Build, deploy and migration comments no longer cite the archived
specification. The
spec Nreferences CHANGELOG[0.5.0]said were gone survived inbuild/,deploy/, the first two migrations and the release workflow; they are removed and the affected comment paragraphs re-wrapped. Comments only — no build or configuration behaviour changes. - Dropped the
parseDomainRateLimitForm/parseAppRateLimitFormpass-through wrappers left behind when the two parsers were merged: nothing but their own tests still called them. - Extracted shared helpers:
internal/atomicfile(atomic temp-write+rename) andinternal/supervisor(supervisorctl start/signal wrappers), and reused them in domain/postfix code paths. docs/roadmap.md: shippedpanel-docsnow references CHANGELOG history instead of a removed plan file.- schema-migrations.md notes that migration
0009changed what the IP list means — permissive (those IPs got the application ceiling) before 1.9.0, restrictive (every other IP refused) after. Its conversion branch only fires for a database that carried application-scopeallowed_ipsfrom before 1.9.0. - Panel help, guide.md and architecture.md:
the client IP allow-list is a sending control, not an authentication
boundary — the SASL login still succeeds, the check runs in the
journal-milter at
MAIL FROM, and it is fail-open like the rate limits. architecture.md's rate-limit description still documented the pre-1.9.0 trusted-IP semantics. - Removed unused duplicates of the inbound validators (
Host,Port,TLSMode,RecipientMode,MailboxInDomain,NormalizeHost) frominternal/web/validate— the live path usesinternal/inbound/validate.go; the copies were reachable only from their own tests. gofmtacrosscmd/andinternal/(10 files had drifted); dead branch removed frompostfix.inboundNexthop.- The planned inbound-antispam migration is renumbered
0011_inbound_spam_log.sql. - Operator and as-built docs aligned with the code after a full pass —
architecture.md (port 25 opens for inbound relay
and/or DMARC ingest; antispam requires inbound relay; DMARC pipe transport
and map paths;
/helpand/dmarc*routes; four panel roles including rate-limit recalc; restore Resync vs manual reload); guide.md and README.md (image pin1.9.1, port 25 conditions, combined listener limits, reload vs Resync); roadmap.md (panel-docsclosed at[1.8.0], next item inbound-antispam-panel); development.md version-cut table through1.9.1; schema-migrations.md last-updated note. No behaviour change.
Removed
- Unused legacy asset
docs/assets/icon.png(superseded by the four brand SVGs andinternal/web/static/favicon.png; nothing referenced it). - Plan documents for already-shipped features:
domain-stats-auto-ratelimit,dmarc-reports,inbound-relay,panel-docs,queue-retries,send-log-retention. Only unimplemented plans remain indocs/plans/.
[1.9.1] - 2026-08-19
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam milter are enabled. Schema-migration reference and roadmap plans for inbound antispam and quarantine. Upgrading from 1.9.0 is a tag bump; no migration.
Added
- docs/plans/inbound-antispam-panel.md —
agreed plan for inbound antispam journal (inbound-journal milter + mail.log
rejects) and instance-wide allow/deny lists synced to rspamd maps; target
1.10.0; Composer → Opus → Fable workflow. - docs/plans/inbound-quarantine.md — candidate plan for held-mail review/release (design TBD).
- docs/schema-migrations.md — living reference for the
SQLite migration chain (
user_versionhead, per-file history, legacy artefacts, 1.x rules, and planned 2.x squash gate).
Changed
- docs/roadmap.md —
inbound-antispam-panel(agreed, 1/12) andinbound-quarantine(candidate) in the index. - docs/development.md — plan checklists may override model routing with Composer → Opus → Fable.
- docs/development.md, docs/roadmap.md,
and docs/architecture.md link to the new file;
schema-squashdefers gate thresholds to it instead of a stale v5 snapshot.
Fixed
build/postfix-config.sh: enabling inbound relay, DMARC ingest, and/or the inbound antispam milter no longer crash-loops on start —postconf -Pcannot setmaster.cf-ovalues that contain whitespace, so port-25smtpd_recipient_restrictionsandsmtpd_miltersare patched directly; DMARC pipeargvuses admarc-ingestsymlink instead ofpanel -dmarc-ingest.
[1.9.0] - 2026-08-18
Application sending controls: client IP allow-list for authorization, and level-2 rate limits that override the domain ceiling per application.
Added
- Per-application client IP allow-list on the domain page: when enabled,
only listed addresses may authenticate and submit as that application; when
off, any client IP is allowed. Form
POST /applications/{id}/authips. - SQLite migration
0009_application_auth_ips.sql(moves legacy trusted IPs fromrate_limitsintoapplications.auth_allowed_ips).
Changed
- Application level-2 rate limit overrides the domain limit for that login
— the ceiling may be higher or lower than the domain setting (still ≤ level
1). Rate limits are no longer tied to client IPs; enforcement is in the
journal-milter at
MAIL FROM. - Domain export/import carries
auth_ip_restrict/auth_allowed_ips; legacyallowed_ipson an applicationrate_limitin export JSON migrate on import. - Panel help, Settings, and guide.md describe override semantics and the IP allow-list boundary.
- guide.md: full-backup section documents all four capture
methods (panel,
selfpost-backup, stoppedtarof the project directory, stoppedtarof./dataonly), warns againsttaron a live container, and compares downtime, archive contents, and restore behaviour in a table.
[1.8.0] - 2026-08-18
Built-in operator documentation in the panel: a CSS-only help drawer and Help page so Status checks and domain controls are explained without leaving the panel.
Added
- Help navigation entry and
/helpindex page. - CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine, TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records, DMARC, connection, applications, settings, export).
- «?» entry points on Status and domain card heads open the matching drawer topic.
Changed
- guide.md documents the boundary: in-panel help for card meaning; the repository guide for installation, env vars, and operations.
[1.7.0] - 2026-08-18
Optional DMARC aggregate report ingest: accept rua= mail on port 25, parse
gzip/XML into SQLite, and show summaries in the panel.
Added
- Optional DMARC aggregate report ingest (
DMARC_REPORTS_ENABLE): port 25 accepts only configuredrua=addresses, gzip/XML is parsed into SQLite, and the panel shows per-domain roll-ups and individual reports under DMARC. - Per-domain SelfPost hosted
rua=mode when ingest is enabled (dmarc-reports+<domain>@<hostname>).
[1.6.0] - 2026-08-18
30-day sending statistics per domain and application, plus optional auto level-2 rate limits derived from average send rate.
Added
- Domain page Sending statistics card: total messages, peak and average msg/h over the last 30 days (or shorter when send-log retention is below 30 days). Per-application stats in the application list.
- Level-2 rate limit Manual / Auto mode on domain and application
forms. Auto sets
max_messagesfromceil(avg × multiplier)over the level-1 window, capped at level 1; background recalc every six hours and Recalculate now on the domain page. - Domain export JSON now includes rate limits (
mode,auto_multiplier, ceilings, trusted IPs).
Changed
- Auto mode uses the level-1 window (
RATE_LIMIT_WINDOW_SECONDS); manual mode behaviour is unchanged.
[1.5.0] - 2026-08-18
Send-log retention is configurable from panel Settings (global
administrator); SEND_LOG_RETENTION_DAYS seeds the initial default only.
Added
- Panel Settings (global administrator): Send log retention (days) —
how long delivery journal rows on
/deliveriesare kept (7–365). Stored in SQLitesettings;SEND_LOG_RETENTION_DAYSseeds the initial default only. The log-tailer re-reads the value every prune cycle (no restart).
Changed
- Deliveries list and delivery detail pages show the configured retention instead of a hardcoded ninety-day message.
[1.4.0] - 2026-08-17
Optional inbound relay (backup-MX / forwarder) on port 25, off by default. Mail is accepted only for domains configured in the panel and forwarded to an upstream; there are still no mailboxes. A dedicated security review of the inbound path is still open on the plan (Fable).
Added
INBOUND_RELAY_ENABLE(defaultfalse): when true, Postfix listens on port 25 without SASL, only forrelay_domains+ known recipients, and forwards viatransport_maps. Empty upstream host is omitted from the maps so mail is never accepted with nowhere to send it.- Panel Inbound pages (global administrator): domains, upstream host/port
and TLS, recipient list or any-at-domain, MX check against
SELFPOST_HOSTNAME. - Optional
INBOUND_ANTISPAM_MILTERon the inbound listener only, plus deploy/antispam/docker-compose.antispam.yml. Default milter action is fail-open (accept). - Inbound rate limit (
INBOUND_RATE_LIMIT_MESSAGES_PER_IP, default 20) andINBOUND_MESSAGE_SIZE_LIMIT(default 25 MiB) on smtpd port 25.
Changed
- Compose publishes host port 25 even when the flag is off (nothing listens
until it is on), the same pattern as 587 /
SUBMISSION_ENABLE. - Full-backup restore Resync also rebuilds inbound maps when the flag is on. Single-domain export/import is still sending domains only.
[1.3.1] - 2026-08-17
Retry policy in the panel, persistent Postfix queue, and self-contained
full backups. Mail queue and delivery history show this Postfix's first
retry, backoff cap, and queue lifetime. Deferred mail survives container
recreate. Full backups now archive data/, compose, .env, and certs/;
the archive layout breaks 1.3.0 flat backups (unpack those with
tar xzf backup.tar.gz -C ./data as before). Upgrading from 1.3.0 is a
tag bump; no schema migration.
Added
-
Mail queue and a delivery's history show this Postfix's retry policy (first delay, backoff cap, queue lifetime), read from
postconf -honce at panel start. A manualpostconf -eoverride is visible after the next panel restart. There is no attempt counter — Postfix retries on time. -
docs: Plan checklists in development.md — format,
Progresscolumn in roadmap.md, per-step commit + CHANGELOG, version cuts1.3.1…1.8.0per roadmap stage. -
docs: Implementation checklists with model routing in docs/plans/ (queue-retries, inbound-relay, send-log-retention, domain-stats-auto-ratelimit, dmarc-reports, panel-docs).
-
docs: roadmap candidates send-log-retention (panel Settings for delivery journal retention) and domain-stats-auto-ratelimit (30-day send stats per domain/application, auto level-2 rate limit from avg × multiplier) — plans in docs/plans/.
-
Postfix queue under
/data/postfix/queue— deferred and active mail survive container recreate and are included in full backups. -
Full backup archives the whole operator project:
data/,docker-compose.yml,.env, andcerts/(requires.:/selfpost-deploy:roin compose). Restore by unpacking into an empty project directory.
Changed
-
Breaking: full-backup archive layout — paths are prefixed with
data/; deploy files sit at the archive root. Old flat archives restore withtar xzf backup.tar.gz -C ./dataas before. -
docs: operator guide, architecture, security, and backup UI updated for self-contained backups and persistent queue.
-
docs: HTML mockups for a full panel UI refresh (docs/assets/panel-ui/) — current screens, agreed and candidate roadmap surfaces (queue-retries, inbound-relay, dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms keep a reading measure), and a 390px emergency layout. The stamp and brick palette stay; extra nav icons are proposals only. Design artefact; the running panel is unchanged.
Changed
-
docs: panel UI mockups after review — Status restored to live readings (queue line, PTR, machine/process tables, Configuration); inbound MX DNS check, recipient mode (list or any), and Danger zone beside recipients; Backup and Settings as two-column ops pages; Save and Delete user on one row; help «?» on domain cards; Host/name ‖ Type height matched to the live panel (
b0ebe06). -
docs: panel UI design system and mockups rebuilt as separate pages (
system.html,status.html,domain.html, …), not one hash sheet. Regions arestack/pair/measure/fill; Host ‖ Type isfield-row; Save + Delete isactions-row. Shared chrome isshell.js. -
docs: panel UI mockups — add-domain sits in the list card (Domains and Inbound); DMARC candidate screens drill into a domain roll-up and a parsed aggregate report (aligned vs third-party fail), not a hub-only summary.
Fixed
- ci (GHCR): per-arch package tags (
X.Y.Z-amd64,X.Y.Z-arm64) are dropped after the manifest merge via the GitHub Packages API. The[1.3.0]merge job calleddocker buildx imagetools rm, which is not a valid subcommand — cleanup failed with a warning and the side-effect tags stayed in the registry until this fix.
[1.3.0] - 2026-08-14
Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration.
Added
-
licence: the SIL Open Font License 1.1 text now travels with the IBM Plex WOFF2 files (
internal/web/view/static/OFL.txt). The image copies it next to LICENSE and NOTICE under/usr/share/doc/selfpost/; the panel serves it at/static/OFL.txt. OFL requires the licence to accompany the font. -
docs: agreed roadmap item queue-retries — show this Postfix's retry policy (first delay, backoff cap, queue lifetime) on Mail queue and on a delivery's history, reading
postconf -honce at panel start so a manual override is visible. Plan:docs/plans/queue-retries.md(removed once shipped). Explanation only; no attempt counter and no panel knobs. Not yet implemented.
Security
- The independent security review of the send-log authorization and fail-closed fixes below (code-review plan § P7; reviewer model ≠ author model) found no further issues: the domain scope holds on every query path, a rate-limit refusal cannot consume window budget, and each failure residue of the reordered application delete fails safe. Nothing was added to docs/security.md § Accepted risks; the review is recorded in that file's header.
Fixed
-
test (e2e): send-log status scrapers follow the badge markup in
deliveries_rows. The release gate still looked for bare<td>sent</td>after the panel started rendering status as<span class="st st-*">badges, sosend_verify_dkim_and_statustimed out even when mail was delivered and logged. A handler regression test catches this drift ingo test ./...without Docker. -
security (panel): the Deliveries list is scoped to a domain administrator's assigned domains for every number of assignments. Previously the send log was narrowed only when exactly one domain was assigned, so an administrator with none or with two or more read every domain's rows (sender, recipient, subject) on the list and its polled fragment. The domain scope is now an
INconstraint carried by the store query — a filter that states no scope returns nothing — and thedomainandappquery parameters are checked against the principal's own domains and applications before the query runs, so a hand-written URL cannot widen the scope. Global administrators are unaffected. -
mail (level-2 rate limit): the ceiling is no longer overshot by messages that arrive at the same instant. The milter counted the stored and in-flight messages and reserved its own slot in two separate steps, so several SMTP sessions could pass the same check before any of them had reserved. Counting and reserving now happen as one operation, and the ceiling is handed out exactly as many times as configured. Postfix's level-1 limit remains the backstop and the level-2 check stays fail-open on store errors.
-
panel (sign-in): a session that cannot be written to the database no longer produces a session cookie. The login used to log the failure, set the cookie and redirect to the dashboard, leaving the browser looking signed in while every request bounced back to
/login; it now fails closed with an error on the sign-in page. -
panel (applications): deleting an application removes its SASL credentials before its registry row. If
saslpasswd2fails, the application stays listed and the delete can be retried, instead of leaving a hidden account that could still authenticate to Postfix. This matches the order domain deletion already used. -
panel (GUI): a rejected rate-limit change on a domain's page now renders on the danger surface (
.flash.error) instead of the success one — it was green with red text, reading as good news. Deleting a panel user now goes through a confirmation page, the same pattern as domain deletion, instead of a plain submit button next to Save with no confirmation at all. -
panel (restore): after a backup is extracted and the version guard passes, the panel runs one mail-path Resync on the first boot — OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and the daemons are reloaded, so drift between the archive and the database is healed before mail flows. Later starts skip that step; the Status page Reload button runs the same Resync on demand. The
internal/backuppackage comment now matches this behaviour.
Changed
-
docs: operator and as-built docs aligned with the code after a full pass — architecture.md route table now marks global routes (404 for domain administrators) and documents the one-time restore Resync in Persistence; session/password and restore-session wording corrected in guide.md and architecture (own-password change vs admin reset, no "logout everywhere", immediate session restore on the next request, PTR cache ≈1 min, decrypt has no version check, restore Resync on first boot, domain add/delete/import and
POST /reloadglobal-only, Settings DMARC global-only); README.md port-587 and quick-start volume wording fixed; security.md CSRF ADR points atauthz.gofor route gating. No behaviour change. -
docs: guide.md reorganised into Installation, Instance administration, and Domain administration — DNS setup, operations, rate limiting, and backup sections follow the instance/domain boundary instead of mixing them. Installation now reads Ports → Local trial → Initial setup → Full deployment (with the fixed image tag nested under it) → Environment variables → Reverse proxy; the step-by-step production deploy and per-proxy TLS commands move here from README's "Reference deploy" (README keeps a short pointer). Internal (non-operator) environment variables move to architecture.md § Configuration; the guide keeps a one-line pointer. Full backup and restore gains worked commands for in-place restore, move-to-a-new-host, and encrypted-backup decrypt-first, plus the version-mismatch error text. README anchors updated for the new headings. No behaviour change.
-
docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7) is closed — and
docs/plans/code-review.mdis deleted per its own exit criteria (history in git and in this file). The plan covered architecture, quality, GUI, tests, and licence work; P0 was domain-admin send-log authorization. The roadmap's recommended order returns to queue-retries and then inbound-relay; it still records schema-squash (replace the 1.x SQLite migration chain with a 2.x baseline; not a reason to cut a major on its own). -
licence: NOTICE tells modifiers to update
SourceURLininternal/legal/legal.go(the value the panel footer actually injects), notlayout.html. Per-fileSPDX-License-Identifierheaders on the two command packages were dropped so the tree is consistent; AGPL-3.0 does not require them (development.md § External libraries). Deleted the completeddocs/plans/logrotate-mode.md(history in git and 1.2.3). -
ci: the release image is published only for a published GitHub Release (
vX.Y.Z) or a manualworkflow_dispatchwith an explicit SemVer version — a bare git tag push no longer starts the build.release.ymllistens forrelease: published, checks out that tag (notmainHEAD), e2e-gates each native arch build, and mergesX.Y.Z-amd64andX.Y.Z-arm64into one manifest. Reliable removal of the per-arch side-effect tags from GHCR landed in[1.3.1]. A dispatch whose version input is missing or notX.Y.Zfails inprepare. development.md documents draft vs published releases, why deleting a release tag converts it back to draft, and Gitea → GitHub tag-mirror pitfalls (do not prune release tags on GitHub; a mirroredv1.0.0still runs that tag'son: push: tagsworkflow). -
test: the authorization and sign-in surfaces that had no tests now have them. The login limiter is covered for its ceiling, its per-address scope, the reset at the end of a window and the sweep that keeps finished buckets out of memory; sign-in for a successful session, for refusals that do not reveal which usernames exist, and for a lockout that a correct password cannot bypass; the one-time setup link for creating the first administrator, closing afterwards, rejecting a wrong or expired token, and refusing credentials the panel would not accept later. Every global-only route (
/users,/backup, domain import,/status,/mail-queue,/system-log, domain add and delete, reload) is checked to answer a domain administrator — and a request with no principal — with 404, the check that would have caught the send-log leak. -
test: restore is covered as the operator performs it, in process. A backup is downloaded from a running panel through
POST /backup(plain and encrypted), unpacked the waytar -xzfunpacks it onto the/databind mount, and a second panel is booted on the result through the startup order the panel itself uses — version guard, database, one Resync when restoring, then services and the HTTP application. The restored panel shows the domain and journal the archive carried, finds the DKIM key, SASL database and Postfix sender map where its configuration says they are, does not reopen the one-time setup link, and still honours a session that predates the backup. Drifted on-disk maps are healed by that Resync step (TestResyncAfterRestoreHealsDriftedMaps). A data directory left by another version is refused with both versions named and the manifest kept.serveHTTPis split in two so that composition can be started without binding a port; no behaviour change. -
test (e2e): the CoreDNS image is pinned to
1.14.6instead oflatest, so the release gate cannot change under a commit between two runs. The level-1 rate-limit failure message quotedRATE_LIMIT_MESSAGES_PER_IP=5while the stand sets50. -
ci: gofmt on eight files that failed the formatting workflow check (panel config, DNS check, domain transfer export, rate-limit tests, auth principal, domain and delivery handlers, web package doc comment).
-
panel (templates): the repeated Host/Type/Value DNS record markup on a domain's page and the duplicated credentials form on Settings are now shared partials (
host_type,host_type_copy,field_value,field_values,credentials_fields) instead of copy-pasted blocks. No behaviour or visible change. -
panel (GUI, accessibility): the Deliveries fragment's
hx-getand pagination links nowurlquery-encode thedomain/appfilters instead of splicing them into the query string raw. The four polled regions (deliveries rows, status, mail queue, system log) carryaria-live="polite"so a screen reader announces the refreshed content. -
docs: security.md accepted risks now note that
data-confirmprompts on destructive forms are JavaScript-only — with JavaScript disabled the form submits immediately, the same as before the prompts existed — and why that is acceptable (the prompt is a mis-click guard, not an authorization boundary). -
docs: security and operator docs updated for the panel that has shipped global administrators and domain-admins since 1.2.0. The CSRF ADR in security.md no longer argues from "single-user"; it now states that cross-user CSRF between panel roles is not the threat the origin check defends against, and gives a new revisit trigger. Dropped the unimplemented "or argon2" alternative for the password hash. guide.md documents the Users page and the two roles, level-2 rate limiting's fail-open behaviour, and that a domain-admin can export working SASL passwords for domains assigned to them. architecture.md gains
/licenseand the/account→/settingsredirect in the route table (later expanded for RBAC in the doc-alignment pass above). Corrected staleadmin.dmarc_report_emailreferences in roadmap.md anddocs/plans/dmarc-reports.md(removed once shipped) to the setting's actual home after migration0005. No behaviour change. -
panel: code-review P6 cleanup — the unused
auth.RequireGlobalmiddleware is gone (handlers already callrequireGlobal); the settings route handler is namedHandleSettingsinhandlers_settings.go; domain lists for a domain-admin now come fromListDomainsForUserin SQL instead of loading every domain and filtering in Go; the login and setup rate limiters sweep expired buckets on a timer and cap the map at 4096 keys; the five show/hide field helpers inpanel.jsare one rule table; DMARC copy no longer promises in-panel report reception in a future release — SelfPost does not receive inbound mail.
[1.2.5] - 2026-08-13
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
Changed
- panel: rate-limit forms refined — level-1 backstop as muted copy on the
domain settings form (
N messages / Ws — Settings) and in message-limit labels (max N); Settings shows the L1 ceiling in a code-row. Domain, application, and Edit-toggle limit state use the sharedstbadge instead of bold text or parenthetical copy. Address mode and trusted-IP override columns carry muted leads and matched control height; trusted-IP help sits under the IP field. Domain settings pairs DMARC reports with the level-2 rate limit using CSS subgrid so titles, fields, and Save / Remove buttons line up across columns. Domain page Export and Danger cards stack naturally again — a shared-baseline flex pin on.splitcards made mismatched body lengths look worse than a plain stack.
[1.2.4] - 2026-08-12
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field height fix. Upgrading is a tag bump; no migration.
Changed
- rate limiting (level 2): domain ceilings apply to every client IP (no IP allowlist). An application ceiling with trusted IPs is an override above the domain limit (still ≤ level 1) and skips the domain check for those IPs; without IPs the application override is inactive. When no domain ceiling is set, non-privileged senders use level 1 only. The panel shows the level-1 backstop on domain/application forms and Settings, rejects maxima above level 1, and requires an application override to exceed the domain maximum. Operator guide and architecture updated.
Fixed
- panel: on the domain DNS status grid, the Type (TXT) field height matches the Host fields.
[1.2.3] - 2026-08-12
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no migration.
Changed
- panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
connection settings ‖ add application; export ‖ danger). DNS status,
Applications and Domain settings are full-width. DNS status is two rows
(DKIM ‖ SPF, DMARC ‖ report authorization) with Host ‖ Type (narrow TXT)
and a Value label when records are present. Domain settings pairs DMARC
report mode with the optional level-2 domain rate limit; application Edit
opens address mode and an optional level-2 application rate limit side by
side (with a note that domain level-2 and global level-1 still apply); the
custom
rua=address field is shown only for Custom address. The in-nav “On this page” section index is removed. Shorter blurbs; Sending server settings renamed Connection settings. - panel: Domains list — Add domain sits beside the domain field; the lead blurb under the form is dropped.
- panel: page URLs, browser titles, and headings are aligned — Settings is
now
/settings(legacy/accountredirects with 308); the domains list title isSelfPost — domains; Status, Users, and user create/edit titles match their nav labels and<h1>text; the backup page title isSelfPost — backup & migrationto reflect domain import as well as full backup. Operator guide and architecture route tables updated.
Fixed
- image:
mail.logrotation no longer silently stops when the build context shipslogrotate-mail.confwith group/other write bits (common after a Windows checkout sync). RuntimeCOPY --chmodpins config and script modes in the Dockerfile;logrotate-loop.shrefuses a config logrotate would ignore. E2e checks mode644, forced rotation, and a group-writable context build.
[1.2.2] - 2026-08-12
Status page layout after 1.2.1: paired cards in a wide column, denser machine and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no migration.
Changed
- panel: Status is wide again so paired cards fill the column. Layout:
Overall; Machine ‖ Processes; Mail queue ‖ TLS certificate; Milter sockets ‖
Hostname / reverse DNS; Configuration. Dropped lead blurbs on Machine, TLS
certificate, and Hostname (and Hostname's trailing detail line); milter
socket paths omitted from the table; queue link reads View queue; milter
ok detail is
Listeningwithout a trailing period and sits in its own Detail column beside the status badge; CPU detail is only core and thread counts (no load average); memory detail isN used of Mwithout the «available to new work» clause; network detail lists per-interface totals only (rates stay in the Usage column). No «On this page» section index — the paired layout is short enough. Hostname stays in the polled fragment so the pair survives HTMX refresh. In-panel docs for the removed blurbs filed as roadmappanel-docs.
Fixed
- panel: cards inside
.splitusedmargin: 0 auto, which in a CSS grid shrinks each card to its content and centres it in the track instead of filling half the row. Side margins are cancelled for.split > .card(Status, Settings, and a delivery's message/history). - panel: on the user create/edit form, Assigned domains checkboxes stacked
the box above the domain name (and stretched it full-width) because the form's
block-label and full-width input rules applied to them. Checkbox rows now use
the shared
label.checklayout; the fieldset has matching spacing.
[1.2.1] - 2026-08-11
Panel refinements after 1.2.0: navigation icons, status-badge centreing, drill-down back-link placement, and safeguards for the sole global administrator. Upgrading is a tag bump; no migration.
Fixed
- panel: status badge text sat low in the box (and below the heading or label beside it). IBM Plex Mono sits low in its em square; the previous top-heavy padding made that worse. Bottom padding is now heavier so the word centres optically.
- panel: the Users navigation icon was two full silhouettes with staggered
baselines, so the pair looked lopsided at 16 px. The rear person is now a
right-side crescent (head + shoulder) behind a full front silhouette aligned
with
icon-account. - panel: the Settings navigation icon was a sun-with-rays (circle plus spokes), not a gear. It now uses a toothed cog so it matches the other session icons and the 1.2.0 release note.
- panel: the user create/edit form placed «Back to users» at the bottom of the
card instead of under the heading like the delivery, domain, and domain-delete
pages. A shared
back_linktemplate now renders every drill-down up-link, andTestDrillDownPagesPlaceBackLinkAboveContentguards its position.
Changed
- panel: the user edit form disables role change and delete for the only global administrator, with a short note, instead of allowing the action and showing an error on submit.
- panel: the user create/edit form hides Assigned domains when the role is global administrator, since that role manages every domain anyway.
- panel: Settings shows panel credentials and DMARC aggregate reports side
by side for global administrators (the same
.splitlayout as a delivery's message and history). Domain-scoped users keep the single narrow card.
[1.2.0] - 2026-08-11
The second MINOR after 1.0.0: domain administrators with per-domain scope, a
panel visual refresh on the SelfPost palette, and refinements to navigation and
the send log. Upgrading runs one SQLite migration (0005_panel_users); the
single administrator becomes a global user.
Added
- panel: domain-admin role — global administrators manage panel users and
assign domains; domain administrators see only their domains (applications,
DKIM/DNS, per-domain DMARC, deliveries, export, L2 limits). Status, full
backup, mail queue, system log, domain add/delete, and
/reloadstay global-only. SQLite migration0005_panel_usersmigrates the single administrator into a global user; sessions and full backup restore carry users and domain bindings.
Fixed
- Docker build:
LICENSEis no longer excluded by.dockerignore, so the runtime image can copy it into/usr/share/doc/selfpost/as AGPL requires. A clean build failed once the cached layer was invalidated. - panel: on signed-in pages with a narrow card (Settings, the user form)
the heading, flash, card and footer now share one left edge.
.card.narrowhad overridden onlymax-widthwhilemain > *still centred siblings on the 48rem measure, so the card floated 12rem to the right of the title.main:has(> .card.narrow) > *keeps the stack on 24rem without narrowing the column, so the navigation stays put; login/setup are unchanged.
Changed
- panel: navigation session icons are distinct — Settings uses a gear, the signed-in user line carries the single-user icon, and Users a two-person group mark instead of the same account silhouette for all three.
- panel:
/redirects domain administrators to/domains; global users still land on/status. Navigation hides global-only sections for domain administrators. - panel: Sign out in the navigation column uses the same type size and weight as the page entries above it; only the red tint and border mark it as destructive.
- panel: visual style brought in line with the SelfPost mark — brick accent
and warm paper in place of the blue-on-cool-grey defaults, IBM Plex Sans and
IBM Plex Mono served by the panel itself, squarer corners, and column
headings, status badges and small labels set in the mono face. Light and dark
schemes both keep their contrast; no page, control or workflow changed. The
send log stops breaking
Detailsanddeferredacross two lines when a row is tight. Badge padding and line-height are tuned so lowercase labels sit centred in the box. The three WOFF2 files add ~76 KB to the image and are served from the panel's own origin, so the Content-Security-Policy is unchanged (default-src 'self'). - panel: the send log's status is a badge, in the same ok/warn/error/unknown
colours the status page and the DNS checks use, instead of the one place in
the panel where a status was bare text. The mapping is the one the delivery
page already applied —
sentis ok,deferreda warning,bouncedandrejectederrors,queuedunknown because nothing has gone wrong yet. - docs: roadmap.md and product.md no
longer list domain-admin or visual-style as open work — both ship in this line.
Completed plan files (
domain-admin,visual-style,web-split,narrow-page-alignment) are removed; history stays in git and the entries above. Inbound relay is the main agreed 1.x+ item left on the roadmap.
[1.1.0] - 2026-08-10
The first MINOR after 1.0.0: send-only DMARC guidance in the panel, AGPL
packaging on every page, and an internal split of internal/web ahead of
domain-admin work. Upgrading runs one SQLite migration (empty defaults;
existing DNS guidance is unchanged until you set a report address).
Changed
internal/websplit into subpackages (web/view,web/auth,web/validate,web/handlers); the composition root (web.New,web.Config,Server.Handler) is unchanged forcmd/panel. Templates and static assets moved underinternal/web/view/.
Added
- panel: DMARC guidance for send-only relays — the suggested
_dmarcrecord is nowp=nonewithoutrua=by default; Settings and each domain page let you configure an optional aggregate-report address (profile default plus per-domain inherit / none / custom). Whenrua=targets another domain, the panel shows and DNS-checks the hub's_report._dmarcauthorisation record. Domain export/import carries per-domain overrides. - AGPL packaging hygiene: NOTICE names the copyright holder and the
bundled third-party works (htmx 0BSD, IBM Plex OFL in outlined logos); the
panel footer on every page — including login and setup — shows copyright, a
link to
/license(embedded AGPL text), a Source link to the public repository, and "No warranty"; the runtime image shipsLICENSEandNOTICEunder/usr/share/doc/selfpost/.docs/development.mdnow lists the vendored htmx asset beside the Go module licences. - docs/roadmap.md — candidate item visual-style (panel visual refresh: typography, colour tokens, spacing, and component styling without behaviour changes). Starting reference: docs/assets/selfpost-proof.html. No semver impact; explicit agreement required before coding, like other candidates.
[1.0.1] - 2026-08-09
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added
SECURITY.md— how to report a vulnerability privately (GitHub private vulnerability reporting,public@mixeme.ruas fallback), which releases get fixes, and what is in and out of scope for a relay. No response time is promised. Without it a finder's default move is a public issue, which discloses a relay flaw to everyone the moment it is filed.- docs/plans/ — one document per agreed extension: the optional
inbound relay, the domain-admin role, and splitting the oversized
webpackage. Each states scope, open questions, and what has to be true before coding starts. docs/roadmap.md is restructured around them as a 1.x+ tracker instead of a 2.x wishlist, and now says how to read it from outside the project: nothing in it is a commitment, there are no dates, and the stated order is a recommendation.
Changed
- The panel's Account entry is now called Settings — nav link, page
heading, browser title, and the operator guide. The route stays
/account, so existing links and bookmarks are unaffected. - The signed-in name in the panel's nav is now labelled
User:, so it reads as the current account rather than as a stray word above the Settings link. - docs/product.md reframes the future line: the inbound relay and the domain-admin role are agreed 1.x+ extensions tracked in the roadmap and the plans, with the inbound relay targeting a MINOR bump by default and a 2.x major still possible pending implementation. Only items the roadmap still marks candidate need explicit approval before coding. It previously put the whole line behind a 2.x.x that nothing had committed to.
- docs/security.md is now in English, matching the rest of
the published docs — it is linked from the README table and from
SECURITY.md, so a reader following either landed in Russian. Content is unchanged: same requirements, same accepted risks, same ADR. The reviewing model is no longer named in the text; the fact that a pre-release review ran, and its date, stay. The roadmap and the plans are in English for the same reason, and neither records the model assigned to an item any more. - The README documentation table now points at
SECURITY.mdfor reporting a vulnerability, and thedocs/security.mdrow is renamed Security design — with two files a reader could reasonably call "security", the table said which is which only by accident. The roadmap row no longer calls the file internal and Russian, because it is neither.development.mdlistsSECURITY.mdamong the user-facing deliverables. docs/development.mdrecords the decision on authorship: SelfPost is written by AI agents under a maintainer's direction and the project discloses that, so theCo-Authored-Bytrailers, the model routing table, and the agent rules file all stay. Written down to settle the question rather than have it reopened at each release.- The two remaining Russian source comments are in English:
deploy/traefik/extract-cert.sh(quote from spec 10.3) andinternal/app/sasl.go, where the quotation from the closed plan is dropped rather than translated — rendered in English it restated the sentence it was attached to. The Cyrillic that remains is test data, where it is the point.
Fixed
- The panel's static assets are served with a content ETag and
Cache-Control: no-cache. They are embedded in the binary, so their modification times are the zero value and noLast-Modifiedwas sent; with no validator at all the browser was free to guess how long to keep them, which is why a tab kept showing the previous favicon after the new mark shipped. Each asset is now hashed once at startup, so an unchanged one costs a bodyless 304 and a changed one is picked up on the next load. A browser that cached an asset before this release still has nothing to revalidate against, so that one copy has to be cleared by hand.
[1.0.0] - 2026-08-09
Added
- A Delivery log on each delivery's page (
/deliveries/{id}): themail.loglines Postfix wrote about that message, oldest first — the connection to the receiving server, its reply, and the status that reply was filed as. It is a table of two columns, when and what, so the seconds between the connection and the reply line up down one edge; the timestamp is the log's own wall clock without its microseconds and offset, and a line whose head is not a timestamp keeps its whole text under Message (logtail.SplitTimestamp, which reads both postlogd's format and syslog's). The queue id was printed on this page as something to go and search the system log for by hand; the search is done for the operator instead (logtail.QueueLines). The read is a bounded tail of the current log and matches only lines carrying this message's queue id, anchored so a shorter id is not found inside a longer one. Send-log rows outlivemail.log— retention is ninety days, rotation keeps fourteen files — so a message with no lines left says so rather than reporting a failure. - A History block on the same page: the journal's two timestamps stated as the steps they stand for — accepted and queued, then delivered, deferred, bounced, or refused before queueing — each with the status it reached in the panel's own ok/warn/error/unknown badge vocabulary. A message still queued shows the delivery report it is waiting for as a step that has not happened.
Fixed
- Postfix config: copy TLS cert/key from the (often
:ro, host-owned) mount into/etc/postfix/tls-internalasroot:rootbeforepostconf/postfix check. Bind-mounted keys owned by the CI/host UID madepostfix checkfail and the container exit before supervisord started. - Postfix config: allow
maillog_fileunder/dataviamaillog_file_prefixes=/var,/dev/stdout,/data, and pin thepostlogmaster.cf service. After mail.log moved to/data/log,postfix checkfatally rejected the path (default prefixes are only/varand/dev/stdout) and often left stderr/mail.log empty. - E2e: read
/data/setup-tokenviadocker compose exec(file is0600panel-owned; hostReadFilegot permission denied on CI). Reclaim/dataownership before TempDir/stage cleanup so panel/postfix UIDs do not fail Go'sRemoveAll. - Entrypoint: check
SELFPOST_HOSTNAMEbefore/datasetup (so a bad identity fails with the FATAL text, not an earlierset -eabort), andchmod 755 /dataafter chown so OpenDKIM/Postfix can traverse bind mounts that arrive as mode0700(GoTempDir, some host umasks) — otherwise KeyTable is unreachable and the container crash-loops. E2e stand usesrestart: "no"and surfaces selfpost logs from the supervisor readiness check. - E2e gate: wait for host-published
/healthzbefore panel setup, and stop orderedTestE2Esubtests after a failure so a nil panel client cannot panic and mask the real error (release CI on both amd64 and arm64). - Release CI: retry
docker push/imagetools createon transient GHCRunknown blob(and similar) errors after layers already uploaded. - A send-log row could stay
queuedforever after the container was recreated.mail.logmoved from the ephemeral/var/loginto the data volume (/data/log/mail.log,./data/log/on the host), so the delivery lines that resolve a queued row now outlive the container the same way the journal does.postlogdwrites the file as userpostfixand the unprivileged panel reads it through the sharedselfpostgroup (directory2750, file0640, re-normalised on every start); logrotate creates each new file the same way. Existing deployments need no action beyond the upgrade — the directory is created on first start — but the log written by the previous image is gone with its container, and the tailer starts the new file from its end. - A row whose delivery lines are gone for good is no longer left
queuedindefinitely: every five minutes the tailer compares rows still queued from more than two minutes ago againstpostqueue -p, and marksbouncedthose whose message Postfix no longer holds — it will never report on them again. The sweep waits until the tailer has read the log to its end (on a restart the log itself holds the answer) and does nothing at all if the queue cannot be listed, so a message merely in flight, or apostqueuethat fails, never closes a row.
Changed
- Document what
.spbkand.spdestand for (SelfPost backup / SelfPost domain export) in the operator guide, security notes, architecture, and the Backup / Export panel copy. - Docs aligned with the code: setup URL is
/setup/<token>(README and guide; local trial rewrites the printedhttps://<hostname>/…link tohttp://127.0.0.1:8080/…); domain import uses the file extension / magic bytes for the password field, not an "encrypted" checkbox; architecture layering and route table matchweb→storeandPOST /domains/import; OpenDKIM drops toopendkimviaUserID; guide drops the archived "spec 7.5" pointer, clarifiesPOSTFIX_SENDER_LOGIN_MAPSvs panel writes, and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts from before the published1.0.0image are called out in the guide anddevelopment.md. Roadmap points at CHANGELOG[0.5.0]Security and refreshedinternal/websize / symbol links. - Full backups no longer carry
/data/log. It is Postfix's raw log plus its fourteen rotated copies — diagnostic output rather than state to restore, and otherwise by far the largest thing in the archive. - Monitoring screens (status, mail queue, system log, deliveries) use adaptive
HTMX polling: 5 s while the operator is active on the page, 30 s when the tab
is visible but idle, and no requests while the tab is hidden. Scheduling
lives in
panel.js(data-pollmarkers) instead ofhx-trigger="every …", which would needunsafe-evalunder the panel's CSP. - Documentation package consolidated into
docs/development.md: Documentation map, user-facing deliverables, maintenance rules, and code-to-prose verification table (from closeddocumentation-plan.md); resuming work, model routing, commits, and phase closure (from closedprogress.md).docs/archive/removed — history is git + CHANGELOG. README Documentation index lists operator docs plus the internal roadmap. Agent rules point atdevelopment.md. - The delivery page is laid out in two columns: what the journal recorded on the left, what happened to the message on the right, and the delivery log at full width under both. The facts the page used to stack one per line — domain, application, queue id, journal id and the two timestamps — are a grid of tiles instead, since a page of mostly empty rows was what the full-width stack came to for six short values. The subject heads the page and the sender, recipient and outcome are the line under it, so what the message was and how it ended are both on the first line. The page takes the whole column rather than the reading measure, as the other three monitoring pages already did.
docs/development.mdrestructured into stack, dependencies, build, release, testing, and CI; agent rules moved to.cursor/rules/agent-rules.mdc; dev-host-specific workflow andexample.comreferences removed from docs.docs/development.mdand.cursor/rules/agent-rules.mdctranslated to English;roadmap.mdremains Russian (internal tracker).- Deploy pin and local-trial image tag set to
ghcr.io/mixeme/selfpost:1.0.0(compose and git tagv1.0.0cut together). Retireddocs/implementation-plan.mdanddocs/v1.x-closure-plan.md; Makefile,release.yml, and e2e comments point atdocs/development.md. Roadmap v1.x documentation/deploy tail closed.
[0.6.0] - 2026-08-08
Added
-
A page per delivery (
/deliveries/{id}), reached from the Details link on every send-log row. It carries what the log itself no longer shows — the sending domain, the application the message was submitted under, the Postfix queue id to search the system log for, when the status was last reported — so the journal can grow fields without the table having to find columns for them. Back returns to the page and filters the row was opened from, rebuilt from the log's own parameters only. -
A DNS badge in the domain list, one per row, carrying the same ok/warn/error/unknown vocabulary as the rest of the panel: the worst of that domain's DKIM, SPF and DMARC checks, so a domain whose records were never published is visible without opening it. The badge links to that domain's DNS status card. The checks run concurrently across the listed domains — each carries its own timeout, and in series a dead resolver would multiply that wait by the number of domains — and share the checker's cache with the domain page, so a repeat view costs no lookups. A domain whose DKIM key cannot be read stays "unknown" rather than being reported as misconfigured, since the missing half is this server's.
-
Machine metrics on the status page: a Machine card reporting the processor (busy percentage, core count, load average), memory and swap, and network throughput and totals per interface, read from the kernel's counters in
/proc(internal/health/machine.go). CPU and throughput are differences between two readings, so they are measured against the previous poll of the status fragment and reported as still being measured until a second reading exists — a page opened after a long idle stretch re-baselines rather than presenting that stretch as the current load. A fully busy processor (≥90%) warns and an exhausted machine (≥97% of memory in use) errors, both counting towards the page's headline verdict, since either delays or kills the mail path; throughput is reported and never graded. Counters that cannot be read — no/procoutside Linux — leave the card in place showing "unknown". 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. -
An index of the current page's own sections in the navigation column, for the two pages long enough to need one: the domain page (nine cards, from the DNS records to publish down to the danger zone) and the status page (eight). Each card carries an id and the page's template defines the list (
{{define "sections"}}); every other page defines nothing and shows no index.panel.jsmarks the section currently in view, looking its 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 are plain fragment links and work with JavaScript blocked; only the highlight needs it.
Changed
-
Every panel page is laid out in one column of the same width, so moving between them no longer shifts the navigation and the cards sideways. The column used to be the 48rem reading measure, which the send log, the mail queue and the system log widened to 64rem for their tables — and since the navigation and the page are centred as a pair, that difference moved everything on screen on the way between two pages. The column is now 64rem throughout, with the reading measure kept inside it: a page's heading, cards, back link and version footer are held to 48rem and centred in the column, and the three pages made of data opt out and take the column whole. Which pages those are is declared by the page itself (a
wideblock in its template, the same mechanism as the section index) rather than derived from the navigation entry, so a single delivery's page — prose, but filed under the send log — keeps the measure. The scrollbar's width is now reserved on every page as well: without it a short page and a long one were laid out in viewports differing by that width, which moved the same things again. -
The mark's small-size variant — the tab icon and the
SPinitials it carries — sets its S in Medium where the wordmark sets it in ExtraLight. Against the P's SemiBold the ExtraLight S is a 0.90 stem against 3.40, which at 16px is a quarter of a pixel against most of one, so the pair rasterised to a P with a smudge beside it. Medium gives up the Self/Post weight play, which needs more pixels than this variant exists to work in, in exchange for both letters being there. The variants big enough to carry the contrast keep it.favicon.pngis regenerated to match, and the outlines are IBM Plex Sans as before — the same font-size, letter-spacing and baseline, with only the S's weight moved. -
The stamp's
SELF-HOSTED SMTP RELAYline is set at 11.5/0.15 instead of 7.2/2.8, and no longer carriesopacity=".78". At the old size its stems rasterised to about half a device pixel, so more than half its ink landed as antialiasing — the typical pixel reached 2.1:1 against the brown rather than the 7.3:1 the two colours are worth, and none reached full strength. The line keeps its footprint and its monospaced cells: the width the tracking was spending went to the glyphs, whose cap height rises from 5.2 to 8.3. The mark is used at 330px on the login and setup pages, which is where this was worst.internal/web/static/logo.svgis a copy ofdocs/assets/selfpost-stamp.svgand both carry the change, as does the proof sheet the outlines are drawn from. -
The delivery log lists what identifies a message and nothing else: time, sender, recipient, subject, status. Domain and application, which were a column each, remain the log's two filters and now appear per message on the delivery page. The two dropped columns were the widest thing in the table after the addresses, and both repeat down the page whenever a filter is set.
-
Subjects are decoded for display as well as on the way in, so the rows the journal-milter recorded before it decoded them itself — the ones an operator is most likely to still be reading — show their text rather than
=?utf-8?Q?…?=. The decoder moved tointernal/mailhdrand is shared by the milter and the panel; it is idempotent, so a row decoded once passes through unchanged. -
The panel's navigation is a column down the left edge instead of a bar across the top. As a bar it 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; standing it up removes the compromise, gives the entries one left edge to scan down, and leaves room under them for the section index above. It is sticky, so both lists stay in view on the long pages, and the current entry is marked down its leading edge rather than underlined. Below the width the two columns need, it lies back down into the wrapping rows it used to be — no drawer and no hamburger, since six entries fit. The markup now lists the blocks in the order they are drawn, so the tab order follows the eye instead of starting at Sign out.
-
An application's mode and rate-limit fields open under its row of controls instead of inside it. Both panels were
<details>, so each opened where its own toggle sat and cut the row of four in half, pushing New password and Delete below a block of fields — the buttons moved every time a panel was opened or closed. 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 four controls keep their places and what a panel reveals is laid out beneath all of them. It stays keyboard-reachable and, being pure CSS, still works with JavaScript blocked, as the disclosure did. Inside a panel the submit buttons take the ordinary form spacing back from the compact row style that was leaving them flush against the field above, and Save limit and Remove limit — two posts, hence two forms — share one row, the first button bound to its form by theformattribute rather than by sitting inside it. -
The mark at the head of the navigation column takes the column's full width instead of the 110px it kept from the bar. In a row that size was all there was room for; in a column it left the mark ending halfway across, with no edge shared with anything below it. At the column's width its edges line up with the page entries under it, as the full mark already does with the card beneath it on the signed-out pages. Where the column lies back down into a bar it returns to the compact size, which is what fits beside the entries.
-
The import card reads the file's extension instead of asking whether the file is encrypted. The checkbox was a question the server never consulted — it decides from the envelope's magic bytes — so the answer could only be wrong. Choosing a
.spdefile reveals the password field and a.jsonfile hides it; an unrecognised extension reveals it, and with no file chosen the field stays hidden, since there is nothing yet for a password to open. With JavaScript blocked the field is shown, so an encrypted import still works.
[0.5.0] - 2026-08-06
Fixed
- A bounce could be recorded as a successful delivery. The log-tailer's
delivery-line pattern matched
status=greedily, so it took the last occurrence on the line — and Postfix appends the remote server's reply verbatim, which the far end controls. A rejection whose reply text containedstatus=sentwas filed assentin the send log. The pattern now takes the firststatus=after the recipient, which is the real field (internal/logtail/logtail.go); found while extendingTestParseDelivery. - Log-tailer resumes where it stopped instead of jumping to end-of-file on
every start (phase 3,
docs/code-review.md): the read position and a fingerprint of the log's head are persisted (logtail_state, migration0003), so delivery lines written while the panel was down are parsed and their send-log rows no longer stayqueuedforever. A log that changed identity while the panel was down is read from the start; a first-ever start, with nothing stored, still begins at the end. Container recreate remains a gap —mail.logis not in/data(docs/security.md). - Level-2 rate limit no longer overshoots under concurrency: messages that
passed the check at MAIL FROM but have not reached the send log yet are
counted alongside the stored rows (
internal/milter/inflight.go), so parallel SMTP sessions cannot each spend the same last slot. Slots are released at end-of-message, on ABORT, and after a 10-minute TTL, so a client that drops mid-transaction cannot hold one — the limiter stays fail-open.
Changed
- The project has a single public home:
github.com/mixeme/selfpost. Codeberg is being retired, so the Go module path moved with it —go.mod,test/e2e/go.mod, every import, theMakefileMODULEvariable and the-ldflagsversion stamp inbuild/Dockerfileanddocs/development.md. An import path pointing at a host that is going away would breakgo getandgo installoutright, which is why this is not only a documentation change. README no longer lists a primary/mirror pair. - Code comments no longer cite the archived specification. References like
"spec 7.6.1" or "spec 5.1" pointed into
docs/archive/specification-v1.0.md, which is explicitly not a source of truth; each is now a reference to the live document that owns the subject —docs/architecture.md(with section),docs/product.md,docs/security.md, or the README. Comments only; no behaviour is affected. docs/code-review.mdis gone. Its plan is finished — phases 0 (bar the release-commit steps), 1, 1.5, 2 and 3 are all closed — and the rest of the document had become a second copy of whatarchitecture.md,security.mdand the code comments already say. What was genuinely open moved todocs/roadmap.md: splittinginternal/webinto subpackages, a consolidated documentation index in the README, the adaptive polling interval for an idle but visible tab, andCONTRIBUTING.md. The review text stays in git history (522425a); the CHANGELOG entries below that cite it are left as written.docs/architecture.mdgained a Code layers section: a diagram of handlers → services → store plus the adapters, and the reason the services layer exists (multi-store writes and their rollback) — closing item A2 ofdocs/code-review.md.- Phase 1 doc/code hygiene (
docs/code-review.md): removed ~30 stale "Phase N" / historical-staging references from code and shell-script comments (cmd/panel,internal/*,build/*) now that v1.0 is done; fixed a stale dashboard comment (internal/web/handlers_domains.go) claiming applications/send-log were unimplemented; added a CSRF ADR todocs/security.md(why Origin-check, not tokens); resolveddocs/logoindocs/roadmap.md(directory doesn't exist, criterion already met); added agofmt -lcheck to CI (.github/workflows/test.yml). - Phase 2 GUI polish (
docs/code-review.md): the monitoring pages stop polling while their tab is hidden — the skip is done in anhtmx:beforeRequestlistener (internal/web/static/panel.js) rather than htmx's own trigger filter, which is evaluated withnew Functionand would be blocked by the panel's CSP. Dark mode is now a single reassignment of CSS custom properties underprefers-color-scheme: darkinstead of a cascade of!importantoverrides, and the duplicatemain { max-width }rule is consolidated into one base rule with documented per-page overrides (internal/web/static/panel.css).
Security
- Pre-release security review (plan § D, model Fable, 2026-08-06): full pass
over the diff from the v1.0 audit (Phase 11,
bd64e80) to HEAD plus the complete spec 7.6 checklist. No exploitable findings; one defence-in-depth fix below. Accepted risks indocs/security.mdunchanged. saslpasswd2argv: the application login is now passed after a--end-of-options marker (internal/app/sasl.go), so a login starting with-(legal under the whitelist) can never be parsed as a flag by getopt.
Added
- Optional password encryption for the two secret-bearing downloads (plan
phase 1.5,
docs/code-review.md): an Encrypt with a password checkbox on the full-backup and domain-export forms writes a.spbk/.spdeenvelope instead of the plain.tar.gz/.json— scrypt key derivation and AES-256-GCM over 64 KiB chunks, each authenticated with the header, its counter and an end-of-stream flag, so a truncated or altered file refuses to open (internal/secretfile). Unticked, both downloads are byte-for-byte what they were. - Domain import accepts an encrypted export: the envelope is detected by its
magic bytes, and a password field appears next to the file picker
(
internal/web/handlers_backup.go,templates/encrypt_fields.html). selfpost-backupwrites encrypted archives and reads them back:-decrypt(with-i/-o) turns a.spbkinto the plain.tar.gza restore unpacks. The password comes fromSELFPOST_BACKUP_PASSWORDor-password-file, never from argv.TestParseDeliverycovers the exotic mail.log shapes the review asked for (docs/code-review.md§ 3): astatus=quoted inside the remote reply, the null recipient of a double bounce,orig_to=alongsideto=, an unrecognised status word, a capitalised one, and a cleanup line.- docs: README Encrypting a backup or export;
docs/security.md§ Backup and domain export + accepted risk (encryption is opt-in);docs/architecture.mdpersistence § envelope summary. - docs:
docs/roadmap.mdv1.x tail — retireimplementation-plan.mdin the release commit (move todocs/archive/, retarget its references in README, docs, Makefile, release workflow and the e2e test comment). - docs:
docs/code-review.md— phase 1.5 plan for optional password encryption of full backup (.spbk) and domain export (.spde); checkbox UI pattern; remove session-resurrection-from-backup as accepted risk. - docs:
docs/code-review.md— full codebase review (architecture, code quality, documentation, GUI, legacy, risks) with prioritized implementation plan and model routing; cross-links inimplementation-plan.mdandprogress.md. - docs (D6): Docker
HEALTHCHECKprobes/healthz; endpoint returns 503 unless opendkim, panel, and postfix are RUNNING (internal/health.Liveness). - docs (D6): README Container health — scope of
/healthzvs authenticated Status. - docs (D7):
cmd/panel/envdoc_test.go— regression test that everyloadConfigand build-script env key is listed in README documentation. - docs (D8):
docs/architecture.md— as-built processes, mail path, routes, persistence (verified against code). - docs (D8):
docs/development.md— local Go workflow,make e2e, dev-server loop, commit/CHANGELOG protocol, agent rules. - docs (D9):
docs/product.md— product purpose, assumptions, out-of-scope, multi-domain model. - docs (D9):
docs/security.md— self-contained mandatory security checklist (former spec §7.6). - docs (D9):
specification.mdarchived todocs/archive/specification-v1.0.md; live docs updated (progress.md,implementation-plan.md,roadmap.md,documentation-plan.md). - docs (D1): README Operations — panel screens (
/status, domains, deliveries, mail queue, system log, backup, account), upgrade procedure, session behaviour (sliding idle, monitoring polls do not extend, password change signs out other sessions), andmail.logrotation cadence. - docs (D1): README Rate limiting — level-1 anvil limits
(
RATE_LIMIT_MESSAGES_PER_IP,RATE_LIMIT_WINDOW_SECONDS) and level-2 per-domain/application limits from the panel; fixes the.env.examplelink that pointed at a missing section. - docs (D2): README environment-variable reference — public
.envtable with code-accurate defaults,TRUSTED_PROXY_CIDRsecurity note, explicit internal-variable list;TRUSTED_PROXY_CIDRwired throughdeploy/docker-compose.yml.
Removed
- docs:
security.md— accepted risk «restore old backup revives session rows» (not a concern in operator deployment).
Changed
- docs:
implementation-plan.mdtrimmed to the sole open v1.x gate — pre-release security review (§ D); closed B.1–C.4 material moved to as-built and ops docs. - docs:
architecture.md— sessions (SQLite, idle renew, password change),mail.logrotation (rename +postfix reload),SELFPOST_HOSTNAMEstartup gate, log-tailer known gaps. - docs:
development.md— expanded e2e stack andrelease.ymlmatrix workflow. - docs:
security.md— accepted risk for send-log rows stuck atqueuedafter panel restart or container recreate. - docs:
roadmap.md— optional send-log /mail.logfollow-ups under v1.x tail. - docs:
progress.md,documentation-plan.md— cross-links updated for the new layout. - docs:
documentation-plan.mdmarked closed (D1–D9); trimmed to package checklist, code-verification method, and ongoing maintenance rules. - docs:
roadmap.md— v1.x doc/deploy tail (Codeberg Quick start, compose image tag at release,docs/logo); archived-spec references replaced withproduct.md/security.md/development.md. - docs:
progress.md— documentation pass closed; deferred polish in roadmap. /healthznow checks supervisord mail-path processes, not HTTP alone.build/Dockerfile:curlforHEALTHCHECK; probe on port 8080.- docs (D3): README backup — stopped-container
tarof./data(with live-container WAL warning),manifest.jsonconsumed after a matching restore. - docs (D4): README status banner (v1.0 implemented, links to open questions and
documentation pass); new Published ports note for 587; compose usage comment
corrected (TLS via
./certsbind mount, not.env). - docs (D5):
implementation-plan.mdB.1 — password change signs out other sessions only (implementation diverged from original plan; README was already correct). - docs: documentation plan now targets retiring
specification.mdafter D9 — migration map toproduct.md,architecture.md,development.md, and expandedsecurity.md; D9 added to the release gate.
[0.4.0] - 2026-08-04
Added
- The project's mark is now in use rather than only on file. The README opens
with the full stamp; the panel carries the compact one at the left of its
navigation bar, linking to the status page, and the full one above the card
on the two pages that have no navigation — sign-in and first-run setup. The
browser tab icon changes with it, from the earlier envelope drawing to the
stamp's small-size variant, so the tab, the panel and the README are one
identity. The four brand files in
docs/assets/had their wordmark converted from live text to outlines: they were set in IBM Plex Sans, which is not installed on the machines that render them, and the light/semibold contrast between Self and Post — the whole of the mark — collapsed into whatever fallback the viewer happened to have.
Changed
-
panel: the sign-in and setup pages are now a column the width of their own card. Both are a single narrow card, which centred itself while the heading above it stayed at the panel's left edge; adding the mark would have made that three alignments on a page with four elements.
-
panel: the three monitoring pages — Deliveries, Mail queue, System log — are now laid out wider (64rem against the 48rem the rest of the panel keeps). They carry data rather than prose: the send-log's seven columns had no room to breathe, and the raw
mail.loglines wrapped every second line.
Fixed
-
panel: Deliveries now shows the subject as text rather than as its MIME encoding. A subject in any non-Latin alphabet reaches the milter as RFC 2047 encoded-words (
=?utf-8?Q?=D0=9F…?=), and the panel printed that verbatim — unreadable, and as one unbreakable run wide enough to push the Status column outside the card. Subjects are decoded when the message is journalled and capped at 200 characters; the column clips anything still too long to one line, with the full text in the tooltip. Rows logged before this release keep their raw string. Subjects in the legacy single-byte charsets (windows-1251, koi8-r) are still stored as sent — there is no decoder for them. -
panel: table cells may now break inside a word, so no single long value can push a table past the edge of its card. A 40-character recipient address did it just as readily as an undecoded subject: a column is at least as wide as the longest unbreakable run it holds, and email addresses have nothing to break on. Timestamps are exempt and stay on one line.
-
panel: the Applications list on a domain page no longer comes apart. It was a four-column table whose last column held six controls, two of them expanding panels with textareas — far more than the width of a column, so the controls broke into a staircase, the login cell grew into a block as tall as the row, and the two text columns were left stranded on the baseline halfway down it. An application is now a block rather than a row: the login on one line, mode and addresses on the next, and the controls in a single wrapping row, with an opened panel claiming the full width for its fields.
[0.3.0] - 2026-08-03
Fixed
- panel: the PTR (reverse DNS) check no longer reports a correctly published
record as wrong. The checks went through the container's own resolver, which
forwards to the host's systemd-resolved — and systemd-resolved answers the
reverse lookup of the machine's own IP from the local hostname instead of
asking public DNS. A server with
203.0.113.10 → selfpost.example.comin DNS was told its PTR pointed at the provider-assigned hostname. All four deliverability checks (PTR, SPF, DKIM, DMARC) now query recursive resolvers directly, so the panel reports what a receiving mail server actually sees. SetSELFPOST_DNS_RESOLVERSif outbound port 53 is closed or you run your own recursor; it defaults to 1.1.1.1, 8.8.8.8 and 9.9.9.9.
Changed
-
panel: the three monitoring pages now live at URLs that match their nav labels — Deliveries at
/deliveries(was/sendlog), Mail queue at/mail-queue(was/queue), System log at/system-log(was/logtail). Bookmarks to the old paths stop working. -
panel: each entry in the navigation bar now carries an icon beside its label, so the bar is scannable at a glance instead of a row of similar-length words — Backup uses a filing-cabinet mark; Sign out has its own icon too. The icons are inline SVG drawn in the entry's own colour — no extra request, no exemption from the panel's Content-Security-Policy — and are hidden from screen readers, which still announce the label alone.
-
panel: the navigation bar is laid out as two rows on purpose — the signed-in user, Account and Sign out along the top right, the page entries below. It no longer fits on one line and used to wrap on its own, which left the session block sitting left-aligned under the entries as if it were more navigation.
[0.2.0] - 2026-08-03
-
panel: every authenticated page now ends with the running version (
SelfPost 0.2.0) in a small footer. It is the value a backup manifest is checked against on restore, and the first thing to establish when the panel behaves unexpectedly. The login and setup pages deliberately do not show it. -
panel: the domain page now shows the SPF and DMARC records it expects, with host, value and a Copy button, next to the DKIM record it already showed — previously it only said "also configure SPF and DMARC (see the documentation)" and the concrete example appeared only once a check had already failed. The SPF value names the addresses this server's hostname resolves to (falling back to an
a:mechanism if it does not resolve), and the DNS checks below build their remediation advice from the same source, so the page and its checks cannot recommend different records. -
panel: one appearance for actions. Several controls — a POST wrapped in an inline form (Re-check, Export domain, Sign out, New password…), the
<details>toggles in the applications table, the delete links — used to render as bold blue text while everything else was a button, so the same kind of control looked like two different things, sometimes within one card. They are all buttons now: filled for a card's own action, compact and outlined where actions cluster in a table row or the nav bar. The two actions that are really navigations — "Delete domain" and the status page's "Full queue" — are anchors carrying the same button styling. A bare link is left only where it reads as part of a sentence, a table cell or the nav. -
panel: on the domain page Add an application now sits directly above the Applications list — the same order the domains page uses for its own add form — instead of being stranded below the domain rate limit.
-
ci: hermetic container e2e suite (
test/e2e, a separate Go module) gates image publishing —make e2elocally, andgo test ./...intest/e2eas a required step inrelease.ymlbefore a version tag's image is pushed. It builds the real image, brings up the shippeddeploy/docker-compose.ymlplus a test-only override (self-signed cert, low ports, a fake DNS zone served by CoreDNS, asmtp-sinksink-MX) on an isolated compose project, then drives the panel over HTTP exactly like an administrator: setup → login → add a domain → publish the DKIM record it prints into the fake zone → add an application → send over SMTP AUTH → verify the delivered message's DKIM signature against the record the panel published → poll the send log tosent. Negative coverage: no-AUTH and unauthenticated-relay rejection, sender/login mismatch, the level-1 (anvil) and level-2 (panel-configured) rate limits, the journal-milter's fail-open behaviour when the panel process is stopped, a missing/malformedSELFPOST_HOSTNAMEfailing the container fast, and a login session survivingdocker restart.release.ymlmoved off qemu to a native per-architecture build (ubuntu-latest/ubuntu-24.04-arm), each gated by this suite before its tag is pushed and merged into the version manifest — running the full Postfix/OpenDKIM stack under emulation for the gate was impractically slow. -
ops:
mail.logrotation switched fromcopytruncateto rename +postfix reload(the same mechanismpostfix logrotateitself uses), eliminating the up-to-one-second window in whichcopytruncatecould drop in-flight delivery lines — a lost line meant a send-log row stuck atqueuedforever.logrotate-mail.confkeepscreate 0644 root rootrather thannocreate: verified on a live container that letting Postfix recreate the file itself on reload produces0600, which the unprivileged panel process cannot read, breaking the mail-log view until the next restart. The panel's log-tailer (internal/logtail) re-drains the old file descriptor once more right before switching to the rotated one, closing a similar small window between polls; a missingmail.logright after rotation is now a normal empty screen rather than a logged error. -
panel: login sessions now persist in SQLite instead of memory, so an administrator's login survives a container restart or redeploy. Only the SHA-256 of the session token is stored, never the token itself. The absolute 12-hour TTL is replaced by a sliding idle timeout (
PANEL_SESSION_IDLE_DAYS, default 7 days, no absolute cap): the monitoring screens' background polling does not count as activity, so a forgotten open tab does not keep a session alive forever. Changing the password still signs out every other session. -
panel: security headers on every response —
Content-Security-Policy,X-Content-Type-Options,X-Frame-Options,Referrer-Policy, andStrict-Transport-Securitywhere the deployment is HTTPS-only. They are emitted by the panel itself, so the reverse proxy still needs no security configuration of its own. -
panel: state-changing requests are now checked against the panel's own origin (
Sec-Fetch-Site, falling back toOriginvsHost). This closes cross-site request forgery from a neighbouring host on the same domain — a CMS or a forgotten staging subdomain next to the panel — which the session cookie'sSameSite=Laxcounts as same-site and therefore cannot stop. A request that sends neither header is still let through, so genuinely ancient browsers keep working. The reverse proxy must pass the originalHostheader through (every shipped fragment already does); one that rewrites it makes the panel refuse every form submission, and the log line names both theOriginand theHostit compared. -
panel: the session cookie is now named
__Host-selfpost_sessionwherever it isSecure(the standard deployment), which makes the browser enforce that no other host can set or overwrite it. Upgrading signs the administrator out once. WithPANEL_COOKIE_SECURE=falsethe old name is kept, because the prefix is invalid without TLS. Signing out clears both names. -
panel: if a request arrives with two cookies of the session cookie's name — what a neighbouring host does when it overwrites the session — the request counts as signed out and the log says so, instead of the panel silently picking the other host's value and looping back to the login form forever.
-
panel: the layout's stylesheet moved to
/static/panel.cssand the confirmation prompts on destructive buttons moved into/static/panel.js. No visible change; the panel's CSP allows no inline script or style, and this is what keeps that policy free of exemptions. -
docs: the first-run setup link is also written to
/data/setup-token(0600) — documented in the README as the way to read it without the token passing through a container-log pipeline. -
panel: new Status page — supervised processes, mail queue, TLS certificate expiry, milter sockets and the server's own hostname/reverse-DNS (FCrDNS) check — and it is now the panel's landing page. The local checks refresh by polling; the DNS lookup is cached with a Re-check button.
-
panel: the domain page shows a DNS status card: the published DKIM record compared against the key this server actually signs with, plus SPF and DMARC. The SPF check is deliberately shallow — it looks for a mechanism literally covering this server's address and does not follow
include:/redirect=, so a record that authorises the server through an include is reported as "cannot tell", not as a failure. -
panel: the domain list moved from
/to/domains;/redirects to the status page. The Reload button moved from the domain list to the status page and now explains what it regenerates and when to use it. -
fix: the panel could never read the mail queue in the documented deployment.
postqueuerelies on its setgid-postdropbit, whichno-new-privileges(set in the shipped compose file) disables, so the Queue screen always said "Could not read the mail queue". Thepaneluser is now a real member ofpostdrop. -
panel: navigation bar is now rendered once from the shared layout, so every authenticated page has it — including the domain page and the delete confirmation, which had no navigation links at all — and the current page is highlighted instead of silently missing from the list.
-
panel: new Account page to change the administrator's username and/or password (the current password is required, throttled on the same limiter as the login form). Changing the password invalidates all other sessions.
-
panel: Backup & migration moved off the domain list onto its own Backup page, with the full backup and the domain import as two separate cards.
-
panel: the domain page now shows the Sending server settings (server, port and encryption) needed to configure a mail client; port 587 is listed only when
SUBMISSION_ENABLE=truefor this deployment. -
panel: Copy buttons on the DKIM record, on a newly issued application login/password and on the sending server name.
-
panel: the Addresses field is hidden while an application's address mode is Any address of the domain, where the server ignores it.
-
deploy:
cap_addgainsCAP_FOWNERandCAP_FSETIDso the entrypoint can chmod and set the setgid bit on/datadirectories it has just chowned undercap_drop: ALL, andCAP_KILLso supervisord can signal OpenDKIM across uids when domains are added or removed. Production boot under the hardened compose file exposed both gaps after Phase 10. -
ci: disable provenance attestation on release image push, so the ghcr.io manifest list shows only
linux/amd64/linux/arm64(nounknown/unknown). -
ci: run
go vetandgo test ./...on every push tomainand every pull request, not only the image build on a release tag. -
security: optionally honour
X-Forwarded-Forfor login/setup rate-limiting when the request's direct peer is in the newTRUSTED_PROXY_CIDRlist, giving real per-client limits behind a reverse proxy instead of one global bucket. Unset by default (unchangedRemoteAddr-only behaviour).
[0.1.0] - 2026-07-15
Initial feature-complete implementation of the v1.0 specification (phases 0-11
of docs/implementation-plan.md).
Added
- Panel (Go, single static binary) with SQLite persistence, one-time crypto-random setup link, bcrypt admin auth, session cookies.
- Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and OpenDKIM KeyTable/SigningTable regeneration + privilege-safe reload.
- Application (sender identity) management: SASL credentials via
sasldb2,smtpd_sender_login_mapsenforcing sender/domain ownership, no open relay. - Full Postfix relay config generated from env at container start: SMTPS 465, optional STARTTLS submission 587, SASL auth, TLS for outbound delivery, anvil-based rate limiting (level 1).
- Journal milter (pure Go,
go-milter) recording every send tosend_log; fail-open by design so a milter fault never blocks mail. - Monitoring UI: send log, Postfix queue, and mail.log tail, all HTMX-polling, HTML-escaped.
- Per-domain/per-application sending rate limit (level 2), enforced in the
journal milter at
MAIL FROM, fail-open on the limiter's own errors. - Full backup/restore (
tar.gzof/data, consistent SQLite snapshot viaVACUUM INTO) with a version guard that refuses to start on a manifest/binary version mismatch. Per-domain export/import for moving a single domain between hosts without re-issuing DNS records. - Deployment: Docker image + compose, reverse-proxy fragments for Apache
(default), nginx, Caddy, and Traefik; CI workflow publishing tagged,
multi-arch images to
ghcr.ioonvX.Y.Ztags. - Security pass against spec 7.6 (exec safety, config-write sanitization, server-side validation, rate limiting, session/cookie hardening, output escaping, non-root panel) — full compliance, no code changes required.
- Live production deployment with a real Let's Encrypt certificate; end-to-end delivery confirmed (DKIM pass, SPF pass).