18 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 403ce93990 release: 2.0.0: Panel redesign; new URLs; database from zero
test / test (push) Canceled after 0s
The panel redesign (docs/plans/panel-redesign.md, 27/27). A breaking
release: every panel URL changed and the database starts from a single
baseline - a 1.x /data is not upgraded.

Since the last cut also fixed, all found on the test server:
  - the panel can read the mail queue;
  - port 25 answers with DMARC report ingest on, also next to the
    inbound relay;
  - DMARC reports reach the panel (the delivery service never worked
    since 1.7.0);
  - Postfix start-up warnings about the queue directory.

Cut: CHANGELOG [Unreleased] -> [2.0.0]; image pin 2.0.0 in compose,
README, guide; roadmap, plan and version table.

On the test server, on 252e5d1: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.0.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:32:08 +03:00
mixandClaude Opus 5.5 252e5d1cff dmarc-ingest: Postfix can run the command, and it is told the recipient
The first DMARC report to reach the test server (Google, 2026-10-11
09:41 UTC) was accepted on port 25 and then sat in the queue:

  postfix/pipe: fatal: missing user= command-line attribute
  postfix/qmgr: warning: private/dmarc-ingest socket: malformed response

Three things stood between an accepted report and the panel, all since
the feature shipped in 1.7.0 - no report had ever been delivered:

- The service was addressed as dmarc-ingest/unix-pipe. Its type is unix:
  postconf -P answered "unmatched service_name/type" for flags=, user=
  and argv= (the warnings in the container log at every start), which
  are arguments of pipe(8) anyway and not -o parameters, and the service
  stayed a bare "pipe". For the same reason postconf -MX never removed
  it. It is dmarc-ingest/unix now, with the arguments in the service line.
- pipe(8) gives the command export_environment and nothing else, so the
  command would have refused to run: it reads DMARC_REPORTS_ENABLE (and
  the data directory, when it is not the default) from the environment.
  Those are now named in export_environment.
- The command took the recipient from RECIPIENT, which local(8) sets and
  pipe(8) does not. With no recipient the check that a report came to the
  address of its own domain is skipped. It is now ${recipient} in argv,
  one recipient per run.

When DMARC ingest is off the service and both parameters are removed.

e2e: every port-25 combination now asks Postfix for the service and the
two parameters (postconf -M / -h), and for their absence when ingest is
off - which is how the wrong type was found. make e2e on the test
server: ok. go test ./cmd/panel/ ./internal/dmarc/ ./internal/postfix/:
ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:25:14 +03:00
mixandClaude Opus 5.5 5af4d6c368 panel-redesign: the health cards of Overview carry no anchor
Follows the design commit before it: Machine, Processes, TLS certificate, Milter sockets and Reverse DNS lead to /server/health as it opens; so does the link in the Needs attention line, which takes its address from the card.

  go vet ./internal/web/...: ok
  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:17 +03:00
mixandClaude Opus 5.5 ddde177776 design: the health cards of Overview open Health at its top
test / test (push) Canceled after 0s
Owner, 2026-10-11, after looking at the test server: the anchors work but mean little, Health is short and there is nothing to scroll past. The five cards (Machine, Processes, TLS certificate, Milter sockets, Reverse DNS) lose their #machine ... #hostname, as the links into Domain settings lost theirs.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:12:53 +03:00
mixandClaude Opus 5.5 e69d139487 docs: the preflight and inbound-antispam plans use the 2.0 paths
test / test (push) Canceled after 0s
/preflight is /server/preflight and the retention setting is on /server/settings; the routes are renamed, so the plan no longer says once they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 3f0d15c9e3 entrypoint: the queue directory and pid/ stay owned by root
The recursive chown to postfix at the end of the entrypoint also takes the queue directory itself and pid/, which Postfix keeps owned by root; postfix-script logs warning: not owned by root for both at every start. They are handed back to root after the chown, as public/ and maildrop/ are handed back to postdrop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 bbd2d83364 panel: show/hide rules reach a form that arrives in an htmx swap
initShowWhen bound a change listener to each control it found at DOMContentLoaded, so a form swapped in later would have been neither synced nor wired. The rules now listen once on the document and are applied again on htmx:load. No form arrives by a swap today; the roadmap row that recorded the gap is struck.

Checked in a browser on the application form: the rate-limit select toggles the manual and auto fields as before, and a copy of the form put into the page with htmx.swap is synced on arrival and follows its select. go vet and go test ./internal/web/... pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 c5588ebba1 panel-redesign: the DMARC hub before the first report says what to do now
- The hint under "Last report: None yet" still said "Point rua= at the
    hosted address below" - there is no such address any more, and nothing
    is below. It names the three steps of the three-choice scheme: set a
    domain to SelfPost hosted in its Domain settings, publish the DMARC
    record the domain then shows, publish MX for the server's name.
  - The hosted address form is Fact.Small, as in the mockup.

  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:13:51 +03:00
mixandClaude Opus 5.5 f6838e4696 design: the hosted address form on the DMARC hub is in the smaller mono size
On the test server the form dmarc-reports+<domain>@selfpost.mixfed.ru
broke over three lines in its column (2026-10-10). It takes the
supporting size the report id and rua of a report already use.

check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:13:51 +03:00
mixandClaude Opus 5.5 36db4223f2 postfix-config: report addresses are relay recipients when the relay is on
Found by talking SMTP to port 25 of the test server once it answered
again (2026-10-10): RCPT TO:<dmarc-reports+bs.mixdep.ru@selfpost.mixfed.ru>
was refused with "550 5.1.1 User unknown in relay recipient table".

With INBOUND_RELAY_ENABLE and DMARC_REPORTS_ENABLE both true the server's
own name is a relay domain (for the reports) and relay_recipient_maps
lists the relay's recipients only. smtpd_reject_unlisted_recipient
applies after the restriction list, so the OK that
check_recipient_access gives a report address does not help: every
report was refused. The table of report addresses is added to
relay_recipient_maps in that combination.

Checked on the server by setting the parameter in the running container
and reloading Postfix: the domain's report address is answered 250, the
former server-wide mailbox and an unknown local part 550, a foreign
domain and an inbound domain without recipients 554. e2e: the combined
case requires relay_recipient_maps to name both tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:05:40 +03:00
mixandClaude Opus 5.5 80748e6c94 postfix-config: no whitespace in the port-25 service's -o values
Seen in the System log of the test server (2026-10-10): on every
connection to port 25

  postfix/smtpd: fatal: unexpected command-line argument:
      texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,...
  postfix/master: warning: .../smtpd: bad command startup -- throttling

With DMARC_REPORTS_ENABLE=true the script wrote
  -o smtpd_recipient_restrictions=check_recipient_access texthash:...,...
into master.cf. Master splits a service line on whitespace, so smtpd got
the option cut at the space plus a stray argument and died at start:
port 25 greeted nobody - no inbound relay, no DMARC reports. postconf -P
smtp/inet/smtpd_recipient_restrictions showed the cut value,
"check_recipient_access". The server's logs have it under the previous
image too (2026-10-09), so it is not from the redesign; postfix check
does not notice, and the e2e check compared the text of master.cf with
an expectation that had the space in it.

  - The restriction list is written with commas only (Postfix splits it
    on commas as well as whitespace).
  - master_cf_set_smtp_inet_o stops the container on a value with
    whitespace instead of writing it.
  - e2e: for every option it checks, the port-25 case also asks
    postconf -P and requires Postfix to read the same value.

Not fixed here: Postfix's start-time warnings "not owned by root" for
the queue directory and its pid/ - the entrypoint's recursive chown
again; harmless, logged at every start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:57:36 +03:00
mixandClaude Opus 5.5 c5086510f0 panel-redesign: links into Domain settings carry no anchor
Follows the design commit before it: the domain page's menu entries
Report address / Rate limit / Export, the DMARC record's note and the
delete page's "Export the domain" lead to /outbound/domains/{id}/settings
as it opens, not scrolled to a box.

  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:57:35 +03:00
mixandClaude Opus 5.5 5b1a868ccf design: links into Domain settings open the page at its top
Seen by the owner on the test server (2026-10-10): following Report
address, Rate limit or Export from the domain's menu opened Domain
settings scrolled down to that box. The page is one screen and a bit, so
the anchor only pushed the title and the first row out of view. The
links (the domain menu, the DMARC record's note, the delete page, Server
> Settings) lose their #reports / #limit / #export.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:57:35 +03:00
mixandClaude Opus 5.5 c36bd60022 panel-redesign: an idle one-shot helper is "on demand", not "stopped"
The owner's choice (2026-10-10) for the last line of the Processes table
on Server > Health: postfix-reload runs when the configuration is
reloaded and exits, so between runs supervisord calls it STOPPED - a
word that reads as a fault beside six running programs, in a box that
rightly says ok. Its row now has the neutral tag "on demand" and says
when it last ran (or that it has not run since the container started).
A one-shot in trouble keeps supervisord's word and level, and a daemon
that is stopped is still an error.

  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:45:05 +03:00
mixandClaude Opus 5.5 baa053d250 panel-redesign: three things the first look at the deployed panel showed
Seen by the owner on the test server, on real data (2026-10-10).

  - Overview's Processes card said "7 of 7 running" with six programs
    running and postfix-reload idle. The card counts the programs that
    are meant to stay up; a one-shot helper (health.Process.OneShot) is
    left out of both numbers and named only when it is in trouble.
  - The Milter sockets card clipped its second line ("OpenDKIM,
    send-log,...") once the spam filter was the third socket. The line
    names the sockets that do not answer when some do not; with all
    answering it is their names, or "incl. the spam filter" - the
    mockup's words - when the filter is among them.
  - A real 2048-bit DKIM value did not fit the three rows of its field
    and scrolled inside it. The field is as tall as the value needs
    (3 to 12 rows).

The fixtures had a shortened key, two sockets and no one-shot program,
so none of this showed in the evidence screenshots.

  go test -count=1 ./internal/health/... ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:39:39 +03:00
mixandClaude Opus 5.5 ce41a8cf34 changelog: the panel can read the mail queue
The entrypoint fix of 783bca8, with what the two e2e runs on the test
server showed: the new subtest fails on the commit before the fix
("the panel could not run postqueue -p") and the whole suite passes on
the commit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:30:41 +03:00
mixandClaude Opus 5.5 783bca806a entrypoint: public/ and maildrop/ of the queue stay in group postdrop
The entrypoint ends with chown -R postfix:postfix on the queue tree, on
every start, after postfix set-permissions has run. That puts public/
and maildrop/ in group postfix; Postfix keeps them in its setgid group,
and postqueue (setgid postdrop) reaches the showq socket through that
group only. So the panel, which runs postqueue -p as its own user, got
"Connect to the Postfix showq service: Permission denied": the Queue
card of Overview said "Could not be read" and the queue page showed no
listing. The data directory of the test server has carried the wrong
group since 2026-08-18, so this is older than the redesign - the
recursive chown came with the persistent queue in 1.3.0; Postfix itself
logs "warning: not owned by group postdrop" for both at start.

The two directories are handed back to postdrop after the chown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:28:13 +03:00
mixandClaude Opus 5.5 088c8a5986 e2e: the panel can read the queue
A subtest that opens Outbound > Queue right after sign-in and expects
postqueue's own line for an empty queue, not the page's "Could not read
the mail queue". Seen on the test server after the 2.0 deployment: the
Queue card said "Could not be read", and the panel's log had
"postqueue: fatal: Connect to the Postfix showq service: Permission
denied" on every poll. The fix follows in the next commit; this one is
expected to fail on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:28:13 +03:00
42 changed files with 479 additions and 121 deletions
+53
View File
@@ -5,6 +5,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [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 ### Added
- panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons - panel-redesign (stage 0): Bulma 1.0.4 and a 57-icon subset of Tabler Icons
@@ -209,6 +215,53 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
application forms (the script applied only the first matching rule). 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 - The DMARC page of a domain no longer answers 500 once the domain has a
reported source. 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 - 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 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 to say the domains were not affected (replaced before release — see the
+1 -1
View File
@@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.9.5 ghcr.io/mixeme/selfpost:2.0.0
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+11
View File
@@ -157,5 +157,16 @@ if [ ! -d /data/postfix/queue/active ]; then
postfix set-permissions postfix set-permissions
fi fi
chown -R postfix:postfix /data/postfix/queue chown -R postfix:postfix /data/postfix/queue
# The recursive chown above would leave public/ and maildrop/ in group postfix.
# Postfix keeps those two in its setgid group: postqueue and postdrop are setgid
# postdrop and reach the showq socket and the maildrop directory through that
# group alone. With the wrong group `postqueue -p` run by the panel answers
# "Connect to the Postfix showq service: Permission denied" and the panel cannot
# read the queue (the Queue card and the Outbound queue page).
chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop
# It would also leave the queue directory itself and pid/ owned by postfix.
# Postfix keeps those two owned by root (master, which runs as root, is the only
# writer) and logs "warning: not owned by root" for each at every start.
chown root /data/postfix/queue /data/postfix/queue/pid
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+51 -11
View File
@@ -13,12 +13,25 @@
# a fixed literal or a container environment variable. # a fixed literal or a container environment variable.
set -eu set -eu
# postconf -P refuses master.cf -o values that contain whitespace; inbound port # Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
# 25 needs restriction lists and milter specs that include spaces. Patch the #
# smtp/inet service stanza directly instead. # A master.cf -o value must not contain whitespace: master splits the line on
# it, so "-o name=check_recipient_access texthash:/x,reject" reaches smtpd as
# the option "name=check_recipient_access" plus a stray argument, and smtpd dies
# at start with "fatal: unexpected command-line argument" - port 25 then
# answers nobody. (postconf -P refuses such values for the same reason.) Postfix
# splits a restriction list on commas as well as on whitespace, so every value
# passed here is written with commas only; a value with whitespace is a bug in
# this script and stops the container.
master_cf_set_smtp_inet_o() { master_cf_set_smtp_inet_o() {
param="$1" param="$1"
value="$2" value="$2"
case "$value" in
*[[:space:]]*)
echo "FATAL: master.cf -o ${param} would contain whitespace: ${value}" >&2
exit 1
;;
esac
awk -v p="$param" -v v="$value" ' awk -v p="$param" -v v="$value" '
/^smtp[ \t]+inet[ \t]/ && !done { /^smtp[ \t]+inet[ \t]/ && !done {
print print
@@ -272,11 +285,21 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
[ -e "$f" ] || : > "$f" [ -e "$f" ] || : > "$f"
done done
ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest ln -sf /usr/local/bin/panel /usr/local/bin/dmarc-ingest
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe" # flags=, user= and argv= are arguments of pipe(8), not -o parameters:
postconf -P \ # postconf -P refuses them ("unmatched service_name/type") and leaves a
"dmarc-ingest/unix-pipe/flags=Rq" \ # bare "pipe", which dies with "fatal: missing user= command-line
"dmarc-ingest/unix-pipe/user=panel" \ # attribute" on every report. They go into the service line itself.
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/dmarc-ingest" # ${recipient} is expanded by pipe(8), not by the shell: the command
# learns which report address the message came to from its argument.
postconf -M 'dmarc-ingest/unix=dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}'
# One recipient per run, so ${recipient} is one argument.
postconf -e "dmarc-ingest_destination_recipient_limit=1"
# pipe(8) starts the command with export_environment only, so what the
# command reads from the container's environment is named there.
DMARC_INGEST_ENV="DMARC_REPORTS_ENABLE=true"
[ -z "${SELFPOST_DATA_DIR:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DATA_DIR=${SELFPOST_DATA_DIR}"
[ -z "${SELFPOST_DB_PATH:-}" ] || DMARC_INGEST_ENV="${DMARC_INGEST_ENV} SELFPOST_DB_PATH=${SELFPOST_DB_PATH}"
postconf -e "export_environment=TZ MAIL_CONFIG LANG ${DMARC_INGEST_ENV}"
if [ -n "$RELAY_DOMAINS_SETTING" ]; then if [ -n "$RELAY_DOMAINS_SETTING" ]; then
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
@@ -284,15 +307,31 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}" RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}" TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
fi fi
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}" # Comma, not a space, between check_recipient_access and its table: see
# master_cf_set_smtp_inet_o.
RECIPIENT_RESTRICTIONS="check_recipient_access,texthash:${DMARC_RECIPIENTS_MAP},${RECIPIENT_RESTRICTIONS}"
else
postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
fi fi
postconf -e \ postconf -e \
"relay_domains=${RELAY_DOMAINS_SETTING}" \ "relay_domains=${RELAY_DOMAINS_SETTING}" \
"transport_maps=${TRANSPORT_SETTING}" \ "transport_maps=${TRANSPORT_SETTING}" \
"smtpd_reject_unlisted_recipient=yes" "smtpd_reject_unlisted_recipient=yes"
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
# With the relay on, Postfix refuses every recipient of a relay domain
# that relay_recipient_maps does not list (smtpd_reject_unlisted_recipient
# above) - and it does so after the restriction list, whatever
# check_recipient_access answered. The DMARC report addresses live on a
# relay domain too (the server's own name), so their table is a recipient
# table as well; without it they are "User unknown in relay recipient
# table" and no report is ever accepted while both features are on.
RELAY_RECIPIENT_SETTING="texthash:${RELAY_RECIPIENTS_MAP}"
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
RELAY_RECIPIENT_SETTING="${RELAY_RECIPIENT_SETTING} texthash:${DMARC_RECIPIENTS_MAP}"
fi
postconf -e \ postconf -e \
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \ "relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" "smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
else else
postconf -e \ postconf -e \
@@ -355,7 +394,8 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}" master_cf_set_smtp_inet_o smtpd_milters "${INBOUND_MILTERS}"
else else
postconf -MX "smtp/inet" 2>/dev/null || true postconf -MX "smtp/inet" 2>/dev/null || true
postconf -MX "dmarc-ingest/unix-pipe" 2>/dev/null || true postconf -MX "dmarc-ingest/unix" 2>/dev/null || true
postconf -X dmarc-ingest_destination_recipient_limit export_environment 2>/dev/null || true
postconf -e \ postconf -e \
"relay_domains=" \ "relay_domains=" \
"transport_maps=" \ "transport_maps=" \
+16 -4
View File
@@ -26,10 +26,7 @@ func runDMARCIngestMode() error {
} }
defer st.Close() defer st.Close()
recipient := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT"))) recipient := ingestRecipient(os.Args[1:])
if recipient == "" {
recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT")))
}
if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil { if err := dmarc.IngestMessage(st, io.LimitReader(os.Stdin, maxIngestStdin), recipient, time.Now().UTC()); err != nil {
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil { if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
log.Printf("dmarc ingest: record failure: %v", incrErr) log.Printf("dmarc ingest: record failure: %v", incrErr)
@@ -39,6 +36,21 @@ func runDMARCIngestMode() error {
return nil return nil
} }
// ingestRecipient is the report address the message was delivered to. Postfix's
// pipe(8) hands it over as an argument (${recipient} in the service's argv,
// build/postfix-config.sh) - it sets no RECIPIENT variable, that is local(8).
// Without it the check that a report came to its own domain's address is
// skipped.
func ingestRecipient(args []string) string {
for _, a := range args {
if a == "-dmarc-ingest" {
continue
}
return strings.ToLower(strings.TrimSpace(a))
}
return ""
}
func isDMARCIngestInvocation() bool { func isDMARCIngestInvocation() bool {
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" { if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
return true return true
+20
View File
@@ -0,0 +1,20 @@
package main
import "testing"
func TestIngestRecipient(t *testing.T) {
for _, tc := range []struct {
args []string
want string
}{
// As pipe(8) starts it: the symlink name, then ${recipient}.
{[]string{"dmarc-reports+Example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest", "dmarc-reports+example.org@mail.example.org"}, "dmarc-reports+example.org@mail.example.org"},
{[]string{"-dmarc-ingest"}, ""},
{nil, ""},
} {
if got := ingestRecipient(tc.args); got != tc.want {
t.Errorf("ingestRecipient(%q) = %q, want %q", tc.args, got, tc.want)
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.9.5 image: ghcr.io/mixeme/selfpost:2.0.0
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+1 -1
View File
@@ -96,7 +96,7 @@
<div class="box" id="import"> <div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p> <p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data"> <form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
<div class="field"><label class="label">Domain export file</label> <div class="field"><label class="label">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div> <div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
+1 -1
View File
@@ -79,7 +79,7 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div> <div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted addresses</dt><dd class="sp-mono">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div> <div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div> <div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl> </dl>
</div> </div>
@@ -97,7 +97,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div> <div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p><a href="out-domain-settings.html#export">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p> <p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -94,9 +94,9 @@
</ul> </ul>
<p class="menu-label">Rarely changed</p> <p class="menu-label">Rarely changed</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="out-domain-settings.html#reports"><i class="ti ti-mail-forward"></i>Report address</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li>
<li><a href="out-domain-settings.html#limit"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li> <li><a href="out-domain-settings.html"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
<li><a href="out-domain-settings.html#export"><i class="ti ti-file-export"></i>Export</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-file-export"></i>Export</a></li>
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li> <li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
</ul> </ul>
</aside> </aside>
@@ -132,7 +132,7 @@
</div> </div>
<div class="sp-record"> <div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">example.org@dmarc.mail.example.org</a></small></div> <div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small></div>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
@@ -78,12 +78,12 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-health"> <div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html#certificate" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a> <a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a> <a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html#sockets"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html#hostname"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
</div> </div>
</div> </div>
@@ -92,7 +92,7 @@
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div> <div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html#limit">domain</a> and <a href="out-app.html">application</a>.</dd></div> <div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html">domain</a> and <a href="out-app.html">application</a>.</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
@@ -30,7 +30,7 @@
<div class="box" id="import"> <div class="box" id="import">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Import a domain</h2><a class="sp-help" href="help.html#backup" title="Moving a domain to another instance"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html#export">domain's settings</a>.</p> <p class="mb-3">A domain export from another instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS needs no change. The export itself is made on the <a href="out-domain-settings.html">domain's settings</a>.</p>
<form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data"> <form class="sp-form" method="post" action="out-domain.html" enctype="multipart/form-data">
<div class="field"><label class="label">Domain export file</label> <div class="field"><label class="label">Domain export file</label>
<div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div> <div class="file has-name is-fullwidth"><label class="file-label"><input class="file-input" type="file" name="file" accept=".json,.spde"><span class="file-cta"><i class="ti ti-upload"></i><span class="file-label ml-2">Choose a file…</span></span><span class="file-name">example.org.spde</span></label></div></div>
@@ -13,7 +13,7 @@
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div> <div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div> <div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
<div><dt>Hosted addresses</dt><dd class="sp-mono">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div> <div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+&lt;domain&gt;@mail.example.org</dd></div>
<div><dt>Retention</dt><dd>500 reports or 90 days</dd></div> <div><dt>Retention</dt><dd>500 reports or 90 days</dd></div>
</dl> </dl>
</div> </div>
@@ -31,7 +31,7 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div> <div class="sp-box-head"><span class="sp-no"><i class="ti ti-file-export"></i></span><h2>Moving it instead?</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<p><a href="out-domain-settings.html#export">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p> <p><a href="out-domain-settings.html">Export the domain</a> first. The file carries the key and the application passwords, so the new instance needs no DNS change.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -28,9 +28,9 @@
</ul> </ul>
<p class="menu-label">Rarely changed</p> <p class="menu-label">Rarely changed</p>
<ul class="menu-list"> <ul class="menu-list">
<li><a href="out-domain-settings.html#reports"><i class="ti ti-mail-forward"></i>Report address</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-mail-forward"></i>Report address</a></li>
<li><a href="out-domain-settings.html#limit"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li> <li><a href="out-domain-settings.html"><i class="ti ti-gauge"></i>Rate limit <span class="tag is-success is-light">auto</span></a></li>
<li><a href="out-domain-settings.html#export"><i class="ti ti-file-export"></i>Export</a></li> <li><a href="out-domain-settings.html"><i class="ti ti-file-export"></i>Export</a></li>
<li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li> <li><a class="has-text-danger" href="out-domain-delete.html"><i class="ti ti-trash has-text-danger"></i>Delete domain</a></li>
</ul> </ul>
</aside> </aside>
@@ -66,7 +66,7 @@
</div> </div>
<div class="sp-record"> <div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">example.org@dmarc.mail.example.org</a></small></div> <div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html">example.org@dmarc.mail.example.org</a></small></div>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
@@ -12,12 +12,12 @@
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-health"> <div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 % · 62 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><span class="sp-h-value">5 of 5 running</span><span class="sp-h-sub">up 18 days</span></span></a>
<a href="health.html#certificate" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a> <a href="health.html" class="sp-warn"><span class="sp-h-icon"><i class="ti ti-certificate"></i></span><span><span class="sp-h-name">TLS certificate</span><span class="sp-h-value">Expires in 12 days</span><span class="sp-h-sub">renewal has not run</span></span></a>
<a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a> <a href="out-queue.html"><span class="sp-h-icon"><i class="ti ti-stack-2"></i></span><span><span class="sp-h-name">Queues</span><span class="sp-h-value">Both empty</span><span class="sp-h-sub">outbound and inbound</span></span></a>
<a href="health.html#sockets"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-plug-connected"></i></span><span><span class="sp-h-name">Milter sockets</span><span class="sp-h-value">3 of 3 answering</span><span class="sp-h-sub">incl. the spam filter</span></span></a>
<a href="health.html#hostname"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a> <a href="health.html"><span class="sp-h-icon"><i class="ti ti-arrows-exchange"></i></span><span><span class="sp-h-name">Reverse DNS</span><span class="sp-h-value">Forward = reverse</span><span class="sp-h-sub">203.0.113.25</span></span></a>
</div> </div>
</div> </div>
@@ -26,7 +26,7 @@
<div class="sp-box-body"> <div class="sp-box-body">
<dl class="sp-facts"> <dl class="sp-facts">
<div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div> <div><dt>Level 1 · per client IP</dt><dd class="sp-big">600 / h</dd><dd class="sp-muted">Set in <code>.env</code>; restart to change. Nothing below may exceed it.</dd></div>
<div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html#limit">domain</a> and <a href="out-app.html">application</a>.</dd></div> <div><dt>Level 2 · domain, application</dt><dd>Set on each <a href="out-domain-settings.html">domain</a> and <a href="out-app.html">application</a>.</dd></div>
</dl> </dl>
</div> </div>
</div> </div>
+2 -2
View File
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
[§ Release image](#release-image)). [§ Release image](#release-image)).
**Planned version cuts** (from pin `1.9.5`; adjust if semver changes mid-track): **Planned version cuts** (from pin `2.0.0`; adjust if semver changes mid-track):
| Stage | ID | Cut | | Stage | ID | Cut |
|---|---|---| |---|---|---|
@@ -123,7 +123,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
| 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) | | 10 | panel-redesign prep (docs only, owner-requested cut) | `1.9.3` (PATCH) |
| 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) | | 11 | panel-redesign plan: component kit, stages, reorder (docs only, owner-requested cut) | `1.9.4` (PATCH) |
| 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) | | 12 | GHCR release fix: keep per-arch images, sweep orphaned versions (CI only) | `1.9.5` (PATCH) |
| 13 | panel-redesign | `2.0.0` (planned; MAJOR, no compatibility) | | 13 | panel-redesign | `2.0.0` (MAJOR, no compatibility) |
| 14 | inbound-antispam-panel | `2.1.0` (planned; was `1.10.0`, reordered 2026-10-09) | | 14 | inbound-antispam-panel | `2.1.0` (planned; was `1.10.0`, reordered 2026-10-09) |
Docs-only prep (checklists in plans, no product code) uses the same per-step Docs-only prep (checklists in plans, no product code) uses the same per-step
+1 -1
View File
@@ -183,7 +183,7 @@ cat ./data/setup-token
#### Fixed image tag #### Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.9.5`. Intermediate deliberately never `:latest`. The current pin is `2.0.0`. Intermediate
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check (see image was published. Pinning matters because of the backup version check (see
[Full backup and restore](#full-backup-and-restore)): the panel binary's [Full backup and restore](#full-backup-and-restore)): the panel binary's
+1 -1
View File
@@ -87,7 +87,7 @@ domain-admin access in v1.
Mirror send-log retention: Mirror send-log retention:
- Settings key `inbound_spam_log_retention_days` on `/settings` (global admin). - Settings key `inbound_spam_log_retention_days` on `/server/settings` (global admin).
- Default **90** days; validation **7–365**; seed from env - Default **90** days; validation **7–365**; seed from env
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing. `INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
- Prune every **6 hours** (same cadence as send-log retention). - Prune every **6 hours** (same cadence as send-log retention).
+2 -2
View File
@@ -1,6 +1,6 @@
# Plan: panel-redesign # Plan: panel-redesign
**Status:** agreed (owner, 2026-10-09) **Status:** shipped in `2.0.0` (2026-10-11)
**Date:** 2026-09-21 (replaces the 2026-09-08 givens) **Date:** 2026-09-21 (replaces the 2026-09-08 givens)
**Version:** `2.0.0` MAJOR — a breaking release by the owner's decision (§ No compatibility). **Version:** `2.0.0` MAJOR — a breaking release by the owner's decision (§ No compatibility).
@@ -532,7 +532,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Technical review of the whole against the mockups, evidence pairs complete — **Opus** - [x] Technical review of the whole against the mockups, evidence pairs complete — **Opus**
- [x] Security review of the route rename, subtree authorization and inbound delegation — **Fable** - [x] Security review of the route rename, subtree authorization and inbound delegation — **Fable**
- [x] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet** - [x] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory; strike the three UI rows of review-2026-08-followups that the redesign retires — **Sonnet**
- [ ] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku** - [x] `go vet`, `go test ./...`, e2e suite; version cut `2.0.0` — **Haiku**
**After 2.0** — features land in the kit, each from its own plan, mockup first **After 2.0** — features land in the kit, each from its own plan, mockup first
(§ Room for roadmap items): inbound-antispam-panel `2.1.0`; then, in whatever (§ Room for roadmap items): inbound-antispam-panel `2.1.0`; then, in whatever
+4 -4
View File
@@ -8,7 +8,7 @@
## Goal ## Goal
A dedicated `/preflight` page (global-admin only) that runs infrastructure-level A dedicated `/server/preflight` page (global-admin only) that runs infrastructure-level
checks and helps the administrator confirm the instance is fully configured and checks and helps the administrator confirm the instance is fully configured and
ready to deliver mail. Each failed check shows an actionable recommendation ready to deliver mail. Each failed check shows an actionable recommendation
(what to do or where to look). (what to do or where to look).
@@ -17,7 +17,7 @@ ready to deliver mail. Each failed check shows an actionable recommendation
[preflight](../assets/panel-redesign/panel/preflight.html), under the design [preflight](../assets/panel-redesign/panel/preflight.html), under the design
contract of [panel-redesign.md](panel-redesign.md): **Server › Preflight**, contract of [panel-redesign.md](panel-redesign.md): **Server › Preflight**,
path `/server/preflight` once the routes are renamed. path `/server/preflight`.
## Scope ## Scope
@@ -47,7 +47,7 @@ path `/server/preflight` once the routes are renamed.
## Done when ## Done when
An administrator can open `/preflight`, see a traffic-light summary of all An administrator can open `/server/preflight`, see a traffic-light summary of all
infrastructure prerequisites, read specific fix instructions for any failures, infrastructure prerequisites, read specific fix instructions for any failures,
and send a test email to verify end-to-end flow. and send a test email to verify end-to-end flow.
@@ -70,7 +70,7 @@ status is **agreed**. See [development.md](../development.md) § Plan checklists
— **Sonnet** — **Sonnet**
- [ ] Handler `handlers_preflight.go` (assemble all checks, actionable - [ ] Handler `handlers_preflight.go` (assemble all checks, actionable
recommendations) — **Sonnet** recommendations) — **Sonnet**
- [ ] Test-email handler (`POST /preflight/test-email`, `net/smtp` to - [ ] Test-email handler (`POST /server/preflight/test-email`, `net/smtp` to
localhost:25) — **Sonnet** localhost:25) — **Sonnet**
- [ ] Template `preflight.html` (traffic-light cards + test-email form) — - [ ] Template `preflight.html` (traffic-light cards + test-email form) —
**Sonnet** **Sonnet**
+5 -5
View File
@@ -39,12 +39,12 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — | | panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 26/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): the next stage is **panel-redesign** (`2.0.0`), development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11); the next stage is
then **inbound-antispam-panel** (`2.1.0`) built in the new component kit — **inbound-antispam-panel** (`2.1.0`), built in the new component kit —
styling its two screens once instead of twice. panel-docs shipped in styling its two screens once instead of twice. panel-docs shipped in
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]` [CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
(security review of the ingest path pending); domain-stats-auto-ratelimit in (security review of the ingest path pending); domain-stats-auto-ratelimit in
@@ -279,7 +279,7 @@ is in the table below; the rest of it shipped in
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not | | Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
| UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit | | UI | ~~`.pair` (DMARC templates) and `.split` (every other page) are two names for one layout~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
| UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head | | UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
| UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does | | UI | ~~`initShowWhen` is not re-run after an htmx swap~~ — struck: the show/hide rules listen on the document and are applied again on `htmx:load` |
**Boundary:** no feature work and no behaviour change an operator would notice, **Boundary:** no feature work and no behaviour change an operator would notice,
apart from the backup re-auth prompt. Anything here that grows past an hour apart from the backup re-auth prompt. Anything here that grows past an hour
@@ -438,7 +438,7 @@ of their own — answered by guard tests that land before the templates, a
`design-first` CI step and evidence-based acceptance (plan § Enforcement). `design-first` CI step and evidence-based acceptance (plan § Enforcement).
Competes with feature work; Bulma is ~680 kB minified, embedded once. Competes with feature work; Bulma is ~680 kB minified, embedded once.
**Version:** `2.0.0` MAJOR; `agreed` (owner, 2026-10-09). **Version:** shipped `2.0.0` (2026-10-11), MAJOR.
--- ---
+9 -4
View File
@@ -23,6 +23,10 @@ type Process struct {
State string // supervisord's own state word, e.g. RUNNING State string // supervisord's own state word, e.g. RUNNING
Detail string // the rest of the line: pid/uptime, or exit information Detail string // the rest of the line: pid/uptime, or exit information
Status Status Status Status
// OneShot marks a program that is started on demand and exits at once
// (oneShotPrograms): idle is its healthy state, and it is not one of the
// programs that are expected to be running.
OneShot bool
} }
// Processes returns the state of every supervised program (architecture.md § // Processes returns the state of every supervised program (architecture.md §
@@ -71,10 +75,11 @@ func parseProcesses(out string) []Process {
} }
name, state := fields[0], fields[1] name, state := fields[0], fields[1]
procs = append(procs, Process{ procs = append(procs, Process{
Name: name, Name: name,
State: state, State: state,
Detail: strings.Join(fields[2:], " "), Detail: strings.Join(fields[2:], " "),
Status: processStatus(name, state), Status: processStatus(name, state),
OneShot: oneShotPrograms[name],
}) })
} }
return procs return procs
+3 -3
View File
@@ -105,7 +105,7 @@ func TestNewDomainHasNoReports(t *testing.T) {
// The domain the stand started with has none either. // The domain the stand started with has none either.
has(t, "domain page", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"), has(t, "domain page", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
`Report address: <a href="/outbound/domains/1/settings#reports">no reports</a>`) `Report address: <a href="/outbound/domains/1/settings">no reports</a>`)
} }
// Each of the three choices is saved, comes back selected, and is what the // Each of the three choices is saved, comes back selected, and is what the
@@ -130,7 +130,7 @@ func TestDomainReportAddressThreeChoices(t *testing.T) {
has(t, "settings after hosted", settings(), `<option value="hosted" selected>`) has(t, "settings after hosted", settings(), `<option value="hosted" selected>`)
lacks(t, "settings after hosted", settings(), `value="`+hostedForExample+`"`) // the typed field stays empty lacks(t, "settings after hosted", settings(), `value="`+hostedForExample+`"`) // the typed field stays empty
has(t, "domain page after hosted", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"), has(t, "domain page after hosted", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
`Report address: <a href="/outbound/domains/1/settings#reports">`+hostedEscaped+`</a>`, `Report address: <a href="/outbound/domains/1/settings">`+hostedEscaped+`</a>`,
`rua=mailto:`+hostedEscaped) `rua=mailto:`+hostedEscaped)
// The ingest allow-list was rebuilt for the new address. // The ingest allow-list was rebuilt for the new address.
if last := maps.got[len(maps.got)-1]; !contains(last.Recipients, hostedForExample) { if last := maps.got[len(maps.got)-1]; !contains(last.Recipients, hostedForExample) {
@@ -145,7 +145,7 @@ func TestDomainReportAddressThreeChoices(t *testing.T) {
} }
has(t, "settings after custom", settings(), `<option value="custom" selected>`, `value="reports@hub.example"`) has(t, "settings after custom", settings(), `<option value="custom" selected>`, `value="reports@hub.example"`)
has(t, "domain page after custom", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"), has(t, "domain page after custom", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
`Report address: <a href="/outbound/domains/1/settings#reports">reports@hub.example</a>`, `rua=mailto:reports@hub.example`, `Report address: <a href="/outbound/domains/1/settings">reports@hub.example</a>`, `rua=mailto:reports@hub.example`,
`<h3>Report authorization</h3>`) // the address is on another domain than the senders `<h3>Report authorization</h3>`) // the address is on another domain than the senders
if last := maps.got[len(maps.got)-1]; contains(last.Recipients, hostedForExample) || contains(last.Recipients, "reports@hub.example") { if last := maps.got[len(maps.got)-1]; contains(last.Recipients, hostedForExample) || contains(last.Recipients, "reports@hub.example") {
t.Errorf("the allow-list after choosing another host: %v", last.Recipients) t.Errorf("the allow-list after choosing another host: %v", last.Recipients)
@@ -92,7 +92,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
} }
} }
card := socketCard(t, h) card := socketCard(t, h)
if card.Value != "3 of 3 answering" || card.Sub != "OpenDKIM, send-log, Spam filter" || card.Level != "" { if card.Value != "3 of 3 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
t.Errorf("card = %+v", card) t.Errorf("card = %+v", card)
} }
@@ -201,7 +201,7 @@ func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
`<h1 class="title is-3">`, `class="sp-postmark`, `<p class="sp-kicker">mail.example.org</p>`, `<h1 class="title is-3">`, `class="sp-postmark`, `<p class="sp-kicker">mail.example.org</p>`,
`href="/server/health#certificate"`, `href="/server/health#sockets"`, `href="/outbound/queue"`, `href="/server/health"`, `href="/server/health"`, `href="/outbound/queue"`,
`<h2>Outbound domains</h2>`, `<a href="/outbound/domains/1"><strong>example.org</strong></a>`, `<h2>Outbound domains</h2>`, `<a href="/outbound/domains/1"><strong>example.org</strong></a>`,
"DKIM", "SPF", "DMARC", "1\u00a0msg", `<th>30 days</th>`, "DKIM", "SPF", "DMARC", "1\u00a0msg", `<th>30 days</th>`,
`href="/server/health"`, `href="/server/health"`,
@@ -98,7 +98,7 @@ func TestDomainPageShowsRecordsApplicationsAndConnection(t *testing.T) {
`<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`, `<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
`href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`, `href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
`mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`, `mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`,
`href="/outbound/log?domain=example.org"`, `href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/delete"`) `href="/outbound/log?domain=example.org"`, `href="/outbound/domains/1/settings"`, `href="/outbound/domains/1/delete"`)
// The flash the redirects carry. // The flash the redirects carry.
rec := send(s.h.HandleDomainDetail, &globalPrincipal, "GET", "/outbound/domains/1?appsaved=1", s.paths, nil) rec := send(s.h.HandleDomainDetail, &globalPrincipal, "GET", "/outbound/domains/1?appsaved=1", s.paths, nil)
@@ -339,7 +339,7 @@ func TestDeleteConfirmNamesTheApplicationsThatGoWithTheDomain(t *testing.T) {
t.Fatalf("delete confirmation = %d:\n%s", rec.Code, body) t.Fatalf("delete confirmation = %d:\n%s", rec.Code, body)
} }
has(t, "delete", body, `<h1 class="title is-3">Delete example.org</h1>`, `permanently delete its DKIM signing key;`, has(t, "delete", body, `<h1 class="title is-3">Delete example.org</h1>`, `permanently delete its DKIM signing key;`,
`<form method="post" action="/outbound/domains/1/delete">`, `Delete example.org</button>`, `href="/outbound/domains/1/settings#export"`) `<form method="post" action="/outbound/domains/1/delete">`, `Delete example.org</button>`, `href="/outbound/domains/1/settings"`)
lacks(t, "delete", body, `credentials`) lacks(t, "delete", body, `credentials`)
for _, login := range []string{"prod-server", "alerts"} { for _, login := range []string{"prod-server", "alerts"} {
+53 -15
View File
@@ -150,9 +150,7 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
page.Processes.End = view.Verdict(string(c["ProcessStatus"].(health.Status))) page.Processes.End = view.Verdict(string(c["ProcessStatus"].(health.Status)))
page.ProcessError = c["ProcessError"].(bool) page.ProcessError = c["ProcessError"].(bool)
for _, p := range c["Processes"].([]health.Process) { for _, p := range c["Processes"].([]health.Process) {
page.ProcessRows = append(page.ProcessRows, view.ProcessRow{ page.ProcessRows = append(page.ProcessRows, processRow(p))
Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail,
})
} }
cert := c["Cert"].(health.Certificate) cert := c["Cert"].(health.Certificate)
@@ -194,6 +192,24 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
return page return page
} }
// processRow is one line of the Processes table. A one-shot helper that sits
// idle is not "stopped" the way a daemon is: it runs when asked and exits, so
// its row says "on demand" in a neutral tag and when it last ran, instead of a
// word that reads as a fault beside the running programs.
func processRow(p health.Process) view.ProcessRow {
if p.OneShot && p.Status == health.StatusOK && p.State != "RUNNING" {
detail := "Runs when the configuration is reloaded"
switch {
case p.Detail == "" || strings.EqualFold(p.Detail, "Not started"):
detail += "; not run since the container started"
default:
detail += "; last run: " + p.Detail
}
return view.ProcessRow{Name: p.Name, State: view.Tag{Status: "off", Label: "on demand"}, Detail: detail}
}
return view.ProcessRow{Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail}
}
// machineRows is the Machine table: processor and memory as bars with their // machineRows is the Machine table: processor and memory as bars with their
// figure, the network as rates, and for each the sentence the sampler gave. A // figure, the network as rates, and for each the sentence the sampler gave. A
// reading that could not be taken keeps its row, without a figure. // reading that could not be taken keeps its row, without a figure.
@@ -245,15 +261,22 @@ func healthCards(c map[string]any) []view.HealthCard {
ram = "RAM " + machine.Memory.PctText() ram = "RAM " + machine.Memory.PctText()
} }
running, notRunning := 0, []string{} // The count is of the programs that are meant to stay up. A one-shot helper
// (postfix-reload) idles between runs: it is not "running" and not missing,
// so it is left out of both numbers and named only when it is in trouble.
running, expected, notRunning := 0, 0, []string{}
for _, p := range procs { for _, p := range procs {
if p.Status == health.StatusOK { if !p.OneShot {
running++ expected++
} else { if p.Status == health.StatusOK {
running++
}
}
if p.Status != health.StatusOK {
notRunning = append(notRunning, p.Name) notRunning = append(notRunning, p.Name)
} }
} }
procValue, procSub := fmt.Sprintf("%d of %d running", running, len(procs)), "all programs" procValue, procSub := fmt.Sprintf("%d of %d running", running, expected), "all programs"
if len(notRunning) > 0 { if len(notRunning) > 0 {
procSub = strings.Join(notRunning, ", ") procSub = strings.Join(notRunning, ", ")
} }
@@ -271,13 +294,28 @@ func healthCards(c map[string]any) []view.HealthCard {
queueValue = "Could not be read" queueValue = "Could not be read"
} }
answering, socketNames := 0, make([]string, 0, len(sockets)) // The line under the count has to fit a card: the sockets that do not
// answer when some do not; otherwise their names, or - with the inbound spam
// filter among them, when three names no longer fit - that it is included.
answering, socketNames, silent, withFilter := 0, make([]string, 0, len(sockets)), []string{}, false
for _, s := range sockets { for _, s := range sockets {
if s.Present { if s.Present {
answering++ answering++
} else {
silent = append(silent, s.Name)
}
if s.Note != "" {
withFilter = true
} }
socketNames = append(socketNames, s.Name) socketNames = append(socketNames, s.Name)
} }
socketSub := strings.Join(socketNames, ", ")
switch {
case len(silent) > 0:
socketSub = strings.Join(silent, ", ")
case withFilter:
socketSub = "incl. the spam filter"
}
ptrValue := "Forward = reverse" ptrValue := "Forward = reverse"
if ptr.Status != health.StatusOK { if ptr.Status != health.StatusOK {
@@ -288,13 +326,13 @@ func healthCards(c map[string]any) []view.HealthCard {
} }
return []view.HealthCard{ return []view.HealthCard{
card("Machine", "ti-cpu", "/server/health#machine", machine.Status, cpu, ram), card("Machine", "ti-cpu", "/server/health", machine.Status, cpu, ram),
card("Processes", "ti-server-cog", "/server/health#processes", c["ProcessStatus"].(health.Status), procValue, procSub), card("Processes", "ti-server-cog", "/server/health", c["ProcessStatus"].(health.Status), procValue, procSub),
card("TLS certificate", "ti-certificate", "/server/health#certificate", cert.Status, certValue, cert.Subject), card("TLS certificate", "ti-certificate", "/server/health", cert.Status, certValue, cert.Subject),
card("Queue", "ti-stack-2", "/outbound/queue", c["QueueStatus"].(health.Status), queueValue, "outbound"), card("Queue", "ti-stack-2", "/outbound/queue", c["QueueStatus"].(health.Status), queueValue, "outbound"),
card("Milter sockets", "ti-plug-connected", "/server/health#sockets", c["SocketStatus"].(health.Status), card("Milter sockets", "ti-plug-connected", "/server/health", c["SocketStatus"].(health.Status),
fmt.Sprintf("%d of %d answering", answering, len(sockets)), strings.Join(socketNames, ", ")), fmt.Sprintf("%d of %d answering", answering, len(sockets)), socketSub),
card("Reverse DNS", "ti-arrows-exchange", "/server/health#hostname", ptr.Status, ptrValue, c["Hostname"].(string)), card("Reverse DNS", "ti-arrows-exchange", "/server/health", ptr.Status, ptrValue, c["Hostname"].(string)),
} }
} }
+52 -5
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"strings"
"testing" "testing"
"github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/dnscheck"
@@ -37,12 +38,12 @@ func checksFixture() map[string]any {
func TestHealthCards(t *testing.T) { func TestHealthCards(t *testing.T) {
cards := healthCards(checksFixture()) cards := healthCards(checksFixture())
want := []struct{ name, level, value, sub, href string }{ want := []struct{ name, level, value, sub, href string }{
{"Machine", "warn", "No reading", "", "/server/health#machine"}, {"Machine", "warn", "No reading", "", "/server/health"},
{"Processes", "fail", "1 of 2 running", "postfix", "/server/health#processes"}, {"Processes", "fail", "1 of 2 running", "postfix", "/server/health"},
{"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health#certificate"}, {"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health"},
{"Queue", "", "Empty", "outbound", "/outbound/queue"}, {"Queue", "", "Empty", "outbound", "/outbound/queue"},
{"Milter sockets", "warn", "1 of 2 answering", "OpenDKIM, send-log", "/server/health#sockets"}, {"Milter sockets", "warn", "1 of 2 answering", "send-log", "/server/health"},
{"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/server/health#hostname"}, {"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/server/health"},
} }
if len(cards) != len(want) { if len(cards) != len(want) {
t.Fatalf("%d cards, want %d", len(cards), len(want)) t.Fatalf("%d cards, want %d", len(cards), len(want))
@@ -80,3 +81,49 @@ func TestQueueCardValue(t *testing.T) {
} }
} }
} }
// The Processes card counts the programs that are meant to stay up. The one-shot
// reload helper idles between runs: it is neither running nor missing.
func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
c := checksFixture()
c["Processes"] = []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true},
}
c["ProcessStatus"] = health.StatusOK
card := healthCards(c)[1]
if card.Value != "2 of 2 running" || card.Sub != "all programs" {
t.Errorf("card = %q / %q, want 2 of 2 running / all programs", card.Value, card.Sub)
}
c["Processes"] = []health.Process{
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
{Name: "postfix-reload", State: "FATAL", Status: health.StatusError, OneShot: true},
}
card = healthCards(c)[1]
if card.Value != "1 of 1 running" || card.Sub != "postfix-reload" {
t.Errorf("card = %q / %q, want 1 of 1 running / postfix-reload", card.Value, card.Sub)
}
}
// An idle one-shot helper is "on demand", not "stopped"; a daemon keeps its
// state word, and a one-shot in trouble keeps its own.
func TestProcessRowOfAnIdleOneShotSaysOnDemand(t *testing.T) {
idle := processRow(health.Process{Name: "postfix-reload", State: "STOPPED", Detail: "Not started", Status: health.StatusOK, OneShot: true})
if idle.State.Label != "on demand" || idle.State.Status != "off" || !strings.Contains(idle.Detail, "not run since the container started") {
t.Errorf("idle one-shot = %+v", idle)
}
ran := processRow(health.Process{Name: "postfix-reload", State: "EXITED", Detail: "Oct 10 08:42 PM", Status: health.StatusOK, OneShot: true})
if ran.State.Label != "on demand" || !strings.HasSuffix(ran.Detail, "last run: Oct 10 08:42 PM") {
t.Errorf("one-shot that ran = %+v", ran)
}
bad := processRow(health.Process{Name: "postfix-reload", State: "FATAL", Detail: "Exited too quickly", Status: health.StatusError, OneShot: true})
if bad.State.Label != "fatal" || bad.State.Status != "error" || bad.Detail != "Exited too quickly" {
t.Errorf("one-shot in trouble = %+v", bad)
}
daemon := processRow(health.Process{Name: "postfix", State: "STOPPED", Detail: "Oct 10 08:42 PM", Status: health.StatusError})
if daemon.State.Label != "stopped" || daemon.State.Status != "error" {
t.Errorf("stopped daemon = %+v", daemon)
}
}
+5 -5
View File
@@ -250,12 +250,12 @@ func TestBoxFootVariants(t *testing.T) {
func TestHealthCards(t *testing.T) { func TestHealthCards(t *testing.T) {
out := squash(partial(t, "health_cards", []HealthCard{ out := squash(partial(t, "health_cards", []HealthCard{
{Name: "Machine", Value: "CPU 12 %", Sub: "RAM 41 %", Icon: "ti-cpu", Href: "/server/health#machine"}, {Name: "Machine", Value: "CPU 12 %", Sub: "RAM 41 %", Icon: "ti-cpu", Href: "/server/health"},
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "renewal has not run", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn}, {Name: "TLS certificate", Value: "Expires in 12 days", Sub: "renewal has not run", Icon: "ti-certificate", Href: "/server/health", Level: LevelWarn},
{Name: "Sockets", Value: "1 of 2", Sub: "x", Icon: "ti-plug-connected-x", Href: "/server/health#sockets", Level: LevelFail}, {Name: "Sockets", Value: "1 of 2", Sub: "x", Icon: "ti-plug-connected-x", Href: "/server/health", Level: LevelFail},
})) }))
mustContain(t, out, `<div class="sp-health"><a href="/server/health#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 %</span></span></a>`, mustContain(t, out, `<div class="sp-health"><a href="/server/health"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><span class="sp-h-value">CPU 12 %</span><span class="sp-h-sub">RAM 41 %</span></span></a>`,
`<a href="/server/health#certificate" class="sp-warn">`, `<a href="/server/health#sockets" class="sp-fail">`) `<a href="/server/health" class="sp-warn">`, `<a href="/server/health" class="sp-fail">`)
} }
func TestDNSRecordShowsInDNSNowOnlyOnAMismatch(t *testing.T) { func TestDNSRecordShowsInDNSNowOnlyOnAMismatch(t *testing.T) {
+2 -2
View File
@@ -95,7 +95,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
} }
last := Fact{Label: "Last report", Value: Plain("None yet")} last := Fact{Label: "Last report", Value: Plain("None yet")}
if in.Last.IsZero() { if in.Last.IsZero() {
last.Note = Rich("Point ", Code("rua="), " at the hosted address below and publish MX for ", Mono(in.Host), " on this server.") last.Note = Rich("Set a domain to SelfPost hosted in its Domain settings, publish the DMARC record it then shows, and publish MX for ", Mono(in.Host), " pointing at this server.")
} else { } else {
last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC") last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC")
} }
@@ -112,7 +112,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
IngestFacts: []Fact{ IngestFacts: []Fact{
last, last,
{Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))}, {Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))},
{Label: "Hosted addresses", Value: Plain(in.Hosted), Mono: true}, {Label: "Hosted addresses", Value: Plain(in.Hosted), Mono: true, Small: true},
{Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports or " + plural(int64(in.RetentionDays), "day", "days"))}, {Label: "Retention", Value: Plain(FormatCount(int64(in.RetentionMax)) + " reports or " + plural(int64(in.RetentionDays), "day", "days"))},
}, },
Recent: Box{No: "02", Title: "Recent reports"}, Recent: Box{No: "02", Title: "Recent reports"},
+2 -2
View File
@@ -12,7 +12,7 @@ func TestDMARCHubShowsIngestAndTheLatestReports(t *testing.T) {
pageHas(t, "DMARC reports", out, pageHas(t, "DMARC reports", out,
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`, `<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
`<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`, `<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
`<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono">dmarc-reports&#43;&lt;domain&gt;@mail.example.org</dd>`, `<dd>500 reports or 90 days</dd>`, `<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono sp-small">dmarc-reports&#43;&lt;domain&gt;@mail.example.org</dd>`, `<dd>500 reports or 90 days</dd>`,
`<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`, `<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
"<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`, "<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`,
) )
@@ -24,7 +24,7 @@ func TestDMARCHubWithoutReports(t *testing.T) {
p := NewDMARCHub(admin(), IngestInput{Hosted: "dmarc@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90}) p := NewDMARCHub(admin(), IngestInput{Hosted: "dmarc@mail.example.org", Host: "mail.example.org", RetentionMax: 500, RetentionDays: 90})
out := renderSignedIn(t, "dmarc", p.WithReports(nil)) out := renderSignedIn(t, "dmarc", p.WithReports(nil))
pageHas(t, "DMARC reports", out, `<span class="tag is-warning is-light">quiet</span>`, `<dd>None yet</dd>`, pageHas(t, "DMARC reports", out, `<span class="tag is-warning is-light">quiet</span>`, `<dd>None yet</dd>`,
`publish MX for <span class="sp-mono">mail.example.org</span> on this server`, `No reports yet.`, `0 kept · 0 parse failures`) `publish MX for <span class="sp-mono">mail.example.org</span> pointing at this server`, `No reports yet.`, `0 kept · 0 parse failures`)
pageLacks(t, "DMARC reports", out, `<table`, `UTC</dd>`) pageLacks(t, "DMARC reports", out, `<table`, `UTC</dd>`)
one := NewDMARCHub(admin(), IngestInput{OK: true, Last: time.Now(), KeptThisWeek: 1, ParseFailures: 1}) one := NewDMARCHub(admin(), IngestInput{OK: true, Last: time.Now(), KeptThisWeek: 1, ParseFailures: 1})
+5 -5
View File
@@ -61,12 +61,12 @@ func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
// checks, three outbound domains and two inbound. // checks, three outbound domains and two inbound.
func overviewFixture() *Overview { func overviewFixture() *Overview {
cards := []HealthCard{ cards := []HealthCard{
{Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health#machine"}, {Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health"},
{Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health#processes"}, {Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health"},
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health#certificate", Level: LevelWarn}, {Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health", Level: LevelWarn},
{Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"}, {Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"},
{Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health#sockets"}, {Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health"},
{Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health#hostname"}, {Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health"},
} }
o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true) o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true)
dns := func(dkim, spf, dmarc string) []Tag { dns := func(dkim, spf, dmarc string) []Tag {
+21 -6
View File
@@ -145,11 +145,26 @@ func applyCheck(r *Record, c DNSCheck, standing Text, mismatch Text) {
} }
} }
// dkimRows is the height of the field that shows a DKIM value: enough lines for
// the whole key, so the field does not scroll inside itself. A 2048-bit key is
// about 410 characters; a line of the field holds some 80 of them at the
// narrowest width the page is drawn for.
func dkimRows(value string) int {
rows := (len(value)+79)/80 + 1
if rows < 3 {
return 3
}
if rows > 12 {
return 12
}
return rows
}
// DKIMRecord is the DKIM record to publish: the key is not a secret, and the // DKIMRecord is the DKIM record to publish: the key is not a secret, and the
// selector says which one it is. // selector says which one it is.
func DKIMRecord(selector, host, value string, c DNSCheck) Record { func DKIMRecord(selector, host, value string, c DNSCheck) Record {
r := Record{Name: "DKIM", Note: Rich("Not a secret · selector ", Code(selector)), r := Record{Name: "DKIM", Note: Rich("Not a secret · selector ", Code(selector)),
Host: host, Type: "TXT", Value: value, ValueRows: 3} Host: host, Type: "TXT", Value: value, ValueRows: dkimRows(value)}
applyCheck(&r, c, nil, nil) applyCheck(&r, c, nil, nil)
return r return r
} }
@@ -181,7 +196,7 @@ type DMARCRecordInput struct {
// delivery, so the standing note says how to tighten it. // delivery, so the standing note says how to tighten it.
func DMARCRecord(in DMARCRecordInput) Record { func DMARCRecord(in DMARCRecordInput) Record {
r := Record{Name: "DMARC", Host: in.Host, Type: "TXT", Value: in.Value, r := Record{Name: "DMARC", Host: in.Host, Type: "TXT", Value: in.Value,
Note: Rich("Report address: ", Link(in.SettingsHref+"#reports", in.Source))} Note: Rich("Report address: ", Link(in.SettingsHref, in.Source))}
reports := Rich("reports") reports := Rich("reports")
if in.ReportsHref != "" { if in.ReportsHref != "" {
reports = Rich(Link(in.ReportsHref, "reports")) reports = Rich(Link(in.ReportsHref, "reports"))
@@ -482,9 +497,9 @@ func (p *OutDomain) Menu() SideMenu {
} }
settings := DomainHref(p.id) + "/settings" settings := DomainHref(p.id) + "/settings"
rare := MenuGroup{Label: "Rarely changed", Items: []MenuItem{ rare := MenuGroup{Label: "Rarely changed", Items: []MenuItem{
{Label: "Report address", Icon: "ti-mail-forward", Href: settings + "#reports"}, {Label: "Report address", Icon: "ti-mail-forward", Href: settings},
{Label: "Rate limit", Icon: "ti-gauge", Href: settings + "#limit", Tag: p.limitTag}, {Label: "Rate limit", Icon: "ti-gauge", Href: settings, Tag: p.limitTag},
{Label: "Export", Icon: "ti-file-export", Href: settings + "#export"}, {Label: "Export", Icon: "ti-file-export", Href: settings},
}} }}
if p.canDelete { if p.canDelete {
rare.Items = append(rare.Items, MenuItem{Label: "Delete domain", Icon: "ti-trash", Href: DomainHref(p.id) + "/delete", Danger: true}) rare.Items = append(rare.Items, MenuItem{Label: "Delete domain", Icon: "ti-trash", Href: DomainHref(p.id) + "/delete", Danger: true})
@@ -733,7 +748,7 @@ func NewOutDomainDelete(m Meta, id int64, name string, logins []string) *OutDoma
Confirm: "Delete " + name, Confirm: "Delete " + name,
KeepHref: href, KeepHref: href,
Moving: Box{Icon: "ti-file-export", Title: "Moving it instead?"}, Moving: Box{Icon: "ti-file-export", Title: "Moving it instead?"},
MovingText: Rich(Link(href+"/settings#export", "Export the domain"), MovingText: Rich(Link(href+"/settings", "Export the domain"),
" first. The file carries the key and the application passwords, so the new instance needs no DNS change."), " first. The file carries the key and the application passwords, so the new instance needs no DNS change."),
} }
if n := appsPhrase(len(logins)); n != "" { if n := appsPhrase(len(logins)); n != "" {
+14 -3
View File
@@ -99,7 +99,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
`sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`, `sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`,
`<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`, `<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`,
`In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`, `In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`,
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">example.org@dmarc.mail.example.org</a>`, `<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings">example.org@dmarc.mail.example.org</a>`,
`<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`, `<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`,
) )
if got := strings.Count(out, `In DNS now`); got != 1 { if got := strings.Count(out, `In DNS now`); got != 1 {
@@ -180,7 +180,7 @@ func TestOutDomainMenuFollowsTheViewer(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture()) out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out, pageHas(t, "Domain", out,
`href="#dns"`, `href="#apps"`, `href="#connection"`, `href="/outbound/log?domain=example.org"`, `href="/outbound/dmarc/domains/1"`, `href="#dns"`, `href="#apps"`, `href="#connection"`, `href="/outbound/log?domain=example.org"`, `href="/outbound/dmarc/domains/1"`,
`href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/settings#limit"`, `href="/outbound/domains/1/settings#export"`, `href="/outbound/domains/1/settings"`, `href="/outbound/domains/1/settings"`, `href="/outbound/domains/1/settings"`,
`href="/outbound/domains/1/delete"`, `id="dns"`, `id="apps"`, `id="connection"`, `href="/outbound/domains/1/delete"`, `id="dns"`, `id="apps"`, `id="connection"`,
`class="sp-help" href="/help#dns"`, `class="sp-help" href="/help#apps"`, `class="sp-help" href="/help#dns"`, `class="sp-help" href="/help#apps"`,
) )
@@ -437,7 +437,7 @@ func TestOutDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
`reload OpenDKIM so the domain is no longer signed.`, `Inbound domains are not touched.`, `reload OpenDKIM so the domain is no longer signed.`, `Inbound domains are not touched.`,
`<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`, `<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`,
`href="/outbound/domains/1">Keep it</a>`, `href="/outbound/domains/1">Keep it</a>`,
`<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings#export">Export the domain</a>`, `<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings">Export the domain</a>`,
) )
// No applications: nothing to say about them. // No applications: nothing to say about them.
@@ -508,3 +508,14 @@ func TestActionConfirmIsOnlyAnAttribute(t *testing.T) {
t.Errorf("Confirm changes more than the attribute:\n%s\n%s", plain, asked) t.Errorf("Confirm changes more than the attribute:\n%s\n%s", plain, asked)
} }
} }
// The DKIM value is shown whole: the field grows with the key, so it does not
// scroll inside itself.
func TestDKIMFieldIsAsTallAsItsValue(t *testing.T) {
short := DKIMRecord("sp", "sp._domainkey.example.org", "v=DKIM1; p=AAA", DNSCheck{})
long := DKIMRecord("sp", "sp._domainkey.example.org", "v=DKIM1; h=sha256; k=rsa; p="+strings.Repeat("A", 392), DNSCheck{})
huge := DKIMRecord("sp", "sp._domainkey.example.org", strings.Repeat("A", 5000), DNSCheck{})
if short.ValueRows != 3 || long.ValueRows != 7 || huge.ValueRows != 12 {
t.Errorf("rows = %d, %d, %d; want 3, 7, 12", short.ValueRows, long.ValueRows, huge.ValueRows)
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ func TestOverviewHeadFollowsTheCards(t *testing.T) {
pageHas(t, "Overview", out, pageHas(t, "Overview", out,
`<h1 class="title is-3">Running, with warnings.</h1>`, `<h1 class="title is-3">Running, with warnings.</h1>`,
`class="sp-postmark sp-warn"`, `<b>WARN</b>`, `1 OF 6 CHECKS`, `class="sp-postmark sp-warn"`, `<b>WARN</b>`, `1 OF 6 CHECKS`,
`Needs attention: <a href="/server/health#certificate">TLS certificate</a> (Expires in 12 days).`, `Needs attention: <a href="/server/health">TLS certificate</a> (Expires in 12 days).`,
`Updated 14:02:31 UTC`, `<p class="sp-kicker">mail.example.org</p>`, `Updated 14:02:31 UTC`, `<p class="sp-kicker">mail.example.org</p>`,
`class="sp-warn"`, // the certificate card `class="sp-warn"`, // the certificate card
`href="/server/health"`, `>All details</a>`, `href="/server/health"`, `>All details</a>`,
+15 -6
View File
@@ -153,19 +153,28 @@
function initShowWhen(root) { function initShowWhen(root) {
showWhenRules.forEach(function (rule) { showWhenRules.forEach(function (rule) {
root.querySelectorAll(rule.match).forEach(function (control) { root.querySelectorAll(rule.match).forEach(syncShowWhen);
syncShowWhen(control);
control.addEventListener("change", function () {
syncShowWhen(control);
});
});
}); });
} }
// One listener for the document rather than one per control: a form that
// arrives in an htmx swap is covered without being wired up again.
document.addEventListener("change", function (ev) {
if (ev.target && ev.target.matches) {
syncShowWhen(ev.target);
}
});
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
initShowWhen(document); initShowWhen(document);
}); });
// htmx:load fires for every element htmx puts into the page, so swapped-in
// markup starts with its blocks shown or hidden the way a full page does.
document.addEventListener("htmx:load", function (ev) {
initShowWhen(ev.detail.elt);
});
// --- Adaptive monitoring polling --------------------------------------- // --- Adaptive monitoring polling ---------------------------------------
// The four monitoring fragments carry data-poll and hx-trigger="load" for // The four monitoring fragments carry data-poll and hx-trigger="load" for
// the first fetch only. panel.js schedules the rest: 5 s while the operator // the first fetch only. panel.js schedules the rest: 5 s while the operator
+19
View File
@@ -160,6 +160,25 @@ func TestE2E(t *testing.T) {
} }
}) })
// The panel runs postqueue as its own user. That works only while the queue's
// public/ directory belongs to Postfix's setgid group, which the entrypoint
// has to restore after its recursive chown of the queue tree.
run("queue_readable_by_the_panel", func(t *testing.T) {
resp, body, err := sc.panel.get("/outbound/queue")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("GET /outbound/queue = %d", resp.StatusCode)
}
if strings.Contains(body, "Could not read the mail queue") {
t.Fatal("the panel could not run postqueue -p (queue directory permissions)")
}
if !strings.Contains(body, "Mail queue is empty") {
t.Fatalf("the queue page does not show postqueue's listing of an empty queue:\n%s", body)
}
})
run("add_domain_and_publish_dkim", func(t *testing.T) { run("add_domain_and_publish_dkim", func(t *testing.T) {
id, err := sc.panel.addDomain(senderDomain) id, err := sc.panel.addDomain(senderDomain)
if err != nil { if err != nil {
+80 -2
View File
@@ -22,8 +22,20 @@ type port25Case struct {
name string name string
env []string env []string
check []optionCheck check []optionCheck
// main are main.cf parameters the combination must end up with, read with
// postconf -h.
main []optionCheck
// ingest is set when the combination must carry the DMARC report delivery
// service, with everything the command it runs depends on.
ingest bool
} }
// ingestService is the dmarc-ingest service as Postfix must read it back.
// flags=, user= and argv= are arguments of pipe(8); set as -o parameters they
// are dropped, and a bare "pipe" dies on every report with "fatal: missing
// user= command-line attribute" - the state of 1.7.0 through 1.9.5.
const ingestService = "dmarc-ingest unix - n n - - pipe flags=Rq user=panel argv=/usr/local/bin/dmarc-ingest ${recipient}"
var port25Cases = []port25Case{ var port25Cases = []port25Case{
{ {
name: "inbound relay only", name: "inbound relay only",
@@ -47,9 +59,10 @@ var port25Cases = []port25Case{
"DMARC_REPORTS_ENABLE=true", "DMARC_REPORTS_ENABLE=true",
}, },
check: []optionCheck{ check: []optionCheck{
{param: "smtpd_recipient_restrictions", contains: "check_recipient_access texthash:/data/postfix/dmarc_recipients,reject", wantExact: true}, {param: "smtpd_recipient_restrictions", contains: "check_recipient_access,texthash:/data/postfix/dmarc_recipients,reject", wantExact: true},
{param: "smtpd_milters", contains: "", wantExact: true}, {param: "smtpd_milters", contains: "", wantExact: true},
}, },
ingest: true,
}, },
{ {
name: "inbound relay and dmarc ingest", name: "inbound relay and dmarc ingest",
@@ -58,9 +71,17 @@ var port25Cases = []port25Case{
"DMARC_REPORTS_ENABLE=true", "DMARC_REPORTS_ENABLE=true",
}, },
check: []optionCheck{ check: []optionCheck{
{param: "smtpd_recipient_restrictions", contains: "check_recipient_access texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,reject_unlisted_recipient", wantExact: true}, {param: "smtpd_recipient_restrictions", contains: "check_recipient_access,texthash:/data/postfix/dmarc_recipients,reject_unauth_destination,reject_unlisted_recipient", wantExact: true},
{param: "smtpd_milters", contains: "", wantExact: true}, {param: "smtpd_milters", contains: "", wantExact: true},
}, },
// Both on: the report addresses sit on a relay domain, and the relay's
// unlisted-recipient check runs after the restriction list. Unless their
// table is a recipient table too, every report is refused as "User
// unknown in relay recipient table".
main: []optionCheck{
{param: "relay_recipient_maps", contains: "texthash:/data/postfix/relay_recipients texthash:/data/postfix/dmarc_recipients", wantExact: true},
},
ingest: true,
}, },
{ {
name: "inbound relay with antispam milter", name: "inbound relay with antispam milter",
@@ -116,6 +137,30 @@ func checkPort25ConfigCombinations(s *stack) error {
case !c.wantExact && !strings.Contains(got, c.contains): case !c.wantExact && !strings.Contains(got, c.contains):
return fmt.Errorf("%s: -o %s = %q, want it to contain %q", tc.name, c.param, got, c.contains) return fmt.Errorf("%s: -o %s = %q, want it to contain %q", tc.name, c.param, got, c.contains)
} }
// What the file says is not yet what Postfix reads: master splits
// a service line on whitespace, so a value with a space in it is
// cut at the space and the rest becomes a stray smtpd argument
// ("fatal: unexpected command-line argument", port 25 dead while
// `postfix check` stays silent). Ask Postfix itself.
seen, err := s.execIn("selfpost", "postconf", "-Ph", "smtp/inet/"+c.param)
if err != nil {
return fmt.Errorf("%s: postconf -P smtp/inet/%s: %v\n%s", tc.name, c.param, err, seen)
}
if strings.TrimSpace(seen) != got {
return fmt.Errorf("%s: master.cf has -o %s = %q but Postfix reads %q - the value contains whitespace", tc.name, c.param, got, strings.TrimSpace(seen))
}
}
for _, c := range tc.main {
got, err := s.execIn("selfpost", "postconf", "-h", c.param)
if err != nil {
return fmt.Errorf("%s: postconf -h %s: %v\n%s", tc.name, c.param, err, got)
}
if got = strings.TrimSpace(got); got != c.contains {
return fmt.Errorf("%s: %s = %q, want %q", tc.name, c.param, got, c.contains)
}
}
if err := checkIngestService(s, tc); err != nil {
return err
} }
} }
@@ -126,6 +171,39 @@ func checkPort25ConfigCombinations(s *stack) error {
return checkInboundRelayOff(s) return checkInboundRelayOff(s)
} }
// checkIngestService asks Postfix for the dmarc-ingest service and the two
// main.cf parameters its command depends on: one recipient per run, so
// ${recipient} is one argument, and DMARC_REPORTS_ENABLE in the environment
// pipe(8) gives the command - it passes export_environment and nothing else.
// A combination without DMARC ingest must have none of the three left over.
func checkIngestService(s *stack, tc port25Case) error {
service, _ := s.execIn("selfpost", "postconf", "-M", "dmarc-ingest/unix")
service = strings.Join(strings.Fields(service), " ")
limit, _ := s.execIn("selfpost", "postconf", "-h", "dmarc-ingest_destination_recipient_limit")
limit = strings.TrimSpace(limit)
environ, err := s.execIn("selfpost", "postconf", "-h", "export_environment")
if err != nil {
return fmt.Errorf("%s: postconf -h export_environment: %v\n%s", tc.name, err, environ)
}
exported := strings.Contains(" "+strings.TrimSpace(environ)+" ", " DMARC_REPORTS_ENABLE=true ")
if !tc.ingest {
if strings.Contains(service, "pipe") || limit == "1" || exported {
return fmt.Errorf("%s: DMARC ingest is off but left behind: service %q, recipient limit %q, export_environment %q", tc.name, service, limit, environ)
}
return nil
}
if service != ingestService {
return fmt.Errorf("%s: Postfix reads the dmarc-ingest service as %q, want %q", tc.name, service, ingestService)
}
if limit != "1" {
return fmt.Errorf("%s: dmarc-ingest_destination_recipient_limit = %q, want 1", tc.name, limit)
}
if !exported {
return fmt.Errorf("%s: export_environment = %q, want DMARC_REPORTS_ENABLE=true in it", tc.name, environ)
}
return nil
}
// runPostfixConfig re-runs the config generator with env overrides. The script // runPostfixConfig re-runs the config generator with env overrides. The script
// ends with `postfix check`, so a non-zero exit is exactly the start-time // ends with `postfix check`, so a non-zero exit is exactly the start-time
// failure an operator would hit. // failure an operator would hit.