audit 4: cards ordered by number, a notification owed to each recipient
test / test (push) Canceled after 0s
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:
+156
-28
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Who a notification is owed to, per subject (audit 2026-10-12, stage 4;
|
||||
-- owner, 2026-10-14). A subject used to count as said once one recipient's
|
||||
-- copy was handed to Postfix: a recipient whose copy failed, or was held back
|
||||
-- by the hourly ceiling, was never told. The recipients a subject's first
|
||||
-- message was addressed to are kept here with whether each was handed it;
|
||||
-- a later check sends to those not told yet, and "cleared" goes to those who
|
||||
-- were told. Rows of a subject go with its notify_state row
|
||||
-- (Store.DeleteNotifyState). A subject opened before this file has none, and
|
||||
-- is told cleared to every recipient, as before.
|
||||
CREATE TABLE notify_addressees (
|
||||
key TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
told_at TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (key, address)
|
||||
);
|
||||
@@ -127,11 +127,69 @@ func (s *Store) SetNotifyState(st NotifyState) error {
|
||||
}
|
||||
|
||||
// DeleteNotifyState forgets a subject: nothing is wrong with it any more, or
|
||||
// it no longer exists.
|
||||
// it no longer exists. Who it was owed to goes with it.
|
||||
func (s *Store) DeleteNotifyState(key string) error {
|
||||
if _, err := s.db.Exec("DELETE FROM notify_state WHERE key = ?", key); err != nil {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete notify state %s: %w", key, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, stmt := range []string{"DELETE FROM notify_addressees WHERE key = ?", "DELETE FROM notify_state WHERE key = ?"} {
|
||||
if _, err := tx.Exec(stmt, key); err != nil {
|
||||
return fmt.Errorf("delete notify state %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("delete notify state %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAddressees returns who the subject's message is owed to, each with
|
||||
// whether they were handed a copy (migration 0011). None for a subject not
|
||||
// said yet, or said before 2.5.4.
|
||||
func (s *Store) NotifyAddressees(key string) (map[string]bool, error) {
|
||||
rows, err := s.db.Query("SELECT address, told_at FROM notify_addressees WHERE key = ?", key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify addressees %s: %w", key, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var addr, toldAt string
|
||||
if err := rows.Scan(&addr, &toldAt); err != nil {
|
||||
return nil, fmt.Errorf("notify addressees %s: %w", key, err)
|
||||
}
|
||||
out[addr] = toldAt != ""
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetNotifyAddressees records who the subject's message is owed to: owed
|
||||
// maps an address to whether it was handed a copy, and replaces what was
|
||||
// recorded. The told are dated at, the time of this record: the date is
|
||||
// for a person reading the table, the code asks only whether.
|
||||
func (s *Store) SetNotifyAddressees(key string, owed map[string]bool, at time.Time) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set notify addressees %s: %w", key, err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec("DELETE FROM notify_addressees WHERE key = ?", key); err != nil {
|
||||
return fmt.Errorf("set notify addressees %s: %w", key, err)
|
||||
}
|
||||
for addr, told := range owed {
|
||||
toldAt := ""
|
||||
if told {
|
||||
toldAt = at.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if _, err := tx.Exec("INSERT INTO notify_addressees (key, address, told_at) VALUES (?, ?, ?)", key, addr, toldAt); err != nil {
|
||||
return fmt.Errorf("set notify addressees %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("set notify addressees %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ func TestBaselineSchema(t *testing.T) {
|
||||
"inbound_spam_list": "id list_type entry_type value note created_at",
|
||||
"send_log_lines": "id queue_id logged_at line",
|
||||
"notify_state": "key phase count mark since sent_at",
|
||||
"notify_addressees": "key address told_at",
|
||||
"sender_check_days": "day down_seconds deferred messages estimated",
|
||||
"delivery_log_days": "day reconcile_closed reconcile_missed reconcile_contradicted tailer_blind_seconds queued_over_lifetime",
|
||||
}
|
||||
@@ -117,8 +118,8 @@ func TestBaselineSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
var version, appID int64
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 10 {
|
||||
t.Errorf("user_version = %d, %v; want 10", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 11 {
|
||||
t.Errorf("user_version = %d, %v; want 11", version, err)
|
||||
}
|
||||
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
|
||||
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
|
||||
@@ -194,8 +195,8 @@ func TestOpenMigratesA20Database(t *testing.T) {
|
||||
}
|
||||
defer st.Close()
|
||||
var version int
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 10 {
|
||||
t.Errorf("user_version = %d, %v; want 10", version, err)
|
||||
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 11 {
|
||||
t.Errorf("user_version = %d, %v; want 11", version, err)
|
||||
}
|
||||
if ok, err := st.UserExists(); err != nil || !ok {
|
||||
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// On a phone the cards layout shows its boxes in the order of their numbers,
|
||||
// whichever stack they stand in: each box of the layout carries its number as
|
||||
// data-order, and the stylesheet has a rule for it. Before, the stylesheet
|
||||
// ordered by a box's place in its stack and knew four to a stack; a fifth
|
||||
// landed out of place with nothing failing (owner, 2026-10-13).
|
||||
func TestCardsBoxesCarryTheirNumberAsTheirOrder(t *testing.T) {
|
||||
css, err := os.ReadFile("static/panel.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box := regexp.MustCompile(`<div class="box[^"]*"[^>]*?(?: data-order="(\d+)")?>\s*<div class="sp-box-head"><span class="sp-no">(\d+)</span>`)
|
||||
rec := httptest.NewRecorder()
|
||||
kitEngine(t).Render(rec, http.StatusOK, "components", KitPage())
|
||||
for name, page := range map[string]string{
|
||||
"health": renderSignedIn(t, "health", healthFixture(false)),
|
||||
"kit": rec.Body.String(),
|
||||
} {
|
||||
start := strings.Index(page, `<div class="columns sp-cards">`)
|
||||
if start < 0 {
|
||||
t.Fatalf("%s has no cards layout", name)
|
||||
}
|
||||
// The layout runs to the first page part after it that is not a box
|
||||
// of it; its boxes are the numbered ones up to there.
|
||||
region := page[start:]
|
||||
if end := strings.Index(region, `<footer`); end > 0 {
|
||||
region = region[:end]
|
||||
}
|
||||
found := 0
|
||||
for _, m := range box.FindAllStringSubmatch(region, -1) {
|
||||
order, no := m[1], strings.TrimLeft(m[2], "0")
|
||||
if name == "kit" && found == 3 {
|
||||
break // the kit's cards hold three numbered boxes; the rest of the page is other sections
|
||||
}
|
||||
found++
|
||||
if order != no {
|
||||
t.Errorf("%s: box %s carries data-order %q", name, m[2], order)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(string(css), `.sp-cards [data-order="`+order+`"] { order: `+order+`; }`) {
|
||||
t.Errorf("%s: panel.css has no rule for data-order %s", name, order)
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Errorf("%s: no numbered boxes in the cards layout", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outside the cards layout a box has no order: the attribute is the layout's.
|
||||
func TestABoxOutsideCardsHasNoOrder(t *testing.T) {
|
||||
if got := (Box{No: "03"}).Order(); got != "" {
|
||||
t.Errorf("a box outside cards has order %q", got)
|
||||
}
|
||||
if got := (Box{No: "03", InCards: true}).Order(); got != "3" {
|
||||
t.Errorf("a cards box 03 has order %q", got)
|
||||
}
|
||||
if got := (Box{Icon: "ti-x", InCards: true}).Order(); got != "" {
|
||||
t.Errorf("a cards box without a number has order %q", got)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ package view
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -301,6 +302,26 @@ type Box struct {
|
||||
OOB bool
|
||||
// DomainPick: see Box.
|
||||
DomainPick bool
|
||||
// InCards is a box of the cards layout, which orders its boxes on a
|
||||
// phone by their numbers (Order).
|
||||
InCards bool
|
||||
}
|
||||
|
||||
// Order is a cards box's number as the layout orders it on a phone
|
||||
// (data-order, panel.css: the boxes come in the order of their numbers
|
||||
// whichever stack they stand in): "3" for "03", empty for a box with no
|
||||
// number or outside the layout. Before, the stylesheet ordered by a box's place in its stack and
|
||||
// knew four to a stack; a fifth landed out of place with nothing failing
|
||||
// (owner, 2026-10-13).
|
||||
func (b Box) Order() string {
|
||||
if !b.InCards {
|
||||
return ""
|
||||
}
|
||||
n, err := strconv.Atoi(b.No)
|
||||
if err != nil || n < 1 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// HelpLink is a Box's link to its Help topic.
|
||||
|
||||
@@ -139,16 +139,16 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/server/health/recheck"}},
|
||||
},
|
||||
// Numbered by importance; the odd ones are the left stack.
|
||||
Processes: Box{No: "01", Title: "Processes", ID: "processes"},
|
||||
Sockets: Box{No: "02", Title: "Milter sockets", ID: "sockets"},
|
||||
SenderCheck: Box{No: "03", Title: "Sender check", ID: "sender-check",
|
||||
Processes: Box{No: "01", InCards: true, Title: "Processes", ID: "processes"},
|
||||
Sockets: Box{No: "02", InCards: true, Title: "Milter sockets", ID: "sockets"},
|
||||
SenderCheck: Box{No: "03", InCards: true, Title: "Sender check", ID: "sender-check",
|
||||
Help: topicLink(HelpSenderCheck, "What is checked and what these numbers are for")},
|
||||
Certificate: Box{No: "04", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
|
||||
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
|
||||
NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail",
|
||||
Certificate: Box{No: "04", InCards: true, Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
|
||||
Hostname: Box{No: "05", InCards: true, Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
|
||||
NotifyMail: Box{No: "06", InCards: true, Title: "Notification mail", ID: "notification-mail",
|
||||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||||
Machine: Box{No: "07", Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
|
||||
Configuration: Box{No: "08", Title: "Configuration", ID: "configuration",
|
||||
Machine: Box{No: "07", InCards: true, Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
|
||||
Configuration: Box{No: "08", InCards: true, Title: "Configuration", ID: "configuration",
|
||||
End: Plain("Safe to run at any time")},
|
||||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
||||
}
|
||||
|
||||
@@ -86,14 +86,14 @@ func KitPage() *Kit {
|
||||
{Label: "signed out", Value: Rich("The split brand / form screen, no navbar. ", Link("/login", "Example"))},
|
||||
},
|
||||
|
||||
Postmark: Box{No: "01", Title: "postmark", End: Plain("Worst status of the page's checks")},
|
||||
Postmark: Box{No: "01", InCards: true, Title: "postmark", End: Plain("Worst status of the page's checks")},
|
||||
Postmarks: []Postmark{
|
||||
{Top: "SELFPOST", Word: "OK", Bottom: "6 OF 6 CHECKS", Level: LevelOK},
|
||||
{Top: "SELFPOST", Word: "WARN", Bottom: "1 OF 6 CHECKS", Level: LevelWarn},
|
||||
{Top: "DNS", Word: "FAIL", Bottom: "1 OF 3 RECORDS", Level: LevelFail},
|
||||
},
|
||||
|
||||
StatusTag: Box{No: "03", Title: "status_tag", End: Plain("One mapping, one function")},
|
||||
StatusTag: Box{No: "03", InCards: true, Title: "status_tag", End: Plain("One mapping, one function")},
|
||||
Sheet: Sheet{ID: "kit-sheet", Tag: Tag{Status: "reject"}, Title: "sheet", Rows: []SheetRow{
|
||||
{Label: "What it holds", Lines: []string{"What does not fit the row, as name and value"}},
|
||||
{Label: "How it closes", Lines: []string{"Esc", "a click outside", "the cross"}, Mono: true},
|
||||
@@ -106,7 +106,7 @@ func KitPage() *Kit {
|
||||
{"held: quarantine · role: global", Tag{Status: "quarantine"}},
|
||||
},
|
||||
|
||||
BoxHead: Box{No: "02", Title: "box · box_head",
|
||||
BoxHead: Box{No: "02", InCards: true, Title: "box · box_head",
|
||||
End: Plain("The end slot: a note, a tag, a link or a filter form"),
|
||||
Help: &HelpLink{Href: "/help#checks", Title: "Optional: the section's Help topic"}},
|
||||
BoxFoot: Foot{Text: Plain("box_foot — a legend, paging"), End: Plain("end slot")},
|
||||
|
||||
@@ -329,20 +329,25 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
|
||||
.sp-cards { display: flex; flex-direction: column; padding-inline: .75rem; }
|
||||
.sp-cards > .column { display: contents; }
|
||||
.sp-cards .box { margin-bottom: 1.5rem; }
|
||||
.sp-cards > .column:first-child > .box:nth-child(1) { order: 1; }
|
||||
.sp-cards > .column:first-child > .box:nth-child(2) { order: 3; }
|
||||
.sp-cards > .column:first-child > .box:nth-child(3) { order: 5; }
|
||||
.sp-cards > .column:first-child > .box:nth-child(4) { order: 7; }
|
||||
.sp-cards > .column:last-child > .box:nth-child(1) { order: 2; }
|
||||
.sp-cards > .column:last-child > .box:nth-child(2) { order: 4; }
|
||||
.sp-cards > .column:last-child > .box:nth-child(3) { order: 6; }
|
||||
.sp-cards > .column:last-child > .box:nth-child(4) { order: 8; }
|
||||
.sp-cards [data-order="1"] { order: 1; }
|
||||
.sp-cards [data-order="2"] { order: 2; }
|
||||
.sp-cards [data-order="3"] { order: 3; }
|
||||
.sp-cards [data-order="4"] { order: 4; }
|
||||
.sp-cards [data-order="5"] { order: 5; }
|
||||
.sp-cards [data-order="6"] { order: 6; }
|
||||
.sp-cards [data-order="7"] { order: 7; }
|
||||
.sp-cards [data-order="8"] { order: 8; }
|
||||
.sp-cards [data-order="9"] { order: 9; }
|
||||
.sp-cards [data-order="10"] { order: 10; }
|
||||
.sp-cards [data-order="11"] { order: 11; }
|
||||
.sp-cards [data-order="12"] { order: 12; }
|
||||
}
|
||||
|
||||
/* cards: two stacks of boxes that are read in rows - the odd ones on the left,
|
||||
the even ones on the right, each stack closed up so a short box leaves no
|
||||
gap beside a tall one. On a phone the stacks dissolve and the boxes come in
|
||||
the order of their numbers (the rules above). */
|
||||
the order of their numbers: each box carries data-order, its own number (the
|
||||
rules above, up to twelve), wherever it stands in its stack. */
|
||||
|
||||
/* sheet: what does not fit a table row, opened over the page from the row's
|
||||
tag. It is the browser's own popover (the tag is a button with
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
sp-credential (a secret shown once). A box foot, a table in its
|
||||
table-container, a run of records, a log or an empty state sit directly
|
||||
inside; anything else goes in body_open … body_close. */}}
|
||||
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}{{if .DomainPick}} data-domain-pick{{end}}>
|
||||
{{define "box_open"}}<div class="box{{if eq .Variant "danger"}} sp-danger-zone{{else if eq .Variant "credential"}} sp-credential{{end}}"{{if .ID}} id="{{.ID}}"{{end}}{{if .OOB}} hx-swap-oob="true"{{end}}{{if .Poll}} data-poll aria-live="polite" hx-get="{{.Poll}}" hx-trigger="load" hx-swap="outerHTML"{{end}}{{if .DomainPick}} data-domain-pick{{end}}{{with .Order}} data-order="{{.}}"{{end}}>
|
||||
{{template "box_head" .}}
|
||||
{{end}}
|
||||
{{define "box_close"}}</div>{{end}}
|
||||
@@ -93,9 +93,9 @@
|
||||
`column is-6` stacks of numbered boxes that are read in rows, the odd ones
|
||||
on the left and the even ones on the right, each stack closed up so that a
|
||||
short box leaves no gap beside a tall one. On a phone the stacks dissolve
|
||||
and the boxes come in the order of their numbers (panel.css) - which
|
||||
knows four boxes to a stack and takes every child of a stack for a box:
|
||||
a fifth, or a flash put between them, needs the rules extended first. */}}
|
||||
and the boxes come in the order of their numbers: box_open writes each
|
||||
numbered box's number as data-order, which panel.css orders by, up to
|
||||
twelve. */}}
|
||||
{{define "cards_open"}}<div class="columns sp-cards">{{end}}
|
||||
{{define "cards_close"}}</div>{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user