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
+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
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
+53 -5
View File
@@ -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
+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] 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 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
- [ ] 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
- [ ] Technical 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 | — | — |
| 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 | 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) |
| password-reset | Password reset by e-mail | 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
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.