test / test (push) Canceled after 0s
Code and config: - C-M7: a DMARC report's explicit <pct>0</pct> stays 0; only an absent pct takes RFC 7489 § 6.3's default 100 (Pct *int). - C-D1: docs/development.md now tables the Debian versions the citations were read at (postfix 3.7.11-0+deb12u1, opendkim and libopendkim11 2.11.0~beta2-8+deb12u1, libmilter1.0.1 8.17.1.9-2+deb12u2, libsasl2-2 and sasl2-bin 2.1.28+dfsg-10, supervisor 4.2.5-1, logrotate 3.21.0-1 - read off the image with dpkg-query, 2026-10-15); the e2e stand's new checkCitedPackageVersions fails when the image it built differs. - C-E2: the e2e zone's SOA serial only grows (CoreDNS v1.14.6's file plugin reloads on a grown serial, not on mtime); Corefile comment says so; TestZoneSerialGrows. - C-K8, C-K9: the e2e compose and stage comments say how --project-directory and .env resolve. - D-arch:154: postfix-config.sh's "Postfix insists" comment says what TLS_README advises and that smtpd reads the key as root. - B-G7: inflight.go's reservationTTL comment: a dropped client does give ABORT (smtpd_proto() -> mail_reset() -> milter_abort()); the TTL is for smtpd dying or the milter connection breaking. - D-guide:301: the nginx fragment's first-certificate command now reaches certbot (--entrypoint certbot, --standalone, port 80 free), and the renewal loop passes --webroot -w /var/www/certbot - a standalone certificate would otherwise renew standalone while nginx holds port 80 (certbot docs, "Renewing certificates": the issuing plugin is used "unless you specify other plugins or options", and options of a successful renewal are saved). The register's proposal missed that. Documents: every D-arch, D-guide and D-sec claim the register's Answers E found false or partly true, rewritten - by three Sonnet agents, one per document, then reviewed: two proposals were obsolete after this stage's fixes and not used (a bounce's From: line - extract_from = "smtp" now; "Origin: null" - Referrer-Policy is same-origin now); a stand observation the agent had dated from a commit is now tied to the e2e test that checks it; the SQLite writers paragraph describes _txlock=immediate; the log tailer's rotation paragraph describes the minute's grace; the guide's nginx steps were put in working order (links and modes before `up`, no reconfigure step). "Deferred" is kept for Postfix's queue in the guide too. guide.md's CRLF from the agent normalised. Stand (2026-10-15, make e2e, exit 0, the version gate and zone serial test included); go vet and go test ./... pass on the server (24 packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2866 lines
170 KiB
Markdown
2866 lines
170 KiB
Markdown
# 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]
|
||
|
||
### Security
|
||
|
||
- **A sender on a private address could have his own spam verdict signed.**
|
||
rspamd's header module by default leaves a message's verdict headers
|
||
alone - removes none, adds none - when the client counts as local (a
|
||
private range, which every client has when Docker's proxy relays port 25)
|
||
or authenticated, while the routine that signs a verdict for SelfPost runs
|
||
for every message. Such a sender's `X-Spamd-Result` and `X-Rspamd-Action`
|
||
then reached Inbound > Log as rspamd's. `milter_headers.conf` now turns
|
||
both exceptions off; tried against rspamd 3.11.1 before and after
|
||
(audit 2026-10-12, stage 2c; assumptions C-R7). Copy the new
|
||
`antispam/rspamd/` files next to your compose fragment.
|
||
- **An address listed for one application could be put in `From:` by
|
||
another of its domain.** The sender check accepted a `From:` address when
|
||
any of its keys in the sender map - the address, the address without its
|
||
extension, the domain - named the application; Postfix takes the first
|
||
key it finds and asks only its owners. An application set to any address
|
||
of a domain could therefore write in `From:` an address listed for
|
||
another application of that domain - which Postfix refuses it in the
|
||
envelope - and have it signed. The check now reads the map as Postfix
|
||
does: the first key found decides, logins are compared without regard to
|
||
case, owners may be separated by blanks or continue on the next line, and
|
||
a key written twice makes the check defer, as it makes Postfix refuse the
|
||
whole table (assumptions B-PS1, B-PS4).
|
||
|
||
### Fixed
|
||
|
||
- **A message Postfix gave up on no longer stays "deferred" for ever.**
|
||
Postfix records the end of a message's queue lifetime in one line for the
|
||
whole message, without the recipients; the panel waited for a line per
|
||
recipient that Postfix never writes, and the reconcile sweep closed only
|
||
`queued` rows, so such rows said `deferred` until they were pruned. The
|
||
log tailer now reads that line and closes the message's rows as
|
||
`bounced`; the sweep also closes a `deferred` row whose message Postfix no
|
||
longer holds (which covers the rows left so far, and `postsuper -d`); a
|
||
message forced to expire (`postsuper -e`, `#` in the queue listing) is
|
||
still counted as held (audit 2026-10-12, stage 2c; assumptions A-8,
|
||
B-PQ1).
|
||
- **Lines Postfix writes while `mail.log` is being rotated are read.**
|
||
Postfix's log writer keeps the old file open until the reload that
|
||
follows the rotation; the log tailer switched to the new file at once,
|
||
so a delivery written in between never reached its row, which the sweep
|
||
later closed as `bounced`. The tailer now reads the rotated file for a
|
||
minute more (assumptions A-33).
|
||
- **Postfix's time values are read as Postfix reads them.** A bare number
|
||
took seconds, so `maximal_queue_lifetime = 3` was three seconds to the
|
||
panel and three days to Postfix; `1h7m` was 67 minutes to the panel and
|
||
one hour to Postfix. A value now takes the parameter's own default unit
|
||
and one unit only; `$name` references are expanded; a
|
||
`bounce_queue_lifetime` longer than `maximal_queue_lifetime` is cut to it,
|
||
as Postfix's queue manager does (assumptions B-PQ10, B-PQ11).
|
||
- **A refusal's line goes on the right application's row.** Postfix's line
|
||
about a refusal at `MAIL FROM` was matched to its row by client address,
|
||
sender and time; two applications behind one address sending as one
|
||
sender could take each other's line. The line carries the login after
|
||
all, and the match now uses it. A login read from smtpd's `client=` line
|
||
no longer keeps a trailing comma when the client also gave `AUTH=` on
|
||
`MAIL FROM`. A refusal written from `mail.log` after a restart says that
|
||
Postfix's line names the last recipient only (assumptions A-15, A-21,
|
||
A-23).
|
||
- **A delivery reaches the row of every recipient Postfix delivers to.** A
|
||
quoted local part with a blank in it (`"a b"@x`) was cut at the blank, a
|
||
needlessly quoted one (`"ab"@x`) kept quotes Postfix does not log, a
|
||
recipient Postfix had rewritten was looked for under its new address, and
|
||
a recipient given twice made two rows of which Postfix reports one - each
|
||
row then waited for a line that never named it, and the sweep closed it
|
||
as `bounced`. The row now holds the recipient as Postfix logs it, once,
|
||
and is found under `orig_to=` when Postfix rewrote it (assumptions B-G5,
|
||
A-9, A-31, D-S20).
|
||
- **Inbound mail between 10 MB and the port's limit is accepted.** Port 25
|
||
said it took messages up to `INBOUND_MESSAGE_SIZE_LIMIT` (25 MiB by
|
||
default), but Postfix writes the message to its queue under a limit of
|
||
its own, main.cf's, never set and so 10240000 bytes: a larger message was
|
||
taken through `DATA` and then refused with 552. That limit now follows
|
||
port 25's when it is larger; 465 and 587 keep theirs (assumptions
|
||
C-P31).
|
||
- **Mail to an address literal of the container is refused, not delivered
|
||
locally.** An empty `mydestination` left one way to local delivery:
|
||
`user@[127.0.0.1]` and the container's other addresses. Local delivery is
|
||
now Postfix's refusal, and Postfix refuses such a recipient at `RCPT TO`:
|
||
`550 5.1.1 ... local mail delivery is disabled` (assumptions C-P8).
|
||
- **A bounce is judged by the filter lists as the panel counts it.**
|
||
rspamd's sender rules took a bounce's `From:` header when the envelope
|
||
sender was empty, so a bounce could be refused or allowed by an entry the
|
||
Filter lists page never showed as used. They now look at the envelope
|
||
sender only (assumptions C-R4).
|
||
- **Stopping or restarting the container stops Postfix as Postfix means it
|
||
to be stopped.** Supervisord's stop signal reached only the shell that
|
||
started Postfix, never Postfix's master process: a restart of the program
|
||
found Postfix "already running", and a container stop cut Postfix off at
|
||
the end. The start wrapper now runs `postfix stop` (assumptions C-P42).
|
||
- **Postfix that cannot start takes the container down instead of waiting
|
||
for ever.** When the milter sockets never appear, the wrapper gives up
|
||
after `MILTER_WAIT_TIMEOUT`; that exit was taken for an ordinary one and
|
||
the wrapper started again, endlessly. It now counts as a failed start,
|
||
and once supervisord's three retries are spent the container exits for
|
||
Docker to restart it. For `MILTER_WAIT_TIMEOUT` plus ten seconds after
|
||
each start, Server > Health shows Postfix as starting (assumptions C-V6).
|
||
- **A logrotate configuration it would ignore ends in a fault shown on
|
||
Server > Health,** where logrotate was restarted every second for ever;
|
||
the container stays up, mail is not affected (assumptions C-V5).
|
||
- **A failed Postfix reload is reported.** The panel took a reload as done
|
||
as soon as it was started, and a reload already running as its own. It now
|
||
waits for `postfix reload` to finish and shows its failure on the page that
|
||
saved; a reload while Postfix is still starting is left to the start
|
||
(assumptions B-U2).
|
||
- **Changing an application's password no longer rewrites the live SASL
|
||
database.** `saslpasswd2` wrote into the file Postfix reads at every
|
||
`AUTH` and the backup copies, with no lock between them: an application
|
||
signing in at that moment could be refused, and a backup taken then could
|
||
carry a damaged database whose restore would refuse every application.
|
||
The change is now made to a copy that replaces the file whole (assumptions
|
||
C-S3, C-Q3).
|
||
- **Server › Health connects to the milter sockets instead of finding their
|
||
files.** A socket's file outlives a killed OpenDKIM or panel and refuses
|
||
every connection; Health showed it as listening while Postfix answered
|
||
every sender with a temporary error. The panel now connects as Postfix
|
||
does and says when nothing listens (assumptions A-38).
|
||
- **Server › Health shows the certificate Postfix serves.** Postfix serves
|
||
its own copy of the mounted certificate and refreshes it once a day; the
|
||
card read the mounted file, so a renewal showed as served up to a day
|
||
before it was, and a renewal Postfix never took up - a key that does not
|
||
belong to it, a failed reload - not at all. The card now shows Postfix's
|
||
copy, names a renewal waiting in the mount, and warns when it has waited
|
||
longer than the daily check allows; the expiry notice is not sent while a
|
||
renewal waits on time (assumptions A-65).
|
||
- **The panel works in browsers that do not say where a request comes
|
||
from.** The panel's `Referrer-Policy: no-referrer` made a browser send a
|
||
form's `Origin` as `null`, which the panel refuses unless the browser also
|
||
sends `Sec-Fetch-Site`: in Safari before 16.4, or in any browser on a
|
||
panel served over plain HTTP at a name, every form - signing in
|
||
included - was refused. The policy is now `same-origin`, which still
|
||
sends nothing to other sites (assumptions D-B3).
|
||
- **A password shown once is emptied when its page is left,** so a browser
|
||
that keeps the page for its Back button shows an empty field; the
|
||
meaningless `Pragma` header goes (assumptions D-B13).
|
||
- **A database update runs once when two programs open the database at
|
||
the same moment.** After an upgrade, the panel and the DMARC report
|
||
intake could both find the database one version behind and both apply
|
||
the next update; the second then failed, and with it that start or that
|
||
report (nothing was damaged). Each update now holds the database from
|
||
its start and checks the version again (assumptions D-S3).
|
||
- **Certificates from certbot reach Postfix and the backup.** The guide had
|
||
`certs` linked to certbot's `live/<host>`, and the nginx fragment mounted
|
||
that directory; its files are links into `archive/`, which the container
|
||
did not have, so Postfix started without a certificate and a full backup
|
||
went without one. `certs/` now holds two links into `/etc/letsencrypt`,
|
||
which is mounted read-only at the same path (a commented line in
|
||
`docker-compose.yml`, mounted in the nginx fragment); the guide gives the
|
||
two commands certbot's documentation has for a server that is not root.
|
||
A full backup follows links under `certs/` and carries the files; a link
|
||
that leads nowhere stops it, named (assumptions C-A1, C-Q4). If you use
|
||
certbot this way, follow the guide's new "Reverse proxy" steps.
|
||
- **A DMARC report with `pct=0` says 0.** An explicit `<pct>0</pct>` - a
|
||
policy applied to no message - was shown as 100, the value for a report
|
||
that leaves `pct` out (assumptions C-M7).
|
||
- **The nginx fragment's first certificate is issued, and renewed.** The
|
||
command it gave passed its arguments to the renewal loop, which ignored
|
||
them, and used the webroot nginx could not serve yet. It is now
|
||
`run --entrypoint certbot ... --standalone` with port 80 free, and the
|
||
renewal loop names nginx's webroot, which a standalone certificate would
|
||
otherwise not use (assumptions D-guide:301).
|
||
- **The guide, architecture and security documents say what the code
|
||
does.** Some forty claims the sources contradict, or only partly bear out,
|
||
are corrected: what the rate limits count, what a backup restore message
|
||
looks like, how to pass the backup password, what the queue and Health
|
||
show, a backup MX's preference, the DMARC report address, what the
|
||
password store keeps, what the delivery log holds, and others
|
||
(assumptions, Answers E).
|
||
- **Words for what happens to mail match Postfix's.** A message refused with
|
||
a temporary error while the panel or the spam filter does not answer was
|
||
called deferred, which in Postfix's words is mail waiting in its queue;
|
||
the Upstream help said Required TLS holds the mail until TLS works, but
|
||
an upstream that refuses STARTTLS with a permanent error bounces it
|
||
(assumptions D-P1, D-P3).
|
||
|
||
### Changed
|
||
|
||
- **The rspamd image is pinned to one build**, `rspamd/rspamd:3.11.1-3`:
|
||
`3.11` and `3.11.1` move with every rebuild (assumptions C-R12). The rules
|
||
lose their `prefilter = true` lines, which rspamd 3.11.1 does not read.
|
||
|
||
## [2.5.1] - 2026-10-14
|
||
|
||
Stage 2b of the 2026-10-12 audit: the log tailer and what the panel shows of
|
||
a refusal. Migration `0009` keeps Postfix's line about a refusal on its row,
|
||
the client a refusal came from, a mark on rows written from `mail.log`, and
|
||
the Sender check's two counts and its estimate mark.
|
||
|
||
### 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).
|
||
- **A message the panel closed itself says so.** The history of a `bounced`
|
||
row read "the receiving server refused the message permanently, or
|
||
Postfix gave up ... The reason is in the delivery log below" also for a
|
||
row the reconcile sweep closed, where neither is known and the log has
|
||
nothing. Such a row's step is now "Closed by the panel", with why.
|
||
- **The Sender check card counts connections and messages apart.** What was
|
||
one column, "Messages deferred", was two things: connections Postfix
|
||
turned away while the panel did not answer - refused before any message
|
||
is offered, so a client trying three times is three - and messages
|
||
deferred in the middle, when the panel stopped answering after a message
|
||
had begun. The card now has a column for each (the owner's decision).
|
||
- **A start at which supervisord does not answer still counts the downtime.**
|
||
The panel asks supervisord once at start since when Postfix runs; when it
|
||
did not answer, that start's downtime was not counted at all. It now asks
|
||
for half a minute, and then takes the time since its last sign of life -
|
||
an upper bound - and the card marks such a period with ≈.
|
||
- **A refusal the panel did not get to write is written from the log.** The
|
||
sender check answers 550 and writes the row a moment later; a panel that
|
||
stopped in between - a rollout, a crash - left an application refused and
|
||
a log that did not know it. When the log tailer reads cleanup's refusal
|
||
line and, after giving the panel half a minute, finds no row for that
|
||
queue id, it writes the row from the line, marked on its page as written
|
||
from `mail.log` after a restart. A refusal at `MAIL FROM` needs no such
|
||
thing: its row is written before Postfix is answered.
|
||
|
||
## [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).
|