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
@@ -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)
);
+60 -2
View File
@@ -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
}
+5 -4
View File
@@ -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)