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})
}
}