Files
selfpost/CHANGELOG.md
T
mixandClaude Opus 5.5 e166a2ebd6 refused messages: the page shows what Postfix wrote (stage 2b, 0009)
The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.

- Wrong From: cleanup gives the message a queue id before the headers are
  judged; the sender check reads {i} at end-of-headers and writes it on the
  refused row. Its lines - stored under the id like any other's - are on the
  page, but only those from 10 minutes before to 1 minute after the refusal
  (store.SendLogLines for a rejected row): Postfix frees the id at once, and
  a later message with it lends nothing. No mail.log fallback for a refused
  row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
  0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
  "NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
  CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
  of that sender (and login, when the line names it) within two minutes; a
  line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
  "none - refused at MAIL FROM, before one is given"; the box's caption is
  "The line from mail.log for this refusal" for the latter; a row from
  before says the line was not kept. LogLineLevel colours a refusal by its
  reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
  not muted as in the picture: the kit has no muted inline.

Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:56:29 +03:00

2653 lines
156 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
All notable changes to this project are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/).
## [Unreleased]
### Fixed
- **A log rotated while the panel was down is read to its end.** On start,
a saved place in a file that had been rotated meant "start on the new
file", and what Postfix wrote between the panel's stop and the rotation -
deliveries, refusals, deferrals - was never read. The tailer now reads the
rotated file (`mail.log.1`) from the saved place to its end first; when
that file is not the one the place belongs to (rotated twice), it says so
in the log, with the time from which nothing was read. The log's
fingerprint now includes the file's inode, so two files that begin alike
are still told apart (audit 2026-10-12, stage 2b).
- **A second deferral of the same second is counted after a restart.** The
Sender check's count of deferred mail remembered the time of the last
line it counted, to pass over lines read again after an unclean stop;
`mail.log` stamps whole seconds, so a second warning of that second was
passed over too. It now remembers the place in the log, saved with the
count in one transaction (audit 2026-10-12, stage 2b).
- **A refused message's page shows what Postfix wrote.** Its Delivery log
box said Postfix wrote nothing about it, which was untrue. A message
refused for its `From:` line had a queue id, given before its headers were
judged; the row now keeps it and the page shows the message's lines, the
refusal among them - only those of the minutes around the refusal, since
Postfix frees the id at once. A message refused at `MAIL FROM` (a level-2
limit, a client address off the list) has no queue id; the log tailer puts
Postfix's `NOQUEUE: milter-reject` line on its row (migration `0009`) and
the page shows it. A row refused before this says the line was not kept.
A refusal's line is red for a permanent reply, amber for a temporary one
(audit 2026-10-12, stage 2b).
## [2.5.0] - 2026-10-14
Stages 1 and 2 of the 2026-10-12 audit: mail the delivery log showed wrong,
and the boundary between tenants. Two migrations: `0007` adds the delivery
log's own measurement, `0008` makes application logins unique without regard
to case and ties every log row to its domain by id (filled once on upgrade).
### Fixed
- **A queue too large to list no longer closes its own messages.** The
reconcile sweep asks `postqueue -p` which messages Postfix still holds and
closes every `queued` row that is not among them. The listing is capped at
4 MiB, and the ids past the cap were read as gone: during a large deferred
backlog - the outage the sweep exists for - mail Postfix still held was
marked `bounced`. A listing over the cap now tells the sweep nothing, as it
already did for the notification about mail waiting too long; the Mail
queue page still shows the cut listing with its note (audit 2026-10-12, H3).
- **The sweep closes only what is still queued, and says it did.** Its close
was the tailer's plain status update, so a `sent` the log wrote an instant
earlier could be overwritten with `bounced`, and a reused queue id could
rewrite an old row of the same recipient. The sweep now closes a row by its
id and only while it is `queued`, and the row it closes carries the reason
`reconcile`; a delivery line updates only the newest row of its queue id
and recipient (audit 2026-10-12, H3).
- **A recipient with a quoted local part is followed to its delivery.** The
journal cut `"a b"@example.net` at its space, and the log tailer cut
`"a>b"@example.net` at its `>`: the row matched no delivery line and the
sweep closed it as bounced. Both now take the whole address.
- **A stranger's HELO name no longer reads as a delivery result.** The
pattern that turns a `mail.log` line into a status matched anywhere in the
line, so a port-25 client whose HELO name read
`<id>: to=<address>, status=sent` could mark the matching row delivered. It
is now matched only at the head of the line, after the host and the
program, as the other patterns of the log tailer are (audit 2026-10-12,
M12).
- **A renewed TLS certificate is served without a restart.** Postfix reads a
copy of the mounted certificate made when the container starts, and the
daily reload re-read that copy, not the renewed files: a certificate renewed
while the container ran expired on 465 and 587 at the old one's end. The
daily job now compares the mount with the copy and, when the certificate and
key belong together, renews the copy and reloads Postfix; when nothing
changed it no longer reloads (audit 2026-10-12, H2).
- **Health counts mail deferred by a panel that hangs mid-message.** The
figure "messages deferred" on the Sender check card read only smtpd's
lines, but once a message's DATA has begun it is cleanup that talks to the
check, and its warning was never counted - the very case that tells a hung
panel from a stopped one. Seen on the e2e stand with the panel frozen, and
now counted, once per message (audit 2026-10-12, M13).
- **Application logins are one name whatever their case.** Postfix compares
the SASL login with the sender map without regard to case, the panel did
not: `Ops` in one domain and `ops` in another were two applications to the
panel and one sender to Postfix, so a tenant could send with another
tenant's envelope sender. A new login is now stored in lower case, and
migration `0008` refuses two logins that differ only in case. A login
stored earlier, or imported from another instance, keeps its case, since
its client uses it (audit 2026-10-12, H4).
- **The last global administrator stays.** Deleting or demoting a global
administrator counted the others first and then wrote, so two
administrators deleting or demoting each other at the same moment could
both succeed and leave nobody able to open Server. The count is now taken
in the same transaction as the write; first-user setup is one statement
(audit 2026-10-12, M7).
- **A user is saved whole or not at all.** The users form wrote the name,
the password, the role and the assignments one after another; a failure
part way left a user renamed with the old role, or a domain administrator
with nothing assigned. Saving and creating a user are now one transaction
each, the e-mail included (audit 2026-10-12, M9).
- **The users form keeps a password changed meanwhile, and says when a
domain it offered is gone.** Saving a user wrote back the password hash
read when the form was opened, which undid a password the user changed on
Account in between; a domain deleted while the form was open ended in an
error page.
- **A domain import is checked like the forms.** The import trusted the file
for the report address, the rate limits and the client addresses: a limit
with a window of no seconds counted nothing, and an address that was not
one made the application refuse every client without a word. The file is
now held to the forms' rules before anything is created, and refused with
the form's own message, naming the field and the application; the DKIM
selector is held to DNS labels; an auto limit's numbers are worked out on
this server right after the import rather than taken from the file
(audit 2026-10-12, M10).
- **An application's numbers are its own.** Statistics, the level-2 count
and an auto limit's recalculation counted every row of the login, so an
application created with the login of a deleted one - in another domain,
even - started with the old one's traffic. They now count the login's rows
of its domain, from the application's creation on. A message with an empty
envelope sender (`MAIL FROM:<>`) is held to its domain's level-2 limit by
its login, like the application's other mail.
- **A domain added again starts with an empty log.** Send-log rows belonged
to a domain by its name, so a domain deleted and added again showed its
earlier mail to whoever it was assigned to next, and its statistics and
level-2 count went on counting it. A row now belongs to the domain of the
application that sent it, by id (migration `0008`, filled once from the
registry); the rows of a deleted domain are the global role's alone. The
inbound log follows the same rule (audit 2026-10-12, L12).
- **A refusal no longer names the other domain.** A `From:` line that named
another sender of the server was refused with that sender's domain in the
reply. The reply now stops at "it names another sender of this server";
the line as written stays in the reply and on the refused message's page.
That such a line is refused at all still tells the sending application
that the domain is hosted here (security.md, accepted risks) (audit
2026-10-12, L8).
### Added
- **The delivery log measures its own guesses** (migration `0007`). A table
`delivery_log_days` counts, per UTC day, the rows the reconcile sweep
closed, those of them whose delivery line was in `mail.log` all along (the
log tailer missed it), those a delivery line read later contradicted (the
log's status then replaces the sweep's, and the row is marked
`reconcile-contradicted`), the seconds the log tailer could not read
`mail.log`, and the rows still queued past Postfix's queue lifetime. No page
shows it: it is read off a copy of the database after a month, to decide
by a rule written in the audit whether the sweep stays (audit 2026-10-12,
H3).
## [2.4.2] - 2026-10-14
Release hygiene: stage 0 of the 2026-10-12 audit. Nothing the panel or the
mail path does changes; the release workflow, the tests, the dependencies and
the documents do.
### Fixed
- **A release image says which release it is.** The e2e gate in
`release.yml` rebuilt the image it was about to push with its own
`VERSION=e2e`, so every published image printed `e2e` for `-version` and in
the panel's footer, and the backup/restore version check compared a
backup's version with `e2e` - 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 checks `panel -version` and
`selfpost-backup -version` against it, and `release.yml` checks the stamp
again on the image right before pushing it. Images already published are
left as they are.
- **The documents no longer promise an image that is not there.** README,
the guide and the compose file pinned `ghcr.io/mixeme/selfpost:2.4.1`, but
no 2.x image has been published (ghcr.io holds `1.0.0` and `1.3.0`). They
now say so and show how to build the image locally under the pinned name,
which Compose and `docker run` then use without pulling (audit 2026-10-12,
M6). The pin stays: the next publication makes it true.
- **Dependencies raised**: `modernc.org/sqlite` 1.53.0 → 1.60.1 (with
`modernc.org/libc` 1.77.1), `golang.org/x/crypto` 0.54.0 → 0.58.0,
`golang.org/x/sys` 0.47.0 → 0.49.0. The three published `x/crypto` fixes
were in code SelfPost does not call (audit 2026-10-12). development.md says
the image is rebuilt after each Go patch release, which is where the
standard library's security fixes come from.
- **The e2e module is compiled on every push** (`test.yml`: `go vet` and
`go test -c` in `test/e2e`), so a type error there no longer first shows
up in `release.yml` after 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](docs/audit-2026-10-12.md). Six zones read file
by file against what `architecture.md` promises; what the readers could not
settle from source was settled on the test server. Four high findings - the
published release images are stamped `VERSION=e2e` because 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 mail
`bounced` while 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 published `1.x` tags 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, into `From:` and have the message signed
with that name's key (security review of `2.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 with
`550 5.7.1`, a domain this server does not sign for included
([plans/from-header-binding.md](docs/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 with
`default_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) or `rate-limited`; a row
refused before this version says `rejected`. A message refused for its
`From:` 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 the `From:` of
another of your domains (give that domain an application); an application
with a list whose envelope and `From:` addresses differ (add the second to
the list); a form that puts its visitor's address into `From:` (use
`Reply-To:`). The line is read strictly: one `From:` 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_delimiter` is 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](docs/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_HOSTNAME` and 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 `a` or `mx` mechanism 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](docs/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 into `From:` - the first part of which has always been so.
## [2.2.0] - 2026-10-12
### Added
- **Schema: `send_log_lines`** (migration `0003_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](docs/plans/delivery-log-storage.md)).
- **The log tailer keeps what Postfix writes about queued messages.** A
`mail.log` line 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.log` rotates.** The
*Delivery log* box on Outbound › Log › a message was filled by searching
the current `mail.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.log` itself is not in a backup. Recorded in
[security.md](docs/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 since `1.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](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_MILTER` and `INBOUND_JOURNAL_MILTER_SOCKET` are 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 read `postfix/inbound/smtpd[…]` where they read
`postfix/smtpd[…]`; the submission ports keep `postfix/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.conf`
into the rspamd sidecar, which makes it write its verdict into
`X-Rspamd-Action` and `X-Spamd-Result`. **Copy `deploy/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 with `antispam/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-Filter` to
every message it lets through - an HMAC of the message's queue id under a
key SelfPost creates in `data/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 new `antispam/rspamd/milter_headers.conf` and 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 the `antispam/` directory with the
compose fragment and the rspamd configuration - copy that from
`deploy/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 and `tar` the
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
`/data` in 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-backup` run with `docker exec`
was 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 in `certs/`: it then refuses and says what to do -
`chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem` on the host,
998 being the container's `selfpost` group, 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_PATH` with 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.go` under `internal/web`) and a `design-first` CI step. The
tests read their rules from the accepted mockups — class vocabulary,
stylesheet, page structure, component outlines, the 2.0 route table — and
pin the vendored assets. `internal/web/view/legacy_pages.txt` lists what is
still pre-2.0 and may only shrink; CI also rejects a commit that changes a
guard together with the implementation, or brings back a template from
history. The kit and route tests stay skipped until their stages land.
- panel-redesign (stage 0): the component kit. `panel.css` is the mockups'
stylesheet rule for rule; `components.html` holds every partial with its
typed input in `components.go`; the new `layout.html` is the shell (brick
navbar with the wordmark, perforated edge, sibling strip, user menu,
footer) and the signed-out screen; `GET /server/components` (global role)
renders every partial in every state. `@kit` is off the ratchet: the kit
is held to the design contract from here on.
- panel-redesign (design): Sign out in the user menu is a POST form in the
mockups too, and its button takes the same hover, focus and press tint as
Account and Help (owner, 2026-10-09).
- panel-redesign (stage 1): the 2.0 schema. `users.email` is the user's own
address; the default DMARC report address is a user's setting
(`dmarc_default_mode` / `dmarc_default_address`) and a domain either has an
address of its own or follows a named user's default
(`domains.dmarc_rua_user_id`) — a new domain follows whoever created it,
and deleting that user leaves it with no report address (replaced before
release — see the entry of 2026-10-10 below).
`user_inbound_domains` and the per-user `all_domains` /
`all_inbound_domains` flags are in the schema for the inbound delegation
that follows. Details: `docs/schema-migrations.md`.
- panel-redesign (stage 1): inbound domains are delegated to domain
administrators, separately from outbound. The user form has two lists, each
with *All* (which includes domains added later; with it ticked the rows
under it are ignored); a domain administrator needs at least one assignment
on either. Such a user sees the inbound domains assigned to them — the list
is filtered in the query — and can change their upstream and recipients;
adding and deleting an inbound domain stays with the global role. Another
tenant's domain, a missing one and a user with no inbound reach all get the
same 404. A menu group with nothing assigned is absent, and `/` leads an
inbound-only administrator to Inbound.
- panel-redesign (stage 1): **Account** (`/account`, every role) and
**Server › Settings** (`/server/settings`, global role) replace the one
Settings page. Account has three forms, each its own POST: profile
(username, e-mail), password, and the default DMARC report address
(SelfPost hosted, my account e-mail, another address, none) (replaced before
release — see the entry of 2026-10-10 below). Saving the profile no longer
asks for the password; changing the password still does.
- panel-redesign (stage 1): the application form is one POST. Who the
application may send as, its client-IP allow-list and its rate limit
("use the domain limit", manual or auto) are validated together and saved
together or not at all — also at creation, so an application can be added
with its limit already in place. The page that shows a generated password
is the response to its POST only and is sent with `Cache-Control: no-store`.
- panel-redesign (stage 1): **Server › Health** (`/server/health`) holds the
tables of the old Status page and its two actions, *Re-check DNS* and
*Reload configuration*. **Overview** (`/overview`) is reduced to one
verdict per check — machine, processes, TLS certificate, queue, milter
sockets, reverse DNS — each leading to its table on Health; a check that
could not run shows as a warning, never as fine. Both read the same checks.
- panel-redesign (stage 2): **Sign in** and **Create administrator** are the
first pages in the new design - the split brand / form screen, built from
the component kit. Sign in names the mail host above its title; a refused
attempt is shown as a notice above the fields. `login` and `setup` are off
the ratchet.
- panel-redesign (design): the sign-in page before any administrator exists
is a drawn screen (`login-no-admin`): the heading *No administrator yet* and
the pointer to the one-time setup link, in the same column as the form. The
signed-out column is sized through `.sp-form`, not through `form` (owner,
2026-10-09).
- panel-redesign (design): the Notifications boxes are out of the Account
and Server › Settings mockups. Event notifications are not built
(panel-notifications is a candidate), and 2.0 carries no box for a feature
that does not exist; they are drawn again as the first step of that
feature (owner, 2026-10-10).
- panel-redesign (stage 2): **Overview**, **Server › Health**, **Account**
and **Server › Settings** are in the new design. Overview shows the verdict
as a stamp with one sentence, the six checks as cards and both domain
lists; Health holds the tables behind the cards. Both refresh themselves.
The Queue card reads "Empty" or "N queued". `status` and `settings` are
off the ratchet.
- panel-redesign (stage 2): the **Outbound** domain pages are in the new
design: the domain list, the domain page (DNS records with what to
publish and what is in DNS now, applications, connection), **Domain
settings** as a page of its own (report address, rate limit, export,
delete), the application form, the password shown once and the delete
confirmation. `dashboard`, `domain_detail` and `domain_delete` are off
the ratchet.
- panel-redesign (stage 2): the **Outbound log**, the message page (facts,
history, the lines of `mail.log`) and the three **DMARC report** pages are
in the new design. The log refreshes its rows without touching the filter
being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and
`dmarc_report` are off the ratchet.
- panel-redesign (stage 2): the **Inbound** pages are in the new design: the
domain list, the domain page (MX record, upstream, valid recipients) and
the delete confirmation. Every inbound domain page has a **Spam filter**
box and the list names the filter's state: the panel reads
`INBOUND_ANTISPAM_MILTER` and `INBOUND_ANTISPAM_MILTER_ACTION` and says
whether inbound mail is filtered and what happens while the filter is
down. The filter's address is shown to the global role only. `inbound`,
`inbound_domain` and `inbound_delete` are off the ratchet.
- panel-redesign (stage 2): the **Server** pages - System log, Backup,
Users, the user form and its delete confirmation - and **Help** are in the
new design. Help is a page of topics that the `?` in a box's head opens;
the help drawer is gone. The user form sets the user's e-mail. The backup
download is encrypted unless the box is unticked. Only the queue page is
left on the ratchet.
- panel-redesign (design, stage 3): **Help** covers every section -
seventeen topics for Overview, Outbound, Inbound, Server and Account - and
the boxes of nearly every page carry a `?` that opens their topic (owner,
2026-10-10). A reader sees the topics of the sections their menu shows.
- panel-redesign (design, stage 3): **a domain's DMARC report address is one
of three choices** - no reports (a new domain), the SelfPost hosted
address, or an address typed for the domain - set in Domain settings, with
a button that fills in one's own profile e-mail. The per-user default
report address, a domain "following" a user and the DMARC box on Account
are gone, and with them `users.dmarc_default_mode`,
`users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner,
2026-10-10; replaces the design of 2026-09-21 described above).
- panel-redesign (stage 3): with the inbound spam filter on, its socket is one
of the milter sockets on **Server › Health** and counts in Overview's card.
The panel connects to the configured address (`inet:host:port` or
`unix:/path`, one second at most, nothing sent). A filter that does not
answer is an error with `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` - inbound
mail is deferred - and a warning otherwise - mail goes through unfiltered.
- **Port 25 no longer accepts `dmarc-reports@<hostname>`**, the report
mailbox of the server as a whole. Only an address a domain has chosen is
accepted: its hosted address `dmarc-reports+<domain>@<hostname>`, or a
typed address that is on this host. A domain whose published `rua=` still
names the general mailbox must be set to *SelfPost hosted* and its DMARC
record republished (owner, 2026-10-10).
- panel-redesign (design, stage 3): **New password** and **Delete** are in
each row of a domain's Applications table again, as POST forms that look
like the links beside them.
- panel-redesign (stage 2): the **Outbound queue** page is in the new shell
with its content unchanged - the listing of `postqueue -p` and how
retries work. With it every page is built from the component kit: the old
stylesheet, the old layout and the engine's second rendering path are
deleted, and the ratchet is empty.
### Changed
- **Breaking — every panel URL changed.** A path now reads like the menu
(`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`, `/account`); the
old paths are removed, not redirected — see *Removed*. Polled fragments all
answer `<page>/fragment`. Everything under `/server/` is guarded for the
global role where the routes are registered, in addition to each handler's
own check. An application is addressed under its domain
(`/outbound/domains/{id}/applications/{aid}`), and an application id that
does not belong to the domain in the path is a 404.
- **Breaking — the database starts from zero.** The 1.x migration chain
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
`/data/selfpost.db` is refused at start-up with an explicit error and left
untouched; nothing is migrated. A domain can be carried over with its
export file.
- The panel role `domain_admin` is stored as `domain`.
- On the old domain page, "Same as Settings" makes the domain follow the
default report address of the user who chose it (replaced before release —
see the entry of 2026-10-10 below).
- The old panel is untouched on screen but steps aside in the tree: its
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
last old page is restyled. The 2.0 menu paths in the new shell do not
exist before stage 1; only the kit page uses that shell so far (a stage-0
state: both files are deleted and every page is on the shell — see
*Removed*).
- panel-redesign is `agreed` (owner, 2026-10-09); work starts with stage 0,
the component kit.
- panel-redesign (stage 3): the operator guide, `architecture.md` (Panel HTTP
surface, Persistence) and `security.md` (roles, sessions, errors shown to
users) describe the 2.0 panel. `security.md` records two accepted risks from
the 2.0 review: a delegated inbound upstream may be any host and port, and
Account's profile is saved without the password.
### Security
- A panel session belongs to a user by id and ends with them. It used to be
keyed by user name: the cookie of a deleted or renamed user stayed valid
and became a session of whoever was next created under that name. Signing
in again is required after upgrading (there is no upgrade from 1.x; this
concerns development builds of 2.0).
- Changing one's password ends one's own other sessions only; it used to
sign every other user out. A password set by an administrator on the user
form ends that user's sessions.
- Internal errors (database, `saslpasswd2`, Postfix reload) are no longer
shown on the application, inbound upstream / recipients and limit forms;
they are logged and the page says so.
- `/static/` no longer answers with a listing of the embedded files.
- The DMARC reports hub is open to a domain administrator for the domains
assigned to them, without the server's ingest statistics; the list of
reports no longer treats "no domains" as "all domains".
### Fixed
- Saving a domain's report-address form untouched no longer moves a domain
that follows another user's default to whoever saved it: that user's
default is its own choice, and "My default instead" is the change
(replaced before release — see the entry of 2026-10-10 below).
- The auto rate-limit fields follow the limit mode on the domain and
application forms (the script applied only the first matching rule).
- The DMARC page of a domain no longer answers 500 once the domain has a
reported source.
- **The panel can read the mail queue.** The container's entrypoint left the
queue's `public/` and `maildrop/` directories in group `postfix` on every
start; Postfix keeps them in `postdrop`, the group `postqueue` works
through, so the panel's `postqueue -p` was refused ("Connect to the Postfix
showq service: Permission denied"), the Queue card said "Could not be read"
and the queue page had no listing. Present since the persistent queue of
1.3.0; mail flow was not affected. The e2e suite opens the queue page now.
- **Port 25 answers again with DMARC report ingest on.** The generated
`master.cf` gave the port-25 service `-o smtpd_recipient_restrictions=
check_recipient_access texthash:…,…` with a space in the value; Postfix
cuts a service line at whitespace, so `smtpd` was started with a stray
argument and died with "fatal: unexpected command-line argument" on every
connection - no inbound relay and no DMARC reports were accepted while
`DMARC_REPORTS_ENABLE=true`, although `postfix check` and the suite were
silent. The list is written with commas only, the generator refuses a
value with whitespace, and the e2e check asks Postfix what it reads.
Present since DMARC ingest was fitted into the 1.9.1 fix of the same
stanza.
- **DMARC reports are accepted with the inbound relay on.** With both
features enabled every report address was refused as "User unknown in
relay recipient table": the relay's unlisted-recipient check runs after the
restriction list, and the report addresses - on the server's own name, a
relay domain - were not in `relay_recipient_maps`. Their table is a
recipient table now. Hidden until now by the port-25 failure above.
- **DMARC reports reach the panel.** A report accepted on port 25 stayed in
the queue: the generated delivery service was a bare `pipe` ("fatal:
missing user= command-line attribute"), because the generator addressed it
under a type Postfix does not have and set `flags=`, `user=` and `argv=`
as `-o` parameters, which they are not. Behind that, the command would
have refused to run (Postfix starts it without the container's
environment) and was never told the recipient, so the check that a report
came to the address of its own domain was skipped. The service line
carries its arguments, the recipient is one of them, and
`export_environment` names what the command reads. No report had been
delivered since the feature shipped in 1.7.0; the e2e check asks Postfix
how it reads the service.
- Postfix no longer logs "warning: not owned by root" for the queue
directory and its `pid/` at every start: the entrypoint hands the two back
to root after its recursive change of owner.
- Overview's Processes card counts the programs that are meant to stay up
("6 of 6 running"); the idle one-shot `postfix-reload` was counted as
running. The Milter sockets card no longer clips its second line with three
sockets, and a DKIM value is shown whole instead of scrolling inside its
field.
- Server › Health shows the idle reload helper (`postfix-reload`) as
**on demand**, with when it last ran, instead of `stopped` beside the
running programs (owner, 2026-10-10).
- The confirmation before deleting a user names the domains that follow
that user's default report address and will be left without one; it used
to say the domains were not affected (replaced before release — see the
entry of 2026-10-10 below).
### Removed
- **2.0 starts from an empty data directory.** A 1.x `/data` is not upgraded:
its database is refused at start and left untouched, and a 1.x backup is not
restored; a domain is carried over with its export file. Gone with it, as
built: the 1.x migration chain, every pre-2.0 panel path (listed next), the
help drawer (Help is a page of topics), the default DMARC report address —
the panel-wide one of 1.x (`dmarc_report_email`, "Same as Settings") and the
per-user one with a domain following a user that earlier entries of this
release describe (`users.dmarc_default_*`, `domains.dmarc_rua_user_id`) —
together with the DMARC box on Account, the old stylesheet `legacy.css`, the
old layout `layout_legacy.html`, and the unused `rate_limits.allowed_ips`
column.
- Old panel paths, GET and POST alike, without redirects: `/status`,
`/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`,
`…/ratelimit`, `…/ratelimit/recalc`, `…/dmarc`, `…/export`), `/inbound` and
`/inbound/{id}/…`, `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}`,
`/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`,
`/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users`
and everything under it, `/settings`, and the `/account` → `/settings`
redirect; `POST /domains/{id}/applications` and `/applications/{aid}/mode`,
`/authips`, `/ratelimit`, `/ratelimit/recalc`, `/password`, `/delete` — the
three separate saves of an application are one form now.
- The instance setting `dmarc_report_email` (a mirror of the global user's
profile field) and the unused `rate_limits.allowed_ips` column.
## [1.9.5] - 2026-10-09
CI-only cut: the release workflow no longer breaks the multi-arch image it
just published. No product code changes.
### Fixed
- ci (GHCR): the release no longer deletes the `X.Y.Z-amd64` / `X.Y.Z-arm64`
package versions after the manifest merge. Those versions are the images the
`X.Y.Z` manifest points at, so `docker pull ghcr.io/mixeme/selfpost:1.3.0`
failed with a missing manifest. The per-arch tags now stay; the merge job
instead removes untagged versions that no tagged manifest references.
## [1.9.4] - 2026-10-09
Plan-only cut, owner-requested: the redesign goes first as `2.0.0` with a
component-kit stage and guards against resurrected pages; antispam moves to
`2.1.0`; a new candidate explains the empty delivery log. No product code
changes.
### Changed
- Roadmap order (owner, 2026-10-09): the panel redesign ships first as
`2.0.0`; inbound-antispam-panel follows as `2.1.0` on the 2.0 baseline
(its migration becomes `0002_inbound_spam_log.sql`), built from the
redesign's component kit so its two screens are styled once. The redesign
plan gains a **Component kit** section and stage (partials with typed
inputs, one `panel.css`, a `/server/components` kit page, acceptance before
any page is restyled), a staged checklist (5/27), Health decided inside 2.0
and the queue pages left as old content in the new shell, and an **Against
resurrected pages** section: a shrink-only `legacyPages` ratchet in the
guard tests, the outline golden, the old-path test and a history-blob
check in CI, so an old template from git history cannot pass as a
redesigned page.
- New roadmap candidate `delivery-log-storage`: the *Delivery log* box on a
message's page is empty for anything older than the last daily rotation,
because the lines are grepped from the current `mail.log` on each view and
never stored; the item proposes keeping them with the send-log row.
## [1.9.3] - 2026-09-22
Panel-redesign prep, docs only: stage 1-2 mockups on all 33 screens, the
accepted header/type-scale/contrast corrections, the account e-mail and
inbound-delegation decisions, and the routes/quarantine/preflight roadmap
screens. Nothing in `internal/web` changes; the design stays `candidate`
(3/21) until the redesign itself ships as `2.0.0`.
### Added
- Panel redesign, account e-mail and notifications: a user's e-mail is its own
field (2.0 baseline `users.email`, today `dmarc_report_email`) — edited under
**Account**, seen and set by the global administrator on the user form and in
the users list; DMARC may *use* it, as one choice beside the hosted address,
another address or none (Settings and domain settings). **Account** gains a
Notifications box and **Settings** a switch for the new roadmap candidate
`panel-notifications`; `password-reset` is added as a candidate too. The
inbound spam filter becomes visible: a *Spam filter* box on every inbound
domain, a tag on the inbound domains list, its socket among the milter
sockets. Preflight loses a box that only filled space — the test form now
shares its box with its result — and the plan's layout rule and contract say
so: what stands beside a form must be real. The role is labelled by its reach,
`global` or `domain` — everyone in the panel is an administrator — and each
assignment list on the user form gains **All**, a per-user flag that covers
domains added later. The DMARC default report address is a **user's**
setting, not the server's: it moves from Server › Settings to **Account**
with its report-authorization record, and a domain follows the default of a
*named* user, so two people sharing a domain never pull it two ways.
- Panel redesign, contrast: a sweep of every text node against its real ground
found the solid red button at 3:1 (Bulma derives a solid button's text from
the fill's lightness; brick had been patched, red had not), the muted grey
at 4.1:1, the amber postmark at 4.0 and Bulma's menu labels at 4.2. Solid
buttons are white on colour, muted is `#5c6774`, the postmark uses the warn
token; all 33 screens now measure 4.5:1 or better. The sweep is kept as
`panel/audit.js` — text size, contrast, overflow, clipped values, missing
icons — and its clean output joins the evidence a restyle step must show.
- Panel redesign, header (owner's choice: variant A, with the perforation on a
light edge as on the mark): the navbar carries the **wordmark** instead of
the stamp — `docs/assets/selfpost-wordmark.svg` and `-ink`, the stamp's own
outlines, recorded in `docs/product.md` — and under the brick bar runs the
stamp's paper margin with the perforation along its outer edge: round holes,
flat teeth, the ink hairline (`panel/perf-edge.svg`). All 33 screens rebuilt;
the components page documents the shell; the comparison sheet is removed.
The six health cards now split into six, three or two columns, so one card
is never left alone on a row. Three corrections after the owner's review:
the paper edge is 8px under the 60px bar — it had been scaled from the hole
size to 16px, where the mark's margin is a tenth of the brick field; the
health row is compact again (71px: smaller icons, each line a block with its
own line height, a verdict never wraps and stretches the row); and a
read-only value is as tall as the Copy button joined to it. `check.py` no
longer counts `min-width: 0` as setting a width. Text sizes: the filter
controls in a box head and the navbar's dropdown entries are 15px like the
bar itself (they had Bulma's `is-small` and its .875rem dropdown); `check.py`
now rejects `is-small` on anything but a progress bar.
- Panel redesign, type scale: about 40 % of the panel's text measured under
13px (labels 11.7, table heads 10.5, fact labels 10.2, tags and help 11.3).
Body is now 16px with two tokens under it — `--sp-fs-s` 14px for supporting
text and `--sp-fs-xs` 13px for upper-case mono labels and tags; Bulma's
small sizes are overridden or dropped (`sp-small` replaces `is-size-7`), and
`check.py` fails any font size under the floor. Every table now sits in a
`table-container` (enforced), addresses in tables break after the `@`, the
inbound log folds the client IP under the sender; all screens fit without
scrolling at 1280px and up. Documented on the components page and in the
plan (§ Type and tables).
- Panel redesign, header comparison sheet
([docs/assets/panel-redesign/header/](docs/assets/panel-redesign/header/index.html)):
at nav size the stamp dissolves into the brick bar and the perforated edge
was drawn inverted (round bumps instead of round holes). Three replacements
beside the current header — **A** wordmark with the stamp's real hole-to-tooth
perforation bitten into the bar, **B** wordmark on a plain bar, **C** a
stamp-paper header with an ink hairline and brick only behind the name. The
wordmark is cut from the stamp's own outlines. Pending the owner's choice;
the mockups are unchanged. `.claude/launch.json` serves the mockups locally
(`python -m http.server 8765` from the repository root).
- Panel redesign, the roadmap's screens: **Inbound › Filter lists** and the
adjusted **Inbound › Log** (client, decision, engine and reason; *Allow /
Deny sender*) for inbound-antispam-panel, **Inbound › Quarantine** (list and
held message with Release / Discard) for inbound-quarantine, and **Server ›
Preflight** for preflight — all from existing components; 33 screens. Their
paths join § Routes; the quarantine and preflight plans link their mockups.
A mockup is the target look, not a decision to build: each item keeps its
status, and quarantine's open questions stay open.
- Panel redesign, two owner decisions recorded in
`docs/plans/panel-redesign.md`: **no compatibility** — the redesign is
`2.0.0`, old paths are removed without redirects, the SQLite migration chain
is replaced by one baseline and 2.0 starts from an empty data directory
(schema-squash ships as a step of this plan, without its upgrade gate; the
only live installation is the owner's); and
**inbound delegation** — a domain administrator manages assigned inbound
domains, granted separately from outbound (`user_inbound_domains`, a second
list on the user form, per-domain authorization under `/inbound/`). Mockups `users`, `user`,
`user-delete` redrawn for the two lists. Checklist `3/21`.
- Panel redesign, routes: the owner decided that paths follow the new names
and structure. `docs/plans/panel-redesign.md` § Routes maps every screen to
its new path (`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`,
`/account`) with the rules that come along — one `<page>/fragment` name, the
application form as a single POST, 301s from old GET paths for one MINOR,
subtree `requireGlobal()` under security review, and
`TestRoutesFollowNavigation`. Mockups gained what the first pass missed: the
*Reload configuration* box (now on Server › Health), the `flash` component
and the `sp-help` link that replaces the help drawer. Checklist `1/17`.
- Panel redesign, design contract:
[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md) rewritten for
the accepted direction — three layers (Bulma as shipped, adapters, own `sp-`
components), three page layouts, what an implementer must not do, the guard
tests that land before any template, a `design-first` CI step and
evidence-based acceptance. A
[components page](docs/assets/panel-redesign/panel/components.html) shows
every building block in every state; `panel/check.py` lints the mockups
against the contract and writes `panel/outlines.json`, the per-screen
component skeleton an implementation is compared with. Own modifier classes
renamed to carry the `sp-` prefix so the vocabulary is machine-checkable.
Agent rule `.cursor/rules/panel-design.mdc`; roadmap row updated (`0/15`).
- Panel redesign, stage 2: the accepted direction drawn on every screen —
29 pages under [docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html).
Direction C (Bulma) is the base, with A's postal signature (postmark verdict,
numbered mono section heads, the stamp's perforated edge) and B's icons.
Overview keeps verdicts only and the tables move to a new **Server › Health**
page; the own password becomes **Account**; queues are drawn as tables
(needs `postqueue -j`) and **Inbound** gets its own Log and Queue. Pages are
built from fragments by `panel/build.py`; the built HTML is committed.
Mockups only; nothing in `internal/web` changes.
- Panel redesign, stage 1: three directions drawn on the three key screens
(Overview, an outbound domain, sign-in) under
[docs/assets/panel-redesign/](docs/assets/panel-redesign/index.html) —
**A** Pico CSS ("Blank"), **B** Tabler ("Console"), **C** Bulma ("Counter"),
with a cover sheet comparing size, JavaScript, CSP fit and own CSS. All three
share a navigation grouped by mail direction (Overview · Outbound · Inbound ·
Server, with a symmetric Log and Queue on both sides), one block per DNS
record instead of separate "status" and "records to publish" cards, and
applications as a table. Mockups only; nothing in `internal/web` changes.
The brief behind them supersedes the "no component library" decision in
`docs/plans/panel-redesign.md`; that file is rewritten once a direction is
accepted.
- Roadmap item and givens for a panel visual redesign
([docs/plans/panel-redesign.md](docs/plans/panel-redesign.md)) — layout
grammar (`measure` / `fill` plus Web Awesome layout CSS), no JS component
library, new mockups rather than `docs/assets/panel-ui/`. Status: candidate.
- Roadmap item and plan for `/preflight` installation check page
([docs/plans/preflight.md](docs/plans/preflight.md)) — instance-level
infrastructure verification (rDNS, TLS, ports, HELO banner, proxy headers,
DKIM socket, test email). Status: candidate.
## [1.9.2] - 2026-09-22
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission
milter chain, DMARC reports are validated before they are stored, and the auto
rate limit is correct for a non-default level-1 window. Schema migration
`0010` (index only). Upgrading is a tag bump.
A full-tree code review on 2026-08-19 (architecture, code quality, security,
GUI, tests, documentation, AGPL compliance; 38 findings) produced most of what
follows. Its four open items live in [roadmap.md](docs/roadmap.md); its working
document is not part of the release.
### Added
- SQLite migration `0010_send_log_app_login_index.sql` —
`idx_send_log_app_login_created_at`. The journal-milter counts an
application's messages on every `MAIL FROM`, and the domain page computes
per-application statistics on every render; both were scanning the
`created_at` range because `app_login` was unindexed.
- DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
(several reporters ship a single-entry zip; those reports previously counted
as parse failures).
- e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
inside the container for all four combinations of `INBOUND_RELAY_ENABLE` /
`DMARC_REPORTS_ENABLE` / `INBOUND_ANTISPAM_MILTER` and requires
`postfix check` to pass. The `1.9.1` crash-loop shipped in `1.4.0` and
survived three releases because the suite only ever exercised the flags-off
path.
### Changed
- README hero line and a **Positioning** block in `docs/product.md` record the
tagline (`YOUR SMTP. YOUR RULES.`) and hero copy; the stamp subtitle stays
`SELF-HOSTED SMTP RELAY`. `docs/assets/selfpost-proof.html` updated to match.
### Fixed
- The setup account's password is hashed at bcrypt cost `12` like every other
panel password; it was still being written at `bcrypt.DefaultCost` (10). The
work factor now has one definition, `auth.BcryptCost`. bcrypt records the
cost in the hash, so existing passwords keep verifying unchanged.
- Login/setup rate limiter: protecting blocked buckets from eviction is now
bounded. Blocked buckets are still never evicted to make room, but past
`maxBuckets × 4` they are evicted too (nearest expiry first) — otherwise an
attacker with many source addresses could grow the bucket map without limit
by making each address fail its way to blocked. The overflow is logged at
most once per window.
- `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
the listing, and therefore the panel's allocation, up to the queue).
- [schema-migrations.md](docs/schema-migrations.md): the schema section was
still headed `v9` while the chain head is `v10`, and three links pointed at
plan files deleted when those features shipped.
- postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
context timeout so a stuck queue command cannot hang the queue panel path
indefinitely.
- DMARC panel layout classes now exist in
`internal/web/view/static/panel.css` (`.pair`, `.attn`, `.desk-only`,
`.phone-only`, `.phone-list`, `.check-row`), so desktop/mobile DMARC views and
attention highlighting render as intended.
- `dmarc-ingest` now bounds stdin with `io.LimitReader` (10 MiB) before parsing.
- User-role update no longer contains a duplicate "last global admin" demotion
guard; one check now returns a context-specific message for self-demotion.
- Settings flash text now reflects what actually changed and only mentions
session sign-out when the password changed.
- Help page/drawer links now use `SourceURL` (`{{.SourceURL}}/blob/main/docs/...`)
instead of a hardcoded repository URL.
- Help drawer close controls include accessibility attributes (`role="button"`,
`aria-label`) for the scrim and close action.
- Test coverage expanded for DMARC map token injection rejection and email
validation edge cases (ASCII/local-part/domain/freemail cases).
- **Port 25 no longer runs the submission milter chain.** `smtpd_milters` is
now always overridden on `smtp/inet` — to the optional antispam milter, or
to nothing. Previously, with `INBOUND_ANTISPAM_MILTER` unset, inbound mail
inherited main.cf's chain: OpenDKIM (strict, `tempfail`) could defer relayed
mail during a signing outage, and the journal-milter filed inbound mail into
the outbound send log under the **sender's** domain — so Deliveries showed
other people's mail, and a forged `From:` on an inbound message consumed that
sending domain's level-2 rate-limit budget. [architecture.md](docs/architecture.md)
already described the intended behaviour; the code now matches it.
- **DMARC reports are validated before they are stored.** A report is accepted
only when the domain in `policy_published` is a sending domain configured
here and, for a per-domain hosted address, matches that address's `+tag`.
The rua address form is predictable, so anyone able to reach it could
previously file reports under any domain, skew the panel's alignment figures
and the tighten-`p=` hint, and evict genuine reports through the 500-report
retention cap.
- Auto level-2 rate limit: the ceiling is now scaled to the level-1 window
before the multiplier is applied. `ceil(avg msg/h × multiplier)` was stored
as the ceiling for a window of `RATE_LIMIT_WINDOW_SECONDS`, so any window
other than the default `3600` shifted the effective allowance by
`window/3600` (a 1800s window allowed twice the intended rate). A domain
that has sent anything now also gets a floor of 1.
- Single-domain export no longer drops rate limits silently when the store
errors — the error is returned instead of being swallowed by `err == nil && ok`.
### Changed
- [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
review left unimplemented, so no finding lives only in a plan file:
`csrf-tokens` (the recorded §4.4 decision that never became a task),
`template-data-typing`, `structured-logging`, and
`review-2026-08-followups` for the small remainder (backup re-auth, the
missing service/handler/error-page tests, and nine minor findings that were
never carried into the review's own phase tables). Each roadmap row stands on
its own — file, symptom, and what "done" means — and the declined finding
(`//go:build linux` for the `/proc` reader) is recorded as declined so it is
not raised again. The review's working document is gone from `docs/plans/`
now that nothing depends on it; git history keeps it.
- Extracted `internal/configsafe` — the empty-value and forbidden-character
check every generated config file shares. Each writer keeps its own forbidden
set next to the file it protects (an OpenDKIM table and a Postfix map break on
different characters); only the check itself is shared.
- Build, deploy and migration comments no longer cite the archived
specification. The `spec N` references CHANGELOG `[0.5.0]` said were gone
survived in `build/`, `deploy/`, the first two migrations and the release
workflow; they are removed and the affected comment paragraphs re-wrapped.
Comments only — no build or configuration behaviour changes.
- Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
wrappers left behind when the two parsers were merged: nothing but their own
tests still called them.
- Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
`internal/supervisor` (supervisorctl start/signal wrappers), and reused them in
domain/postfix code paths.
- `docs/roadmap.md`: shipped `panel-docs` now references CHANGELOG history
instead of a removed plan file.
- [schema-migrations.md](docs/schema-migrations.md) notes that migration `0009`
changed what the IP list *means* — permissive (those IPs got the application
ceiling) before 1.9.0, restrictive (every other IP refused) after. Its
conversion branch only fires for a database that carried application-scope
`allowed_ips` from before 1.9.0.
- Panel help, [guide.md](docs/guide.md) and [architecture.md](docs/architecture.md):
the client IP allow-list is a **sending** control, not an authentication
boundary — the SASL login still succeeds, the check runs in the
journal-milter at `MAIL FROM`, and it is fail-open like the rate limits.
architecture.md's rate-limit description still documented the pre-1.9.0
trusted-IP semantics.
- Removed unused duplicates of the inbound validators (`Host`, `Port`,
`TLSMode`, `RecipientMode`, `MailboxInDomain`, `NormalizeHost`) from
`internal/web/validate` — the live path uses `internal/inbound/validate.go`;
the copies were reachable only from their own tests.
- `gofmt` across `cmd/` and `internal/` (10 files had drifted); dead branch
removed from `postfix.inboundNexthop`.
- The planned inbound-antispam migration is renumbered `0011_inbound_spam_log.sql`.
- Operator and as-built docs aligned with the code after a full pass —
[architecture.md](docs/architecture.md) (port 25 opens for inbound relay
and/or DMARC ingest; antispam requires inbound relay; DMARC pipe transport
and map paths; `/help` and `/dmarc*` routes; four panel roles including
rate-limit recalc; restore Resync vs manual reload); [guide.md](docs/guide.md)
and [README.md](README.md) (image pin `1.9.1`, port 25 conditions, combined
listener limits, reload vs Resync); [roadmap.md](docs/roadmap.md) (`panel-docs`
closed at `[1.8.0]`, next item inbound-antispam-panel); [development.md](docs/development.md)
version-cut table through `1.9.1`; [schema-migrations.md](docs/schema-migrations.md)
last-updated note. No behaviour change.
### Removed
- Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
and `internal/web/static/favicon.png`; nothing referenced it).
- Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,
`dmarc-reports`, `inbound-relay`, `panel-docs`, `queue-retries`,
`send-log-retention`. Only unimplemented plans remain in `docs/plans/`.
## [1.9.1] - 2026-08-19
Port-25 Postfix startup when inbound relay, DMARC ingest, or the inbound antispam
milter are enabled. Schema-migration reference and roadmap plans for inbound
antispam and quarantine. Upgrading from 1.9.0 is a tag bump; no migration.
### Added
- [docs/plans/inbound-antispam-panel.md](docs/plans/inbound-antispam-panel.md) —
agreed plan for inbound antispam journal (inbound-journal milter + mail.log
rejects) and instance-wide allow/deny lists synced to rspamd maps; target
`1.10.0`; Composer → Opus → Fable workflow.
- [docs/plans/inbound-quarantine.md](docs/plans/inbound-quarantine.md) — candidate
plan for held-mail review/release (design TBD).
- [docs/schema-migrations.md](docs/schema-migrations.md) — living reference for the
SQLite migration chain (`user_version` head, per-file history, legacy artefacts,
1.x rules, and planned 2.x squash gate).
### Changed
- [docs/roadmap.md](docs/roadmap.md) — `inbound-antispam-panel` (agreed, 1/12)
and `inbound-quarantine` (candidate) in the index.
- [docs/development.md](docs/development.md) — plan checklists may override
model routing with Composer → Opus → Fable.
- [docs/development.md](docs/development.md), [docs/roadmap.md](docs/roadmap.md),
and [docs/architecture.md](docs/architecture.md) link to the new file;
`schema-squash` defers gate thresholds to it instead of a stale v5 snapshot.
### Fixed
- `build/postfix-config.sh`: enabling inbound relay, DMARC ingest, and/or the
inbound antispam milter no longer crash-loops on start — `postconf -P` cannot
set `master.cf` `-o` values that contain whitespace, so port-25
`smtpd_recipient_restrictions` and `smtpd_milters` are patched directly; DMARC
pipe `argv` uses a `dmarc-ingest` symlink instead of `panel -dmarc-ingest`.
## [1.9.0] - 2026-08-18
Application sending controls: client IP allow-list for authorization, and
level-2 rate limits that override the domain ceiling per application.
### Added
- Per-application **client IP allow-list** on the domain page: when enabled,
only listed addresses may authenticate and submit as that application; when
off, any client IP is allowed. Form `POST /applications/{id}/authips`.
- SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs
from `rate_limits` into `applications.auth_allowed_ips`).
### Changed
- Application level-2 rate limit **overrides** the domain limit for that login
— the ceiling may be higher or lower than the domain setting (still ≤ level
1). Rate limits are no longer tied to client IPs; enforcement is in the
journal-milter at `MAIL FROM`.
- Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy
`allowed_ips` on an application `rate_limit` in export JSON migrate on import.
- Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics
and the IP allow-list boundary.
- [guide.md](docs/guide.md): full-backup section documents all four capture
methods (panel, `selfpost-backup`, stopped `tar` of the project directory,
stopped `tar` of `./data` only), warns against `tar` on a live container, and
compares downtime, archive contents, and restore behaviour in a table.
## [1.8.0] - 2026-08-18
Built-in operator documentation in the panel: a CSS-only help drawer and Help
page so Status checks and domain controls are explained without leaving the
panel.
### Added
- **Help** navigation entry and `/help` index page.
- CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine,
TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records,
DMARC, connection, applications, settings, export).
- «?» entry points on Status and domain card heads open the matching drawer
topic.
### Changed
- [guide.md](docs/guide.md) documents the boundary: in-panel help for card
meaning; the repository guide for installation, env vars, and operations.
## [1.7.0] - 2026-08-18
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
gzip/XML into SQLite, and show summaries in the panel.
### Added
- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25
accepts only configured `rua=` addresses, gzip/XML is parsed into SQLite, and
the panel shows per-domain roll-ups and individual reports under *DMARC*.
- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled
(`dmarc-reports+<domain>@<hostname>`).
## [1.6.0] - 2026-08-18
30-day sending statistics per domain and application, plus optional auto
level-2 rate limits derived from average send rate.
### Added
- Domain page **Sending statistics** card: total messages, peak and average
msg/h over the last 30 days (or shorter when send-log retention is below
30 days). Per-application stats in the application list.
- Level-2 rate limit **Manual** / **Auto** mode on domain and application
forms. Auto sets `max_messages` from `ceil(avg × multiplier)` over the
level-1 window, capped at level 1; background recalc every six hours and
**Recalculate now** on the domain page.
- Domain export JSON now includes rate limits (`mode`, `auto_multiplier`,
ceilings, trusted IPs).
### Changed
- Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual
mode behaviour is unchanged.
## [1.5.0] - 2026-08-18
Send-log retention is configurable from panel **Settings** (global
administrator); `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
### Added
- Panel **Settings** (global administrator): **Send log retention (days)** —
how long delivery journal rows on `/deliveries` are kept (7–365). Stored in
SQLite `settings`; `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
The log-tailer re-reads the value every prune cycle (no restart).
### Changed
- Deliveries list and delivery detail pages show the configured retention
instead of a hardcoded ninety-day message.
## [1.4.0] - 2026-08-17
Optional inbound relay (backup-MX / forwarder) on port 25, off by default.
Mail is accepted only for domains configured in the panel and forwarded to
an upstream; there are still no mailboxes. A dedicated security review of
the inbound path is still open on the plan (Fable).
### Added
- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
port 25 without SASL, only for `relay_domains` + known recipients, and
forwards via `transport_maps`. Empty upstream host is omitted from the
maps so mail is never accepted with nowhere to send it.
- Panel **Inbound** pages (global administrator): domains, upstream host/port
and TLS, recipient list or any-at-domain, MX check against
`SELFPOST_HOSTNAME`.
- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
[deploy/antispam/docker-compose.antispam.yml](deploy/antispam/docker-compose.antispam.yml).
Default milter action is fail-open (`accept`).
- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
`INBOUND_MESSAGE_SIZE_LIMIT` (default 25 MiB) on smtpd port 25.
### Changed
- Compose publishes host port 25 even when the flag is off (nothing listens
until it is on), the same pattern as 587 / `SUBMISSION_ENABLE`.
- Full-backup restore Resync also rebuilds inbound maps when the flag is on.
Single-domain export/import is still sending domains only.
## [1.3.1] - 2026-08-17
Retry policy in the panel, persistent Postfix queue, and self-contained
full backups. Mail queue and delivery history show this Postfix's first
retry, backoff cap, and queue lifetime. Deferred mail survives container
recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`;
the archive layout breaks 1.3.0 flat backups (unpack those with
`tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a
tag bump; no schema migration.
### Added
- Mail queue and a delivery's history show this Postfix's retry policy
(first delay, backoff cap, queue lifetime), read from `postconf -h` once
at panel start. A manual `postconf -e` override is visible after the next
panel restart. There is no attempt counter — Postfix retries on time.
- docs: **Plan checklists** in [development.md](docs/development.md) — format,
`Progress` column in [roadmap.md](docs/roadmap.md), per-step commit +
CHANGELOG, version cuts `1.3.1`…`1.8.0` per roadmap stage.
- docs: **Implementation checklists** with model routing in
[docs/plans/](docs/plans/) (queue-retries, inbound-relay, send-log-retention,
domain-stats-auto-ratelimit, dmarc-reports, panel-docs).
- docs: roadmap candidates **send-log-retention** (panel Settings for delivery
journal retention) and **domain-stats-auto-ratelimit** (30-day send stats per
domain/application, auto level-2 rate limit from avg × multiplier) — plans in
[docs/plans/](docs/plans/).
- Postfix queue under `/data/postfix/queue` — deferred and active mail survive
container recreate and are included in full backups.
- Full backup archives the whole operator project: `data/`, `docker-compose.yml`,
`.env`, and `certs/` (requires `.:/selfpost-deploy:ro` in compose). Restore
by unpacking into an empty project directory.
### Changed
- **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
deploy files sit at the archive root. Old flat archives restore with
`tar xzf backup.tar.gz -C ./data` as before.
- docs: operator guide, architecture, security, and backup UI updated for
self-contained backups and persistent queue.
- docs: HTML mockups for a full panel UI refresh
([docs/assets/panel-ui/](docs/assets/panel-ui/index.html)) — current screens,
agreed and candidate roadmap surfaces (queue-retries, inbound-relay,
dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms
keep a reading measure), and a 390px emergency layout. The stamp and brick
palette stay; extra nav icons are proposals only. Design artefact; the
running panel is unchanged.
### Changed
- docs: panel UI mockups after review — Status restored to live readings
(queue line, PTR, machine/process tables, Configuration); inbound MX DNS
check, recipient mode (list or any), and Danger zone beside recipients;
Backup and Settings as two-column ops pages; Save and Delete user on one
row; help «?» on domain cards; Host/name ‖ Type height matched to the live
panel (`b0ebe06`).
- docs: panel UI design system and mockups rebuilt as separate pages
(`system.html`, `status.html`, `domain.html`, …), not one hash sheet.
Regions are `stack` / `pair` / `measure` / `fill`; Host ‖ Type is
`field-row`; Save + Delete is `actions-row`. Shared chrome is `shell.js`.
- docs: panel UI mockups — add-domain sits in the list card (Domains and
Inbound); DMARC candidate screens drill into a domain roll-up and a
parsed aggregate report (aligned vs third-party fail), not a hub-only
summary.
### Fixed
- ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped
after the manifest merge via the GitHub Packages API. The `[1.3.0]` merge job
called `docker buildx imagetools rm`, which is not a valid subcommand — cleanup
failed with a warning and the side-effect tags stayed in the registry until
this fix.
## [1.3.0] - 2026-08-14
Security and quality after 1.2.5: domain-admin send-log authorization,
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 -h` once 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](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,
so `send_verify_dkim_and_status` timed out even when mail was delivered and
logged. A handler regression test catches this drift in `go 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 `IN`
constraint carried by the store query — a filter that states no scope returns
nothing — and the `domain` and `app` query 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 `saslpasswd2` fails, 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/backup` package comment now matches
this behaviour.
### Changed
- docs: operator and as-built docs aligned with the code after a full
pass — [architecture.md](docs/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](docs/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 /reload` global-only,
Settings DMARC global-only); [README.md](README.md) port-587 and quick-start
volume wording fixed; [security.md](docs/security.md) CSRF ADR points at
`authz.go` for route gating. No behaviour change.
- docs: [guide.md](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](docs/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.md` is 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](docs/roadmap.md)'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](NOTICE) tells modifiers to update `SourceURL` in
`internal/legal/legal.go` (the value the panel footer actually injects), not
`layout.html`. Per-file `SPDX-License-Identifier` headers on the two command
packages were dropped so the tree is consistent; AGPL-3.0 does not require
them ([development.md](docs/development.md) § External libraries). Deleted the
completed `docs/plans/logrotate-mode.md` (history in git and
[1.2.3](#123---2026-08-12)).
- ci: the release image is published only for a **published** GitHub Release
(`vX.Y.Z`) or a manual `workflow_dispatch` with an explicit SemVer version — a
bare git tag push no longer starts the build. `release.yml` listens for
`release: published`, checks out that tag (not `main` HEAD), e2e-gates each
native arch build, and merges `X.Y.Z-amd64` and `X.Y.Z-arm64` into one
manifest. Reliable removal of the per-arch side-effect tags from GHCR landed
in `[1.3.1]`. A dispatch whose version input is missing or not `X.Y.Z` fails
in `prepare`. [development.md](docs/development.md) documents draft vs
published releases, why deleting a release tag converts it back to draft, and
Gitea → GitHub tag-mirror pitfalls (do not prune release tags on GitHub; a
mirrored `v1.0.0` still runs that tag's `on: push: tags` workflow).
- test: the authorization and sign-in surfaces that had no tests now have them.
The login limiter is covered for its ceiling, its per-address scope, the reset
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 way `tar -xzf` unpacks it onto the `/data` bind 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. `serveHTTP`
is 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.6` instead of `latest`, so
the release gate cannot change under a commit between two runs. The level-1
rate-limit failure message quoted `RATE_LIMIT_MESSAGES_PER_IP=5` while the
stand sets `50`.
- 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-get` and pagination
links now `urlquery`-encode the `domain`/`app` filters instead of splicing
them into the query string raw. The four polled regions (deliveries rows,
status, mail queue, system log) carry `aria-live="polite"` so a screen
reader announces the refreshed content.
- docs: [security.md](docs/security.md) accepted risks now note that
`data-confirm` prompts 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](docs/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](docs/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](docs/architecture.md) gains `/license` and the
`/account` → `/settings` redirect in the route table (later expanded for
RBAC in the doc-alignment pass above). Corrected stale
`admin.dmarc_report_email` references in
[roadmap.md](docs/roadmap.md) and
`docs/plans/dmarc-reports.md` (removed once shipped) to the setting's
actual home after migration `0005`. No behaviour change.
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
gone (handlers already call `requireGlobal`); the settings route handler is
named `HandleSettings` in `handlers_settings.go`; domain lists for a
domain-admin now come from `ListDomainsForUser` in 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 in `panel.js` are 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 shared `st` badge 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 `.split` cards 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 `/account` redirects with 308); the domains list title
is `SelfPost — domains`; Status, Users, and user create/edit titles match their
nav labels and `<h1>` text; the backup page title is `SelfPost — backup &
migration` to reflect domain import as well as full backup. Operator guide and
architecture route tables updated.
### Fixed
- image: `mail.log` rotation no longer silently stops when the build context
ships `logrotate-mail.conf` with group/other write bits (common after a
Windows checkout sync). Runtime `COPY --chmod` pins config and script modes
in the Dockerfile; `logrotate-loop.sh` refuses a config logrotate would
ignore. E2e checks mode `644`, 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 `Listening` without 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 is `N used of M` without 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 roadmap `panel-docs`.
### Fixed
- panel: cards inside `.split` used `margin: 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.check` layout; 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_link` template now renders every drill-down up-link, and
`TestDrillDownPagesPlaceBackLinkAboveContent` guards 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 `.split` layout 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 `/reload` stay
global-only. SQLite migration `0005_panel_users` migrates the single
administrator into a global user; sessions and full backup restore carry users
and domain bindings.
### Fixed
- Docker build: `LICENSE` is 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.narrow`
had overridden only `max-width` while `main > *` 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 `Details` and `deferred` across 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 — `sent` is ok, `deferred` a warning, `bounced` and
`rejected` errors, `queued` unknown because nothing has gone wrong yet.
- docs: [roadmap.md](docs/roadmap.md) and [product.md](docs/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/web` split into subpackages (`web/view`, `web/auth`, `web/validate`,
`web/handlers`); the composition root (`web.New`, `web.Config`, `Server.Handler`)
is unchanged for `cmd/panel`. Templates and static assets moved under
`internal/web/view/`.
### Added
- panel: DMARC guidance for send-only relays — the suggested `_dmarc` record
is now `p=none` without `rua=` by default; *Settings* and each domain page
let you configure an optional aggregate-report address (profile default plus
per-domain inherit / none / custom). When `rua=` targets another domain, the
panel shows and DNS-checks the hub's `_report._dmarc` authorisation record.
Domain export/import carries per-domain overrides.
- AGPL packaging hygiene: [NOTICE](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 ships `LICENSE` and
`NOTICE` under `/usr/share/doc/selfpost/`. `docs/development.md` now lists
the vendored htmx asset beside the Go module licences.
- [docs/roadmap.md](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](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.ru` as 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/](docs/plans/) — one document per agreed extension: the optional
inbound relay, the domain-admin role, and splitting the oversized `web`
package. Each states scope, open questions, and what has to be true before
coding starts. [docs/roadmap.md](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](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](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.md` for reporting a
vulnerability, and the `docs/security.md` row 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.md` lists
`SECURITY.md` among the user-facing deliverables.
- `docs/development.md` records the decision on authorship: SelfPost is written
by AI agents under a maintainer's direction and the project discloses that,
so the `Co-Authored-By` trailers, 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) and
`internal/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 no `Last-Modified` was 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}`): the
`mail.log` lines 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 outlive `mail.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-internal` as `root:root` before `postconf` /
`postfix check`. Bind-mounted keys owned by the CI/host UID made
`postfix check` fail and the container exit before supervisord started.
- Postfix config: allow `maillog_file` under `/data` via
`maillog_file_prefixes=/var,/dev/stdout,/data`, and pin the `postlog`
master.cf service. After mail.log moved to `/data/log`, `postfix check`
fatally rejected the path (default prefixes are only `/var` and
`/dev/stdout`) and often left stderr/mail.log empty.
- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
panel-owned; host `ReadFile` got permission denied on CI). Reclaim `/data`
ownership before TempDir/stage cleanup so panel/postfix UIDs do not fail
Go's `RemoveAll`.
- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
fails with the FATAL text, not an earlier `set -e` abort), and `chmod 755
/data` after chown so OpenDKIM/Postfix can traverse bind mounts that arrive
as mode `0700` (Go `TempDir`, some host umasks) — otherwise KeyTable is
unreachable and the container crash-loops. E2e stand uses `restart: "no"` and
surfaces selfpost logs from the supervisor readiness check.
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
ordered `TestE2E` subtests 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 create` on transient GHCR
`unknown blob` (and similar) errors after layers already uploaded.
- A send-log row could stay `queued` forever after the container was recreated.
`mail.log` moved from the ephemeral `/var/log` into 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.
`postlogd` writes the file as user `postfix` and the unprivileged panel reads
it through the shared `selfpost` group (directory `2750`, file `0640`,
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 `queued`
indefinitely: every five minutes the tailer compares rows still queued from
more than two minutes ago against `postqueue -p`, and marks `bounced` those
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 a `postqueue` that fails, never
closes a row.
### Changed
- Document what `.spbk` and `.spde` stand 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 printed `https://<hostname>/…` link to
`http://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 match `web`→`store` and `POST /domains/import`;
OpenDKIM drops to `opendkim` via `UserID`; guide drops the archived
"spec 7.5" pointer, clarifies `POSTFIX_SENDER_LOGIN_MAPS` vs panel writes,
and states logrotate keeps 14 daily files. Intermediate CHANGELOG cuts from
before the published `1.0.0` image are called out in the guide and
`development.md`. Roadmap points at CHANGELOG `[0.5.0]` Security and
refreshed `internal/web` size / 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-poll` markers) instead of `hx-trigger="every …"`,
which would need `unsafe-eval` under 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 closed `documentation-plan.md`); resuming work,
model routing, commits, and phase closure (from closed `progress.md`).
`docs/archive/` removed — history is git + CHANGELOG. README Documentation
index lists operator docs plus the internal roadmap. Agent rules point at
`development.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.md` restructured into stack, dependencies, build, release,
testing, and CI; agent rules moved to `.cursor/rules/agent-rules.mdc`;
dev-host-specific workflow and `example.com` references removed from docs.
- `docs/development.md` and `.cursor/rules/agent-rules.mdc` translated to
English; `roadmap.md` remains Russian (internal tracker).
- Deploy pin and local-trial image tag set to `ghcr.io/mixeme/selfpost:1.0.0`
(compose and git tag `v1.0.0` cut together). Retired
`docs/implementation-plan.md` and `docs/v1.x-closure-plan.md`; Makefile,
`release.yml`, and e2e comments point at `docs/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 `/proc` outside 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.js` marks 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 `wide` block 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 `SP` initials 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.png` is 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 RELAY` line is set at 11.5/0.15 instead of
7.2/2.8, and no longer carries `opacity=".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.svg` is a copy of
`docs/assets/selfpost-stamp.svg` and 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 to `internal/mailhdr` and 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 the
`form` attribute 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 `.spde` file reveals the password field and a `.json` file 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 contained
`status=sent` was filed as `sent` in the send log. The pattern now takes the
first `status=` after the recipient, which is the real field
(`internal/logtail/logtail.go`); found while extending `TestParseDelivery`.
- 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`, migration
`0003`), so delivery lines written while the panel was down are parsed and
their send-log rows no longer stay `queued` forever. 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.log` is 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, the `Makefile` `MODULE` variable and the
`-ldflags` version stamp in `build/Dockerfile` and `docs/development.md`. An
import path pointing at a host that is going away would break `go get` and
`go install` outright, 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.md` is 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 what `architecture.md`, `security.md`
and the code comments already say. What was genuinely open moved to
`docs/roadmap.md`: splitting `internal/web` into subpackages, a consolidated
documentation index in the README, the adaptive polling interval for an idle
but visible tab, and `CONTRIBUTING.md`. The review text stays in git history
(`522425a`); the CHANGELOG entries below that cite it are left as written.
- `docs/architecture.md` gained 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 of
`docs/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 to
`docs/security.md` (why Origin-check, not tokens); resolved `docs/logo` in
`docs/roadmap.md` (directory doesn't exist, criterion already met); added a
`gofmt -l` check 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 an
`htmx:beforeRequest` listener (`internal/web/static/panel.js`) rather than
htmx's own trigger filter, which is evaluated with `new Function` and would
be blocked by the panel's CSP. Dark mode is now a single reassignment of CSS
custom properties under `prefers-color-scheme: dark` instead of a cascade of
`!important` overrides, and the duplicate `main { 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 in `docs/security.md` unchanged.
- `saslpasswd2` argv: 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` / `.spde` envelope
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-backup` writes encrypted archives and reads them back:
`-decrypt` (with `-i`/`-o`) turns a `.spbk` into the plain `.tar.gz` a
restore unpacks. The password comes from `SELFPOST_BACKUP_PASSWORD` or
`-password-file`, never from argv.
- `TestParseDelivery` covers the exotic mail.log shapes the review asked for
(`docs/code-review.md` § 3): a `status=` quoted inside the remote reply, the
null recipient of a double bounce, `orig_to=` alongside `to=`, 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.md` persistence § envelope summary.
- docs: `docs/roadmap.md` v1.x tail — retire `implementation-plan.md` in the
release commit (move to `docs/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 in `implementation-plan.md` and `progress.md`.
- docs (D6): Docker `HEALTHCHECK` probes `/healthz`; endpoint returns 503 unless
opendkim, panel, and postfix are RUNNING (`internal/health.Liveness`).
- docs (D6): README *Container health* — scope of `/healthz` vs authenticated Status.
- docs (D7): `cmd/panel/envdoc_test.go` — regression test that every
`loadConfig` and 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.md` archived to
`docs/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), and `mail.log` rotation 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.example` link
that pointed at a missing section.
- docs (D2): README environment-variable reference — public `.env` table with
code-accurate defaults, `TRUSTED_PROXY_CIDR` security note, explicit
internal-variable list; `TRUSTED_PROXY_CIDR` wired through
`deploy/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.md` trimmed 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.log` rotation (rename + `postfix reload`), `SELFPOST_HOSTNAME` startup
gate, log-tailer known gaps.
- docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow.
- docs: `security.md` — accepted risk for send-log rows stuck at `queued` after
panel restart or container recreate.
- docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail.
- docs: `progress.md`, `documentation-plan.md` — cross-links updated for the new layout.
- docs: `documentation-plan.md` marked 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 with
`product.md` / `security.md` / `development.md`.
- docs: `progress.md` — documentation pass closed; deferred polish in roadmap.
- `/healthz` now checks supervisord mail-path processes, not HTTP alone.
- `build/Dockerfile`: `curl` for `HEALTHCHECK`; probe on port 8080.
- docs (D3): README backup — stopped-container `tar` of `./data` (with live-container
WAL warning), `manifest.json` consumed 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 `./certs` bind mount, not `.env`).
- docs (D5): `implementation-plan.md` B.1 — password change signs out other
sessions only (implementation diverged from original plan; README was already
correct).
- docs: documentation plan now targets retiring `specification.md` after D9 —
migration map to `product.md`, `architecture.md`, `development.md`, and
expanded `security.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.log` lines 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.com` in 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.
Set `SELFPOST_DNS_RESOLVERS` if 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 e2e` locally, and `go test ./...` in `test/e2e` as
a required step in `release.yml` before a version tag's image is pushed.
It builds the real image, brings up the shipped `deploy/docker-compose.yml`
plus a test-only override (self-signed cert, low ports, a fake DNS zone
served by CoreDNS, a `smtp-sink` sink-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
to `sent`. 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/malformed `SELFPOST_HOSTNAME` failing the container
fast, and a login session surviving `docker restart`. `release.yml` moved
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.log` rotation switched from `copytruncate` to rename +
`postfix reload` (the same mechanism `postfix logrotate` itself uses),
eliminating the up-to-one-second window in which `copytruncate` could drop
in-flight delivery lines — a lost line meant a send-log row stuck at
`queued` forever. `logrotate-mail.conf` keeps `create 0644 root root`
rather than `nocreate`: verified on a live container that letting Postfix
recreate the file itself on reload produces `0600`, 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 missing `mail.log` right 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`, and
`Strict-Transport-Security` where 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 to `Origin` vs `Host`). 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's `SameSite=Lax` counts 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 original `Host`
header through** (every shipped fragment already does); one that rewrites it
makes the panel refuse every form submission, and the log line names both
the `Origin` and the `Host` it compared.
- panel: the session cookie is now named `__Host-selfpost_session` wherever it
is `Secure` (the standard deployment), which makes the browser enforce that
no other host can set or overwrite it. **Upgrading signs the administrator
out once.** With `PANEL_COOKIE_SECURE=false` the 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.css` and 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.
`postqueue` relies on its setgid-`postdrop` bit, which `no-new-privileges`
(set in the shipped compose file) disables, so the *Queue* screen always said
"Could not read the mail queue". The `panel` user is now a real member of
`postdrop`.
- 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=true` for 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_add` gains `CAP_FOWNER` and `CAP_FSETID` so the entrypoint can
chmod and set the setgid bit on `/data` directories it has just chowned under
`cap_drop: ALL`, and `CAP_KILL` so supervisord can signal OpenDKIM across uids
when domains are added or removed. Production boot under the hardened compose
file exposed both gaps after Phase 10.
- ci: disable provenance attestation on release image push, so the ghcr.io
manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`).
- ci: run `go vet` and `go test ./...` on every push to `main` and every pull
request, not only the image build on a release tag.
- security: optionally honour `X-Forwarded-For` for login/setup rate-limiting
when the request's direct peer is in the new `TRUSTED_PROXY_CIDR` list,
giving real per-client limits behind a reverse proxy instead of one global
bucket. Unset by default (unchanged `RemoteAddr`-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_maps` enforcing 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 to `send_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.gz` of `/data`, consistent SQLite snapshot via
`VACUUM 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.io` on `vX.Y.Z` tags.
- 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).