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:
@@ -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.",
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user