Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b905f3e560 | ||
|
|
c9027381e5 | ||
|
|
50fd6b859f | ||
|
|
06ad174fb9 | ||
|
|
87fa0a4381 | ||
|
|
eff8010d39 |
@@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Schema for notifications** (migration `0004_notifications.sql`, DDL only):
|
||||
a user's own switch, `users.notify`, on for everyone; `notify_state`, what
|
||||
the panel has already written to its users about. The instance switch is a
|
||||
settings key, on unless turned off
|
||||
([plans/panel-notifications.md](docs/plans/panel-notifications.md)).
|
||||
- **The panel writes to its users when something needs them.** Four things,
|
||||
each said once: a sending domain's DKIM, SPF or DMARC record stops matching
|
||||
(after two hourly checks in a row, and once more when it matches again); a
|
||||
level-2 rate limit refuses mail (at most once a day per limit); the TLS
|
||||
certificate is 14 days from expiry, and 3; a message has waited in the
|
||||
queue for more than three days (and once more when none has). Global users
|
||||
are told all of it, domain users about their own domains. Sent from
|
||||
`postmaster@<SELFPOST_HOSTNAME>` by local submission, one message per
|
||||
recipient; not rows of the Outbound log and counted against no limit.
|
||||
- **The server's own name is signed.** The panel generates a DKIM key for
|
||||
`SELFPOST_HOSTNAME` and OpenDKIM signs mail submitted locally as that name
|
||||
(`non_smtpd_milters`), so that notifications arrive. The name is not a
|
||||
sending domain: no application can send as it.
|
||||
- **Notifications in the panel.** Account has *Send me notifications* beside
|
||||
the e-mail; Server › Settings has the switch for everyone and the two DNS
|
||||
records of the server's own name, DKIM and SPF, with the values to publish;
|
||||
Server › Health has a *Notification mail* card with the state of each. Help
|
||||
has a Notifications topic. Every user starts with notifications on, and so
|
||||
does the instance.
|
||||
|
||||
## [2.2.0] - 2026-10-12
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -76,7 +76,9 @@ RUN groupadd --system --gid 998 selfpost \
|
||||
# with `no-new-privileges`, which disables setgid transitions, so the panel
|
||||
# would always see "Permission denied". Making `panel` a real member of postdrop
|
||||
# grants the same access without relying on a setgid escalation the hardening
|
||||
# deliberately forbids. postdrop membership is read-side only: it does not let
|
||||
# deliberately forbids. The same membership is what lets the panel submit its
|
||||
# own notifications with sendmail(1) - postdrop, too, is setgid and would fail
|
||||
# to write the maildrop directory (internal/notify/mailer.go). It does not let
|
||||
# the panel bypass any Postfix restriction that a local user does not already
|
||||
# have through the world-executable sendmail.
|
||||
RUN usermod -aG postdrop panel
|
||||
|
||||
@@ -224,11 +224,17 @@ postconf -e \
|
||||
# if it is unreachable, defer rather than send unsigned). The journal-milter is
|
||||
# monitoring only and is fail-open (default_action=accept): its failure must
|
||||
# never block the relay. Per-milter settings use Postfix 3.0+ brace syntax.
|
||||
#
|
||||
# non_smtpd_milters is the chain for mail submitted locally with sendmail(1) -
|
||||
# the panel's own notifications, sent as postmaster@ the hostname. OpenDKIM
|
||||
# signs them with the key of the server's own name; the journal-milter is not
|
||||
# on this chain, since they are not an application's mail. Bounces Postfix
|
||||
# generates itself do not pass it (internal_mail_filter_classes is empty).
|
||||
postconf -e \
|
||||
"milter_protocol=6" \
|
||||
"milter_default_action=tempfail" \
|
||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||
"non_smtpd_milters="
|
||||
"non_smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }"
|
||||
|
||||
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's
|
||||
# address. The panel's milters judge every message by that address (an
|
||||
|
||||
@@ -37,6 +37,9 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||
// The panel's notifications are sent as postmaster@ the server's own name,
|
||||
// and signed as it.
|
||||
domains.SetServerName(cfg.hostname)
|
||||
inb := inbound.NewService(st, pf)
|
||||
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||
return &mailStack{
|
||||
|
||||
+3
-1
@@ -341,7 +341,8 @@ func run() error {
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errc := make(chan error, 5)
|
||||
// Room for every role to report a failure without blocking: six at most.
|
||||
errc := make(chan error, 6)
|
||||
|
||||
roles := []struct {
|
||||
name string
|
||||
@@ -363,6 +364,7 @@ func run() error {
|
||||
runAutoRateLimitRecalc(ctx, cfg, st)
|
||||
return nil
|
||||
}},
|
||||
{"notifier", func(ctx context.Context) error { return runNotifier(ctx, cfg, st) }},
|
||||
}
|
||||
|
||||
if cfg.inboundEnabled {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// runNotifier writes to the panel's users when something needs them
|
||||
// (architecture.md § Notifications), until ctx is cancelled. It reads what the
|
||||
// pages read - a domain's published records against the key this server signs
|
||||
// with, the certificate file, Postfix's queue - with a DNS checker of its own,
|
||||
// so that its hourly look is never a page's cached one.
|
||||
func runNotifier(ctx context.Context, cfg config, st *store.Store) error {
|
||||
ms := newMailStack(cfg, st)
|
||||
dns := dnscheck.New(cfg.dnsResolvers)
|
||||
|
||||
n := notify.New(st, notify.Checks{
|
||||
Domain: func(d store.Domain) (dnscheck.Domain, error) {
|
||||
rec, err := ms.Domains.DKIMRecord(d)
|
||||
if err != nil {
|
||||
return dnscheck.Domain{}, err
|
||||
}
|
||||
srv := dns.Server(cfg.hostname, true)
|
||||
return dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: rec.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: d.DMARCRua,
|
||||
}, true), nil
|
||||
},
|
||||
Certificate: func() health.Certificate { return health.CheckCertificate(cfg.tlsCertFile) },
|
||||
Queue: postfix.QueueMessages,
|
||||
}, notify.NewMailer(cfg.hostname), cfg.hostname)
|
||||
return n.Run(ctx)
|
||||
}
|
||||
+52
-1
@@ -156,9 +156,17 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
|
||||
by the panel. Socket `/run/opendkim/opendkim.sock`.
|
||||
|
||||
The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both
|
||||
tables too, without being a row of the registry
|
||||
(`domain.Service.SetServerName`): the panel's notifications are sent as
|
||||
`postmaster@` that name and signed with it. No application can send as it -
|
||||
the sender map is built from the registry. A sending domain of the same name
|
||||
shares its key and selector with the server, and deleting that domain leaves
|
||||
the key in place.
|
||||
|
||||
### Panel binary ([cmd/panel](../cmd/panel))
|
||||
|
||||
One process, four roles:
|
||||
One process, five roles (six with the inbound relay on):
|
||||
|
||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
|
||||
@@ -188,8 +196,51 @@ One process, four roles:
|
||||
page (§ Log tailer, *Stored lines*).
|
||||
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and
|
||||
the application form), recomputes level-2 **Auto** rate limits from send-log statistics.
|
||||
5. **notifier** — writes to the panel's users when something needs them
|
||||
(§ Notifications below).
|
||||
|
||||
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
|
||||
Mail submitted locally with `sendmail(1)` — the notifier's — passes OpenDKIM
|
||||
alone (`non_smtpd_milters`): it is signed, and it is not an application's
|
||||
mail to be journalled or limited. Bounces Postfix generates pass no milter.
|
||||
|
||||
### Notifications
|
||||
|
||||
[internal/notify](../internal/notify/notify.go) reports four things to the
|
||||
users of the panel: a sending domain's DNS record that stopped matching, a
|
||||
level-2 rate limit that refused mail, the TLS certificate about to expire, a
|
||||
message more than three days in the queue. The rules of when a message goes
|
||||
out are the owner's and are written in
|
||||
[plans/panel-notifications.md](plans/panel-notifications.md) § When a message
|
||||
is sent; the operator's view is guide § Notifications.
|
||||
|
||||
- **Readings.** Once an hour, starting three minutes after the process does:
|
||||
every sending domain's records through a DNS checker of the notifier's own,
|
||||
forced past the cache (`cmd/panel/notifier.go` builds the same query a
|
||||
domain's page does); the certificate file; `postqueue -j` for the arrival
|
||||
time of each queued message. Once a minute: the send log's `rejected` rows
|
||||
of the last day, per domain and application.
|
||||
- **State** is a row per subject in `notify_state` (`dns:<domain>`, `cert`,
|
||||
`queue`, `limit:<domain>:<login>`), which is what makes each thing be said
|
||||
once and a restart say nothing again. A DNS subject goes *pending* on the
|
||||
first bad check and *open*, with a message, on the second; back the same
|
||||
way. The certificate's row remembers which threshold was said for which
|
||||
expiry. A deleted domain's row goes with it.
|
||||
- **No information is not a finding.** A lookup that did not answer, a queue
|
||||
that cannot be listed, a certificate that cannot be read move no state.
|
||||
- **Recipients** come from the store at the moment of sending: global users
|
||||
for everything, domain users for subjects that are a domain's; only users
|
||||
with an e-mail and their own switch on. One message per recipient, so users
|
||||
do not see each other's addresses.
|
||||
- **The instance switch** (settings key `notifications`, on unless turned
|
||||
off) stops the checks themselves, not only the mail: no state moves while
|
||||
it is off.
|
||||
- **Sending** is `sendmail -i -f postmaster@<hostname> -- <address>` as the
|
||||
unprivileged panel user, which is a member of `postdrop` and so can write
|
||||
the maildrop directory without the setgid bit `no-new-privileges` forbids.
|
||||
A message that could not be handed over leaves the state where it was and
|
||||
is tried at the next check. The text names the page to open and has no
|
||||
link: the panel does not know its own address.
|
||||
|
||||
### Log tailer and `mail.log` rotation
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -145,13 +145,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
|
||||
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="configuration">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
|
||||
</div>
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
|
||||
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
|
||||
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
|
||||
<li><a href="#notifications"><i class="ti ti-mail"></i>Notifications</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Account</p>
|
||||
<ul class="menu-list">
|
||||
@@ -141,11 +142,12 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 998 on the host (<code>chgrp 998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"is-6": [
|
||||
"box[Notification mail]:table+body"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-6": [
|
||||
"box[Configuration]:body"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"help": [
|
||||
"head",
|
||||
@@ -124,7 +137,7 @@
|
||||
"box[Overview]:help×3",
|
||||
"box[Outbound]:help×6",
|
||||
"box[Inbound]:help×6",
|
||||
"box[Server]:help×3",
|
||||
"box[Server]:help×4",
|
||||
"box[Account]:help"
|
||||
]
|
||||
}
|
||||
@@ -406,12 +419,14 @@
|
||||
"columns": [
|
||||
{
|
||||
"is-6": [
|
||||
"box[Log retention]:body"
|
||||
"box[Log retention]:body",
|
||||
"box[Sending rate limits]:body"
|
||||
]
|
||||
},
|
||||
{
|
||||
"is-6": [
|
||||
"box[Sending rate limits]:body"
|
||||
"box[Notifications]:body",
|
||||
"box[Notification mail]:record×2"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -87,9 +87,7 @@
|
||||
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="rate-limits">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
@@ -100,6 +98,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notifications">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Notification mail</h2><div class="sp-end">DNS records of the server's own name</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.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"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.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"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="help.html#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -79,10 +79,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span></td></tr>
|
||||
<tr><td>SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="sp-muted">Sent from <span class="sp-mono">postmaster@mail.example.org</span>. What to publish is under <a href="settings.html#notification-mail">Settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="box" id="configuration">
|
||||
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
|
||||
<div class="sp-box-body">
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<li><a href="#backup"><i class="ti ti-archive"></i>Backup and moving a domain</a></li>
|
||||
<li><a href="#users"><i class="ti ti-users"></i>Users and roles</a></li>
|
||||
<li><a href="#settings"><i class="ti ti-settings"></i>Settings</a></li>
|
||||
<li><a href="#notifications"><i class="ti ti-mail"></i>Notifications</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Account</p>
|
||||
<ul class="menu-list">
|
||||
@@ -75,11 +76,12 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 998 on the host (<code>chgrp 998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Account</h2></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
<div class="sp-help-topic" id="account"><h3>Your account</h3><p>Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="rate-limits">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Sending rate limits</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
@@ -34,6 +32,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div class="box" id="notifications">
|
||||
<div class="sp-box-head"><span class="sp-no">03</span><h2>Notifications</h2><a class="sp-help" href="help.html#notifications" title="What is sent and when"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify" checked> Send notification e-mails</label>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="account.html">Account</a>.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="notification-mail">
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Notification mail</h2><div class="sp-end">DNS records of the server's own name</div><a class="sp-help" href="help.html#notifications" title="What the panel sends and what makes it arrive"><i class="ti ti-help-circle"></i></a></div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="sp2026._domainkey.mail.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"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><textarea class="textarea" rows="5" readonly>v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq7n2Lw0c5vXeR1dYt8uJb3mKs6pHf9aZoWg4iNy2TqCx0lVjE7rUh5sBk1dMa8fPz3oGc6yXw9tIe4nQv2uL…IDAQAB</textarea></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sp-record">
|
||||
<div class="sp-record-head"><h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small></div>
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label">Host / name</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="mail.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"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label">Value to publish</label>
|
||||
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=spf1 a -all"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
|
||||
<p class="help">Notifications are sent without it, and a receiver may file them as spam.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
@@ -127,6 +127,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
| 14 | DMARC ingest drops and bounds, full backup from the panel, no queue in a backup | `2.0.1` (PATCH) |
|
||||
| 15 | inbound-antispam-panel | `2.1.0` (was `1.10.0`, reordered 2026-10-09) |
|
||||
| 16 | delivery-log-storage | `2.2.0` |
|
||||
| 17 | panel-notifications | `2.3.0` |
|
||||
|
||||
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||
commit rule but **no** version cut until the next product stage ships.
|
||||
|
||||
+53
-5
@@ -33,6 +33,7 @@ changed (see the CHANGELOG, *Removed*).
|
||||
- [Queue and System log](#queue-and-system-log)
|
||||
- [Account](#account)
|
||||
- [Settings](#settings)
|
||||
- [Notifications](#notifications)
|
||||
- [Users](#users)
|
||||
- [Sessions](#sessions)
|
||||
- [Upgrading](#upgrading)
|
||||
@@ -419,10 +420,11 @@ readings:
|
||||
**Account** (`/account`, every role) is the signed-in user's own: **Profile**
|
||||
(username and e-mail) and **Password**. Saving the profile does not ask for the
|
||||
password; changing the password asks for the current one and is rate-limited
|
||||
like a sign-in. The e-mail is the user's own address. Nothing is sent to it
|
||||
yet; it is what a domain's *Use my e-mail* button fills in (see [Domain-level
|
||||
DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins are separate
|
||||
and are not changed here.
|
||||
like a sign-in. The e-mail is the user's own address: it is where the panel
|
||||
sends [notifications](#notifications) while *Send me notifications* is ticked
|
||||
beside it, and what a domain's *Use my e-mail* button fills in (see
|
||||
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins
|
||||
are separate and are not changed here.
|
||||
|
||||
### Settings
|
||||
|
||||
@@ -431,7 +433,53 @@ the whole instance: how many days the **Outbound log** keeps its rows before the
|
||||
background sweep deletes them (7–365 days; takes effect on the next six-hour
|
||||
prune cycle without a container restart), and, read-only, the level-1 rate
|
||||
limit that comes from `.env` ([Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)).
|
||||
There is no panel-wide DMARC report address: each domain has its own.
|
||||
There is no panel-wide DMARC report address: each domain has its own. The page
|
||||
also has the switch for [notifications](#notifications) and the two DNS records
|
||||
that make them arrive.
|
||||
|
||||
### Notifications
|
||||
|
||||
The panel writes to its users when something needs them. Four things are
|
||||
reported, each once:
|
||||
|
||||
| What | When a message is sent | A second message |
|
||||
|---|---|---|
|
||||
| A sending domain's DKIM, SPF or DMARC record stops matching | after two hourly checks in a row found it wrong. A resolver that did not answer is not a mismatch; a missing DMARC record is advice on the domain's page, not a notification | when two checks in a row find the records right again |
|
||||
| A level-2 rate limit refuses mail | within a minute of the first refusal | at most once a day for the same domain and application, while it goes on |
|
||||
| The TLS certificate is about to expire | 14 days before | 3 days before, if it is still the same certificate |
|
||||
| A message has waited in the queue for more than three days | at the next hourly check | when no message that old is left |
|
||||
|
||||
A user with the **global** role is told all of it; a user with the **domain**
|
||||
role is told about the DNS records and rate limits of the outbound domains
|
||||
assigned to them. A message goes to the e-mail on the user's
|
||||
[Account](#account) page and only while *Send me notifications* is ticked
|
||||
there; a user without an e-mail gets none. **Server › Settings** has one switch
|
||||
that turns notifications off for everyone. With it off nothing is checked, so
|
||||
what is wrong when it is turned on again is reported then.
|
||||
|
||||
Notifications are the panel's own mail. They are sent from
|
||||
`postmaster@<SELFPOST_HOSTNAME>`, one message per recipient, are not rows of
|
||||
the Outbound log and count against no rate limit. They name the page of the
|
||||
panel to open and carry no link: the panel does not know the address its
|
||||
reverse proxy serves it under.
|
||||
|
||||
**Two DNS records make them arrive.** The server's own name is not one of your
|
||||
sending domains, so it needs records of its own, shown with the values to
|
||||
publish under **Server › Settings › Notification mail**:
|
||||
|
||||
- a **DKIM** key for the hostname, which the panel generates and signs
|
||||
notifications with (`<selector>._domainkey.<hostname>`, TXT);
|
||||
- an **SPF** record for the hostname: `v=spf1 a -all` (TXT).
|
||||
|
||||
**Server › Health** shows whether each is in place in the *Notification mail*
|
||||
card. Without them notifications are still sent, and a receiver may file them
|
||||
as spam or, if the parent domain publishes a strict DMARC policy, refuse them.
|
||||
Mail of your sending domains depends on neither. If the hostname is itself one
|
||||
of your sending domains, the records are that domain's and are already
|
||||
published.
|
||||
|
||||
A relay that cannot send cannot say so: none of the four is "the relay is
|
||||
down". Watch that from outside ([Container health](#container-health)).
|
||||
|
||||
### Users
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan: panel-notifications
|
||||
|
||||
**Status:** candidate - the design questions are closed with the owner (2026-10-12); taking it into work still needs his word
|
||||
**Status:** agreed 2026-10-12 - the design questions were closed with the owner and he took it into work ("Делаем")
|
||||
**Date:** 2026-10-12
|
||||
**Version:** `2.3.0` MINOR (one migration; opt-out by a switch).
|
||||
|
||||
@@ -40,10 +40,23 @@ From `postmaster@<SELFPOST_HOSTNAME>`, submitted locally. Not from one of the
|
||||
sending domains (owner): with several domains the choice is arbitrary, and a
|
||||
message about the certificate has no business coming from a domain.
|
||||
|
||||
The server's name usually has no DKIM key. One DNS record is enough for such
|
||||
mail to pass SPF and DMARC on the name itself: `v=spf1 a -all` for the
|
||||
hostname. Server › Health gains that check next to reverse DNS, and the guide
|
||||
describes it. Without the record notifications are still sent.
|
||||
These messages have to arrive (owner: "we need the best deliverability for
|
||||
them"), so the server's own name gets what a sending domain has:
|
||||
|
||||
- a **DKIM key for the hostname**, generated by the panel, with which
|
||||
notifications are signed - no domain has to be chosen for it. An SPF record
|
||||
alone would pass DMARC, but not after the user's mailbox forwards the
|
||||
message on, and large receivers treat unsigned mail more strictly;
|
||||
- an **SPF record** for the hostname, `v=spf1 a -all`.
|
||||
|
||||
The records themselves are under **Server › Settings** (owner: "the DNS
|
||||
records go to Settings, Health has only the status"), in a box of their own
|
||||
drawn with the record component of a domain's DNS page: host, value to
|
||||
publish, copy buttons, state. **Server › Health** has a card of its own,
|
||||
*Notification mail*, with the state of each record and a link to Settings.
|
||||
A DMARC record for the hostname is not asked for: the name inherits its
|
||||
parent domain's policy, and with SPF and DKIM aligned it passes any.
|
||||
Without the records notifications are still sent, and the card warns.
|
||||
|
||||
Notifications are the panel's own mail: they are not rows of the Outbound log
|
||||
and count against no level-2 limit.
|
||||
@@ -92,15 +105,15 @@ Server › Settings, the SPF check on Server › Health - is the first step, sho
|
||||
to the owner before any code ([panel-redesign.md](panel-redesign.md)
|
||||
§ Enforcement).
|
||||
|
||||
## Implementation checklist (draft; counted from the day the item is agreed)
|
||||
## Implementation checklist
|
||||
|
||||
- [ ] Mockups: Account checkbox, Settings switch, Health SPF check, help topic; accepted by the owner (`design:` commit)
|
||||
- [ ] Migration and store: user flag, instance switch, event state
|
||||
- [ ] Sending: local submission from `postmaster@<hostname>`, message text
|
||||
- [ ] Background checker: DNS, certificate, queue; the rules of *When a message is sent*
|
||||
- [ ] Level-2 refusal event
|
||||
- [ ] Panel: Account, Settings, Health check, help
|
||||
- [ ] guide.md, architecture.md, security.md, schema-migrations.md
|
||||
- [ ] `go vet`, `go test`, e2e: an event reaches a mailbox once and clears once
|
||||
- [x] Mockups: Account checkbox, Settings switch and records, Health status card, help topic; accepted by the owner 2026-10-12 (`design:` commit)
|
||||
- [x] Migration and store: user flag, instance switch, event state
|
||||
- [x] Sending: local submission from `postmaster@<hostname>`, message text (`sendmail` as the panel user; signed through `non_smtpd_milters` with a key of the server's own name; no link in the text - the panel does not know its own address)
|
||||
- [x] Background checker: DNS, certificate, queue; the rules of *When a message is sent*
|
||||
- [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute)
|
||||
- [x] Panel: Account, Settings, Health card, help
|
||||
- [x] guide.md, architecture.md, security.md, schema-migrations.md
|
||||
- [x] `go vet`, `go test`, e2e: a level-2 refusal reaches a mailbox as a signed notification (on the test server: vet ok, 23 packages ok, e2e ok; "once" and "clears" are held by the unit tests of `internal/notify`)
|
||||
- [ ] Technical review
|
||||
- [ ] Security review
|
||||
|
||||
+6
-5
@@ -35,7 +35,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
||||
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
|
||||
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
|
||||
| panel-notifications | E-mail notifications about important events | candidate, design closed | — | [plans/panel-notifications.md](plans/panel-notifications.md) |
|
||||
| panel-notifications | E-mail notifications about important events | in progress | 8/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
|
||||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
|
||||
| password-reset | Password reset by e-mail | candidate | — | — |
|
||||
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
|
||||
@@ -46,7 +46,8 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||||
**inbound-antispam-panel** as `2.1.0` (2026-10-12), built in the new component
|
||||
kit — styling its two screens once instead of twice. **delivery-log-storage**
|
||||
shipped as `2.2.0` the same day. Nothing is agreed after it: every remaining
|
||||
shipped as `2.2.0` the same day. Agreed after it: **panel-notifications**
|
||||
(`2.3.0`); every other remaining
|
||||
item is a candidate. panel-docs shipped in
|
||||
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
|
||||
(its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
|
||||
@@ -363,10 +364,10 @@ restarts so a reboot does not re-announce everything. The screens are not
|
||||
drawn: the Notifications box was taken out of `account` and `settings` on
|
||||
2026-10-10 and is redrawn as the first step.
|
||||
|
||||
**Version:** `2.3.0` MINOR; `candidate` - the design questions were closed
|
||||
**Version:** `2.3.0` MINOR; agreed 2026-10-12 - the design questions were closed
|
||||
with the owner on 2026-10-12
|
||||
([plans/panel-notifications.md](plans/panel-notifications.md)); taking it into
|
||||
work still needs his word.
|
||||
([plans/panel-notifications.md](plans/panel-notifications.md)) and he took it into
|
||||
work the same day.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ When you add or review a migration, update this file in the same change.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Chain head | `user_version = 3` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql` |
|
||||
| Chain head | `user_version = 4` |
|
||||
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql` |
|
||||
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
|
||||
| Database file | `/data/selfpost.db` (bind mount) |
|
||||
| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
|
||||
@@ -63,7 +63,8 @@ again and no file is deleted, renamed or reordered — every 2.x database has
|
||||
already run it. `2.0.0` and `2.0.1` shipped with the baseline alone; the
|
||||
features after them add files, the first being `0002_inbound_spam_log.sql` of
|
||||
inbound-antispam-panel (`2.1.0`), then `0003_send_log_lines.sql` of
|
||||
delivery-log-storage (`2.2.0`).
|
||||
delivery-log-storage (`2.2.0`) and `0004_notifications.sql` of
|
||||
panel-notifications (`2.3.0`).
|
||||
|
||||
---
|
||||
|
||||
@@ -74,6 +75,7 @@ delivery-log-storage (`2.2.0`).
|
||||
| 1 | `0001_init.sql` | 2.0.0 | DDL | The whole 2.0 schema, below |
|
||||
| 2 | `0002_inbound_spam_log.sql` | 2.1.0 | DDL | `inbound_spam_log`, `inbound_spam_list` and two indexes; nothing existing changes |
|
||||
| 3 | `0003_send_log_lines.sql` | 2.2.0 | DDL | `send_log_lines` and one index; nothing existing changes |
|
||||
| 4 | `0004_notifications.sql` | 2.3.0 | DDL | `users.notify` (default 1: every user starts with notifications on) and `notify_state` |
|
||||
|
||||
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||
for operators upgrading from older 2.x images.
|
||||
@@ -85,7 +87,7 @@ for operators upgrading from older 2.x images.
|
||||
| Table | Role |
|
||||
|-------|------|
|
||||
| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
|
||||
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
|
||||
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
|
||||
| `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) |
|
||||
| `domains` | Sending (outbound) domains. `dmarc_rua` is where aggregate reports go (`''` = no reports) |
|
||||
| `user_domains` | Outbound domains assigned to a `domain` user |
|
||||
@@ -102,6 +104,7 @@ for operators upgrading from older 2.x images.
|
||||
| `dmarc_report_records` | Per-source rows inside a report |
|
||||
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2) |
|
||||
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
|
||||
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>:<login>`), so that each thing is said once and a restart says nothing again (v4) |
|
||||
| `send_log_lines` | The `mail.log` lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to `send_log`: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) |
|
||||
|
||||
`TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column
|
||||
|
||||
@@ -412,6 +412,21 @@ deferred item from the roadmap.
|
||||
diagnostics. Since 2.2.0 the tailer copies the lines of queued outbound
|
||||
messages into the database for the message's page, and those are in a
|
||||
backup (§ Backup and domain export); mail offered on port 25 is not copied.
|
||||
- **The panel submits mail of its own.** Notifications leave through
|
||||
`sendmail(1)` run by the unprivileged panel user: a fixed argument list, the
|
||||
two addresses after `--`, no shell. The recipient is a panel user's account
|
||||
e-mail, validated when it was saved and checked again before it reaches a
|
||||
header or the command line; the subject and body are composed by the panel
|
||||
from its own readings. Two of those readings can carry text from outside - a
|
||||
DNS check's finding, and a queued message's sender address - and both are
|
||||
reduced to printable ASCII and cut before they are printed, so a
|
||||
notification's body can contain an address a stranger chose, as a few plain
|
||||
characters and nothing else. The panel's
|
||||
membership in `postdrop`, described in the image as read-side, is now what
|
||||
lets it submit; any local user could already do that through the
|
||||
world-executable `sendmail`. Notifications are signed with a key of the
|
||||
server's own name and bypass the level-1 and level-2 limits: they are a few
|
||||
messages a day at most, to addresses only a panel user can set.
|
||||
- **A message's page can show another message's log lines after a queue-id
|
||||
collision.** Postfix's short queue ids repeat: the id is the microsecond of
|
||||
arrival and the queue file's inode, and inodes come round within seconds.
|
||||
|
||||
@@ -100,6 +100,7 @@ type Checker struct {
|
||||
servers map[string]cached[Server]
|
||||
domains map[string]cached[Domain]
|
||||
inbound map[string]cached[Result]
|
||||
mail map[string]cached[Mail]
|
||||
}
|
||||
|
||||
type cached[T any] struct {
|
||||
@@ -123,6 +124,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
||||
servers: make(map[string]cached[Server]),
|
||||
domains: make(map[string]cached[Domain]),
|
||||
inbound: make(map[string]cached[Result]),
|
||||
mail: make(map[string]cached[Mail]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +172,56 @@ func (c *Checker) Domain(q Query, force bool) Domain {
|
||||
return d
|
||||
}
|
||||
|
||||
// Mail is the published-DNS state of the server's own name as a sender: the
|
||||
// panel's notifications go out from postmaster@ the hostname, signed with a
|
||||
// key of that name.
|
||||
type Mail struct {
|
||||
Name string
|
||||
DKIM Result
|
||||
SPF Result
|
||||
Overall health.Status
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// ServerMailSPF is the SPF record to publish for the server's own name: its
|
||||
// address record is the one sender.
|
||||
const ServerMailSPF = "v=spf1 a -all"
|
||||
|
||||
// ServerMail checks the DKIM and SPF records of the server's own name. q is as
|
||||
// for a domain, with Name the hostname. Cached like a domain; force skips the
|
||||
// cache.
|
||||
func (c *Checker) ServerMail(q Query, force bool) Mail {
|
||||
key := "\x00mail\x00" + q.Name
|
||||
if !force {
|
||||
c.mu.Lock()
|
||||
entry, ok := c.mail[key]
|
||||
c.mu.Unlock()
|
||||
if ok && time.Now().Before(entry.expires) {
|
||||
return entry.value
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||
defer cancel()
|
||||
m := Mail{Name: q.Name, CheckedAt: time.Now()}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); m.DKIM = c.checkDKIM(ctx, q) }()
|
||||
go func() { defer wg.Done(); m.SPF = c.checkSPF(ctx, q) }()
|
||||
wg.Wait()
|
||||
// checkSPF words a missing record for a sending domain; for the server's
|
||||
// own name the record to publish is a different one.
|
||||
if m.SPF.Status == health.StatusError && len(m.SPF.Records) == 0 {
|
||||
m.SPF.Detail = fmt.Sprintf("No SPF record is published for %s. Publish the TXT record %q - without it a receiver has nothing authorising this server to send as its own name.", q.Name, ServerMailSPF)
|
||||
}
|
||||
m.Overall = health.Worst(m.DKIM.Status, m.SPF.Status)
|
||||
|
||||
c.mu.Lock()
|
||||
c.mail[key] = cached[Mail]{value: m, expires: m.CheckedAt.Add(c.domainTTL)}
|
||||
c.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
||||
// Forget drops a domain's cached result, so the next page view re-checks it.
|
||||
// Used when a domain is removed or re-imported.
|
||||
func (c *Checker) Forget(domainName string) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package dnscheck
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
)
|
||||
|
||||
const serverDKIM = "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAserverkey"
|
||||
|
||||
func serverMailQuery() Query {
|
||||
return Query{
|
||||
Name: "mail.example.org",
|
||||
Selector: "sp",
|
||||
ExpectedDKIM: serverDKIM,
|
||||
Hostname: "mail.example.org",
|
||||
ServerIPs: []string{"203.0.113.25"},
|
||||
}
|
||||
}
|
||||
|
||||
// The server's own name passes with its key published and "v=spf1 a -all":
|
||||
// the name's address record is this server.
|
||||
func TestServerMailPublished(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
txt: map[string][]string{
|
||||
"sp._domainkey.mail.example.org": {serverDKIM},
|
||||
"mail.example.org": {ServerMailSPF},
|
||||
},
|
||||
addr: map[string][]net.IPAddr{"mail.example.org": ipAddrs("203.0.113.25")},
|
||||
}
|
||||
c := newTestChecker(f)
|
||||
|
||||
m := c.ServerMail(serverMailQuery(), false)
|
||||
if m.DKIM.Status != health.StatusOK || m.SPF.Status != health.StatusOK || m.Overall != health.StatusOK {
|
||||
t.Fatalf("DKIM %s (%s), SPF %s (%s), overall %s", m.DKIM.Status, m.DKIM.Detail, m.SPF.Status, m.SPF.Detail, m.Overall)
|
||||
}
|
||||
|
||||
// Cached like a domain, and re-read when forced.
|
||||
before := f.lookups
|
||||
c.ServerMail(serverMailQuery(), false)
|
||||
if f.lookups != before {
|
||||
t.Errorf("a second reading looked DNS up again (%d lookups)", f.lookups-before)
|
||||
}
|
||||
c.ServerMail(serverMailQuery(), true)
|
||||
if f.lookups == before {
|
||||
t.Error("a forced reading used the cache")
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing published both records are errors, and the SPF advice names the
|
||||
// record for a server's own name, not the one for a sending domain.
|
||||
func TestServerMailMissing(t *testing.T) {
|
||||
c := newTestChecker(&fakeResolver{})
|
||||
|
||||
m := c.ServerMail(serverMailQuery(), false)
|
||||
if m.DKIM.Status != health.StatusError || m.SPF.Status != health.StatusError || m.Overall != health.StatusError {
|
||||
t.Fatalf("DKIM %s, SPF %s, overall %s; want errors", m.DKIM.Status, m.SPF.Status, m.Overall)
|
||||
}
|
||||
if !strings.Contains(m.SPF.Detail, ServerMailSPF) || strings.Contains(m.SPF.Detail, "a:mail.example.org") {
|
||||
t.Errorf("SPF advice: %s", m.SPF.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// A sending domain of the same name does not share the cache entry.
|
||||
func TestServerMailCacheIsItsOwn(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
txt: map[string][]string{
|
||||
"sp._domainkey.mail.example.org": {serverDKIM},
|
||||
"mail.example.org": {ServerMailSPF},
|
||||
},
|
||||
addr: map[string][]net.IPAddr{"mail.example.org": ipAddrs("203.0.113.25")},
|
||||
}
|
||||
c := newTestChecker(f)
|
||||
c.Domain(serverMailQuery(), false)
|
||||
before := f.lookups
|
||||
c.ServerMail(serverMailQuery(), false)
|
||||
if f.lookups == before {
|
||||
t.Error("the server's mail check was answered from the domain's cache entry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readTables(t *testing.T, odk *OpenDKIM) (keyTable, signingTable string) {
|
||||
t.Helper()
|
||||
kt, err := os.ReadFile(odk.keyTablePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read KeyTable: %v", err)
|
||||
}
|
||||
st, err := os.ReadFile(odk.signingTablePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read SigningTable: %v", err)
|
||||
}
|
||||
return string(kt), string(st)
|
||||
}
|
||||
|
||||
// The server's own name is signed like a sending domain - a key, a line in
|
||||
// each table - without being one: no row in the registry, so no application
|
||||
// can send as it.
|
||||
func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
svc.SetServerName("mail.example.org")
|
||||
|
||||
if _, err := svc.Add("example.com"); err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
kt, st := readTables(t, odk)
|
||||
for _, want := range []string{"mail.example.org mail.example.org:selfpost:", "example.com example.com:selfpost:"} {
|
||||
if !strings.Contains(kt, want) {
|
||||
t.Errorf("KeyTable lacks %q:\n%s", want, kt)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(st, "*@mail.example.org mail.example.org\n") {
|
||||
t.Errorf("SigningTable does not sign the server's own name:\n%s", st)
|
||||
}
|
||||
domains, err := svc.List()
|
||||
if err != nil || len(domains) != 1 {
|
||||
t.Fatalf("registry holds %d domains, %v; want the one that was added", len(domains), err)
|
||||
}
|
||||
|
||||
mail, err := svc.ServerMail()
|
||||
if err != nil {
|
||||
t.Fatalf("ServerMail: %v", err)
|
||||
}
|
||||
if mail.Name != "mail.example.org" || mail.Selector != "selfpost" ||
|
||||
mail.Record.Name != "selfpost._domainkey.mail.example.org" || !strings.HasPrefix(mail.Record.Value, "v=DKIM1;") {
|
||||
t.Errorf("server mail = %+v", mail)
|
||||
}
|
||||
// Asked again, it is the same key: the record published in DNS stays valid.
|
||||
again, err := svc.ServerMail()
|
||||
if err != nil || again.Record.Value != mail.Record.Value {
|
||||
t.Errorf("the server's key changed between two readings (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the hostname is itself a sending domain, there is one key and one line,
|
||||
// the domain's; deleting the domain leaves the server's mail signed with it.
|
||||
func TestServerNameThatIsAlsoADomain(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
svc.SetServerName("example.com")
|
||||
|
||||
d, err := svc.Add("example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
_, st := readTables(t, odk)
|
||||
if strings.Count(st, "*@example.com ") != 1 {
|
||||
t.Errorf("the name is in the SigningTable more than once:\n%s", st)
|
||||
}
|
||||
asDomain, err := svc.DKIMRecord(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mail, err := svc.ServerMail()
|
||||
if err != nil || mail.Record.Value != asDomain.Value {
|
||||
t.Fatalf("the server's record is not the domain's (%v)", err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(d.ID); err != nil {
|
||||
t.Fatalf("delete domain: %v", err)
|
||||
}
|
||||
_, st = readTables(t, odk)
|
||||
if !strings.Contains(st, "*@example.com example.com\n") {
|
||||
t.Errorf("deleting the domain stopped the server's own mail being signed:\n%s", st)
|
||||
}
|
||||
after, err := svc.ServerMail()
|
||||
if err != nil || after.Record.Value != asDomain.Value {
|
||||
t.Errorf("deleting the domain changed the server's key (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a hostname nothing is added and nothing is signed for it.
|
||||
func TestNoServerName(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
if _, err := svc.Add("example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, st := readTables(t, odk); strings.Count(st, "\n") != 1 {
|
||||
t.Errorf("SigningTable has more than the one domain:\n%s", st)
|
||||
}
|
||||
if _, err := svc.ServerMail(); err == nil {
|
||||
t.Error("ServerMail without a hostname returned no error")
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,64 @@ type Service struct {
|
||||
odk *OpenDKIM
|
||||
apps Applications
|
||||
selector string
|
||||
// serverName is the server's own hostname, which the panel's notifications
|
||||
// are sent from and signed as (SetServerName). Empty: nothing is signed
|
||||
// for it.
|
||||
serverName string
|
||||
}
|
||||
|
||||
// SetServerName gives the service the server's own hostname. The panel sends
|
||||
// its notifications from postmaster@ that name, and for a receiver to believe
|
||||
// them the name is signed like a sending domain: it gets a DKIM key and a line
|
||||
// in OpenDKIM's tables, without being a row of the registry - no application
|
||||
// can send as it. The name comes from SELFPOST_HOSTNAME, which the entrypoint
|
||||
// has validated; it is not user input.
|
||||
func (s *Service) SetServerName(hostname string) {
|
||||
s.serverName = hostname
|
||||
}
|
||||
|
||||
// ServerMail is how the server's own name is signed.
|
||||
type ServerMail struct {
|
||||
Name string
|
||||
Selector string
|
||||
Record DKIMRecord
|
||||
}
|
||||
|
||||
// ServerMail returns the name notifications are sent as, its DKIM selector and
|
||||
// the record to publish for it, creating the key the first time. When the
|
||||
// hostname is itself a sending domain, it is that domain's key and selector:
|
||||
// one name has one key.
|
||||
func (s *Service) ServerMail() (ServerMail, error) {
|
||||
if s.serverName == "" {
|
||||
return ServerMail{}, fmt.Errorf("server mail: no hostname")
|
||||
}
|
||||
selector, err := s.serverSelector()
|
||||
if err != nil {
|
||||
return ServerMail{}, err
|
||||
}
|
||||
if _, err := s.odk.EnsureKey(s.serverName, selector); err != nil {
|
||||
return ServerMail{}, err
|
||||
}
|
||||
rec, err := s.odk.Record(s.serverName, selector)
|
||||
if err != nil {
|
||||
return ServerMail{}, err
|
||||
}
|
||||
return ServerMail{Name: s.serverName, Selector: selector, Record: rec}, nil
|
||||
}
|
||||
|
||||
// serverSelector is the selector the server's own name is signed with: that of
|
||||
// the sending domain of the same name if there is one, the default otherwise.
|
||||
func (s *Service) serverSelector() (string, error) {
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, d := range domains {
|
||||
if d.Name == s.serverName {
|
||||
return d.DKIMSelector, nil
|
||||
}
|
||||
}
|
||||
return s.selector, nil
|
||||
}
|
||||
|
||||
// NewService builds the domain service. selectorDefault is the DKIM selector
|
||||
@@ -125,6 +183,12 @@ func (s *Service) Delete(id int64) error {
|
||||
if err := s.apps.Resync(); err != nil {
|
||||
return fmt.Errorf("rebuild sender map after deleting %s: %w", d.Name, err)
|
||||
}
|
||||
// A sending domain named like the server shared its key with the server's
|
||||
// own mail, which goes on being signed with it (resync above kept the name
|
||||
// in the tables).
|
||||
if d.Name == s.serverName {
|
||||
return nil
|
||||
}
|
||||
if err := s.odk.RemoveKey(d.Name); err != nil {
|
||||
// The domain is gone from the registry and tables; a leftover key
|
||||
// directory is harmless. Surface it so it is not silently ignored.
|
||||
@@ -172,9 +236,21 @@ func (s *Service) resync() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signing := make([]SigningDomain, 0, len(domains))
|
||||
signing := make([]SigningDomain, 0, len(domains)+1)
|
||||
registered := false
|
||||
for _, d := range domains {
|
||||
signing = append(signing, SigningDomain{Name: d.Name, Selector: d.DKIMSelector})
|
||||
if d.Name == s.serverName {
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
// The server's own name, for the panel's notifications - unless it is a
|
||||
// sending domain already and so in the tables above.
|
||||
if s.serverName != "" && !registered {
|
||||
if _, err := s.odk.EnsureKey(s.serverName, s.selector); err != nil {
|
||||
return fmt.Errorf("dkim key for %s: %w", s.serverName, err)
|
||||
}
|
||||
signing = append(signing, SigningDomain{Name: s.serverName, Selector: s.selector})
|
||||
}
|
||||
return s.odk.Rebuild(signing)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sendmailPath = "/usr/sbin/sendmail"
|
||||
sendmailTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// Mailer hands notifications to Postfix by local submission, as
|
||||
// postmaster@<hostname>. They are the panel's own mail: they pass no SASL
|
||||
// login, are not rows of the send log and count against no rate limit.
|
||||
// Postfix signs them through OpenDKIM with the key of the server's own name
|
||||
// (non_smtpd_milters in build/postfix-config.sh, domain.Service.SetServerName).
|
||||
type Mailer struct {
|
||||
hostname string
|
||||
// run submits a finished message; a field so tests can read it instead.
|
||||
run func(from, to string, msg []byte) error
|
||||
}
|
||||
|
||||
// NewMailer returns a mailer for the server named hostname.
|
||||
func NewMailer(hostname string) *Mailer {
|
||||
return &Mailer{hostname: hostname, run: sendmail}
|
||||
}
|
||||
|
||||
// From is the address notifications are sent from.
|
||||
func From(hostname string) string { return "postmaster@" + hostname }
|
||||
|
||||
// Send submits one message to one recipient.
|
||||
func (m *Mailer) Send(to, subject, body string) error {
|
||||
// The address is a panel user's, validated when it was saved. It goes into
|
||||
// a header and onto a command line all the same, so what cannot be part of
|
||||
// an address is refused here as well (security.md).
|
||||
if to == "" || strings.ContainsAny(to, " \t\r\n<>,;\"") || strings.HasPrefix(to, "-") || !strings.Contains(to, "@") {
|
||||
return fmt.Errorf("not an address a notification can go to: %q", to)
|
||||
}
|
||||
from := From(m.hostname)
|
||||
id, err := messageID(m.hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, "From: SelfPost <%s>\r\n", from)
|
||||
fmt.Fprintf(&b, "To: <%s>\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", oneLine(subject)))
|
||||
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&b, "Message-ID: %s\r\n", id)
|
||||
// Tells a vacation responder and a ticket system that nobody wrote this.
|
||||
b.WriteString("Auto-Submitted: auto-generated\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n"))
|
||||
return m.run(from, to, b.Bytes())
|
||||
}
|
||||
|
||||
// oneLine keeps a subject on its line whatever went into it.
|
||||
func oneLine(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
func messageID(hostname string) (string, error) {
|
||||
var raw [12]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", fmt.Errorf("message id: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("<%s.%d@%s>", hex.EncodeToString(raw[:]), time.Now().Unix(), hostname), nil
|
||||
}
|
||||
|
||||
// sendmail submits msg through Postfix's sendmail command. The argument list is
|
||||
// fixed apart from the two addresses, which follow "--" so that neither can be
|
||||
// read as an option; nothing goes through a shell (security.md). -i keeps a
|
||||
// line holding a single dot from ending the message.
|
||||
func sendmail(from, to string, msg []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sendmailTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, sendmailPath, "-i", "-f", from, "--", to)
|
||||
cmd.Stdin = bytes.NewReader(msg)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("sendmail timed out after %s", sendmailTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendmail: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Package notify writes to the panel's users when something needs them
|
||||
// (architecture.md § Notifications): a sending domain's DNS record stops
|
||||
// matching, a level-2 rate limit refuses mail, the TLS certificate is about to
|
||||
// expire, a message has waited in the queue for more than three days.
|
||||
//
|
||||
// Each subject has a row of state in the database, so a thing is said once and
|
||||
// a restart says nothing again. The rules of when a message goes out are the
|
||||
// owner's (docs/plans/panel-notifications.md § When a message is sent) and
|
||||
// live in this file; what the messages say is in text.go, how they leave in
|
||||
// mailer.go.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// CheckInterval is how often DNS, the certificate and the queue are looked
|
||||
// at. A var-free constant: the rules below count in checks.
|
||||
CheckInterval = time.Hour
|
||||
// LimitInterval is how often refused mail is looked for. A refusal is
|
||||
// reported within the minute rather than on the hour: it is mail not being
|
||||
// sent now. The look is one indexed query.
|
||||
LimitInterval = time.Minute
|
||||
|
||||
// dnsChecks is how many checks in a row must agree before a DNS mismatch is
|
||||
// reported, and before it is reported gone. One check is a resolver's bad
|
||||
// minute; two, an hour apart, is a record that is wrong.
|
||||
dnsChecks = 2
|
||||
|
||||
// QueueAge is how long a message waits before the queue is reported. Postfix
|
||||
// keeps a message for five days by default, so two are left to act in.
|
||||
QueueAge = 3 * 24 * time.Hour
|
||||
|
||||
// The certificate is reported this many days before it expires, and again at
|
||||
// the second number if it is still the same certificate.
|
||||
certFirstDays = 14
|
||||
certLastDays = 3
|
||||
|
||||
// limitRepeat is the shortest time between two messages about the same
|
||||
// level-2 limit.
|
||||
limitRepeat = 24 * time.Hour
|
||||
|
||||
phasePending = "pending" // seen wrong, not yet said
|
||||
phaseOpen = "open" // said
|
||||
)
|
||||
|
||||
// Store is what the notifier needs of the database. *store.Store satisfies it.
|
||||
type Store interface {
|
||||
NotificationsEnabled() (bool, error)
|
||||
NotifyRecipients(domainName string) ([]string, error)
|
||||
GetNotifyState(key string) (store.NotifyState, bool, error)
|
||||
SetNotifyState(st store.NotifyState) error
|
||||
DeleteNotifyState(key string) error
|
||||
ListNotifyStateKeys(prefix string) ([]string, error)
|
||||
LimitRefusalsSince(since time.Time) ([]store.LimitRefusal, error)
|
||||
ListDomains() ([]store.Domain, error)
|
||||
}
|
||||
|
||||
// Sender hands one message to the mail system. *Mailer does; tests collect.
|
||||
type Sender interface {
|
||||
Send(to, subject, body string) error
|
||||
}
|
||||
|
||||
// Checks are the readings the notifier acts on, as functions so that tests
|
||||
// need neither DNS nor a certificate nor a queue.
|
||||
type Checks struct {
|
||||
// Domain is a fresh check of a sending domain's published records.
|
||||
Domain func(d store.Domain) (dnscheck.Domain, error)
|
||||
// Certificate is the state of the TLS certificate Postfix serves.
|
||||
Certificate func() health.Certificate
|
||||
// Queue lists the messages Postfix holds; an error says nothing about any.
|
||||
Queue func() ([]postfix.QueuedMessage, error)
|
||||
}
|
||||
|
||||
// Notifier applies the rules. Create one with New and call Run.
|
||||
type Notifier struct {
|
||||
st Store
|
||||
checks Checks
|
||||
sender Sender
|
||||
hostname string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds a notifier for the server named hostname.
|
||||
func New(st Store, checks Checks, sender Sender, hostname string) *Notifier {
|
||||
return &Notifier{st: st, checks: checks, sender: sender, hostname: hostname, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// Run checks on both cadences until ctx is cancelled. The first hourly check
|
||||
// runs a few minutes after the start, not at it: a container that has just
|
||||
// come up is still starting Postfix and may have no network yet.
|
||||
func (n *Notifier) Run(ctx context.Context) error {
|
||||
first := time.NewTimer(3 * time.Minute)
|
||||
defer first.Stop()
|
||||
hourly := time.NewTicker(CheckInterval)
|
||||
defer hourly.Stop()
|
||||
limits := time.NewTicker(LimitInterval)
|
||||
defer limits.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-first.C:
|
||||
n.Check()
|
||||
case <-hourly.C:
|
||||
n.Check()
|
||||
case <-limits.C:
|
||||
n.CheckLimits()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enabled reports whether anything is to be done at all. With the instance
|
||||
// switch off nothing is checked and no state moves, so that what is wrong
|
||||
// when it is turned on again is said then.
|
||||
func (n *Notifier) enabled() bool {
|
||||
on, err := n.st.NotificationsEnabled()
|
||||
if err != nil {
|
||||
log.Printf("notify: read the switch: %v", err)
|
||||
return false
|
||||
}
|
||||
return on
|
||||
}
|
||||
|
||||
// Check is the hourly pass: DNS of every sending domain, the certificate, the
|
||||
// queue.
|
||||
func (n *Notifier) Check() {
|
||||
if !n.enabled() {
|
||||
return
|
||||
}
|
||||
n.checkDNS()
|
||||
n.checkCertificate()
|
||||
n.checkQueue()
|
||||
}
|
||||
|
||||
// send writes one message to every recipient of a subject, each in a message
|
||||
// of their own - users are not shown each other's addresses. It reports
|
||||
// whether the subject counts as said: nobody to tell is said, a message that
|
||||
// could not be handed over to anyone is not, and is tried at the next check.
|
||||
func (n *Notifier) send(domainName string, m message) bool {
|
||||
to, err := n.st.NotifyRecipients(domainName)
|
||||
if err != nil {
|
||||
log.Printf("notify: recipients: %v", err)
|
||||
return false
|
||||
}
|
||||
if len(to) == 0 {
|
||||
return true
|
||||
}
|
||||
sent := 0
|
||||
for _, addr := range to {
|
||||
if err := n.sender.Send(addr, m.subject, m.body); err != nil {
|
||||
log.Printf("notify: send %q to %s: %v", m.subject, addr, err)
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
if sent > 0 {
|
||||
log.Printf("notify: %q sent to %d of %d", m.subject, sent, len(to))
|
||||
}
|
||||
return sent > 0
|
||||
}
|
||||
|
||||
func (n *Notifier) save(st store.NotifyState) {
|
||||
if err := n.st.SetNotifyState(st); err != nil {
|
||||
log.Printf("notify: save state %s: %v", st.Key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) forget(key string) {
|
||||
if err := n.st.DeleteNotifyState(key); err != nil {
|
||||
log.Printf("notify: forget state %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DNS
|
||||
|
||||
// dnsFault is what is wrong with a domain's records: the checks that failed,
|
||||
// in the order the page shows them.
|
||||
type dnsFault struct {
|
||||
Record string // "DKIM", "SPF", "DMARC"
|
||||
Detail string
|
||||
}
|
||||
|
||||
// dnsReading sorts a check into wrong, right, or nothing learned. A record
|
||||
// that fails is wrong whatever the others say. A lookup that did not answer is
|
||||
// nothing learned - unless another record is plainly wrong. A warning (no DMARC
|
||||
// record, an SPF include this check does not follow) is advice the page gives
|
||||
// and not a thing to write about.
|
||||
func dnsReading(d dnscheck.Domain) (faults []dnsFault, known bool) {
|
||||
known = true
|
||||
for _, r := range []struct {
|
||||
name string
|
||||
res dnscheck.Result
|
||||
}{{"DKIM", d.DKIM}, {"SPF", d.SPF}, {"DMARC", d.DMARC}} {
|
||||
switch r.res.Status {
|
||||
case health.StatusError:
|
||||
faults = append(faults, dnsFault{Record: r.name, Detail: r.res.Detail})
|
||||
case health.StatusUnknown:
|
||||
known = false
|
||||
}
|
||||
}
|
||||
if len(faults) > 0 {
|
||||
known = true
|
||||
}
|
||||
return faults, known
|
||||
}
|
||||
|
||||
func (n *Notifier) checkDNS() {
|
||||
domains, err := n.st.ListDomains()
|
||||
if err != nil {
|
||||
log.Printf("notify: list domains: %v", err)
|
||||
return
|
||||
}
|
||||
names := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
names[d.Name] = true
|
||||
res, err := n.checks.Domain(d)
|
||||
if err != nil {
|
||||
log.Printf("notify: check %s: %v", d.Name, err)
|
||||
continue
|
||||
}
|
||||
n.dnsStep(d.Name, res)
|
||||
}
|
||||
// A deleted domain has no records to be right or wrong.
|
||||
keys, err := n.st.ListNotifyStateKeys("dns:")
|
||||
if err != nil {
|
||||
log.Printf("notify: list dns state: %v", err)
|
||||
return
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !names[strings.TrimPrefix(key, "dns:")] {
|
||||
n.forget(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dnsStep moves one domain's state by one check.
|
||||
func (n *Notifier) dnsStep(name string, res dnscheck.Domain) {
|
||||
faults, known := dnsReading(res)
|
||||
if !known {
|
||||
return
|
||||
}
|
||||
bad := len(faults) > 0
|
||||
key := "dns:" + name
|
||||
st, have, err := n.st.GetNotifyState(key)
|
||||
if err != nil {
|
||||
log.Printf("notify: %v", err)
|
||||
return
|
||||
}
|
||||
now := n.now()
|
||||
|
||||
switch {
|
||||
case !have && !bad:
|
||||
// Nothing wrong and nothing said.
|
||||
case !have && bad:
|
||||
n.save(store.NotifyState{Key: key, Phase: phasePending, Count: 1, Since: now})
|
||||
case st.Phase == phasePending && !bad:
|
||||
n.forget(key) // it was the resolver's bad minute
|
||||
case st.Phase == phasePending && bad:
|
||||
st.Count++
|
||||
if st.Count >= dnsChecks {
|
||||
if !n.send(name, dnsProblem(n.hostname, name, faults, st.Since)) {
|
||||
n.save(st)
|
||||
return
|
||||
}
|
||||
st.Phase, st.Count, st.SentAt = phaseOpen, 0, now
|
||||
}
|
||||
n.save(st)
|
||||
case st.Phase == phaseOpen && bad:
|
||||
if st.Count != 0 {
|
||||
st.Count = 0 // it went right for a check and wrong again: still the same trouble
|
||||
n.save(st)
|
||||
}
|
||||
case st.Phase == phaseOpen && !bad:
|
||||
st.Count++
|
||||
if st.Count >= dnsChecks {
|
||||
if n.send(name, dnsCleared(n.hostname, name, st.Since)) {
|
||||
n.forget(key)
|
||||
return
|
||||
}
|
||||
}
|
||||
n.save(st)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- certificate
|
||||
|
||||
func (n *Notifier) checkCertificate() {
|
||||
c := n.checks.Certificate()
|
||||
if c.NotAfter.IsZero() {
|
||||
return // no certificate configured, or one that cannot be read: Health says so
|
||||
}
|
||||
const key = "cert"
|
||||
mark := c.NotAfter.UTC().Format(time.RFC3339)
|
||||
st, have, err := n.st.GetNotifyState(key)
|
||||
if err != nil {
|
||||
log.Printf("notify: %v", err)
|
||||
return
|
||||
}
|
||||
if have && st.Mark != mark {
|
||||
// A different certificate: what was said about the old one is over.
|
||||
n.forget(key)
|
||||
have, st = false, store.NotifyState{}
|
||||
}
|
||||
|
||||
left := c.NotAfter.Sub(n.now())
|
||||
level := ""
|
||||
switch {
|
||||
case left <= certLastDays*24*time.Hour:
|
||||
level = "last"
|
||||
case left <= certFirstDays*24*time.Hour:
|
||||
level = "first"
|
||||
}
|
||||
if level == "" || (have && (st.Phase == level || st.Phase == "last")) {
|
||||
return
|
||||
}
|
||||
if !n.send("", certExpiring(n.hostname, c.NotAfter, left)) {
|
||||
return
|
||||
}
|
||||
n.save(store.NotifyState{Key: key, Phase: level, Mark: mark, Since: n.now(), SentAt: n.now()})
|
||||
}
|
||||
|
||||
// ---- queue
|
||||
|
||||
func (n *Notifier) checkQueue() {
|
||||
msgs, err := n.checks.Queue()
|
||||
if err != nil {
|
||||
log.Printf("notify: read the queue: %v", err)
|
||||
return // no listing is no information
|
||||
}
|
||||
now := n.now()
|
||||
var old []postfix.QueuedMessage
|
||||
for _, m := range msgs {
|
||||
if now.Sub(m.Arrival) > QueueAge {
|
||||
old = append(old, m)
|
||||
}
|
||||
}
|
||||
sort.Slice(old, func(i, j int) bool { return old[i].Arrival.Before(old[j].Arrival) })
|
||||
|
||||
const key = "queue"
|
||||
st, have, err := n.st.GetNotifyState(key)
|
||||
if err != nil {
|
||||
log.Printf("notify: %v", err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case len(old) > 0 && !have:
|
||||
if n.send("", queueStuck(n.hostname, old, now)) {
|
||||
n.save(store.NotifyState{Key: key, Phase: phaseOpen, Since: now, SentAt: now})
|
||||
}
|
||||
case len(old) == 0 && have:
|
||||
if n.send("", queueCleared(n.hostname, st.Since)) {
|
||||
n.forget(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- level-2 limits
|
||||
|
||||
// CheckLimits looks for mail refused under a level-2 rate limit and reports
|
||||
// each limit that refused, at most once in limitRepeat.
|
||||
func (n *Notifier) CheckLimits() {
|
||||
if !n.enabled() {
|
||||
return
|
||||
}
|
||||
now := n.now()
|
||||
refusals, err := n.st.LimitRefusalsSince(now.Add(-limitRepeat))
|
||||
if err != nil {
|
||||
log.Printf("notify: %v", err)
|
||||
return
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if r.Domain == "" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("limit:%s:%s", r.Domain, r.AppLogin)
|
||||
st, have, err := n.st.GetNotifyState(key)
|
||||
if err != nil {
|
||||
log.Printf("notify: %v", err)
|
||||
continue
|
||||
}
|
||||
// Told within the day: the refusals counted now are the ones it was
|
||||
// told about, or more of the same. Told longer ago: everything in the
|
||||
// window is new since then.
|
||||
if have && now.Sub(st.SentAt) < limitRepeat {
|
||||
continue
|
||||
}
|
||||
if n.send(r.Domain, limitRefused(n.hostname, r)) {
|
||||
n.save(store.NotifyState{Key: key, Phase: phaseOpen, Since: r.First, SentAt: now})
|
||||
}
|
||||
}
|
||||
|
||||
// A limit nobody has hit for two days is not a subject any more.
|
||||
keys, err := n.st.ListNotifyStateKeys("limit:")
|
||||
if err != nil {
|
||||
log.Printf("notify: list limit state: %v", err)
|
||||
return
|
||||
}
|
||||
for _, key := range keys {
|
||||
st, have, err := n.st.GetNotifyState(key)
|
||||
if err == nil && have && now.Sub(st.SentAt) > 2*limitRepeat {
|
||||
n.forget(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// outbox collects what would have been handed to Postfix.
|
||||
type outbox struct {
|
||||
sent []string // "to|subject"
|
||||
body []string
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (o *outbox) Send(to, subject, body string) error {
|
||||
if o.fail {
|
||||
return errors.New("sendmail: queue file write error")
|
||||
}
|
||||
o.sent = append(o.sent, to+"|"+subject)
|
||||
o.body = append(o.body, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *outbox) take() []string {
|
||||
s := o.sent
|
||||
o.sent, o.body = nil, nil
|
||||
return s
|
||||
}
|
||||
|
||||
// bench is a notifier over a real database with a clock and readings the test
|
||||
// sets: one global user, one user of example.com, one user of another domain.
|
||||
type bench struct {
|
||||
n *Notifier
|
||||
st *store.Store
|
||||
out *outbox
|
||||
now time.Time
|
||||
dns map[string]dnscheck.Domain
|
||||
cert health.Certificate
|
||||
q []postfix.QueuedMessage
|
||||
qErr error
|
||||
}
|
||||
|
||||
func newBench(t *testing.T) *bench {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
b := &bench{st: st, out: &outbox{}, now: time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC), dns: map[string]dnscheck.Domain{}}
|
||||
mine, err := st.AddDomain("example.com", "sp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := st.AddDomain("other.example", "sp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range []struct {
|
||||
name string
|
||||
role store.Role
|
||||
reach store.Reach
|
||||
}{
|
||||
{"root", store.RoleGlobal, store.Reach{}},
|
||||
{"mine", store.RoleDomain, store.Reach{DomainIDs: []int64{mine.ID}}},
|
||||
{"theirs", store.RoleDomain, store.Reach{DomainIDs: []int64{other.ID}}},
|
||||
} {
|
||||
id, err := st.CreateUser(u.name, "hash", u.role, u.reach)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpdateUser(id, u.name, "hash", u.name+"@example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
b.n = New(st, Checks{
|
||||
Domain: func(d store.Domain) (dnscheck.Domain, error) {
|
||||
res, ok := b.dns[d.Name]
|
||||
if !ok {
|
||||
return good(), nil
|
||||
}
|
||||
return res, nil
|
||||
},
|
||||
Certificate: func() health.Certificate { return b.cert },
|
||||
Queue: func() ([]postfix.QueuedMessage, error) { return b.q, b.qErr },
|
||||
}, b.out, "mail.example.org")
|
||||
b.n.now = func() time.Time { return b.now }
|
||||
return b
|
||||
}
|
||||
|
||||
// hour runs the hourly check and moves the clock on by an hour.
|
||||
func (b *bench) hour() []string {
|
||||
b.n.Check()
|
||||
b.now = b.now.Add(time.Hour)
|
||||
return b.out.take()
|
||||
}
|
||||
|
||||
func good() dnscheck.Domain {
|
||||
ok := dnscheck.Result{Status: health.StatusOK}
|
||||
return dnscheck.Domain{DKIM: ok, SPF: ok, DMARC: ok}
|
||||
}
|
||||
|
||||
func badDKIM() dnscheck.Domain {
|
||||
d := good()
|
||||
d.DKIM = dnscheck.Result{Status: health.StatusError, Detail: "A TXT record exists but its public key is not the one this server signs with."}
|
||||
return d
|
||||
}
|
||||
|
||||
func want(t *testing.T, what string, got []string, wanted ...string) {
|
||||
t.Helper()
|
||||
if strings.Join(got, "\n") != strings.Join(wanted, "\n") {
|
||||
t.Errorf("%s: sent\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(wanted, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// A DNS mismatch is said once, after two checks in a row found it, to the
|
||||
// global users and the domain's own; it is said to be over once, after two
|
||||
// checks in a row found the records right.
|
||||
func TestDNSMismatchIsSaidOnceAndClearedOnce(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.dns["example.com"] = badDKIM()
|
||||
|
||||
want(t, "first bad check", b.hour())
|
||||
problem := "[SelfPost] example.com: DKIM no longer matches"
|
||||
want(t, "second bad check", b.hour(), "mine@example.org|"+problem, "root@example.org|"+problem)
|
||||
want(t, "third bad check", b.hour())
|
||||
|
||||
b.dns["example.com"] = good()
|
||||
want(t, "first good check", b.hour())
|
||||
// One good check does not end it: wrong again is the same trouble, not a new one.
|
||||
b.dns["example.com"] = badDKIM()
|
||||
want(t, "wrong again", b.hour())
|
||||
b.dns["example.com"] = good()
|
||||
want(t, "good once more", b.hour())
|
||||
cleared := "[SelfPost] example.com: DNS records match again"
|
||||
want(t, "second good check in a row", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
|
||||
want(t, "after it cleared", b.hour())
|
||||
|
||||
if _, have, _ := b.st.GetNotifyState("dns:example.com"); have {
|
||||
t.Error("state left behind after the trouble was over")
|
||||
}
|
||||
}
|
||||
|
||||
// One bad check followed by a good one was the resolver, and nothing is said.
|
||||
// A lookup that did not answer says nothing either way; a warning is advice
|
||||
// the page gives, not a thing to write about.
|
||||
func TestDNSNoiseIsNotSaid(t *testing.T) {
|
||||
b := newBench(t)
|
||||
|
||||
b.dns["example.com"] = badDKIM()
|
||||
want(t, "one bad check", b.hour())
|
||||
b.dns["example.com"] = good()
|
||||
want(t, "then good", b.hour())
|
||||
b.dns["example.com"] = badDKIM()
|
||||
want(t, "bad again, counted from one", b.hour())
|
||||
|
||||
unknown := good()
|
||||
unknown.SPF = dnscheck.Result{Status: health.StatusUnknown, Detail: "Could not look up the SPF record."}
|
||||
b.dns["example.com"] = unknown
|
||||
want(t, "resolver did not answer", b.hour())
|
||||
if st, have, _ := b.st.GetNotifyState("dns:example.com"); !have || st.Count != 1 {
|
||||
t.Errorf("an unanswered lookup moved the state: %+v, %v", st, have)
|
||||
}
|
||||
|
||||
warn := good()
|
||||
warn.DMARC = dnscheck.Result{Status: health.StatusWarn, Detail: "No DMARC record."}
|
||||
b.dns["other.example"] = warn
|
||||
want(t, "a warning", b.hour())
|
||||
want(t, "a warning, twice", b.hour())
|
||||
}
|
||||
|
||||
// The instance switch stops everything, state included; a domain that is
|
||||
// deleted takes its state with it; a message that could not be handed over is
|
||||
// tried again at the next check.
|
||||
func TestDNSSwitchDeletionAndRetry(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.dns["example.com"] = badDKIM()
|
||||
|
||||
if err := b.st.SetNotificationsEnabled(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want(t, "switched off", b.hour())
|
||||
want(t, "switched off", b.hour())
|
||||
if _, have, _ := b.st.GetNotifyState("dns:example.com"); have {
|
||||
t.Fatal("state moved while notifications were off")
|
||||
}
|
||||
if err := b.st.SetNotificationsEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want(t, "on again, first check", b.hour())
|
||||
b.out.fail = true
|
||||
want(t, "second check, mail system down", b.hour())
|
||||
b.out.fail = false
|
||||
if got := b.hour(); len(got) != 2 {
|
||||
t.Errorf("after the mail system came back: %v, want the two messages", got)
|
||||
}
|
||||
|
||||
domains, _ := b.st.ListDomains()
|
||||
for _, d := range domains {
|
||||
if d.Name == "example.com" {
|
||||
if err := b.st.DeleteDomain(d.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
want(t, "domain deleted", b.hour())
|
||||
if _, have, _ := b.st.GetNotifyState("dns:example.com"); have {
|
||||
t.Error("a deleted domain kept its state")
|
||||
}
|
||||
}
|
||||
|
||||
// The certificate is said at 14 days and again at 3 if it is still the same
|
||||
// one, to global users only; a new certificate starts over.
|
||||
func TestCertificateIsSaidTwice(t *testing.T) {
|
||||
b := newBench(t)
|
||||
expiry := b.now.Add(20 * 24 * time.Hour)
|
||||
b.cert = health.Certificate{NotAfter: expiry}
|
||||
want(t, "20 days left", b.hour())
|
||||
|
||||
b.now = expiry.Add(-14 * 24 * time.Hour)
|
||||
want(t, "14 days left", b.hour(), "root@example.org|[SelfPost] TLS certificate of mail.example.org expires in 14 days")
|
||||
want(t, "an hour later", b.hour())
|
||||
b.now = expiry.Add(-8 * 24 * time.Hour)
|
||||
want(t, "8 days left", b.hour())
|
||||
|
||||
b.now = expiry.Add(-3 * 24 * time.Hour)
|
||||
want(t, "3 days left", b.hour(), "root@example.org|[SelfPost] TLS certificate of mail.example.org expires in 3 days")
|
||||
b.now = expiry.Add(time.Hour)
|
||||
want(t, "expired, already said", b.hour())
|
||||
|
||||
// Renewed: nothing to say, and the next expiry is a new subject.
|
||||
renewed := b.now.Add(90 * 24 * time.Hour)
|
||||
b.cert = health.Certificate{NotAfter: renewed}
|
||||
want(t, "renewed", b.hour())
|
||||
b.now = renewed.Add(-2 * 24 * time.Hour)
|
||||
want(t, "the new one, first seen with 2 days left", b.hour(), "root@example.org|[SelfPost] TLS certificate of mail.example.org expires in 2 days")
|
||||
|
||||
// No certificate configured: Health says so, mail does not.
|
||||
b.cert = health.Certificate{Status: health.StatusUnknown}
|
||||
want(t, "no certificate", b.hour())
|
||||
}
|
||||
|
||||
// A message older than three days is said once, and its going once; a queue
|
||||
// that cannot be listed says nothing.
|
||||
func TestQueueIsSaidOnceAndClearedOnce(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.q = []postfix.QueuedMessage{
|
||||
{ID: "FRESH001", Queue: "deferred", Sender: "app@example.com", Arrival: b.now.Add(-2 * 24 * time.Hour)},
|
||||
}
|
||||
want(t, "two days old", b.hour())
|
||||
|
||||
b.q = append(b.q, postfix.QueuedMessage{ID: "OLD00001", Queue: "deferred", Sender: "app@example.com", Arrival: b.now.Add(-4 * 24 * time.Hour)})
|
||||
b.n.Check()
|
||||
if len(b.out.sent) != 1 || b.out.sent[0] != "root@example.org|[SelfPost] A message has been in the queue of mail.example.org for more than 3 days" {
|
||||
t.Fatalf("four days old: %v", b.out.sent)
|
||||
}
|
||||
if body := b.out.body[0]; !strings.Contains(body, "OLD00001") || strings.Contains(body, "FRESH001") {
|
||||
t.Errorf("the message names the wrong mail:\n%s", body)
|
||||
}
|
||||
b.out.take()
|
||||
want(t, "still there", b.hour())
|
||||
|
||||
b.qErr = errors.New("postqueue: Permission denied")
|
||||
b.q = nil
|
||||
want(t, "queue cannot be listed", b.hour())
|
||||
b.qErr = nil
|
||||
want(t, "gone", b.hour(), "root@example.org|[SelfPost] The queue of mail.example.org holds nothing older than 3 days")
|
||||
want(t, "after", b.hour())
|
||||
}
|
||||
|
||||
// A limit that refuses mail is said to the domain's users, at most once a day.
|
||||
func TestLimitRefusalIsSaidOnceADay(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.now = time.Now().UTC() // refusals are dated by the store's clock
|
||||
refuse := func() {
|
||||
t.Helper()
|
||||
if err := b.st.InsertRejected(store.SendLogEntry{Domain: "example.com", AppLogin: "app1", From: "a@example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
b.n.CheckLimits()
|
||||
want(t, "nothing refused", b.out.take())
|
||||
|
||||
refuse()
|
||||
b.n.CheckLimits()
|
||||
subject := "[SelfPost] example.com: a rate limit is refusing mail"
|
||||
want(t, "first refusal", b.out.take(), "mine@example.org|"+subject, "root@example.org|"+subject)
|
||||
|
||||
refuse()
|
||||
b.now = b.now.Add(2 * time.Hour)
|
||||
b.n.CheckLimits()
|
||||
want(t, "more of it two hours later", b.out.take())
|
||||
|
||||
// A day after it was said, mail is still being refused: said again. The
|
||||
// refusals are dated by the store's clock, so it is the telling that is
|
||||
// moved back, not the clock forward.
|
||||
st, have, err := b.st.GetNotifyState("limit:example.com:app1")
|
||||
if err != nil || !have {
|
||||
t.Fatalf("no state for the limit: %v, %v", have, err)
|
||||
}
|
||||
b.now = time.Now().UTC()
|
||||
st.SentAt = b.now.Add(-25 * time.Hour)
|
||||
if err := b.st.SetNotifyState(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.n.CheckLimits()
|
||||
want(t, "a day later, still refusing", b.out.take(), "mine@example.org|"+subject, "root@example.org|"+subject)
|
||||
if body := limitRefused("mail.example.org", store.LimitRefusal{Domain: "example.com", AppLogin: "app1", Count: 3, First: b.now}).body; !strings.Contains(body, "3 messages from the application app1 of example.com") {
|
||||
t.Errorf("the message does not say who was refused how much:\n%s", body)
|
||||
}
|
||||
|
||||
// Nobody has hit the limit for two days: it is not a subject any more.
|
||||
st.SentAt = b.now.Add(-49 * time.Hour)
|
||||
if err := b.st.SetNotifyState(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.st.LimitRefusalsSince(b.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.now = b.now.Add(25 * time.Hour) // past the window the refusals lie in
|
||||
b.n.CheckLimits()
|
||||
want(t, "long quiet", b.out.take())
|
||||
if _, have, _ := b.st.GetNotifyState("limit:example.com:app1"); have {
|
||||
t.Error("a limit nobody hits kept its state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailerComposesAndRefuses(t *testing.T) {
|
||||
var from, to string
|
||||
var msg []byte
|
||||
m := NewMailer("mail.example.org")
|
||||
m.run = func(f, t string, b []byte) error { from, to, msg = f, t, b; return nil }
|
||||
|
||||
if err := m.Send("root@example.org", "[SelfPost] example.com:\r\nBcc: x@evil.example", "line one\n.\nline three\n"); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
if from != "postmaster@mail.example.org" || to != "root@example.org" {
|
||||
t.Errorf("envelope %s -> %s", from, to)
|
||||
}
|
||||
text := string(msg)
|
||||
head, body, ok := strings.Cut(text, "\r\n\r\n")
|
||||
if !ok {
|
||||
t.Fatalf("no header/body break:\n%s", text)
|
||||
}
|
||||
for _, h := range []string{
|
||||
"From: SelfPost <postmaster@mail.example.org>",
|
||||
"To: <root@example.org>",
|
||||
"Subject: [SelfPost] example.com: Bcc: x@evil.example",
|
||||
"Auto-Submitted: auto-generated",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
} {
|
||||
if !strings.Contains(head, h+"\r\n") && !strings.HasSuffix(head, h) {
|
||||
t.Errorf("header %q missing from:\n%s", h, head)
|
||||
}
|
||||
}
|
||||
if strings.Count(head, "\r\n")+1 != 9 {
|
||||
t.Errorf("a subject broke out of its line; headers:\n%s", head)
|
||||
}
|
||||
if body != "line one\r\n.\r\nline three\r\n" {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"", "no-at-sign", "-oQ/tmp@example.org", "a@example.org\nBcc: b@example.org", "a b@example.org", "<a@example.org>"} {
|
||||
if err := m.Send(bad, "s", "b"); err == nil {
|
||||
t.Errorf("address %q was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// message is one notification: a subject line and a plain-text body.
|
||||
//
|
||||
// The texts name the page of the panel to open rather than link to it: the
|
||||
// panel does not know its own address - it sits behind the operator's reverse
|
||||
// proxy - and a link built from a guess would be a wrong link in a message
|
||||
// people are asked to trust.
|
||||
type message struct {
|
||||
subject string
|
||||
body string
|
||||
}
|
||||
|
||||
// maxListed is how many messages of the queue a notification names. The rest
|
||||
// are counted: the page shows them all.
|
||||
const maxListed = 10
|
||||
|
||||
func compose(hostname, subject string, paragraphs ...string) message {
|
||||
paragraphs = append(paragraphs,
|
||||
fmt.Sprintf("Sent by the SelfPost panel on %s. You receive this because notifications are on in your Account there; turn them off on the same page.", hostname))
|
||||
return message{
|
||||
subject: "[SelfPost] " + subject,
|
||||
body: strings.Join(paragraphs, "\n\n") + "\n",
|
||||
}
|
||||
}
|
||||
|
||||
func stamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") }
|
||||
|
||||
// plain makes text that came from outside fit for a message body: a queued
|
||||
// message's sender is whatever its sender wrote, and a DNS finding may quote a
|
||||
// name. What is not printable ASCII becomes "?", and it is cut to max bytes -
|
||||
// a notification is read by a person, and nothing in it is theirs to act on
|
||||
// but the panel's own words.
|
||||
func plain(s string, max int) string {
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
}
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if c < 0x20 || c > 0x7e {
|
||||
b[i] = '?'
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// days words a duration in whole days, the unit these messages count in.
|
||||
func days(d time.Duration) string {
|
||||
n := int(d.Hours() / 24)
|
||||
if n == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
return fmt.Sprintf("%d days", n)
|
||||
}
|
||||
|
||||
func dnsProblem(hostname, domain string, faults []dnsFault, since time.Time) message {
|
||||
names := make([]string, len(faults))
|
||||
lines := make([]string, len(faults))
|
||||
for i, f := range faults {
|
||||
names[i] = f.Record
|
||||
lines[i] = f.Record + ": " + plain(f.Detail, 600)
|
||||
}
|
||||
return compose(hostname,
|
||||
fmt.Sprintf("%s: %s no longer matches", domain, strings.Join(names, ", ")),
|
||||
fmt.Sprintf("The DNS records of %s do not match what this server sends with. Two checks an hour apart found the same, the first at %s.", domain, stamp(since)),
|
||||
strings.Join(lines, "\n\n"),
|
||||
fmt.Sprintf("Until the records are right, receivers may refuse mail from %s or file it as spam. The records to publish are in the panel under Outbound > Domains > %s.", domain, domain),
|
||||
"One more message follows when the records match again.",
|
||||
)
|
||||
}
|
||||
|
||||
func dnsCleared(hostname, domain string, since time.Time) message {
|
||||
return compose(hostname,
|
||||
fmt.Sprintf("%s: DNS records match again", domain),
|
||||
fmt.Sprintf("The DNS records of %s match what this server sends with again; two checks an hour apart agree. They had not since %s.", domain, stamp(since)),
|
||||
)
|
||||
}
|
||||
|
||||
func certExpiring(hostname string, notAfter time.Time, left time.Duration) message {
|
||||
subject := fmt.Sprintf("TLS certificate of %s expires in %s", hostname, days(left))
|
||||
first := fmt.Sprintf("The TLS certificate this server presents on its submission ports expires on %s, in %s.", stamp(notAfter), days(left))
|
||||
if left <= 0 {
|
||||
subject = fmt.Sprintf("TLS certificate of %s has expired", hostname)
|
||||
first = fmt.Sprintf("The TLS certificate this server presents on its submission ports expired on %s.", stamp(notAfter))
|
||||
}
|
||||
return compose(hostname, subject,
|
||||
first,
|
||||
"Once it has expired, applications that verify the certificate can no longer submit mail. Renewal normally happens on the host, at the reverse proxy the certificate comes from; check that it still runs. The panel shows the certificate under Server > Health.",
|
||||
)
|
||||
}
|
||||
|
||||
func queueStuck(hostname string, old []postfix.QueuedMessage, now time.Time) message {
|
||||
var b strings.Builder
|
||||
for i, m := range old {
|
||||
if i == maxListed {
|
||||
fmt.Fprintf(&b, "... and %d more\n", len(old)-maxListed)
|
||||
break
|
||||
}
|
||||
sender := m.Sender
|
||||
if sender == "" {
|
||||
sender = "(bounce)"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s from %s waiting %s, since %s\n", plain(m.ID, 24), plain(sender, 120), days(now.Sub(m.Arrival)), stamp(m.Arrival))
|
||||
}
|
||||
what := "A message has"
|
||||
if len(old) > 1 {
|
||||
what = fmt.Sprintf("%d messages have", len(old))
|
||||
}
|
||||
return compose(hostname,
|
||||
fmt.Sprintf("%s been in the queue of %s for more than %s", what, hostname, days(QueueAge)),
|
||||
fmt.Sprintf("%s waited in the mail queue for more than %s. Postfix goes on retrying and returns a message to its sender when the queue lifetime runs out.", what, days(QueueAge)),
|
||||
strings.TrimRight(b.String(), "\n"),
|
||||
"The reason the receiving server gives is in the panel: Outbound > Queue lists what is held, and the message's page in Outbound > Log shows what Postfix logged about it.",
|
||||
"One more message follows when nothing that old is left in the queue.",
|
||||
)
|
||||
}
|
||||
|
||||
func queueCleared(hostname string, since time.Time) message {
|
||||
return compose(hostname,
|
||||
fmt.Sprintf("The queue of %s holds nothing older than %s", hostname, days(QueueAge)),
|
||||
fmt.Sprintf("No message in the mail queue is older than %s any more: what was waiting since the notification of %s was delivered, or returned to its sender.", days(QueueAge), stamp(since)),
|
||||
)
|
||||
}
|
||||
|
||||
func limitRefused(hostname string, r store.LimitRefusal) message {
|
||||
who := "the domain " + r.Domain
|
||||
if r.AppLogin != "" {
|
||||
who = fmt.Sprintf("the application %s of %s", r.AppLogin, r.Domain)
|
||||
}
|
||||
count := "1 message"
|
||||
if r.Count != 1 {
|
||||
count = fmt.Sprintf("%d messages", r.Count)
|
||||
}
|
||||
return compose(hostname,
|
||||
fmt.Sprintf("%s: a rate limit is refusing mail", r.Domain),
|
||||
fmt.Sprintf("In the last 24 hours this server refused %s from %s under a level-2 rate limit, the first at %s. Refused mail is not queued: the application has to send it again.", count, who, stamp(r.First)),
|
||||
fmt.Sprintf("If the volume is expected, raise the limit in the panel: Outbound > Domains > %s, on the domain or on the application. If it is not, the application is sending more than it should.", r.Domain),
|
||||
"This limit is reported at most once a day.",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QueuedMessage is one message Postfix is holding, as `postqueue -j` reports
|
||||
// it: where it is, and since when.
|
||||
type QueuedMessage struct {
|
||||
ID string
|
||||
Queue string // "deferred", "active", "hold", "incoming", "maildrop"
|
||||
Sender string
|
||||
Arrival time.Time
|
||||
}
|
||||
|
||||
// readQueueJSON runs `postqueue -j`. It is a var so tests can answer without a
|
||||
// running Postfix, the way readRetryConf is.
|
||||
var readQueueJSON = postqueueJSON
|
||||
|
||||
func postqueueJSON() ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), postqueueTimeout)
|
||||
defer cancel()
|
||||
// A fixed flag and no user input, never through a shell (security.md).
|
||||
cmd := exec.CommandContext(ctx, "postqueue", "-j")
|
||||
out := &limitWriter{remaining: maxQueueOutput}
|
||||
errOut := &limitWriter{remaining: 4096}
|
||||
cmd.Stdout = out
|
||||
cmd.Stderr = errOut
|
||||
err := cmd.Run()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("postqueue -j timed out after %s", postqueueTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postqueue -j: %w: %s", err, strings.TrimSpace(errOut.buf.String()))
|
||||
}
|
||||
// A listing cut at the size limit is still a list of whole lines up to the
|
||||
// cut; the last, broken one is dropped by the parser.
|
||||
return out.buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// QueueMessages returns the messages Postfix holds with the time each arrived,
|
||||
// for the notification about mail that has waited too long. An error means the
|
||||
// queue could not be listed and says nothing about any message.
|
||||
func QueueMessages() ([]QueuedMessage, error) {
|
||||
out, err := readQueueJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseQueueJSON(out), nil
|
||||
}
|
||||
|
||||
// parseQueueJSON reads `postqueue -j` output: one JSON object per line. A line
|
||||
// that is not one - the tail of a listing cut at its size limit - is skipped.
|
||||
func parseQueueJSON(out []byte) []QueuedMessage {
|
||||
var msgs []QueuedMessage
|
||||
sc := bufio.NewScanner(strings.NewReader(string(out)))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var e struct {
|
||||
QueueName string `json:"queue_name"`
|
||||
QueueID string `json:"queue_id"`
|
||||
ArrivalTime int64 `json:"arrival_time"`
|
||||
Sender string `json:"sender"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &e); err != nil || e.QueueID == "" || e.ArrivalTime <= 0 {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, QueuedMessage{
|
||||
ID: e.QueueID,
|
||||
Queue: e.QueueName,
|
||||
Sender: e.Sender,
|
||||
Arrival: time.Unix(e.ArrivalTime, 0).UTC(),
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseQueueJSON(t *testing.T) {
|
||||
out := []byte(`{"queue_name": "deferred", "queue_id": "41E862C00D9E", "arrival_time": 1791000000, "message_size": 446, "forced_expire": false, "sender": "app@example.com", "recipients": [{"address": "a@example.net", "delay_reason": "connect to mx.example.net[203.0.113.9]:25: Connection timed out"}]}
|
||||
{"queue_name": "active", "queue_id": "5F00AA12", "arrival_time": 1791250000, "message_size": 900, "sender": "", "recipients": [{"address": "b@example.net"}]}
|
||||
|
||||
not json at all
|
||||
{"queue_name": "deferred", "queue_id": "", "arrival_time": 1791250000}
|
||||
{"queue_name": "deferred", "queue_id": "6C00BB34", "arrival_time": 17912`)
|
||||
|
||||
got := parseQueueJSON(out)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d messages, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != "41E862C00D9E" || got[0].Queue != "deferred" || got[0].Sender != "app@example.com" ||
|
||||
!got[0].Arrival.Equal(time.Unix(1791000000, 0)) {
|
||||
t.Errorf("first message = %+v", got[0])
|
||||
}
|
||||
if got[1].ID != "5F00AA12" || got[1].Queue != "active" {
|
||||
t.Errorf("second message = %+v", got[1])
|
||||
}
|
||||
if len(parseQueueJSON(nil)) != 0 {
|
||||
t.Error("an empty queue is not empty")
|
||||
}
|
||||
}
|
||||
|
||||
// A queue that cannot be listed is an error, not an empty queue: the caller
|
||||
// must not conclude that nothing is waiting.
|
||||
func TestQueueMessagesReportsAFailedListing(t *testing.T) {
|
||||
old := readQueueJSON
|
||||
t.Cleanup(func() { readQueueJSON = old })
|
||||
readQueueJSON = func() ([]byte, error) { return nil, errors.New("postqueue: Permission denied") }
|
||||
|
||||
if msgs, err := QueueMessages(); err == nil {
|
||||
t.Fatalf("no error for a failed listing (got %v)", msgs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Notifications: the panel writes to its users when something needs them
|
||||
-- (panel-notifications, 2.3.0). DDL only; nothing existing changes, and every
|
||||
-- user starts with notifications on.
|
||||
|
||||
-- A user's own switch, on Account. It decides nothing by itself: a user is
|
||||
-- written to when it is on, the profile has an e-mail and the instance switch
|
||||
-- (settings key "notifications") is on.
|
||||
ALTER TABLE users ADD COLUMN notify INTEGER NOT NULL DEFAULT 1 CHECK (notify IN (0, 1));
|
||||
|
||||
-- What the panel has already said, so that it says each thing once and a
|
||||
-- restart announces nothing again. One row per subject:
|
||||
-- dns:<domain> a sending domain's records
|
||||
-- cert the TLS certificate
|
||||
-- queue a message older than three days
|
||||
-- limit:<domain>:<login> a level-2 limit that refused mail
|
||||
-- phase is where the subject stands ("pending": seen wrong, not yet said;
|
||||
-- "open": said; for the certificate, the threshold last said). count is the
|
||||
-- checks in a row that agree with the phase's direction, mark what the phase
|
||||
-- refers to (the certificate's expiry), since when it began, sent_at when the
|
||||
-- last message about it went out.
|
||||
CREATE TABLE notify_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
phase TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
mark TEXT NOT NULL DEFAULT '',
|
||||
since TEXT NOT NULL,
|
||||
sent_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
@@ -0,0 +1,194 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// settingNotifications is the instance switch for notification e-mails. A
|
||||
// missing value means on: the switch exists to turn them off.
|
||||
const settingNotifications = "notifications"
|
||||
|
||||
// NotificationsEnabled reports whether the instance sends notification e-mails
|
||||
// at all (Server › Settings).
|
||||
func (s *Store) NotificationsEnabled() (bool, error) {
|
||||
v, err := s.GetSetting(settingNotifications)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v != "off", nil
|
||||
}
|
||||
|
||||
// SetNotificationsEnabled turns notification e-mails on or off for everyone.
|
||||
func (s *Store) SetNotificationsEnabled(on bool) error {
|
||||
v := "on"
|
||||
if !on {
|
||||
v = "off"
|
||||
}
|
||||
return s.SetSetting(settingNotifications, v)
|
||||
}
|
||||
|
||||
// SetUserNotify sets a user's own switch (Account).
|
||||
func (s *Store) SetUserNotify(id int64, on bool) error {
|
||||
res, err := s.db.Exec("UPDATE users SET notify = ? WHERE id = ?", on, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set user notify: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyRecipients returns the addresses a notification goes to: every global
|
||||
// user, and for a subject that is a sending domain's (domainName not empty)
|
||||
// also the domain users it is assigned to - by name on their list, or by
|
||||
// "all domains". Only users with an e-mail and their own switch on; an address
|
||||
// two users share is returned once.
|
||||
func (s *Store) NotifyRecipients(domainName string) ([]string, error) {
|
||||
query := `SELECT DISTINCT email FROM users
|
||||
WHERE notify = 1 AND email <> '' AND role = ?`
|
||||
args := []any{RoleGlobal}
|
||||
if domainName != "" {
|
||||
query = `SELECT DISTINCT email FROM users
|
||||
WHERE notify = 1 AND email <> '' AND (
|
||||
role = ?
|
||||
OR all_domains = 1
|
||||
OR EXISTS (SELECT 1 FROM user_domains ud JOIN domains d ON d.id = ud.domain_id
|
||||
WHERE ud.user_id = users.id AND d.name = ?))`
|
||||
args = append(args, domainName)
|
||||
}
|
||||
rows, err := s.db.Query(query+" ORDER BY email", args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return nil, fmt.Errorf("notify recipients scan: %w", err)
|
||||
}
|
||||
out = append(out, email)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NotifyState is what the panel remembers about one subject of notification
|
||||
// (migration 0004 describes the keys and the fields).
|
||||
type NotifyState struct {
|
||||
Key string
|
||||
Phase string
|
||||
Count int
|
||||
Mark string
|
||||
Since time.Time
|
||||
SentAt time.Time // zero when nothing was sent yet
|
||||
}
|
||||
|
||||
// GetNotifyState returns the state kept under key; ok is false when there is
|
||||
// none, which is the state of a subject nothing is wrong with.
|
||||
func (s *Store) GetNotifyState(key string) (NotifyState, bool, error) {
|
||||
st := NotifyState{Key: key}
|
||||
var since, sentAt string
|
||||
err := s.db.QueryRow(
|
||||
"SELECT phase, count, mark, since, sent_at FROM notify_state WHERE key = ?", key,
|
||||
).Scan(&st.Phase, &st.Count, &st.Mark, &since, &sentAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NotifyState{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return NotifyState{}, false, fmt.Errorf("get notify state %s: %w", key, err)
|
||||
}
|
||||
st.Since, _ = time.Parse(time.RFC3339, since)
|
||||
if sentAt != "" {
|
||||
st.SentAt, _ = time.Parse(time.RFC3339, sentAt)
|
||||
}
|
||||
return st, true, nil
|
||||
}
|
||||
|
||||
// SetNotifyState writes the state of one subject, replacing what was there.
|
||||
func (s *Store) SetNotifyState(st NotifyState) error {
|
||||
sentAt := ""
|
||||
if !st.SentAt.IsZero() {
|
||||
sentAt = st.SentAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO notify_state (key, phase, count, mark, since, sent_at) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET phase = excluded.phase, count = excluded.count,
|
||||
mark = excluded.mark, since = excluded.since, sent_at = excluded.sent_at`,
|
||||
st.Key, st.Phase, st.Count, st.Mark, st.Since.UTC().Format(time.RFC3339), sentAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set notify state %s: %w", st.Key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteNotifyState forgets a subject: nothing is wrong with it any more, or
|
||||
// it no longer exists.
|
||||
func (s *Store) DeleteNotifyState(key string) error {
|
||||
if _, err := s.db.Exec("DELETE FROM notify_state WHERE key = ?", key); err != nil {
|
||||
return fmt.Errorf("delete notify state %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
|
||||
// states of subjects that are gone - a deleted domain - can be found.
|
||||
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list notify state: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, fmt.Errorf("list notify state scan: %w", err)
|
||||
}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LimitRefusal is the mail one application was refused under a level-2 rate
|
||||
// limit in a span of time: how many messages and when the first.
|
||||
type LimitRefusal struct {
|
||||
Domain string
|
||||
AppLogin string
|
||||
Count int
|
||||
First time.Time
|
||||
}
|
||||
|
||||
// LimitRefusalsSince returns, per domain and application, the messages the
|
||||
// journal milter refused under a level-2 limit at or after since (send-log
|
||||
// rows with status "rejected").
|
||||
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT COALESCE(domain, ''), COALESCE(app_login, ''), COUNT(*), MIN(created_at) FROM send_log
|
||||
WHERE status = ? AND created_at >= ?
|
||||
GROUP BY domain, app_login ORDER BY domain, app_login`,
|
||||
StatusRejected, since.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("limit refusals: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LimitRefusal
|
||||
for rows.Next() {
|
||||
var (
|
||||
r LimitRefusal
|
||||
first string
|
||||
)
|
||||
if err := rows.Scan(&r.Domain, &r.AppLogin, &r.Count, &first); err != nil {
|
||||
return nil, fmt.Errorf("limit refusals scan: %w", err)
|
||||
}
|
||||
r.First, _ = time.Parse(time.RFC3339, first)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The instance switch is on until someone turns it off: a database that never
|
||||
// saw the setting sends notifications.
|
||||
func TestNotificationsEnabledByDefault(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if on, err := s.NotificationsEnabled(); err != nil || !on {
|
||||
t.Fatalf("a fresh database: enabled = %v, %v; want on", on, err)
|
||||
}
|
||||
if err := s.SetNotificationsEnabled(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if on, err := s.NotificationsEnabled(); err != nil || on {
|
||||
t.Errorf("after turning off: enabled = %v, %v", on, err)
|
||||
}
|
||||
if err := s.SetNotificationsEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if on, err := s.NotificationsEnabled(); err != nil || !on {
|
||||
t.Errorf("after turning on again: enabled = %v, %v", on, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Who is written to: every global user about everything, a domain user about
|
||||
// the domains on their list or all of them - and nobody without an e-mail or
|
||||
// with their own switch off.
|
||||
func TestNotifyRecipients(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
mine, err := s.AddDomain("mine.example.com", "sp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := s.AddDomain("other.example.com", "sp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user := func(name string, role Role, reach Reach, email string, notify bool) {
|
||||
t.Helper()
|
||||
id, err := s.CreateUser(name, "hash", role, reach)
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
if err := s.UpdateUser(id, name, "hash", email); err != nil {
|
||||
t.Fatalf("update %s: %v", name, err)
|
||||
}
|
||||
if u, err := s.GetUser(id); err != nil || !u.Notify {
|
||||
t.Fatalf("%s: a new user's switch is %v, %v; want on", name, u.Notify, err)
|
||||
}
|
||||
if !notify {
|
||||
if err := s.SetUserNotify(id, false); err != nil {
|
||||
t.Fatalf("switch off %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
user("root", RoleGlobal, Reach{}, "root@example.org", true)
|
||||
user("root2", RoleGlobal, Reach{}, "root@example.org", true) // the same mailbox
|
||||
user("quiet", RoleGlobal, Reach{}, "quiet@example.org", false)
|
||||
user("nomail", RoleGlobal, Reach{}, "", true)
|
||||
user("mine", RoleDomain, Reach{DomainIDs: []int64{mine.ID}}, "mine@example.org", true)
|
||||
user("theirs", RoleDomain, Reach{DomainIDs: []int64{other.ID}}, "theirs@example.org", true)
|
||||
user("every", RoleDomain, Reach{AllDomains: true}, "every@example.org", true)
|
||||
user("mineoff", RoleDomain, Reach{DomainIDs: []int64{mine.ID}}, "mineoff@example.org", false)
|
||||
|
||||
for _, c := range []struct{ domain, want string }{
|
||||
{"", "root@example.org"},
|
||||
{"mine.example.com", "every@example.org mine@example.org root@example.org"},
|
||||
{"other.example.com", "every@example.org root@example.org theirs@example.org"},
|
||||
{"gone.example.com", "every@example.org root@example.org"},
|
||||
} {
|
||||
got, err := s.NotifyRecipients(c.domain)
|
||||
if err != nil {
|
||||
t.Fatalf("recipients for %q: %v", c.domain, err)
|
||||
}
|
||||
if strings.Join(got, " ") != c.want {
|
||||
t.Errorf("recipients for %q = %q, want %q", c.domain, strings.Join(got, " "), c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.SetUserNotify(99999, true); err != ErrUserNotFound {
|
||||
t.Errorf("switch of a user that does not exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What has been said is remembered by key, replaced whole, and forgotten.
|
||||
func TestNotifyStateRoundTrip(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if _, ok, err := s.GetNotifyState("dns:example.com"); err != nil || ok {
|
||||
t.Fatalf("no state yet: ok = %v, %v", ok, err)
|
||||
}
|
||||
since := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
|
||||
st := NotifyState{Key: "dns:example.com", Phase: "pending", Count: 1, Since: since}
|
||||
if err := s.SetNotifyState(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := s.GetNotifyState("dns:example.com")
|
||||
if err != nil || !ok || got.Phase != "pending" || got.Count != 1 || !got.Since.Equal(since) || !got.SentAt.IsZero() {
|
||||
t.Fatalf("state = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
|
||||
st.Phase, st.Count, st.Mark, st.SentAt = "open", 0, "x", since.Add(time.Hour)
|
||||
if err := s.SetNotifyState(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, _ = s.GetNotifyState("dns:example.com")
|
||||
if got.Phase != "open" || got.Mark != "x" || !got.SentAt.Equal(since.Add(time.Hour)) {
|
||||
t.Errorf("replaced state = %+v", got)
|
||||
}
|
||||
|
||||
for _, key := range []string{"dns:other.example.com", "queue", "limit:example.com:app"} {
|
||||
if err := s.SetNotifyState(NotifyState{Key: key, Phase: "open", Since: since}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
keys, err := s.ListNotifyStateKeys("dns:")
|
||||
if err != nil || strings.Join(keys, " ") != "dns:example.com dns:other.example.com" {
|
||||
t.Errorf("dns keys = %v, %v", keys, err)
|
||||
}
|
||||
if err := s.DeleteNotifyState("dns:example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := s.GetNotifyState("dns:example.com"); ok {
|
||||
t.Error("state still there after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Refused mail is counted per domain and application from a point in time;
|
||||
// delivered mail and older refusals are not.
|
||||
func TestLimitRefusalsSince(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
for _, e := range []SendLogEntry{
|
||||
{Domain: "example.com", AppLogin: "app1", From: "a@example.com"},
|
||||
{Domain: "example.com", AppLogin: "app1", From: "a@example.com"},
|
||||
{Domain: "example.com", AppLogin: "app2", From: "b@example.com"},
|
||||
} {
|
||||
if err := s.InsertRejected(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.InsertQueued(SendLogEntry{QueueID: "ABC123", Domain: "example.com", AppLogin: "app3", To: "x@example.net"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.InsertRejected(SendLogEntry{Domain: "old.example.com", AppLogin: "old"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE send_log SET created_at = '2026-01-01T00:00:00Z' WHERE app_login = 'old'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.LimitRefusalsSince(time.Now().UTC().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].AppLogin != "app1" || got[0].Count != 2 || got[1].AppLogin != "app2" || got[1].Count != 1 {
|
||||
t.Fatalf("refusals = %+v", got)
|
||||
}
|
||||
if got[0].Domain != "example.com" || got[0].First.IsZero() {
|
||||
t.Errorf("first refusal = %+v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
want := map[string]string{
|
||||
"settings": "key value",
|
||||
"users": "id username password_hash role email all_domains all_inbound_domains created_at",
|
||||
"users": "id username password_hash role email all_domains all_inbound_domains created_at notify",
|
||||
"sessions": "token_hash user_id created_at expires_at",
|
||||
"domains": "id name dkim_selector dmarc_rua created_at",
|
||||
"user_domains": "user_id domain_id",
|
||||
@@ -58,6 +58,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
"inbound_spam_log": "id inbound_domain client_ip from_addr to_addr subject decision engine detail created_at",
|
||||
"inbound_spam_list": "id list_type entry_type value note created_at",
|
||||
"send_log_lines": "id queue_id logged_at line",
|
||||
"notify_state": "key phase count mark since sent_at",
|
||||
}
|
||||
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
@@ -112,8 +113,8 @@ func TestBaselineSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
var version, appID int64
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 3 {
|
||||
t.Errorf("user_version = %d, %v; want 3", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 4 {
|
||||
t.Errorf("user_version = %d, %v; want 4", version, err)
|
||||
}
|
||||
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
||||
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
||||
@@ -189,13 +190,13 @@ func TestOpenMigratesA20Database(t *testing.T) {
|
||||
}
|
||||
defer st.Close()
|
||||
var version int
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 3 {
|
||||
t.Errorf("user_version = %d, %v; want 3", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 4 {
|
||||
t.Errorf("user_version = %d, %v; want 4", version, err)
|
||||
}
|
||||
if ok, err := st.UserExists(); err != nil || !ok {
|
||||
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
|
||||
}
|
||||
for _, table := range []string{"inbound_spam_log", "inbound_spam_list", "send_log_lines"} {
|
||||
for _, table := range []string{"inbound_spam_log", "inbound_spam_list", "send_log_lines", "notify_state"} {
|
||||
if len(tableColumns(t, st, table)) == 0 {
|
||||
t.Errorf("table %s was not created", table)
|
||||
}
|
||||
|
||||
@@ -43,12 +43,15 @@ type User struct {
|
||||
// are then ignored.
|
||||
AllDomains bool
|
||||
AllInboundDomains bool
|
||||
// Notify is the user's own switch for notification e-mails (Account). It
|
||||
// is on for a new user and does nothing without an e-mail.
|
||||
Notify bool
|
||||
CreatedAt time.Time
|
||||
DomainIDs []int64
|
||||
InboundDomainIDs []int64
|
||||
}
|
||||
|
||||
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, created_at"
|
||||
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, notify, created_at"
|
||||
|
||||
func scanUser(r scanRow) (User, error) {
|
||||
var (
|
||||
@@ -56,7 +59,7 @@ func scanUser(r scanRow) (User, error) {
|
||||
createdAt string
|
||||
)
|
||||
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email,
|
||||
&u.AllDomains, &u.AllInboundDomains, &createdAt)
|
||||
&u.AllDomains, &u.AllInboundDomains, &u.Notify, &createdAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeZone is a DNS server on the loopback that answers from a table, so that a
|
||||
// page can be rendered against records that are published, wrong or absent
|
||||
// without leaving the machine. Names are lower case without the trailing dot;
|
||||
// a name the zone does not hold is NXDOMAIN, which is how a record that is not
|
||||
// published looks to the checker.
|
||||
type fakeZone struct {
|
||||
mu sync.Mutex
|
||||
txt map[string][]string
|
||||
addr map[string]string // name -> IPv4 address
|
||||
}
|
||||
|
||||
// newFakeZone starts the server for the length of the test and returns the
|
||||
// resolver address to give dnscheck.New.
|
||||
func newFakeZone(t *testing.T) (*fakeZone, string) {
|
||||
t.Helper()
|
||||
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("fake DNS: listen: %v", err)
|
||||
}
|
||||
z := &fakeZone{txt: map[string][]string{}, addr: map[string]string{}}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, from, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if resp := z.answer(buf[:n]); resp != nil {
|
||||
_, _ = conn.WriteTo(resp, from)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return z, conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
// publishTXT sets the TXT record of a name; publishA its IPv4 address.
|
||||
func (z *fakeZone) publishTXT(name, value string) {
|
||||
z.mu.Lock()
|
||||
defer z.mu.Unlock()
|
||||
z.txt[strings.ToLower(name)] = append(z.txt[strings.ToLower(name)], value)
|
||||
}
|
||||
|
||||
func (z *fakeZone) publishA(name, ip string) {
|
||||
z.mu.Lock()
|
||||
defer z.mu.Unlock()
|
||||
z.addr[strings.ToLower(name)] = ip
|
||||
}
|
||||
|
||||
// answer builds the reply to one query: the question is copied back, then the
|
||||
// records of the asked type if the zone has any. A name with nothing of the
|
||||
// asked type but known under another type is an empty answer (NOERROR), an
|
||||
// unknown name is NXDOMAIN.
|
||||
func (z *fakeZone) answer(q []byte) []byte {
|
||||
if len(q) < 12 || binary.BigEndian.Uint16(q[4:6]) != 1 {
|
||||
return nil
|
||||
}
|
||||
i, labels := 12, []string{}
|
||||
for {
|
||||
if i >= len(q) {
|
||||
return nil
|
||||
}
|
||||
l := int(q[i])
|
||||
if l == 0 {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if i+1+l > len(q) {
|
||||
return nil
|
||||
}
|
||||
labels = append(labels, string(q[i+1:i+1+l]))
|
||||
i += 1 + l
|
||||
}
|
||||
if i+4 > len(q) {
|
||||
return nil
|
||||
}
|
||||
name, qtype := strings.ToLower(strings.Join(labels, ".")), binary.BigEndian.Uint16(q[i:i+2])
|
||||
question := q[12 : i+4]
|
||||
|
||||
z.mu.Lock()
|
||||
txt, ip := z.txt[name], z.addr[name]
|
||||
z.mu.Unlock()
|
||||
|
||||
var answers [][]byte
|
||||
switch {
|
||||
case qtype == 16:
|
||||
for _, v := range txt {
|
||||
var rdata []byte
|
||||
for len(v) > 255 {
|
||||
rdata = append(rdata, 255)
|
||||
rdata = append(rdata, v[:255]...)
|
||||
v = v[255:]
|
||||
}
|
||||
rdata = append(rdata, byte(len(v)))
|
||||
rdata = append(rdata, v...)
|
||||
answers = append(answers, record(16, rdata))
|
||||
}
|
||||
case qtype == 1 && ip != "":
|
||||
answers = append(answers, record(1, net.ParseIP(ip).To4()))
|
||||
}
|
||||
rcode := uint16(0)
|
||||
if len(txt) == 0 && ip == "" {
|
||||
rcode = 3
|
||||
}
|
||||
resp := make([]byte, 12, 512)
|
||||
copy(resp, q[:2])
|
||||
binary.BigEndian.PutUint16(resp[2:], 0x8180|rcode)
|
||||
binary.BigEndian.PutUint16(resp[4:], 1)
|
||||
binary.BigEndian.PutUint16(resp[6:], uint16(len(answers)))
|
||||
resp = append(resp, question...)
|
||||
for _, a := range answers {
|
||||
resp = append(resp, a...)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// record is one answer record named by the question (a pointer to offset 12),
|
||||
// class IN, with a TTL of a minute.
|
||||
func record(rtype uint16, rdata []byte) []byte {
|
||||
r := []byte{0xC0, 0x0C, 0, 0, 0, 1, 0, 0, 0, 60, 0, 0}
|
||||
binary.BigEndian.PutUint16(r[2:], rtype)
|
||||
binary.BigEndian.PutUint16(r[10:], uint16(len(rdata)))
|
||||
return append(r, rdata...)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type accountForm struct {
|
||||
Err string
|
||||
Username string
|
||||
Email string
|
||||
Notify bool
|
||||
}
|
||||
|
||||
func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
|
||||
@@ -44,7 +45,7 @@ func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.Us
|
||||
}
|
||||
|
||||
func formOf(u store.User) accountForm {
|
||||
return accountForm{Username: u.Username, Email: u.Email}
|
||||
return accountForm{Username: u.Username, Email: u.Email, Notify: u.Notify}
|
||||
}
|
||||
|
||||
// HandleAccount shows the Account page.
|
||||
@@ -58,7 +59,7 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
|
||||
page := view.NewAccount(h.shellMeta(r), string(u.Role))
|
||||
page.Username, page.Email = f.Username, f.Email
|
||||
page.Username, page.Email, page.Notify = f.Username, f.Email, f.Notify
|
||||
page.WithResult(accountFlash(r), f.Err)
|
||||
h.view.Render(w, status, "account", page)
|
||||
}
|
||||
@@ -90,6 +91,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
f.Username = strings.TrimSpace(r.PostFormValue("username"))
|
||||
f.Email = strings.TrimSpace(r.PostFormValue("email"))
|
||||
// An unticked checkbox is not sent at all: absent is off.
|
||||
f.Notify = r.PostFormValue("notify") != ""
|
||||
if f.Username == "" {
|
||||
f.Username = u.Username
|
||||
}
|
||||
@@ -112,7 +115,14 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
||||
return
|
||||
}
|
||||
logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
|
||||
if f.Notify != u.Notify {
|
||||
if err := h.store.SetUserNotify(u.ID, f.Notify); err != nil {
|
||||
logf("panel: account: set notifications of user %d: %v", u.ID, err)
|
||||
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
||||
return
|
||||
}
|
||||
}
|
||||
logf("panel: user %d profile updated (username: %t, e-mail: %t, notifications: %t)", u.ID, f.Username != u.Username, f.Email != u.Email, f.Notify)
|
||||
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// The panel pages of notifications (plans/panel-notifications.md): the user's
|
||||
// checkbox on Account, the instance's switch and the DNS records of the server's
|
||||
// own name on Server › Settings, and the state of those records on Server ›
|
||||
// Health. What is sent and when is internal/notify's and tested there.
|
||||
|
||||
const notifyHost = "mail.example.org"
|
||||
|
||||
// ---- Account
|
||||
|
||||
// The checkbox under the e-mail is the user's own switch: the page shows what is
|
||||
// stored, Save profile writes it, and an unchecked box is a form without the
|
||||
// field, which means off. Saving it changes nothing else of the profile.
|
||||
func TestAccountNotifyCheckbox(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
notify := func() bool {
|
||||
u, _ := h.store.GetUser(globalPrincipal.ID)
|
||||
return u.Notify
|
||||
}
|
||||
save := func(extra url.Values) {
|
||||
t.Helper()
|
||||
form := url.Values{"username": {"admin"}, "email": {"mix@example.org"}}
|
||||
for k, v := range extra {
|
||||
form[k] = v
|
||||
}
|
||||
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, form); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /account/profile %v = %d:\n%s", extra, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
save(url.Values{"notify": {"on"}})
|
||||
if !notify() {
|
||||
t.Error("a ticked box did not turn notifications on")
|
||||
}
|
||||
if out := getBody(t, h.HandleAccount, "/account"); !strings.Contains(out, `name="notify" checked`) {
|
||||
t.Errorf("a user who has them on is shown an unticked box:\n%s", out)
|
||||
}
|
||||
|
||||
save(nil)
|
||||
if notify() {
|
||||
t.Error("a form without the field did not turn notifications off")
|
||||
}
|
||||
if out := getBody(t, h.HandleAccount, "/account"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) {
|
||||
t.Errorf("a user who has them off is shown a ticked box, or none:\n%s", out)
|
||||
}
|
||||
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Email != "mix@example.org" {
|
||||
t.Errorf("saving the box lost the e-mail: %q", u.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that is refused saves nothing, the box included, and shows the form
|
||||
// again as it was submitted.
|
||||
func TestAccountNotifyIsNotSavedWithARefusedProfile(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
if err := h.store.SetUserNotify(globalPrincipal.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
|
||||
url.Values{"username": {"admin"}, "email": {"not-an-address"}, "notify": {"on"}})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("POST /account/profile with a bad e-mail = %d, want 400", rec.Code)
|
||||
}
|
||||
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Notify {
|
||||
t.Error("a refused profile turned notifications on")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `name="notify" checked`) {
|
||||
t.Error("the form shown again with the error lost the box the user ticked")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server › Settings
|
||||
|
||||
// The switch is saved with the rest of the page and only a global user can save
|
||||
// it: a domain administrator is told the page does not exist, and nothing moves.
|
||||
func TestSettingsNotificationsSwitch(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
on := func() bool {
|
||||
v, err := h.store.NotificationsEnabled()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
save := func(p auth.Principal, notify bool) *httptest.ResponseRecorder {
|
||||
form := url.Values{"send_log_retention_days": {"60"}}
|
||||
if notify {
|
||||
form.Set("notify", "on")
|
||||
}
|
||||
return postFormAs(h.HandleServerSettings, p, "/server/settings", nil, form)
|
||||
}
|
||||
|
||||
if !on() {
|
||||
t.Fatal("a fresh instance has notifications off")
|
||||
}
|
||||
if rec := save(globalPrincipal, false); rec.Code != http.StatusSeeOther || on() {
|
||||
t.Errorf("an unticked box = %d, switch on: %v; want 303 and off", rec.Code, on())
|
||||
}
|
||||
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) {
|
||||
t.Errorf("the page shows the switch on while it is off:\n%s", out)
|
||||
}
|
||||
if rec := save(globalPrincipal, true); rec.Code != http.StatusSeeOther || !on() {
|
||||
t.Errorf("a ticked box = %d, switch on: %v; want 303 and on", rec.Code, on())
|
||||
}
|
||||
if out := getBody(t, h.HandleServerSettings, "/server/settings"); !strings.Contains(out, `name="notify" checked`) {
|
||||
t.Errorf("the page shows the switch off while it is on:\n%s", out)
|
||||
}
|
||||
|
||||
d, err := h.store.AddDomain("example.com", "mail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ops := domainAdmin(t, h.store, "ops", d.ID)
|
||||
if rec := save(ops, false); rec.Code != http.StatusNotFound || !on() {
|
||||
t.Errorf("a domain administrator turning the switch off = %d, switch on: %v; want 404 and on", rec.Code, on())
|
||||
}
|
||||
}
|
||||
|
||||
// A page that is refused (here: a retention out of range) leaves the switch as
|
||||
// it was, like every other setting on it.
|
||||
func TestSettingsRefusalLeavesTheNotificationsSwitch(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"3"}})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("POST /server/settings = %d, want 400", rec.Code)
|
||||
}
|
||||
if on, _ := h.store.NotificationsEnabled(); !on {
|
||||
t.Error("a refused page turned notifications off")
|
||||
}
|
||||
}
|
||||
|
||||
// serverMailStand is a panel that knows its own name, has the key notifications
|
||||
// are signed with, and asks a DNS server that answers from a table: the address
|
||||
// of the server's name is there, everything else is for the test to publish.
|
||||
func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
|
||||
t.Helper()
|
||||
h, _ := settingsServer(t)
|
||||
zone, addr := newFakeZone(t)
|
||||
zone.publishA(notifyHost, "192.0.2.10")
|
||||
h.dns = dnscheck.New([]string{addr})
|
||||
h.cfg.Hostname = notifyHost
|
||||
h.machine = &health.MachineSampler{}
|
||||
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "sp2026")
|
||||
h.domains.SetServerName(notifyHost)
|
||||
mail, err := h.domains.ServerMail()
|
||||
if err != nil {
|
||||
t.Fatalf("ServerMail: %v", err)
|
||||
}
|
||||
return h, zone, mail
|
||||
}
|
||||
|
||||
// publishMail puts both records of the server's own name into the zone.
|
||||
func publishMail(zone *fakeZone, mail domain.ServerMail) {
|
||||
zone.publishTXT(mail.Record.Name, mail.Record.Value)
|
||||
zone.publishTXT(notifyHost, dnscheck.ServerMailSPF)
|
||||
}
|
||||
|
||||
// With both records in DNS, Settings gives each with the value to publish and
|
||||
// the state "published", and no warning about what is missing.
|
||||
func TestSettingsMailRecordsPublished(t *testing.T) {
|
||||
h, zone, mail := serverMailStand(t)
|
||||
publishMail(zone, mail)
|
||||
|
||||
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||
for _, want := range []string{
|
||||
`id="notification-mail"`, `DNS records of the server's own name`,
|
||||
`<h3>DKIM</h3><span class="tag is-success is-light">published</span>`,
|
||||
`selector <code>sp2026</code>`, `value="sp2026._domainkey.mail.example.org"`,
|
||||
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
|
||||
`For the server's own name, not for your domains`,
|
||||
`value="v=spf1 a -all"`,
|
||||
`<span class="sp-mono">postmaster@mail.example.org</span>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("settings page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "may file them as spam") {
|
||||
t.Error("the warning about a missing SPF record is shown while it is published")
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing published the records are the same and the states say so, as a
|
||||
// warning and not as the red of a domain: notifications are sent without them.
|
||||
// The page says what that costs under SPF and what is missing under DKIM.
|
||||
func TestSettingsMailRecordsMissing(t *testing.T) {
|
||||
h, _, _ := serverMailStand(t)
|
||||
|
||||
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||
for _, want := range []string{
|
||||
`<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`,
|
||||
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span>`,
|
||||
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
|
||||
`Notifications are sent without it, and a receiver may file them as spam.`,
|
||||
`No TXT record is published at sp2026._domainkey.mail.example.org.`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("settings page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `is-danger is-light">missing`) {
|
||||
t.Error("a record of the server's own name that is missing is drawn as an error")
|
||||
}
|
||||
}
|
||||
|
||||
// A DKIM record that holds some other key is a mismatch, and the page shows what
|
||||
// DNS holds next to what to publish.
|
||||
func TestSettingsMailDKIMWithAnotherKey(t *testing.T) {
|
||||
h, zone, mail := serverMailStand(t)
|
||||
zone.publishTXT(mail.Record.Name, "v=DKIM1; k=rsa; p=AAAAB3NzaC1yc2EAAAADAQABAAABAQDhWrongKeyEntirely")
|
||||
|
||||
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||
for _, want := range []string{`<span class="tag is-warning is-light">mismatch</span>`, `In DNS now`, `WrongKeyEntirely`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("settings page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the key of the server's own name cannot be read the page is still there:
|
||||
// the DKIM record is left out and SPF is shown unchecked.
|
||||
func TestSettingsWithoutAServerKeyStillRenders(t *testing.T) {
|
||||
h, _ := settingsServer(t) // its domain service does not know the server's name
|
||||
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||
for _, want := range []string{`id="notification-mail"`, `<h3>SPF</h3><span class="tag is-light">not checked</span>`, `name="notify"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("settings page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "<h3>DKIM</h3>") {
|
||||
t.Error("a DKIM record is shown for a key that could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server › Health
|
||||
|
||||
// Health has the card with the state of each record, the host under its name,
|
||||
// and the way to the values on Settings. A record that is missing makes the
|
||||
// card a warning.
|
||||
func TestHealthNotificationMailCard(t *testing.T) {
|
||||
h, zone, mail := serverMailStand(t)
|
||||
zone.publishTXT(mail.Record.Name, mail.Record.Value) // DKIM only
|
||||
|
||||
out := getBody(t, h.HandleHealth, "/server/health")
|
||||
for _, want := range []string{
|
||||
`id="notification-mail"`, `<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
|
||||
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
|
||||
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
|
||||
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
|
||||
`href="/server/settings#notification-mail"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("health page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With both records in place the card is green. The card is part of the polled
|
||||
// fragment, swapped in by its id like the second row.
|
||||
func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
|
||||
h, zone, mail := serverMailStand(t)
|
||||
publishMail(zone, mail)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/server/health/fragment", nil)
|
||||
h.HandleHealthFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal))
|
||||
out := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`id="health-mail" hx-swap-oob="true"`,
|
||||
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div>`,
|
||||
`<td><span class="tag is-success is-light">published</span></td>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("health fragment is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out[strings.Index(out, `id="health-mail"`):], "is-warning") {
|
||||
t.Error("the card warns while both records are published")
|
||||
}
|
||||
}
|
||||
|
||||
// The DNS answers are cached for a few minutes; Re-check DNS on Health asks for
|
||||
// the records of the server's own name again as well as for its address.
|
||||
func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
|
||||
h, zone, mail := serverMailStand(t)
|
||||
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 2 {
|
||||
t.Fatalf("before publishing, both records should be missing:\n%s", out)
|
||||
}
|
||||
|
||||
publishMail(zone, mail)
|
||||
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Contains(out, `is-success is-light">published`) {
|
||||
t.Fatal("the cached answer was not used: the test cannot tell a re-check from a fresh read")
|
||||
}
|
||||
|
||||
rec := postFormAs(h.HandleHealthRecheck, globalPrincipal, "/server/health/recheck", nil, url.Values{})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /server/health/recheck = %d, want 303", rec.Code)
|
||||
}
|
||||
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 2 {
|
||||
t.Errorf("after Re-check DNS both records should be published:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,18 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// Server › Settings holds what is true of the whole instance, whoever is
|
||||
// signed in: log retention (the outbound log's, and the inbound log's while the
|
||||
// inbound relay is on) and the read-only view of the rate limits. A
|
||||
// inbound relay is on), the read-only view of the rate limits, the switch for
|
||||
// notification e-mails and the DNS records of the server's own name those are
|
||||
// sent from. A
|
||||
// user's own name, e-mail, password and DMARC default are on Account
|
||||
// (handlers_account.go). Before placing a setting here, ask whose it is.
|
||||
|
||||
@@ -47,6 +52,12 @@ func (h *Handlers) currentRetentions() retentions {
|
||||
|
||||
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, form retentions) {
|
||||
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(form.outbound), view.FormatRate(h.l1Messages(), h.l1Window()))
|
||||
on, err := h.store.NotificationsEnabled()
|
||||
if err != nil {
|
||||
logf("panel: settings: read the notifications switch: %v", err)
|
||||
}
|
||||
page.WithNotifications(on, notify.From(h.cfg.Hostname))
|
||||
page.WithMail(h.serverMail(false).records)
|
||||
// The inbound field exists while there is an inbound journal to keep.
|
||||
if h.cfg.InboundEnabled {
|
||||
page.WithInbound(strconv.Itoa(form.inbound))
|
||||
@@ -87,12 +98,20 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
// An unticked checkbox is not sent at all: absent is off.
|
||||
notifications := r.PostFormValue("notify") != ""
|
||||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||
logf("panel: settings: set send-log retention failed: %v", err)
|
||||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||
"Could not save send log retention. Please check the logs and try again.", form)
|
||||
return
|
||||
}
|
||||
if err := h.store.SetNotificationsEnabled(notifications); err != nil {
|
||||
logf("panel: settings: set the notifications switch failed: %v", err)
|
||||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||
"Could not save the notifications switch. Please check the logs and try again.", form)
|
||||
return
|
||||
}
|
||||
if h.cfg.InboundEnabled {
|
||||
if err := h.store.SetInboundSpamLogRetentionDays(form.inbound); err != nil {
|
||||
logf("panel: settings: set inbound-log retention failed: %v", err)
|
||||
@@ -100,9 +119,9 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
||||
"Could not save inbound log retention. Please check the logs and try again.", form)
|
||||
return
|
||||
}
|
||||
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days)", days, form.inbound)
|
||||
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days, notifications: %t)", days, form.inbound, notifications)
|
||||
} else {
|
||||
logf("panel: instance settings updated (send-log retention: %d days)", days)
|
||||
logf("panel: instance settings updated (send-log retention: %d days, notifications: %t)", days, notifications)
|
||||
}
|
||||
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
@@ -119,3 +138,48 @@ func (h *Handlers) sendLogRetentionDays() int {
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// serverMailState is the server's own name as a sender of notifications: the
|
||||
// records to publish for it, as Settings draws them, and their state, as
|
||||
// Health lists it.
|
||||
type serverMailState struct {
|
||||
records []view.Record
|
||||
rows []view.MailRow
|
||||
// status is the verdict of Health's card: ok, warn or unknown. A record
|
||||
// that is not in place is a warning, never an error: without them the
|
||||
// notifications are still sent.
|
||||
status string
|
||||
}
|
||||
|
||||
// serverMail reads the records of the server's own name and checks them in DNS
|
||||
// (cached like a domain's; force skips the cache, for Re-check DNS). When the
|
||||
// DKIM key cannot be read the page is still shown: the DKIM record is left out,
|
||||
// the SPF record is shown unchecked, and the card does not claim it is fine.
|
||||
func (h *Handlers) serverMail(force bool) serverMailState {
|
||||
host := h.cfg.Hostname
|
||||
spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
|
||||
return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c)
|
||||
}
|
||||
mail, err := h.domains.ServerMail()
|
||||
if err != nil {
|
||||
logf("panel: server mail: %v", err)
|
||||
rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)})
|
||||
return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, status: string(health.StatusUnknown)}
|
||||
}
|
||||
srv := h.dns.Server(host, false)
|
||||
check := h.dns.ServerMail(dnscheck.Query{
|
||||
Name: host, Selector: mail.Selector, ExpectedDKIM: mail.Record.Value,
|
||||
Hostname: srv.Hostname, ServerIPs: srv.IPs,
|
||||
}, force)
|
||||
dkim := dnsCheck(check.DKIM)
|
||||
spfRec, spfRow := spfOf(dnsCheck(check.SPF))
|
||||
status := check.Overall
|
||||
if status == health.StatusError {
|
||||
status = health.StatusWarn
|
||||
}
|
||||
return serverMailState{
|
||||
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec},
|
||||
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow},
|
||||
status: string(status),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -120,6 +121,10 @@ func settingsServerAt(t *testing.T, dbPath string) (*Handlers, string) {
|
||||
view: v,
|
||||
auth: a,
|
||||
dns: dnscheck.New(nil),
|
||||
// A domain service that does not know the server's own name, so that
|
||||
// the records of Settings and Health come out as "cannot be read";
|
||||
// the tests of those records use serverMailStand.
|
||||
domains: domain.NewService(st, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost"),
|
||||
cfg: Config{SendLogRetentionEnvDefault: 90},
|
||||
}, password
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
@@ -114,7 +115,7 @@ func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "health", healthPage(h.shellMeta(r), h.healthChecks(), statusFlash(r)))
|
||||
h.view.Render(w, http.StatusOK, "health", h.healthView(r, statusFlash(r)))
|
||||
}
|
||||
|
||||
// HandleHealthFragment is the polled part of Health: the same two rows of
|
||||
@@ -123,7 +124,7 @@ func (h *Handlers) HandleHealthFragment(w http.ResponseWriter, r *http.Request)
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
page := healthPage(h.shellMeta(r), h.healthChecks(), "")
|
||||
page := h.healthView(r, "")
|
||||
page.Refresh = true
|
||||
h.view.RenderFragment(w, http.StatusOK, "health_body", page)
|
||||
}
|
||||
@@ -134,9 +135,18 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.dns.Server(h.cfg.Hostname, true)
|
||||
h.serverMail(true)
|
||||
http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// healthView is the Health page for the request: the checks behind the cards
|
||||
// of Overview and the Notification mail card, which only Health shows.
|
||||
func (h *Handlers) healthView(r *http.Request, flash string) *view.Health {
|
||||
page := healthPage(h.shellMeta(r), h.healthChecks(), flash)
|
||||
mail := h.serverMail(false)
|
||||
return page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status)
|
||||
}
|
||||
|
||||
// healthPage turns one reading of the checks (healthChecks) into the Health
|
||||
// page: a table row per resource, process and socket, and the verdict of each
|
||||
// box in its head.
|
||||
|
||||
@@ -19,6 +19,9 @@ type Account struct {
|
||||
Profile Box
|
||||
Username string
|
||||
Email string
|
||||
// Notify is whether the panel writes to this user: the checkbox under the
|
||||
// e-mail.
|
||||
Notify bool
|
||||
|
||||
Password Box
|
||||
}
|
||||
@@ -63,6 +66,18 @@ type Settings struct {
|
||||
|
||||
RateLimits Box
|
||||
RateFacts []Fact
|
||||
|
||||
// Notifications is the instance switch for notification e-mails; Notify is
|
||||
// its state and NotifyNote the line under it, which says from where and to
|
||||
// whom.
|
||||
Notifications Box
|
||||
Notify bool
|
||||
NotifyNote Text
|
||||
|
||||
// Mail holds the DNS records of the server's own name, which notifications
|
||||
// are signed and authorised with, drawn like a domain's.
|
||||
Mail Box
|
||||
MailRecords []Record
|
||||
}
|
||||
|
||||
// NewSettings builds the page. retention is what the field shows (the stored
|
||||
@@ -87,9 +102,28 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
|
||||
{Label: "Level 2 · domain, application",
|
||||
Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")},
|
||||
},
|
||||
Notifications: Box{No: "03", Title: "Notifications", ID: "notifications", Help: topicLink(HelpNotifications, "What is sent and when")},
|
||||
Mail: Box{No: "04", Title: "Notification mail", ID: "notification-mail", End: Plain("DNS records of the server's own name"),
|
||||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||||
}
|
||||
}
|
||||
|
||||
// WithNotifications sets the instance switch: on is whether notification
|
||||
// e-mails are sent at all, from is the address they come from.
|
||||
func (s *Settings) WithNotifications(on bool, from string) *Settings {
|
||||
s.Notify = on
|
||||
s.NotifyNote = Rich("From ", Mono(from), ", to every user who has an e-mail and has not turned them off under ",
|
||||
Link("/account", "Account"), ".")
|
||||
return s
|
||||
}
|
||||
|
||||
// WithMail sets the records of the server's own name, in the order they are
|
||||
// shown (ServerMailDKIM, ServerMailSPF).
|
||||
func (s *Settings) WithMail(records []Record) *Settings {
|
||||
s.MailRecords = records
|
||||
return s
|
||||
}
|
||||
|
||||
// WithInbound adds the retention of the Inbound log to the Log retention box.
|
||||
// days is what the field shows, like retention in NewSettings. The field exists
|
||||
// only while the inbound relay is on: with it off there is no journal to keep.
|
||||
|
||||
@@ -2,6 +2,7 @@ package view
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -119,6 +120,10 @@ func healthFixture(refresh bool) *Health {
|
||||
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
|
||||
{Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← mail.example.org"), Mono: true},
|
||||
}
|
||||
h.WithNotificationMail("postmaster@mail.example.org", []MailRow{
|
||||
NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}),
|
||||
NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}),
|
||||
}, "warn")
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -126,14 +131,25 @@ func healthFixture(refresh bool) *Health {
|
||||
// and e-mail.
|
||||
func accountFixture() *Account {
|
||||
a := NewAccount(admin(), "global")
|
||||
a.Username, a.Email = "admin", "mix@example.org"
|
||||
a.Username, a.Email, a.Notify = "admin", "mix@example.org", true
|
||||
return a
|
||||
}
|
||||
|
||||
// mailFixtureDKIMHost is where the mockup publishes the DKIM key of the server's
|
||||
// own name.
|
||||
const mailFixtureDKIMHost = "sp2026._domainkey.mail.example.org"
|
||||
|
||||
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
||||
// messages an hour.
|
||||
// messages an hour, notifications on, and the records of the server's own name
|
||||
// with the DKIM one published and the SPF one missing.
|
||||
func settingsFixture() *Settings {
|
||||
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90")
|
||||
key := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A", 10) + "IDAQAB"
|
||||
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90").
|
||||
WithNotifications(true, "postmaster@mail.example.org").
|
||||
WithMail([]Record{
|
||||
ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}),
|
||||
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "error", Detail: "No SPF record is published for mail.example.org."}),
|
||||
})
|
||||
}
|
||||
|
||||
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
||||
|
||||
@@ -34,6 +34,15 @@ type Health struct {
|
||||
SocketRows []SocketRow
|
||||
Hostname Box
|
||||
HostFacts []Fact
|
||||
|
||||
// NotifyMail is the card of the DNS records notifications are signed and
|
||||
// authorised with (their values are under Server › Settings); NotifyNote
|
||||
// is the line under its table. It is polled with the rest, so Refresh swaps
|
||||
// it into place on its own.
|
||||
NotifyMail Box
|
||||
NotifyRows []MailRow
|
||||
NotifyNote Text
|
||||
|
||||
Configuration Box
|
||||
Reload Action
|
||||
}
|
||||
@@ -87,6 +96,20 @@ type SocketRow struct {
|
||||
Detail string
|
||||
}
|
||||
|
||||
// MailRow is one record of the Notification mail table: its name, the host it
|
||||
// is published at and its state as a tag.
|
||||
type MailRow struct {
|
||||
Name string
|
||||
Host string
|
||||
State Tag
|
||||
}
|
||||
|
||||
// NewMailRow is the row of one record of the server's own name, from what the
|
||||
// DNS check found. A record that is not in place is a warning, as on Settings.
|
||||
func NewMailRow(name, host string, c DNSCheck) MailRow {
|
||||
return MailRow{Name: name, Host: host, State: mailTag(c)}
|
||||
}
|
||||
|
||||
// NewHealth builds the page around its content. The handler fills the tables;
|
||||
// the boxes, the head and the reload action are the page's own.
|
||||
func NewHealth(m Meta, flash string) *Health {
|
||||
@@ -104,7 +127,9 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
Certificate: Box{No: "03", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
|
||||
Sockets: Box{No: "04", Title: "Milter sockets", ID: "sockets"},
|
||||
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
|
||||
Configuration: Box{No: "06", Title: "Configuration", ID: "configuration",
|
||||
NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail",
|
||||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||||
Configuration: Box{No: "07", Title: "Configuration", ID: "configuration",
|
||||
End: Plain("Safe to run at any time")},
|
||||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
||||
}
|
||||
@@ -114,5 +139,16 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithNotificationMail fills the Notification mail card: from is the address
|
||||
// notifications come from, rows the state of each record, and status the
|
||||
// card's verdict for its head.
|
||||
func (h *Health) WithNotificationMail(from string, rows []MailRow, status string) *Health {
|
||||
h.NotifyMail.End = Verdict(status)
|
||||
h.NotifyRows = rows
|
||||
h.NotifyNote = Rich("Sent from ", Mono(from), ". What to publish is under ",
|
||||
Link("/server/settings#notification-mail", "Settings"), ".")
|
||||
return h
|
||||
}
|
||||
|
||||
// Verdict puts a check's status in the end slot of its box, as the mockup does.
|
||||
func Verdict(status string) Text { return Text{TagOf(status, "")} }
|
||||
|
||||
@@ -59,6 +59,9 @@ const (
|
||||
HelpBackup = "backup"
|
||||
HelpUsers = "users"
|
||||
HelpSettings = "settings"
|
||||
// HelpNotifications is what the panel writes to its users and what makes
|
||||
// that mail arrive; Account, Settings and Health link to it.
|
||||
HelpNotifications = "notifications"
|
||||
// Account
|
||||
HelpAccount = "account"
|
||||
)
|
||||
@@ -121,6 +124,7 @@ func NewHelp(m Meta, inbound bool) *Help {
|
||||
{Label: "Backup and moving a domain", Icon: "ti-archive", Href: "#" + HelpBackup},
|
||||
{Label: "Users and roles", Icon: "ti-users", Href: "#" + HelpUsers},
|
||||
{Label: "Settings", Icon: "ti-settings", Href: "#" + HelpSettings},
|
||||
{Label: "Notifications", Icon: "ti-mail", Href: "#" + HelpNotifications},
|
||||
})
|
||||
}
|
||||
add("Account", accountTopics, []MenuItem{
|
||||
@@ -233,12 +237,19 @@ var serverTopics = []HelpTopic{
|
||||
Rich("Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ",
|
||||
Code("/data"), " grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of ", Code("mail.log"),
|
||||
", which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in ",
|
||||
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it."),
|
||||
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone."),
|
||||
}},
|
||||
{ID: HelpNotifications, Title: "Notifications", Body: []Text{
|
||||
Rich("The panel writes to its users when something needs them, from ", Code("postmaster@"), " the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.",
|
||||
Br(), Br(),
|
||||
"Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.",
|
||||
Br(), Br(),
|
||||
"For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, ", Code("v=spf1 a -all"), ". Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either."),
|
||||
}},
|
||||
}
|
||||
|
||||
var accountTopics = []HelpTopic{
|
||||
{ID: HelpAccount, Title: "Your account", Body: []Text{
|
||||
Plain("Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."),
|
||||
Plain("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."),
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -178,6 +178,41 @@ func SPFRecord(domain, value string, c DNSCheck) Record {
|
||||
return r
|
||||
}
|
||||
|
||||
// mailTag is the tag of a record of the server's own name. The records are not
|
||||
// needed for notifications to be sent, only for them to arrive, so a record
|
||||
// that is missing, wrong or weak is a warning here and never the red a
|
||||
// sending domain's would be; published and not checked keep their meaning.
|
||||
func mailTag(c DNSCheck) Tag {
|
||||
t := DNSStatusTag(c.Status, len(c.Found) > 0)
|
||||
if tagLevel(t.Status) == LevelFail {
|
||||
t.Status = "warn"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ServerMailDKIM is the DKIM record of the server's own name, which the panel
|
||||
// signs its notifications with: a domain's DKIM record in every other respect.
|
||||
func ServerMailDKIM(selector, host, value string, c DNSCheck) Record {
|
||||
r := DKIMRecord(selector, host, value, c)
|
||||
r.Status = mailTag(c)
|
||||
return r
|
||||
}
|
||||
|
||||
// ServerMailSPF is the SPF record of the server's own name. Without it
|
||||
// notifications are still sent, which is said under the value while it is not
|
||||
// in place.
|
||||
func ServerMailSPF(host, value string, c DNSCheck) Record {
|
||||
r := Record{Name: "SPF", Note: Plain("For the server's own name, not for your domains"),
|
||||
Host: host, Type: "TXT", Value: value}
|
||||
var standing Text
|
||||
if c.Status != "ok" {
|
||||
standing = Plain("Notifications are sent without it, and a receiver may file them as spam.")
|
||||
}
|
||||
applyCheck(&r, c, standing, nil)
|
||||
r.Status = mailTag(c)
|
||||
return r
|
||||
}
|
||||
|
||||
// DMARCRecordInput is what a DMARC record needs besides its check: where the
|
||||
// reports go in words (Source: the address, or "no reports") and the page that changes it,
|
||||
// the page of the reports themselves ("" when the panel has none), and whether
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
// the two actions: Re-check DNS in the head, Reload configuration under the tables
|
||||
`action="/server/health/recheck"`, `action="/server/health/reload"`,
|
||||
// the anchors the Overview cards lead to
|
||||
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="configuration"`,
|
||||
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="notification-mail"`, `id="configuration"`,
|
||||
// the machine card: bars carry their reading in an attribute (the CSP rules out sizing them with a style)
|
||||
`<progress class="progress is-small is-success mb-1" value="12" max="100">`, `value="41"`,
|
||||
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
|
||||
@@ -158,8 +158,8 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
`hx-get="/server/health/fragment"`,
|
||||
)
|
||||
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
|
||||
if got := strings.Count(out, `class="columns"`); got != 2 {
|
||||
t.Errorf("Health has %d rows of boxes, want 2", got)
|
||||
if got := strings.Count(out, `class="columns"`); got != 3 {
|
||||
t.Errorf("Health has %d rows of boxes, want 3", got)
|
||||
}
|
||||
// The Configuration box is outside the polled rows: a refresh must not re-render a control.
|
||||
if strings.Index(out, `id="configuration"`) < strings.Index(out, `id="health-checks"`) ||
|
||||
@@ -169,29 +169,54 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
}
|
||||
|
||||
// The fragment is the polled part of the page and nothing else: the same rows,
|
||||
// the second one marked to be swapped in by its id, no shell and no actions.
|
||||
// the second one marked to be swapped in by its id, and the Notification mail
|
||||
// card, a column of the last row, swapped in the same way. No shell and no
|
||||
// actions.
|
||||
func TestHealthFragmentIsThePolledRows(t *testing.T) {
|
||||
page := renderSignedIn(t, "health", healthFixture(false))
|
||||
frag := renderFragment(t, "health_body", healthFixture(true))
|
||||
|
||||
const card = `<div class="column is-6" id="health-mail"`
|
||||
// The part of s before the card, and the card itself up to what follows it
|
||||
// on the page (the Configuration box).
|
||||
rows := func(s string) string {
|
||||
a := strings.Index(s, `<div class="columns" id="health-machine"`)
|
||||
b := strings.Index(s, `id="configuration"`)
|
||||
if b < 0 {
|
||||
b = len(s)
|
||||
return s[strings.Index(s, `<div class="columns" id="health-machine"`):strings.Index(s, card)]
|
||||
}
|
||||
mail := func(s string) string {
|
||||
a := strings.Index(s, card)
|
||||
if b := strings.Index(s, `id="configuration"`); b > a {
|
||||
return s[a:b]
|
||||
}
|
||||
// Same markup, apart from the out-of-band mark and what follows the rows on the page.
|
||||
got := strings.Replace(strings.TrimSpace(frag), ` hx-swap-oob="true"`, "", 1)
|
||||
want := strings.TrimSpace(rows(page))
|
||||
if !strings.HasPrefix(want, got) {
|
||||
return s[a:]
|
||||
}
|
||||
// Same markup, apart from the out-of-band marks and what follows the card on the page.
|
||||
oob := func(s string) string { return strings.ReplaceAll(strings.TrimSpace(s), ` hx-swap-oob="true"`, "") }
|
||||
if got, want := oob(rows(frag)), oob(rows(page)); !strings.HasPrefix(want, got) {
|
||||
t.Errorf("the fragment is not the page's polled rows\nfragment: %.400s\npage: %.400s", got, want)
|
||||
}
|
||||
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`)
|
||||
if got, want := oob(mail(frag)), oob(mail(page)); !strings.HasPrefix(want, got) {
|
||||
t.Errorf("the fragment's Notification mail card is not the page's\nfragment: %.400s\npage: %.400s", got, want)
|
||||
}
|
||||
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`,
|
||||
`id="health-mail" hx-swap-oob="true"`, `id="notification-mail"`)
|
||||
pageLacks(t, "fragment", frag, `<h1`, `navbar`, `action="/server/health/reload"`, `action="/server/health/recheck"`)
|
||||
}
|
||||
|
||||
// The Notification mail card says which record is in place and which is not,
|
||||
// with the host each is published at; a missing record is a warning, since
|
||||
// notifications are sent without it. The way to the values is a link.
|
||||
func TestHealthNotificationMailCard(t *testing.T) {
|
||||
out := renderSignedIn(t, "health", healthFixture(false))
|
||||
pageHas(t, "Health", out,
|
||||
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
|
||||
`href="/help#notifications"`,
|
||||
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
|
||||
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
|
||||
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
|
||||
`href="/server/settings#notification-mail"`,
|
||||
)
|
||||
}
|
||||
|
||||
// A machine whose counters could not be read keeps its rows: the figure becomes
|
||||
// a dash and the reason stays in the detail column.
|
||||
func TestHealthMachineWithoutReadings(t *testing.T) {
|
||||
@@ -289,15 +314,17 @@ func TestAccountEscapesTheFormValues(t *testing.T) {
|
||||
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
|
||||
}
|
||||
|
||||
// Event notifications are not built (roadmap: panel-notifications), and a page
|
||||
// carries no box for a feature that does not exist.
|
||||
func TestAccountAndSettingsHaveNoNotificationsBox(t *testing.T) {
|
||||
for name, out := range map[string]string{
|
||||
"Account": renderSignedIn(t, "account", accountFixture()),
|
||||
"Settings": renderSignedIn(t, "settings", settingsFixture()),
|
||||
} {
|
||||
pageLacks(t, name, out, `<h2>Notifications</h2>`, `type="checkbox"`, `/account/notifications`, `name="notify"`)
|
||||
}
|
||||
// The checkbox under the e-mail is the user's own switch for notifications: it
|
||||
// shows what is stored, and an unchecked box is the absence of the attribute.
|
||||
func TestAccountShowsTheNotifyCheckbox(t *testing.T) {
|
||||
a := accountFixture()
|
||||
out := renderSignedIn(t, "account", a)
|
||||
pageHas(t, "Account", out, `<input type="checkbox" name="notify" checked> Send me notifications`, `href="/help#notifications"`)
|
||||
|
||||
a.Notify = false
|
||||
out = renderSignedIn(t, "account", a)
|
||||
pageHas(t, "Account", out, `<input type="checkbox" name="notify"> Send me notifications`)
|
||||
pageLacks(t, "Account", out, `name="notify" checked`)
|
||||
}
|
||||
|
||||
// ---- Settings
|
||||
@@ -310,6 +337,54 @@ func TestSettingsIsOneFormWithOneSaveButton(t *testing.T) {
|
||||
pageHas(t, "Settings", out, `Save settings`, `<h1 class="title is-3">Settings</h1>`, `href="/account"`)
|
||||
}
|
||||
|
||||
// Notifications are one switch for the instance, saved with the rest of the
|
||||
// page, and the records of the server's own name are drawn like a domain's.
|
||||
func TestSettingsNotificationsSwitchAndRecords(t *testing.T) {
|
||||
s := settingsFixture()
|
||||
out := renderSignedIn(t, "settings", s)
|
||||
pageHas(t, "Settings", out,
|
||||
`<input type="checkbox" name="notify" checked> Send notification e-mails`,
|
||||
`From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="/account">Account</a>.`,
|
||||
`id="notification-mail"`, `href="/help#notifications"`,
|
||||
// DKIM is published; SPF is not, and says what that costs.
|
||||
`<h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small>`,
|
||||
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small>`,
|
||||
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
|
||||
`Notifications are sent without it, and a receiver may file them as spam.`,
|
||||
)
|
||||
|
||||
// Switched off, and both records in place: the box is unchecked and the
|
||||
// warning under SPF is gone.
|
||||
s.Notify = false
|
||||
s.WithMail([]Record{
|
||||
ServerMailDKIM("sp2026", mailFixtureDKIMHost, "v=DKIM1; k=rsa; p=AAAA", DNSCheck{Status: "ok"}),
|
||||
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "ok"}),
|
||||
})
|
||||
out = renderSignedIn(t, "settings", s)
|
||||
pageHas(t, "Settings", out, `<input type="checkbox" name="notify"> Send notification e-mails`,
|
||||
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`)
|
||||
pageLacks(t, "Settings", out, `name="notify" checked`, `a receiver may file them as spam`)
|
||||
}
|
||||
|
||||
// A record of the server's own name that is wrong or missing is a warning, never
|
||||
// the red of a sending domain's: notifications are sent without it.
|
||||
func TestMailRecordsAreWarningsNotErrors(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
check DNSCheck
|
||||
want Tag
|
||||
}{
|
||||
{DNSCheck{Status: "ok"}, Tag{Status: "published", Label: "published"}},
|
||||
{DNSCheck{Status: "error"}, Tag{Status: "warn", Label: "missing"}},
|
||||
{DNSCheck{Status: "error", Found: []string{"v=spf1 -all"}}, Tag{Status: "warn", Label: "mismatch"}},
|
||||
{DNSCheck{Status: "warn"}, Tag{Status: "warn", Label: "weak"}},
|
||||
{DNSCheck{Status: "unknown"}, Tag{Status: "unknown", Label: "not checked"}},
|
||||
} {
|
||||
if got := NewMailRow("SPF", "mail.example.org", c.check).State; got != c.want {
|
||||
t.Errorf("a record that checked as %+v is %+v, want %+v", c.check, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsShowsTheRefusalAndKeepsWhatWasTyped(t *testing.T) {
|
||||
s := NewSettings(admin(), "3", FormatRate(600, 3600)).WithResult("", "Send log retention must be between 7 and 365.")
|
||||
out := renderSignedIn(t, "settings", s)
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="/help#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
{{/* Server > Health (mockup health.html): every check behind the cards of
|
||||
Overview, and the one action that is not about DNS, Reload configuration. The
|
||||
two rows of tables are the polled part (health_body.html); the head and the
|
||||
Configuration box are static. */}}
|
||||
two rows of tables are the polled part (health_body.html), and so is the
|
||||
Notification mail card (health_mail), which sits in the last row beside the
|
||||
static Configuration box. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
|
||||
{{template "health_body" .}}
|
||||
|
||||
<div class="columns">
|
||||
{{template "health_mail" .}}
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Configuration}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
{{template "action" .Reload}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
fragment (GET /server/health/fragment) alike. panel.js polls the first row
|
||||
(data-poll, outerHTML) and htmx swaps the second into place by its id when
|
||||
the fragment marks it out of band (.Refresh) — the two rows are siblings, and
|
||||
a fragment can replace only the element that asked for it. */}}
|
||||
a fragment can replace only the element that asked for it. The Notification
|
||||
mail card is the third thing the fragment swaps in by id: it sits in a row of
|
||||
the page with the static Configuration box, so it is a column of its own. */}}
|
||||
{{define "health_body"}}<div class="columns" id="health-machine" data-poll aria-live="polite" hx-get="/server/health/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Machine}}
|
||||
@@ -58,4 +60,21 @@
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>{{if .Refresh}}
|
||||
{{template "health_mail" .}}{{end}}{{end}}
|
||||
|
||||
{{/* health_mail is the Notification mail card (the page calls it inside its own
|
||||
row; the fragment sends it out of band). */}}
|
||||
{{define "health_mail"}}<div class="column is-6" id="health-mail"{{if .Refresh}} hx-swap-oob="true"{{end}}>
|
||||
{{template "box_open" .NotifyMail}}
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>{{range .NotifyRows}}
|
||||
<tr><td>{{.Name}}<br><span class="sp-muted sp-mono sp-small">{{.Host}}</span></td><td>{{template "tag" .State}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "body_open"}}
|
||||
<p class="sp-muted">{{template "inline" .NotifyNote}}</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>{{end}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{{/* Server > Settings (mockup settings.html): what holds for the whole instance.
|
||||
One form, one Save button; the rate limits are read-only facts. A user's own
|
||||
name, e-mail, password and DMARC address are on Account (account.html). */}}
|
||||
One form, one Save button; the rate limits are read-only facts, and the
|
||||
records of the server's own name are the ones notifications are signed and
|
||||
authorised with. A user's own name, e-mail, password and DMARC address are on
|
||||
Account (account.html). */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
@@ -19,15 +21,26 @@
|
||||
{{end}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .RateLimits}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .RateFacts}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Notifications}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send notification e-mails</label>
|
||||
<p class="help">{{template "inline" .NotifyNote}}</p></div>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .Mail}}
|
||||
{{range .MailRecords}}{{template "dns_record" .}}
|
||||
{{end}}{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
@@ -53,6 +53,16 @@ func testDeliveryLogSurvivesRotation(t *testing.T, sc *scenario) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The rotation ends with `postfix reload`, which fails while Postfix is
|
||||
// still starting - and this step follows one that restarts the container.
|
||||
if err := waitFor("Postfix to be running", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
if _, err := h.execIn("selfpost", "postfix", "status"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.execIn("selfpost", "logrotate", "-f", "/etc/logrotate.d/mail"); err != nil {
|
||||
t.Fatalf("logrotate -f: %v", err)
|
||||
}
|
||||
|
||||
@@ -263,9 +263,15 @@ func TestE2E(t *testing.T) {
|
||||
sc.deliveryPage = page
|
||||
})
|
||||
|
||||
run("notifications_set_up", func(t *testing.T) {
|
||||
testNotificationsSetUp(t, sc)
|
||||
})
|
||||
run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
||||
testLevel2RateLimit(t, sc)
|
||||
})
|
||||
run("rate_limit_refusal_is_notified", func(t *testing.T) {
|
||||
testRateLimitRefusalIsNotified(t, sc)
|
||||
})
|
||||
run("negative_sender_login_mismatch", func(t *testing.T) {
|
||||
testSenderLoginMismatch(t, sc)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The server's own name in the run, and where the administrator is written to:
|
||||
// a mailbox behind the sink-MX, so a notification can be read back.
|
||||
const (
|
||||
serverName = "mail.e2e.test"
|
||||
adminEmail = "admin@sink.e2e.test"
|
||||
)
|
||||
|
||||
// serverMailRecord scrapes the DKIM record of the server's own name off
|
||||
// Server › Settings, where the panel tells the administrator to publish it.
|
||||
func (c *panelClient) serverMailRecord() (name, value string, err error) {
|
||||
_, body, err := c.get("/server/settings")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
name, ok := extractCodeRow(body, "Host / name")
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("could not find the server's DKIM record name on Settings")
|
||||
}
|
||||
value, ok = extractCodeRow(body, "Value to publish")
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("could not find the server's DKIM record value on Settings")
|
||||
}
|
||||
return name, value, nil
|
||||
}
|
||||
|
||||
// setAccountEmail saves the signed-in user's profile with an e-mail and
|
||||
// notifications on.
|
||||
func (c *panelClient) setAccountEmail(username, email string) error {
|
||||
resp, body, err := c.postForm("/account/profile", url.Values{
|
||||
"username": {username},
|
||||
"email": {email},
|
||||
"notify": {"on"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("save profile: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
_, page, err := c.get("/account")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(page, email) {
|
||||
return fmt.Errorf("the Account page does not show %s after saving it", email)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// testNotificationsSetUp gives the administrator a mailbox and publishes the
|
||||
// DKIM record of the server's own name, as an operator would after reading
|
||||
// Server › Settings. It runs before the step that makes a rate limit refuse
|
||||
// mail, so that the refusal finds someone to tell.
|
||||
func testNotificationsSetUp(t *testing.T, sc *scenario) {
|
||||
if err := sc.panel.setAccountEmail(adminUser, adminEmail); err != nil {
|
||||
t.Fatalf("set the administrator's e-mail: %v", err)
|
||||
}
|
||||
|
||||
name, value, err := sc.panel.serverMailRecord()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(name, "._domainkey."+serverName) {
|
||||
t.Fatalf("the record on Settings is %q, want a DKIM key of %s", name, serverName)
|
||||
}
|
||||
rel, ok := trimZoneSuffix(name)
|
||||
if !ok {
|
||||
t.Fatalf("record name %q is not under e2e.test", name)
|
||||
}
|
||||
records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: rel, value: value})
|
||||
if err != nil {
|
||||
t.Fatalf("publish the server's dkim record to the fake zone: %v", err)
|
||||
}
|
||||
sc.zoneRecords = records
|
||||
}
|
||||
|
||||
// testRateLimitRefusalIsNotified checks the whole path of a notification: the
|
||||
// level-2 refusal of the previous step is noticed by the notifier (it looks
|
||||
// every minute), written to the administrator, submitted by the unprivileged
|
||||
// panel through sendmail, signed by OpenDKIM with the key of the server's own
|
||||
// name and delivered. It also holds what a notification is not: a row of the
|
||||
// Outbound log. That it is said once is for the unit tests to hold
|
||||
// (internal/notify): here it would be a minute of waiting for nothing to happen.
|
||||
func testRateLimitRefusalIsNotified(t *testing.T, sc *scenario) {
|
||||
subject := "[SelfPost] " + senderDomain + ": a rate limit is refusing mail"
|
||||
raw, err := findSinkMessage(h.stageDir, subject, 100*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("no notification reached %s: %v\npanel log:\n%s", adminEmail, err, tailLines(h.logs("selfpost"), "notify", 20))
|
||||
}
|
||||
msg := string(raw)
|
||||
for _, want := range []string{
|
||||
"From: SelfPost <postmaster@" + serverName + ">",
|
||||
"To: <" + adminEmail + ">",
|
||||
"Auto-Submitted: auto-generated",
|
||||
"refused",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("the notification lacks %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
if err := verifyDKIM(raw, serverName); err != nil {
|
||||
t.Errorf("the notification is not signed as %s with the key Settings shows: %v", serverName, err)
|
||||
}
|
||||
|
||||
// The panel's own mail is not an application's: no row in the log.
|
||||
rows, err := sc.panel.sendLogRows(senderDomain, "")
|
||||
if err != nil {
|
||||
t.Fatalf("read the send log: %v", err)
|
||||
}
|
||||
if strings.Contains(rows, "rate limit is refusing") || strings.Contains(rows, "postmaster@"+serverName) {
|
||||
t.Errorf("a notification is listed in the Outbound log:\n%s", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// tailLines returns the last n lines of log that contain filter.
|
||||
func tailLines(log, filter string, n int) string {
|
||||
var keep []string
|
||||
for _, line := range strings.Split(log, "\n") {
|
||||
if strings.Contains(line, filter) {
|
||||
keep = append(keep, line)
|
||||
}
|
||||
}
|
||||
if len(keep) > n {
|
||||
keep = keep[len(keep)-n:]
|
||||
}
|
||||
return strings.Join(keep, "\n")
|
||||
}
|
||||
Reference in New Issue
Block a user