panel-notifications: guide, architecture, security; refusals looked for every minute

guide.md gains Notifications: the four things, when each is said and said
again, who is told, the two switches, and the two DNS records of the server's
own name that make the mail arrive. architecture.md: the notifier as the
fifth role, its readings, state, recipients and sending; the server's own key
in OpenDKIM's tables; non_smtpd_milters. security.md: the panel submits mail
of its own - how, to whom, and what text from outside can reach a body.

That text - a queued message's sender, a DNS finding - is reduced to
printable ASCII and cut before it is printed. Refused mail is looked for
every minute instead of every five: it is one indexed query, and a refusal is
mail not being sent now. The image's note on the panel's postdrop membership
no longer calls it read-side only.

go test ./internal/notify passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 16:00:06 +03:00
co-authored by Claude Opus 5.5
parent 50fd6b859f
commit c9027381e5
8 changed files with 149 additions and 14 deletions
+3 -1
View File
@@ -76,7 +76,9 @@ RUN groupadd --system --gid 998 selfpost \
# with `no-new-privileges`, which disables setgid transitions, so the panel # with `no-new-privileges`, which disables setgid transitions, so the panel
# would always see "Permission denied". Making `panel` a real member of postdrop # would always see "Permission denied". Making `panel` a real member of postdrop
# grants the same access without relying on a setgid escalation the hardening # 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 # the panel bypass any Postfix restriction that a local user does not already
# have through the world-executable sendmail. # have through the world-executable sendmail.
RUN usermod -aG postdrop panel RUN usermod -aG postdrop panel
+52 -1
View File
@@ -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 Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
by the panel. Socket `/run/opendkim/opendkim.sock`. 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)) ### 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 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 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*). page (§ Log tailer, *Stored lines*).
4. **rate-limit recalc** — every six hours (and on demand from Domain settings and 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. 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). 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 ### Log tailer and `mail.log` rotation
+53 -5
View File
@@ -33,6 +33,7 @@ changed (see the CHANGELOG, *Removed*).
- [Queue and System log](#queue-and-system-log) - [Queue and System log](#queue-and-system-log)
- [Account](#account) - [Account](#account)
- [Settings](#settings) - [Settings](#settings)
- [Notifications](#notifications)
- [Users](#users) - [Users](#users)
- [Sessions](#sessions) - [Sessions](#sessions)
- [Upgrading](#upgrading) - [Upgrading](#upgrading)
@@ -419,10 +420,11 @@ readings:
**Account** (`/account`, every role) is the signed-in user's own: **Profile** **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 (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 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 like a sign-in. The e-mail is the user's own address: it is where the panel
yet; it is what a domain's *Use my e-mail* button fills in (see [Domain-level sends [notifications](#notifications) while *Send me notifications* is ticked
DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins are separate beside it, and what a domain's *Use my e-mail* button fills in (see
and are not changed here. [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc)). Application SASL logins
are separate and are not changed here.
### Settings ### 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 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 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)). 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 ### Users
+2 -2
View File
@@ -111,9 +111,9 @@ to the owner before any code ([panel-redesign.md](panel-redesign.md)
- [x] Migration and store: user flag, instance switch, event state - [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] 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] 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 five minutes) - [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute)
- [ ] Panel: Account, Settings, Health check, help - [ ] Panel: Account, Settings, Health check, help
- [ ] guide.md, architecture.md, security.md, schema-migrations.md - [x] guide.md, architecture.md, security.md, schema-migrations.md
- [ ] `go vet`, `go test`, e2e: an event reaches a mailbox once and clears once - [ ] `go vet`, `go test`, e2e: an event reaches a mailbox once and clears once
- [ ] Technical review - [ ] Technical review
- [ ] Security review - [ ] Security review
+1 -1
View File
@@ -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 | — | — | | template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
| structured-logging | `log/slog` with levels and fields | candidate | — | — | | structured-logging | `log/slog` with levels and fields | candidate | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
| panel-notifications | E-mail notifications about important events | in progress | 5/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | | panel-notifications | E-mail notifications about important events | in progress | 6/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) | | 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 | — | — | | password-reset | Password reset by e-mail | candidate | — | — |
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — | | phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
+15
View File
@@ -412,6 +412,21 @@ deferred item from the roadmap.
diagnostics. Since 2.2.0 the tailer copies the lines of queued outbound 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 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. 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 - **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 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. arrival and the queue file's inode, and inodes come round within seconds.
+3 -2
View File
@@ -29,8 +29,9 @@ const (
// at. A var-free constant: the rules below count in checks. // at. A var-free constant: the rules below count in checks.
CheckInterval = time.Hour CheckInterval = time.Hour
// LimitInterval is how often refused mail is looked for. A refusal is // LimitInterval is how often refused mail is looked for. A refusal is
// reported at once rather than on the hour: it is mail not being sent now. // reported within the minute rather than on the hour: it is mail not being
LimitInterval = 5 * time.Minute // 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 // 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 // reported, and before it is reported gone. One check is a resolver's bad
+20 -2
View File
@@ -35,6 +35,24 @@ func compose(hostname, subject string, paragraphs ...string) message {
func stamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") } 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. // days words a duration in whole days, the unit these messages count in.
func days(d time.Duration) string { func days(d time.Duration) string {
n := int(d.Hours() / 24) n := int(d.Hours() / 24)
@@ -49,7 +67,7 @@ func dnsProblem(hostname, domain string, faults []dnsFault, since time.Time) mes
lines := make([]string, len(faults)) lines := make([]string, len(faults))
for i, f := range faults { for i, f := range faults {
names[i] = f.Record names[i] = f.Record
lines[i] = f.Record + ": " + f.Detail lines[i] = f.Record + ": " + plain(f.Detail, 600)
} }
return compose(hostname, return compose(hostname,
fmt.Sprintf("%s: %s no longer matches", domain, strings.Join(names, ", ")), fmt.Sprintf("%s: %s no longer matches", domain, strings.Join(names, ", ")),
@@ -91,7 +109,7 @@ func queueStuck(hostname string, old []postfix.QueuedMessage, now time.Time) mes
if sender == "" { if sender == "" {
sender = "(bounce)" sender = "(bounce)"
} }
fmt.Fprintf(&b, "%s from %s waiting %s, since %s\n", m.ID, sender, days(now.Sub(m.Arrival)), stamp(m.Arrival)) 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" what := "A message has"
if len(old) > 1 { if len(old) > 1 {