Files
selfpost/internal/notify/notify_test.go
T
mixandClaude Opus 5.5 006bed7022
test / test (push) Canceled after 0s
audit 4: DNS outages unknown, DMARC limits said, typed Health, one TLS mode per upstream
M14, M15: queryEach gives each resolver 2 s within the check's context and
asks the one that answered last first; an address or PTR lookup not
answered is Unknown, an answered mismatch beside it still an error;
Checker.Server keeps the last answer's addresses, so SPF does not go Unknown
with it. Notifications report faults only, now tested with SPF unknown
alone and beside a wrong DKIM.

DMARC reports: tests of malformed XML, gzip and zip bombs, a message over
the limit, the service's Resync, hosted suggestion and validation. The
finding: both limits cut silently and an oversized report failed as
damaged; readAtMost refuses it for its size (MaxMessage 10 MiB, moved into
IngestMessage).

healthReadings replaces map[string]any for Health and Overview.

L5: an inbound upstream has one TLS mode - a second domain to the same
[host]:port with another mode is refused, naming the first; the TLS policy
map holds each next hop once.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 631 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:15:58 +03:00

647 lines
22 KiB
Go

package notify
import (
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// outbox collects what would have been handed to Postfix.
type outbox struct {
sent []string // "to|subject"
body []string
fail bool
failTo map[string]bool // addresses whose copy fails
}
func (o *outbox) Send(to, subject, body string) error {
if o.fail || o.failTo[to] {
return errors.New("sendmail: queue file write error")
}
o.sent = append(o.sent, to+"|"+subject)
o.body = append(o.body, body)
return nil
}
func (o *outbox) take() []string {
s := o.sent
o.sent, o.body = nil, nil
return s
}
// bench is a notifier over a real database with a clock and readings the test
// sets: one global user, one user of example.com, one user of another domain.
type bench struct {
n *Notifier
st *store.Store
out *outbox
now time.Time
dns map[string]dnscheck.Domain
cert health.Certificate
q []postfix.QueuedMessage
qErr error
}
func newBench(t *testing.T) *bench {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
b := &bench{st: st, out: &outbox{}, now: time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC), dns: map[string]dnscheck.Domain{}}
mine, err := st.AddDomain("example.com", "sp")
if err != nil {
t.Fatal(err)
}
other, err := st.AddDomain("other.example", "sp")
if err != nil {
t.Fatal(err)
}
for _, u := range []struct {
name string
role store.Role
reach store.Reach
}{
{"root", store.RoleGlobal, store.Reach{}},
{"mine", store.RoleDomain, store.Reach{DomainIDs: []int64{mine.ID}}},
{"theirs", store.RoleDomain, store.Reach{DomainIDs: []int64{other.ID}}},
} {
id, err := st.CreateUser(u.name, "hash", u.role, u.reach)
if err != nil {
t.Fatal(err)
}
if err := st.UpdateUser(id, u.name, "hash", u.name+"@example.org"); err != nil {
t.Fatal(err)
}
}
b.n = New(st, Checks{
Domain: func(d store.Domain) (dnscheck.Domain, error) {
res, ok := b.dns[d.Name]
if !ok {
return good(), nil
}
return res, nil
},
Certificate: func() health.Certificate { return b.cert },
Queue: func() ([]postfix.QueuedMessage, error) { return b.q, b.qErr },
}, b.out, "mail.example.org")
b.n.now = func() time.Time { return b.now }
return b
}
// hour runs the hourly check and moves the clock on by an hour.
func (b *bench) hour() []string {
b.n.Check()
b.now = b.now.Add(time.Hour)
return b.out.take()
}
func good() dnscheck.Domain {
ok := dnscheck.Result{Status: health.StatusOK}
return dnscheck.Domain{DKIM: ok, SPF: ok, DMARC: ok}
}
func badDKIM() dnscheck.Domain {
d := good()
d.DKIM = dnscheck.Result{Status: health.StatusError, Detail: "A TXT record exists but its public key is not the one this server signs with."}
return d
}
func want(t *testing.T, what string, got []string, wanted ...string) {
t.Helper()
if strings.Join(got, "\n") != strings.Join(wanted, "\n") {
t.Errorf("%s: sent\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(wanted, "\n "))
}
}
// A DNS mismatch is said once, after two checks in a row found it, to the
// global users and the domain's own; it is said to be over once, after two
// checks in a row found the records right.
func TestDNSMismatchIsSaidOnceAndClearedOnce(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
want(t, "first bad check", b.hour())
problem := "[SelfPost] example.com: DKIM does not match"
want(t, "second bad check", b.hour(), "mine@example.org|"+problem, "root@example.org|"+problem)
want(t, "third bad check", b.hour())
b.dns["example.com"] = good()
want(t, "first good check", b.hour())
// One good check does not end it: wrong again is the same trouble, not a new one.
b.dns["example.com"] = badDKIM()
want(t, "wrong again", b.hour())
b.dns["example.com"] = good()
want(t, "good once more", b.hour())
cleared := "[SelfPost] example.com: DNS records match again"
want(t, "second good check in a row", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
want(t, "after it cleared", b.hour())
if _, have, _ := b.st.GetNotifyState("dns:example.com"); have {
t.Error("state left behind after the trouble was over")
}
}
// One bad check followed by a good one was the resolver, and nothing is said.
// A lookup that did not answer says nothing either way; a warning is advice
// the page gives, not a thing to write about.
func TestDNSNoiseIsNotSaid(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
want(t, "one bad check", b.hour())
b.dns["example.com"] = good()
want(t, "then good", b.hour())
b.dns["example.com"] = badDKIM()
want(t, "bad again, counted from one", b.hour())
unknown := good()
unknown.SPF = dnscheck.Result{Status: health.StatusUnknown, Detail: "Could not look up the SPF record."}
b.dns["example.com"] = unknown
want(t, "resolver did not answer", b.hour())
if st, have, _ := b.st.GetNotifyState("dns:example.com"); !have || st.Count != 1 {
t.Errorf("an unanswered lookup moved the state: %+v, %v", st, have)
}
warn := good()
warn.DMARC = dnscheck.Result{Status: health.StatusWarn, Detail: "No DMARC record."}
b.dns["other.example"] = warn
want(t, "a warning", b.hour())
want(t, "a warning, twice", b.hour())
}
// The instance switch stops everything, state included; a domain that is
// deleted takes its state with it; a message that could not be handed over is
// tried again at the next check.
func TestDNSSwitchDeletionAndRetry(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
if err := b.st.SetNotificationsEnabled(false); err != nil {
t.Fatal(err)
}
want(t, "switched off", b.hour())
want(t, "switched off", b.hour())
if _, have, _ := b.st.GetNotifyState("dns:example.com"); have {
t.Fatal("state moved while notifications were off")
}
if err := b.st.SetNotificationsEnabled(true); err != nil {
t.Fatal(err)
}
want(t, "on again, first check", b.hour())
b.out.fail = true
want(t, "second check, mail system down", b.hour())
b.out.fail = false
if got := b.hour(); len(got) != 2 {
t.Errorf("after the mail system came back: %v, want the two messages", got)
}
domains, _ := b.st.ListDomains()
for _, d := range domains {
if d.Name == "example.com" {
if err := b.st.DeleteDomain(d.ID); err != nil {
t.Fatal(err)
}
}
}
want(t, "domain deleted", b.hour())
if _, have, _ := b.st.GetNotifyState("dns:example.com"); have {
t.Error("a deleted domain kept its state")
}
}
// The certificate is said at 14 days and again at 3 if it is still the same
// one, to global users only; a new certificate starts over.
func TestCertificateIsSaidTwice(t *testing.T) {
b := newBench(t)
expiry := b.now.Add(20 * 24 * time.Hour)
b.cert = health.Certificate{NotAfter: expiry}
want(t, "20 days left", b.hour())
b.now = expiry.Add(-14 * 24 * time.Hour)
want(t, "14 days left", b.hour(), "root@example.org|[SelfPost] TLS certificate of mail.example.org expires in 14 days")
want(t, "an hour later", b.hour())
b.now = expiry.Add(-8 * 24 * time.Hour)
want(t, "8 days left", b.hour())
b.now = expiry.Add(-3 * 24 * time.Hour)
want(t, "3 days left", b.hour(), "root@example.org|[SelfPost] TLS certificate of mail.example.org expires in 3 days")
b.now = expiry.Add(time.Hour)
want(t, "expired, already said", b.hour())
// Renewed: nothing to say, and the next expiry is a new subject.
renewed := b.now.Add(90 * 24 * time.Hour)
b.cert = health.Certificate{NotAfter: renewed}
want(t, "renewed", b.hour())
b.now = renewed.Add(-2 * 24 * time.Hour)
want(t, "the new one, first seen with 2 days left", b.hour(), "root@example.org|[SelfPost] TLS certificate of mail.example.org expires in 2 days")
// No certificate configured: Health says so, mail does not.
b.cert = health.Certificate{Status: health.StatusUnknown}
want(t, "no certificate", b.hour())
}
// The certificate Postfix serves nears its end while a renewal waits in the
// mount: nothing is said while Postfix's check for it is still to come, and
// the usual notice once the renewal has waited past it (A-65).
func TestCertificateWithARenewalWaiting(t *testing.T) {
b := newBench(t)
expiry := b.now.Add(10 * 24 * time.Hour)
b.cert = health.Certificate{NotAfter: expiry, Renewal: &health.Renewal{NotAfter: expiry.Add(80 * 24 * time.Hour)}}
want(t, "renewal waiting", b.hour())
b.cert.Renewal.Late = true
want(t, "renewal late", b.hour(), "root@example.org|[SelfPost] TLS certificate of mail.example.org expires in 10 days")
}
// A message older than three days is said once, and its going once; a queue
// that cannot be listed says nothing.
func TestQueueIsSaidOnceAndClearedOnce(t *testing.T) {
b := newBench(t)
b.q = []postfix.QueuedMessage{
{ID: "FRESH001", Queue: "deferred", Sender: "app@example.com", Arrival: b.now.Add(-2 * 24 * time.Hour)},
}
want(t, "two days old", b.hour())
b.q = append(b.q, postfix.QueuedMessage{ID: "OLD00001", Queue: "deferred", Sender: "app@example.com", Arrival: b.now.Add(-4 * 24 * time.Hour)})
b.n.Check()
if len(b.out.sent) != 1 || b.out.sent[0] != "root@example.org|[SelfPost] A message has been in the queue of mail.example.org for more than 3 days" {
t.Fatalf("four days old: %v", b.out.sent)
}
if body := b.out.body[0]; !strings.Contains(body, "OLD00001") || strings.Contains(body, "FRESH001") {
t.Errorf("the message names the wrong mail:\n%s", body)
}
b.out.take()
want(t, "still there", b.hour())
b.qErr = errors.New("postqueue: Permission denied")
b.q = nil
want(t, "queue cannot be listed", b.hour())
b.qErr = nil
want(t, "gone", b.hour(), "root@example.org|[SelfPost] The queue of mail.example.org holds nothing older than 3 days")
want(t, "after", b.hour())
}
// A limit that refuses mail is said to the domain's users, at most once a day
// for the domain, whichever of its applications are refused.
func TestLimitRefusalIsSaidOnceADay(t *testing.T) {
b := newBench(t)
b.now = time.Now().UTC() // refusals are dated by the store's clock
domains, _ := b.st.ListDomains()
var domainID int64
for _, d := range domains {
if d.Name == "example.com" {
domainID = d.ID
}
}
for _, login := range []string{"app1", "app2"} {
a, err := b.st.AddApplication(domainID, login, store.AddressModeWildcard, nil)
if err != nil {
t.Fatal(err)
}
if err := b.st.SetRateLimit(store.RateLimit{Scope: store.RateLimitScopeApp, RefID: a.ID, MaxMessages: 1, WindowSeconds: 3600}); err != nil {
t.Fatal(err)
}
}
refuse := func(login, mailFromDomain string) {
t.Helper()
if err := b.st.InsertRejected(store.SendLogEntry{Domain: mailFromDomain, AppLogin: login, From: "a@" + mailFromDomain}); err != nil {
t.Fatal(err)
}
}
b.n.CheckLimits()
want(t, "nothing refused", b.out.take())
refuse("app1", "example.com")
b.n.CheckLimits()
subject := "[SelfPost] example.com: a rate limit is refusing mail"
want(t, "first refusal", b.out.take(), "mine@example.org|"+subject, "root@example.org|"+subject)
// The second application of the domain is refused, and the first goes on
// under domains it makes up in MAIL FROM - another tenant's among them.
// It is all the same subject: example.com, told today.
refuse("app2", "example.com")
refuse("app1", "other.example")
refuse("app1", "made-up.example")
b.now = b.now.Add(2 * time.Hour)
b.n.CheckLimits()
want(t, "more of it two hours later", b.out.take())
if keys, _ := b.st.ListNotifyStateKeys("limit:"); strings.Join(keys, " ") != "limit:example.com" {
t.Errorf("limit subjects = %v, want the one registered domain", keys)
}
// A day after it was said, mail is still being refused: said again. The
// refusals are dated by the store's clock, so it is the telling that is
// moved back, not the clock forward.
st, have, err := b.st.GetNotifyState("limit:example.com")
if err != nil || !have {
t.Fatalf("no state for the limit: %v, %v", have, err)
}
b.now = time.Now().UTC()
st.SentAt = b.now.Add(-25 * time.Hour)
if err := b.st.SetNotifyState(st); err != nil {
t.Fatal(err)
}
b.n.CheckLimits()
if len(b.out.sent) != 2 || !strings.Contains(b.out.body[0], "4 messages from the applications app1, app2 of example.com") {
t.Fatalf("a day later, still refusing: %v\n%s", b.out.sent, strings.Join(b.out.body, "\n---\n"))
}
b.out.take()
// Nobody was refused for two days: it is not a subject any more.
st.SentAt = b.now.Add(-49 * time.Hour)
if err := b.st.SetNotifyState(st); err != nil {
t.Fatal(err)
}
b.now = b.now.Add(25 * time.Hour) // past the window the refusals lie in
b.n.CheckLimits()
want(t, "long quiet", b.out.take())
if _, have, _ := b.st.GetNotifyState("limit:example.com"); have {
t.Error("a limit nobody hits kept its state")
}
}
// Two checks minutes apart - the last before a restart and the first after it
// - are not "an hour apart": the mismatch is said when a check finds it again
// with the time passed.
func TestDNSChecksMustBeApart(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
b.n.Check()
b.now = b.now.Add(3 * time.Minute) // the process restarted
b.n.Check()
want(t, "two checks three minutes apart", b.out.take())
b.now = b.now.Add(time.Hour)
b.n.Check()
if got := b.out.take(); len(got) != 2 {
t.Fatalf("an hour later: %v, want the two messages", got)
}
// The same for its going: two good checks minutes apart do not end it.
b.dns["example.com"] = good()
b.now = b.now.Add(time.Hour)
b.n.Check()
b.now = b.now.Add(3 * time.Minute)
b.n.Check()
want(t, "two good checks three minutes apart", b.out.take())
b.now = b.now.Add(time.Hour)
b.n.Check()
if got := b.out.take(); len(got) != 2 || !strings.Contains(got[0], "match again") {
t.Errorf("an hour later: %v, want the two messages that it is over", got)
}
}
// What arises while there is nobody to tell is told when there is somebody:
// a thing is said only when it was sent. Its going, with nobody left to tell,
// is simply over.
func TestNobodyToTellIsNotSaid(t *testing.T) {
b := newBench(t)
users, _ := b.st.ListUsers()
for _, u := range users {
if err := b.st.SetUserNotify(u.ID, false); err != nil {
t.Fatal(err)
}
}
expiry := b.now.Add(10 * 24 * time.Hour)
b.cert = health.Certificate{NotAfter: expiry}
b.dns["example.com"] = badDKIM()
b.q = []postfix.QueuedMessage{{ID: "OLD00001", Sender: "app@example.com", Arrival: b.now.Add(-5 * 24 * time.Hour)}}
want(t, "nobody listens", b.hour())
want(t, "nobody listens", b.hour())
for _, u := range users {
if u.Username == "root" {
if err := b.st.SetUserNotify(u.ID, true); err != nil {
t.Fatal(err)
}
}
}
got := b.hour()
if len(got) != 3 {
t.Fatalf("somebody listens now: %v, want the DNS mismatch, the certificate and the queue", got)
}
// Everything is put right while nobody listens again: no state is left to
// announce an ending later.
for _, u := range users {
if err := b.st.SetUserNotify(u.ID, false); err != nil {
t.Fatal(err)
}
}
b.dns["example.com"] = good()
b.q = nil
want(t, "put right", b.hour())
want(t, "put right", b.hour())
for _, key := range []string{"dns:example.com", "queue"} {
if _, have, _ := b.st.GetNotifyState(key); have {
t.Errorf("state %s left behind", key)
}
}
}
// The notifier's own mail in the queue - a notification to a mailbox that does
// not answer, or its bounce coming back to this server's postmaster - is not
// the mail the queue is watched for.
func TestQueueLeavesOutTheNotifiersOwnMail(t *testing.T) {
b := newBench(t)
old := b.now.Add(-4 * 24 * time.Hour)
b.q = []postfix.QueuedMessage{
{ID: "NOTE0001", Sender: "postmaster@mail.example.org", Recipients: []string{"gone@example.net"}, Arrival: old},
{ID: "BOUNCE01", Sender: "", Recipients: []string{"Postmaster@Mail.Example.Org"}, Arrival: old},
}
want(t, "only its own mail is old", b.hour())
b.q = append(b.q, postfix.QueuedMessage{ID: "APP00001", Sender: "", Recipients: []string{"app@example.com"}, Arrival: old})
if got := b.hour(); len(got) != 1 {
t.Errorf("an application's bounce is old too: %v, want one message", got)
}
}
// Whatever the rules arrive at, no more than the ceiling leaves in an hour:
// these messages bypass every rate limit.
func TestHourlyCeiling(t *testing.T) {
b := newBench(t)
for i := 0; i < hourlyCeiling+5; i++ {
b.n.open(fmt.Sprintf("test:%d", i), "", message{subject: "s", body: "b"})
}
if got := len(b.out.take()); got != hourlyCeiling {
t.Errorf("%d messages left in one hour, ceiling is %d", got, hourlyCeiling)
}
if b.n.open("test:x", "", message{subject: "s", body: "b"}) != failed {
t.Error("a message over the ceiling was counted as said")
}
b.now = b.now.Add(61 * time.Minute)
if b.n.open("test:y", "", message{subject: "s", body: "b"}) != sent {
t.Error("the ceiling did not lift after an hour")
}
}
// The subject of a certificate message counts in days as a person would:
// found an hour past the threshold it still has "14 days".
func TestCertificateSubjectRoundsDays(t *testing.T) {
for _, c := range []struct {
left time.Duration
want string
}{
{14*24*time.Hour - time.Hour, "expires in 14 days"},
{3*24*time.Hour - 20*time.Minute, "expires in 3 days"},
{30 * time.Hour, "expires in 1 day"},
{-time.Hour, "has expired"},
} {
m := certExpiring("mail.example.org", time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), c.left)
if !strings.Contains(m.subject, c.want) {
t.Errorf("%v left: subject %q, want %q", c.left, m.subject, c.want)
}
}
}
func TestMailerComposesAndRefuses(t *testing.T) {
var from, to string
var msg []byte
m := NewMailer("mail.example.org")
m.run = func(f, t string, b []byte) error { from, to, msg = f, t, b; return nil }
if err := m.Send("root@example.org", "[SelfPost] example.com:\r\nBcc: x@evil.example", "line one\n.\nline three\n"); err != nil {
t.Fatalf("Send: %v", err)
}
if from != "postmaster@mail.example.org" || to != "root@example.org" {
t.Errorf("envelope %s -> %s", from, to)
}
text := string(msg)
head, body, ok := strings.Cut(text, "\r\n\r\n")
if !ok {
t.Fatalf("no header/body break:\n%s", text)
}
for _, h := range []string{
"From: SelfPost <postmaster@mail.example.org>",
"To: <root@example.org>",
"Subject: [SelfPost] example.com: Bcc: x@evil.example",
"Auto-Submitted: auto-generated",
"Content-Type: text/plain; charset=utf-8",
} {
if !strings.Contains(head, h+"\r\n") && !strings.HasSuffix(head, h) {
t.Errorf("header %q missing from:\n%s", h, head)
}
}
if strings.Count(head, "\r\n")+1 != 9 {
t.Errorf("a subject broke out of its line; headers:\n%s", head)
}
if body != "line one\r\n.\r\nline three\r\n" {
t.Errorf("body = %q", body)
}
for _, bad := range []string{"", "no-at-sign", "-oQ/tmp@example.org", "a@example.org\nBcc: b@example.org", "a b@example.org", "<a@example.org>"} {
if err := m.Send(bad, "s", "b"); err == nil {
t.Errorf("address %q was accepted", bad)
}
}
}
// A notification is owed to each recipient (owner, 2026-10-14): a copy that
// failed goes out at the next check, once, and the subject's end goes to
// those who were told.
func TestACopyThatFailedGoesOutAtTheNextCheck(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
problem := "[SelfPost] example.com: DKIM does not match"
b.out.failTo = map[string]bool{"mine@example.org": true}
want(t, "first bad check", b.hour())
want(t, "said, one copy failing", b.hour(), "root@example.org|"+problem)
b.out.failTo = nil
want(t, "the next check", b.hour(), "mine@example.org|"+problem)
want(t, "and not again", b.hour())
b.dns["example.com"] = good()
want(t, "first good check", b.hour())
cleared := "[SelfPost] example.com: DNS records match again"
want(t, "over", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
}
// The hourly ceiling holds a copy back instead of dropping it: it goes out at
// a check after the hour.
func TestTheCeilingHoldsACopyBack(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
want(t, "first bad check", b.hour())
// All but one of the hour's messages are spent.
for i := 0; i < hourlyCeiling-1; i++ {
b.n.sentAt = append(b.n.sentAt, b.now)
}
problem := "[SelfPost] example.com: DKIM does not match"
got := b.hour()
if len(got) != 1 {
t.Fatalf("under the ceiling with room for one: sent %v", got)
}
want(t, "the next check, the hour over", b.hour(), map[string]string{
"mine@example.org|" + problem: "root@example.org|" + problem,
"root@example.org|" + problem: "mine@example.org|" + problem,
}[got[0]])
want(t, "and not again", b.hour())
}
// A user who comes to hear of a subject while it is open is not mailed about
// it, nor about its end: the message was owed to those there when it was
// said.
func TestAUserAddedWhileASubjectIsOpenIsNotMailed(t *testing.T) {
b := newBench(t)
b.dns["example.com"] = badDKIM()
want(t, "first bad check", b.hour())
problem := "[SelfPost] example.com: DKIM does not match"
want(t, "said", b.hour(), "mine@example.org|"+problem, "root@example.org|"+problem)
id, err := b.st.CreateUser("late", "hash", store.RoleGlobal, store.Reach{})
if err != nil {
t.Fatal(err)
}
if err := b.st.UpdateUser(id, "late", "hash", "late@example.org"); err != nil {
t.Fatal(err)
}
want(t, "still wrong, a user added", b.hour())
b.dns["example.com"] = good()
want(t, "first good check", b.hour())
cleared := "[SelfPost] example.com: DNS records match again"
want(t, "over", b.hour(), "mine@example.org|"+cleared, "root@example.org|"+cleared)
if owed, _ := b.st.NotifyAddressees("dns:example.com"); len(owed) != 0 {
t.Errorf("who the subject was owed to outlived it: %v", owed)
}
}
// A domain is reported broken on what is wrong, never on what is not known: a
// check whose SPF lookup did not answer beside a wrong DKIM says DKIM only,
// and one with SPF unknown alone says nothing (audit 2026-10-12, M14/M15).
func TestDNSUnknownIsNeverReported(t *testing.T) {
b := newBench(t)
unknownSPF := good()
unknownSPF.SPF = dnscheck.Result{Status: health.StatusUnknown, Detail: "Could not check SPF: the DNS lookup failed (timed out)."}
b.dns["example.com"] = unknownSPF
want(t, "SPF unknown", b.hour())
want(t, "SPF unknown again", b.hour())
want(t, "SPF unknown a third time", b.hour())
mixed := badDKIM()
mixed.SPF = unknownSPF.SPF
b.dns["example.com"] = mixed
want(t, "first bad check", b.hour())
problem := "[SelfPost] example.com: DKIM does not match"
b.n.Check()
body := strings.Join(b.out.body, "")
want(t, "DKIM wrong, SPF unknown", b.out.take(), "mine@example.org|"+problem, "root@example.org|"+problem)
if body == "" || strings.Contains(body, "SPF") {
t.Errorf("the message speaks of SPF, which was not known:\n%s", body)
}
}