audit 4: cards ordered by number, a notification owed to each recipient
test / test (push) Canceled after 0s

The cards layout (owner, 2026-10-13; the mockup first, b90a2f0): box_open
writes data-order for a box with InCards and a number, and panel.css orders
by it up to twelve - Health's eight and the kit's three.
TestCardsBoxesCarryTheirNumberAsTheirOrder reads the rendered pages.

Notifications (owner, 2026-10-14): migration 0011, notify_addressees. open
sends a subject's first message to every recipient and records who was
handed it; followUp, on every check while the subject is open and still
wrong, hands it to the owed and untold who are still recipients; closed
sends the end to the told (all recipients for a subject from before the
table). Tests: a failed copy goes out at the next check, once; the hourly
ceiling holds one back; a user added mid-subject hears nothing of it.

Also in the roadmap, from the owner today: dns-record-collapse,
notification-mail-recheck, settings-save-row (agreed, design first).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 11:49:50 +03:00
co-authored by Claude Opus 5.5
parent b90a2f0321
commit eee089ad4b
17 changed files with 538 additions and 74 deletions
+156 -28
View File
@@ -73,6 +73,8 @@ type Store interface {
SetNotifyState(st store.NotifyState) error
DeleteNotifyState(key string) error
ListNotifyStateKeys(prefix string) ([]string, error)
NotifyAddressees(key string) (map[string]bool, error)
SetNotifyAddressees(key string, owed map[string]bool, at time.Time) error
LimitRefusalsSince(since time.Time) ([]store.LimitRefusal, error)
ListDomains() ([]store.Domain, error)
}
@@ -175,24 +177,20 @@ const (
failed
)
// send writes one message to every recipient of a subject, each in a message
// of their own - users are not shown each other's addresses.
//
// What the caller makes of the outcome is the rule that keeps a thing from
// being said never: a problem counts as said only when it was sent, so one
// that arose while nobody could be told, or while the mail system was down, is
// told at a later check. A recipient whose copy failed while another's went
// out is not tried again: the subject is said.
func (n *Notifier) send(domainName string, m message) outcome {
to, err := n.st.NotifyRecipients(domainName)
if err != nil {
log.Printf("notify: recipients: %v", err)
return failed
}
if len(to) == 0 {
return nobody
}
handed := 0
// A message is owed to each recipient, not to the subject (owner,
// 2026-10-14). The first message of a subject goes to everyone who is to hear
// of it then (open), and who was handed a copy is recorded; while the subject
// is open, each later check hands it to those owed it who have not had it -
// a copy that failed, or that the hourly ceiling held back - and who are
// still to hear of it (followUp); a user who comes to hear of the subject
// only while it is open is not mailed about it. That it is over goes to
// those who were told (closed).
// deliver hands m to each address, each in a message of its own - users are
// not shown each other's addresses - under the hourly ceiling, and returns
// those that were handed a copy.
func (n *Notifier) deliver(to []string, m message) []string {
var handed []string
for _, addr := range to {
if !n.underCeiling() {
log.Printf("notify: more than %d messages in an hour; %q to %s is held back", hourlyCeiling, m.subject, addr)
@@ -203,15 +201,137 @@ func (n *Notifier) send(domainName string, m message) outcome {
continue
}
n.sentAt = append(n.sentAt, n.now())
handed++
handed = append(handed, addr)
}
if handed == 0 {
if len(to) > 0 {
log.Printf("notify: %q sent to %d of %d", m.subject, len(handed), len(to))
}
return handed
}
// recipients are who is to hear of a subject of domainName now ("" for the
// server's own subjects).
func (n *Notifier) recipients(domainName string) ([]string, bool) {
to, err := n.st.NotifyRecipients(domainName)
if err != nil {
log.Printf("notify: recipients: %v", err)
return nil, false
}
return to, true
}
// open says m, the first message of the subject key, to everyone who is to
// hear of it, and records who was handed a copy.
//
// What the caller makes of the outcome is the rule that keeps a thing from
// being said never: a problem counts as said only when it was sent, so one
// that arose while nobody could be told, or while the mail system was down, is
// told at a later check.
func (n *Notifier) open(key, domainName string, m message) outcome {
to, ok := n.recipients(domainName)
if !ok {
return failed
}
log.Printf("notify: %q sent to %d of %d", m.subject, handed, len(to))
if len(to) == 0 {
return nobody
}
handed := n.deliver(to, m)
if len(handed) == 0 {
return failed
}
owed := make(map[string]bool, len(to))
for _, addr := range to {
owed[addr] = false
}
for _, addr := range handed {
owed[addr] = true
}
if err := n.st.SetNotifyAddressees(key, owed, n.now()); err != nil {
log.Printf("notify: %v", err)
}
return sent
}
// followUp hands m, the message of an open subject, to those it is owed to
// who have not had it and are still to hear of it.
func (n *Notifier) followUp(key, domainName string, m message) {
owed, err := n.st.NotifyAddressees(key)
if err != nil {
log.Printf("notify: %v", err)
return
}
var due []string
for addr, told := range owed {
if !told {
due = append(due, addr)
}
}
if len(due) == 0 {
return
}
now, ok := n.recipients(domainName)
if !ok {
return
}
due = intersect(due, now)
handed := n.deliver(due, m)
if len(handed) == 0 {
return
}
for _, addr := range handed {
owed[addr] = true
}
if err := n.st.SetNotifyAddressees(key, owed, n.now()); err != nil {
log.Printf("notify: %v", err)
}
}
// closed says m - the subject is over - to those who were told of it and are
// still to hear of it. A subject said before 2.5.4 has no record of who was
// told, and its end goes to everyone who is to hear of it, as it did then.
func (n *Notifier) closed(key, domainName string, m message) outcome {
owed, err := n.st.NotifyAddressees(key)
if err != nil {
log.Printf("notify: %v", err)
return failed
}
to, ok := n.recipients(domainName)
if !ok {
return failed
}
if len(owed) > 0 {
var told []string
for addr, t := range owed {
if t {
told = append(told, addr)
}
}
to = intersect(told, to)
}
if len(to) == 0 {
return nobody
}
if len(n.deliver(to, m)) == 0 {
return failed
}
return sent
}
// intersect is the addresses of a that are in b, in b's order.
func intersect(a, b []string) []string {
in := make(map[string]bool, len(a))
for _, x := range a {
in[x] = true
}
var out []string
for _, x := range b {
if in[x] {
out = append(out, x)
}
}
return out
}
// underCeiling reports whether one more message may go out this hour.
func (n *Notifier) underCeiling() bool {
cutoff := n.now().Add(-time.Hour)
@@ -335,7 +455,7 @@ func (n *Notifier) dnsStep(name string, res dnscheck.Domain) {
case st.Phase == phasePending && bad:
st.Count++
if st.Count >= dnsChecks && now.Sub(st.Since) >= dnsApart {
if n.send(name, dnsProblem(n.hostname, name, faults, st.Since)) == sent {
if n.open(key, name, dnsProblem(n.hostname, name, faults, st.Since)) == sent {
st.Phase, st.Count, st.Mark, st.SentAt = phaseOpen, 0, "", now
}
}
@@ -346,6 +466,7 @@ func (n *Notifier) dnsStep(name string, res dnscheck.Domain) {
st.Count, st.Mark = 0, ""
n.save(st)
}
n.followUp(key, name, dnsProblem(n.hostname, name, faults, st.Since))
case st.Phase == phaseOpen && !bad:
st.Count++
if st.Count == 1 {
@@ -354,7 +475,7 @@ func (n *Notifier) dnsStep(name string, res dnscheck.Domain) {
firstGood, _ := time.Parse(time.RFC3339, st.Mark)
if st.Count >= dnsChecks && now.Sub(firstGood) >= dnsApart {
// With nobody to tell any more, the trouble is over all the same.
if n.send(name, dnsCleared(n.hostname, name, st.Since)) != failed {
if n.closed(key, name, dnsCleared(n.hostname, name, st.Since)) != failed {
n.forget(key)
return
}
@@ -394,10 +515,14 @@ func (n *Notifier) checkCertificate() {
case left <= certFirstDays*24*time.Hour:
level = "first"
}
if level == "" || (have && (st.Phase == level || st.Phase == "last")) {
if level == "" {
return
}
if n.send("", certExpiring(n.hostname, c.NotAfter, left)) != sent {
if have && (st.Phase == level || st.Phase == "last") {
n.followUp(key, "", certExpiring(n.hostname, c.NotAfter, left))
return
}
if n.open(key, "", certExpiring(n.hostname, c.NotAfter, left)) != sent {
return
}
n.save(store.NotifyState{Key: key, Phase: level, Mark: mark, Since: n.now(), SentAt: n.now()})
@@ -446,11 +571,13 @@ func (n *Notifier) checkQueue() {
}
switch {
case len(old) > 0 && !have:
if n.send("", queueStuck(n.hostname, old, now)) == sent {
if n.open(key, "", queueStuck(n.hostname, old, now)) == sent {
n.save(store.NotifyState{Key: key, Phase: phaseOpen, Since: now, SentAt: now})
}
case len(old) > 0 && have:
n.followUp(key, "", queueStuck(n.hostname, old, now))
case len(old) == 0 && have:
if n.send("", queueCleared(n.hostname, st.Since)) != failed {
if n.closed(key, "", queueCleared(n.hostname, st.Since)) != failed {
n.forget(key)
}
}
@@ -482,9 +609,10 @@ func (n *Notifier) CheckLimits() {
// told about, or more of the same. Told longer ago: everything in the
// window is new since then.
if have && now.Sub(st.SentAt) < limitRepeat {
n.followUp(key, r.Domain, limitRefused(n.hostname, r))
continue
}
if n.send(r.Domain, limitRefused(n.hostname, r)) == sent {
if n.open(key, r.Domain, limitRefused(n.hostname, r)) == sent {
n.save(store.NotifyState{Key: key, Phase: phaseOpen, Since: r.First, SentAt: now})
}
}
+78 -7
View File
@@ -2,6 +2,7 @@ package notify
import (
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
@@ -15,13 +16,14 @@ import (
// outbox collects what would have been handed to Postfix.
type outbox struct {
sent []string // "to|subject"
body []string
fail bool
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 {
if o.fail || o.failTo[to] {
return errors.New("sendmail: queue file write error")
}
o.sent = append(o.sent, to+"|"+subject)
@@ -474,16 +476,16 @@ func TestQueueLeavesOutTheNotifiersOwnMail(t *testing.T) {
func TestHourlyCeiling(t *testing.T) {
b := newBench(t)
for i := 0; i < hourlyCeiling+5; i++ {
b.n.send("", message{subject: "s", body: "b"})
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.send("", message{subject: "s", body: "b"}) != failed {
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.send("", message{subject: "s", body: "b"}) != sent {
if b.n.open("test:y", "", message{subject: "s", body: "b"}) != sent {
t.Error("the ceiling did not lift after an hour")
}
}
@@ -548,3 +550,72 @@ func TestMailerComposesAndRefuses(t *testing.T) {
}
}
}
// 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)
}
}