Compare commits
18
Commits
b951eae58c
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
403ce93990 | ||
|
|
252e5d1cff | ||
|
|
5af4d6c368 | ||
|
|
ddde177776 | ||
|
|
e69d139487 | ||
|
|
3f0d15c9e3 | ||
|
|
bbd2d83364 | ||
|
|
c5588ebba1 | ||
|
|
f6838e4696 | ||
|
|
36db4223f2 | ||
|
|
80748e6c94 | ||
|
|
c5086510f0 | ||
|
|
5b1a868ccf | ||
|
|
c36bd60022 | ||
|
|
baa053d250 | ||
|
|
ce41a8cf34 | ||
|
|
783bca806a | ||
|
|
088c8a5986 |
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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=" \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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+<domain>@mail.example.org</dd></div>
|
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@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+<domain>@mail.example.org</dd></div>
|
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 §
|
||||||
@@ -75,6 +79,7 @@ func parseProcesses(out string) []Process {
|
|||||||
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
|
||||||
|
|||||||
@@ -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"} {
|
||||||
|
|||||||
@@ -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.OneShot {
|
||||||
|
expected++
|
||||||
if p.Status == health.StatusOK {
|
if p.Status == health.StatusOK {
|
||||||
running++
|
running++
|
||||||
} else {
|
}
|
||||||
|
}
|
||||||
|
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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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+<domain>@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+<domain>@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})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user