Compare commits

..
14 Commits
Author SHA1 Message Date
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
29 changed files with 313 additions and 69 deletions
+32
View File
@@ -209,6 +209,38 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
application forms (the script applied only the first matching rule).
- The DMARC page of a domain no longer answers 500 once the domain has a
reported source.
- **The panel can read the mail queue.** The container's entrypoint left the
queue's `public/` and `maildrop/` directories in group `postfix` on every
start; Postfix keeps them in `postdrop`, the group `postqueue` works
through, so the panel's `postqueue -p` was refused ("Connect to the Postfix
showq service: Permission denied"), the Queue card said "Could not be read"
and the queue page had no listing. Present since the persistent queue of
1.3.0; mail flow was not affected. The e2e suite opens the queue page now.
- **Port 25 answers again with DMARC report ingest on.** The generated
`master.cf` gave the port-25 service `-o smtpd_recipient_restrictions=
check_recipient_access texthash:…,…` with a space in the value; Postfix
cuts a service line at whitespace, so `smtpd` was started with a stray
argument and died with "fatal: unexpected command-line argument" on every
connection - no inbound relay and no DMARC reports were accepted while
`DMARC_REPORTS_ENABLE=true`, although `postfix check` and the suite were
silent. The list is written with commas only, the generator refuses a
value with whitespace, and the e2e check asks Postfix what it reads.
Present since DMARC ingest was fitted into the 1.9.1 fix of the same
stanza.
- **DMARC reports are accepted with the inbound relay on.** With both
features enabled every report address was refused as "User unknown in
relay recipient table": the relay's unlisted-recipient check runs after the
restriction list, and the report addresses - on the server's own name, a
relay domain - were not in `relay_recipient_maps`. Their table is a
recipient table now. Hidden until now by the port-25 failure above.
- Overview's Processes card counts the programs that are meant to stay up
("6 of 6 running"); the idle one-shot `postfix-reload` was counted as
running. The Milter sockets card no longer clips its second line with three
sockets, and a DKIM value is shown whole instead of scrolling inside its
field.
- Server › Health shows the idle reload helper (`postfix-reload`) as
**on demand**, with when it last ran, instead of `stopped` beside the
running programs (owner, 2026-10-10).
- The confirmation before deleting a user names the domains that follow
that user's default report address and will be left without one; it used
to say the domains were not affected (replaced before release — see the
+11
View File
@@ -157,5 +157,16 @@ if [ ! -d /data/postfix/queue/active ]; then
postfix set-permissions
fi
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
+31 -5
View File
@@ -13,12 +13,25 @@
# a fixed literal or a container environment variable.
set -eu
# postconf -P refuses master.cf -o values that contain whitespace; inbound port
# 25 needs restriction lists and milter specs that include spaces. Patch the
# smtp/inet service stanza directly instead.
# Sets one -o option of the smtp/inet (port 25) service by patching its stanza.
#
# 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() {
param="$1"
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" '
/^smtp[ \t]+inet[ \t]/ && !done {
print
@@ -284,15 +297,28 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
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}"
fi
postconf -e \
"relay_domains=${RELAY_DOMAINS_SETTING}" \
"transport_maps=${TRANSPORT_SETTING}" \
"smtpd_reject_unlisted_recipient=yes"
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 \
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
"relay_recipient_maps=${RELAY_RECIPIENT_SETTING}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
else
postconf -e \
+1 -1
View File
@@ -96,7 +96,7 @@
<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-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">
<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>
+1 -1
View File
@@ -79,7 +79,7 @@
<dl class="sp-facts">
<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>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>
</dl>
</div>
@@ -97,7 +97,7 @@
<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-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>
@@ -94,9 +94,9 @@
</ul>
<p class="menu-label">Rarely changed</p>
<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#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#export"><i class="ti ti-file-export"></i>Export</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"><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-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>
</ul>
</aside>
@@ -132,7 +132,7 @@
</div>
<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="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>
@@ -92,7 +92,7 @@
<div class="sp-box-body">
<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 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>
</div>
</div>
@@ -30,7 +30,7 @@
<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-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">
<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>
@@ -13,7 +13,7 @@
<dl class="sp-facts">
<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>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>
</dl>
</div>
@@ -31,7 +31,7 @@
<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-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>
@@ -28,9 +28,9 @@
</ul>
<p class="menu-label">Rarely changed</p>
<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#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#export"><i class="ti ti-file-export"></i>Export</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"><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-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>
</ul>
</aside>
@@ -66,7 +66,7 @@
</div>
<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="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>
@@ -26,7 +26,7 @@
<div class="sp-box-body">
<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 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>
</div>
</div>
+1 -1
View File
@@ -87,7 +87,7 @@ domain-admin access in v1.
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
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
- Prune every **6 hours** (same cadence as send-log retention).
+4 -4
View File
@@ -8,7 +8,7 @@
## 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
ready to deliver mail. Each failed check shows an actionable recommendation
(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
contract of [panel-redesign.md](panel-redesign.md): **Server › Preflight**,
path `/server/preflight` once the routes are renamed.
path `/server/preflight`.
## Scope
@@ -47,7 +47,7 @@ path `/server/preflight` once the routes are renamed.
## 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,
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**
- [ ] Handler `handlers_preflight.go` (assemble all checks, actionable
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**
- [ ] Template `preflight.html` (traffic-light cards + test-email form) —
**Sonnet**
+1 -1
View File
@@ -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 |
| 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 | `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,
apart from the backup re-auth prompt. Anything here that grows past an hour
+5
View File
@@ -23,6 +23,10 @@ type Process struct {
State string // supervisord's own state word, e.g. RUNNING
Detail string // the rest of the line: pid/uptime, or exit information
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 §
@@ -75,6 +79,7 @@ func parseProcesses(out string) []Process {
State: state,
Detail: strings.Join(fields[2:], " "),
Status: processStatus(name, state),
OneShot: oneShotPrograms[name],
})
}
return procs
+3 -3
View File
@@ -105,7 +105,7 @@ func TestNewDomainHasNoReports(t *testing.T) {
// The domain the stand started with has none either.
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
@@ -130,7 +130,7 @@ func TestDomainReportAddressThreeChoices(t *testing.T) {
has(t, "settings after hosted", settings(), `<option value="hosted" selected>`)
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"),
`Report address: <a href="/outbound/domains/1/settings#reports">`+hostedEscaped+`</a>`,
`Report address: <a href="/outbound/domains/1/settings">`+hostedEscaped+`</a>`,
`rua=mailto:`+hostedEscaped)
// The ingest allow-list was rebuilt for the new address.
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, "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
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)
@@ -92,7 +92,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
}
}
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)
}
@@ -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`,
`href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
`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.
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)
}
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`)
for _, login := range []string{"prod-server", "alerts"} {
+46 -8
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.ProcessError = c["ProcessError"].(bool)
for _, p := range c["Processes"].([]health.Process) {
page.ProcessRows = append(page.ProcessRows, view.ProcessRow{
Name: p.Name, State: view.Tag{Status: string(p.Status), Label: strings.ToLower(p.State)}, Detail: p.Detail,
})
page.ProcessRows = append(page.ProcessRows, processRow(p))
}
cert := c["Cert"].(health.Certificate)
@@ -194,6 +192,24 @@ func healthPage(m view.Meta, c map[string]any, flash string) *view.Health {
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
// 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.
@@ -245,15 +261,22 @@ func healthCards(c map[string]any) []view.HealthCard {
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 {
if !p.OneShot {
expected++
if p.Status == health.StatusOK {
running++
} else {
}
}
if p.Status != health.StatusOK {
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 {
procSub = strings.Join(notRunning, ", ")
}
@@ -271,13 +294,28 @@ func healthCards(c map[string]any) []view.HealthCard {
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 {
if s.Present {
answering++
} else {
silent = append(silent, s.Name)
}
if s.Note != "" {
withFilter = true
}
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"
if ptr.Status != health.StatusOK {
@@ -293,7 +331,7 @@ func healthCards(c map[string]any) []view.HealthCard {
card("TLS certificate", "ti-certificate", "/server/health#certificate", cert.Status, certValue, cert.Subject),
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),
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)),
}
}
+48 -1
View File
@@ -1,6 +1,7 @@
package handlers
import (
"strings"
"testing"
"github.com/mixeme/selfpost/internal/dnscheck"
@@ -41,7 +42,7 @@ func TestHealthCards(t *testing.T) {
{"Processes", "fail", "1 of 2 running", "postfix", "/server/health#processes"},
{"TLS certificate", "warn", "Expires in 12 days", "mail.example.org", "/server/health#certificate"},
{"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#sockets"},
{"Reverse DNS", "", "Forward = reverse", "mail.example.org", "/server/health#hostname"},
}
if 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)
}
}
+2 -2
View File
@@ -95,7 +95,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
}
last := Fact{Label: "Last report", Value: Plain("None yet")}
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 {
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{
last,
{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"))},
},
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,
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
`<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>`,
"<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})
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>`,
`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>`)
one := NewDMARCHub(admin(), IngestInput{OK: true, Last: time.Now(), KeptThisWeek: 1, ParseFailures: 1})
+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
// selector says which one it is.
func DKIMRecord(selector, host, value string, c DNSCheck) Record {
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)
return r
}
@@ -181,7 +196,7 @@ type DMARCRecordInput struct {
// delivery, so the standing note says how to tighten it.
func DMARCRecord(in DMARCRecordInput) Record {
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")
if in.ReportsHref != "" {
reports = Rich(Link(in.ReportsHref, "reports"))
@@ -482,9 +497,9 @@ func (p *OutDomain) Menu() SideMenu {
}
settings := DomainHref(p.id) + "/settings"
rare := MenuGroup{Label: "Rarely changed", Items: []MenuItem{
{Label: "Report address", Icon: "ti-mail-forward", Href: settings + "#reports"},
{Label: "Rate limit", Icon: "ti-gauge", Href: settings + "#limit", Tag: p.limitTag},
{Label: "Export", Icon: "ti-file-export", Href: settings + "#export"},
{Label: "Report address", Icon: "ti-mail-forward", Href: settings},
{Label: "Rate limit", Icon: "ti-gauge", Href: settings, Tag: p.limitTag},
{Label: "Export", Icon: "ti-file-export", Href: settings},
}}
if p.canDelete {
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,
KeepHref: href,
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."),
}
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>`,
`<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.`,
`<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>`,
)
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())
pageHas(t, "Domain", out,
`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"`,
`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.`,
`<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`,
`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.
@@ -508,3 +508,14 @@ func TestActionConfirmIsOnlyAnAttribute(t *testing.T) {
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)
}
}
+15 -6
View File
@@ -153,19 +153,28 @@
function initShowWhen(root) {
showWhenRules.forEach(function (rule) {
root.querySelectorAll(rule.match).forEach(function (control) {
syncShowWhen(control);
control.addEventListener("change", function () {
syncShowWhen(control);
});
});
root.querySelectorAll(rule.match).forEach(syncShowWhen);
});
}
// 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 () {
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 ---------------------------------------
// 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
+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) {
id, err := sc.panel.addDomain(senderDomain)
if err != nil {
+33 -2
View File
@@ -22,6 +22,9 @@ type port25Case struct {
name string
env []string
check []optionCheck
// main are main.cf parameters the combination must end up with, read with
// postconf -h.
main []optionCheck
}
var port25Cases = []port25Case{
@@ -47,7 +50,7 @@ var port25Cases = []port25Case{
"DMARC_REPORTS_ENABLE=true",
},
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},
},
},
@@ -58,9 +61,16 @@ var port25Cases = []port25Case{
"DMARC_REPORTS_ENABLE=true",
},
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},
},
// 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},
},
},
{
name: "inbound relay with antispam milter",
@@ -116,6 +126,27 @@ func checkPort25ConfigCombinations(s *stack) error {
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)
}
// 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)
}
}
}