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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user