panel-notifications: the notifier, its mail and the server's own DKIM key

internal/notify applies the owner's rules. Each subject has a row of state,
so a thing is said once and a restart says nothing again:

- DNS of a sending domain: said after two hourly checks in a row found a
  record wrong, said to be over after two found it right. A lookup that did
  not answer moves nothing; a warning (no DMARC record) is not a subject.
- TLS certificate: at 14 days and at 3, per certificate.
- Queue: a message older than three days, once; once more when none is.
- Level-2 limit: from the rejected rows of the send log, every five minutes;
  at most once a day per domain and application.

Global users are told everything, domain users about their domains; one
message per recipient. With the instance switch off nothing is checked and no
state moves. A message that could not be handed to Postfix is tried again at
the next check.

Mail leaves by sendmail(1) as postmaster@<hostname>, addresses after "--",
no shell. For it to be believed the server's own name is signed like a
sending domain: domain.Service keeps a key for it and a line in OpenDKIM's
tables without a registry row, and non_smtpd_milters now names OpenDKIM. A
sending domain of the same name shares its key with the server.
dnscheck.ServerMail checks the name's DKIM and SPF records;
postfix.QueueMessages reads postqueue -j for arrival times.

The notifier is a sixth role of the panel process. Nothing in the panel
shows or switches it yet.

Checked on Windows: go vet clean; go test passes for notify, store, postfix,
dnscheck, cmd, and domain apart from TestWriteLoadPrivateKeyRoundtrip, which
fails there on file modes and always did. Not yet run in a container: whether
the unprivileged panel may submit is for the e2e step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 15:56:13 +03:00
co-authored by Claude Opus 5.5
parent 06ad174fb9
commit 50fd6b859f
17 changed files with 1547 additions and 9 deletions
+131
View File
@@ -0,0 +1,131 @@
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") }
// 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 + ": " + f.Detail
}
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", m.ID, sender, 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.",
)
}