console: the rename, purge and reap sweeps take mail_sender_counts on its day column (plan 2, task 10)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 files changed,
+111
−3
internal/console/mailevents.go+27 −3internal/console/mailevents_test.go+84 −0
diff --git a/internal/console/mailevents.go b/internal/console/mailevents.go| index 46874d64f..dcbb12f28 100644 |
| --- a/internal/console/mailevents.go |
| +++ b/internal/console/mailevents.go |
| @@ -91,7 +91,15 @@ func mailNoticeKey(notice string, ev mail.Event, now time.Time) string { |
| // mailAppTables are the tables keyed by an app's (account sqid, name) that |
| // belong to that app alone: a name that is freed must not hand them on. |
| -var mailAppTables = []string{"mail_events", "mail_event_hooks", "mail_event_deliveries"} |
| +// |
| +// mail_sender_counts joins the list (senders design §8), which also closes |
| +// the gap where a rename left an app's counts behind under its old name. |
| +// It cannot join blindly, though: every sweep here keys on a TIMESTAMP |
| +// column and this table has a day string instead, so it gets the explicit |
| +// case in mailAppTimeColumn and carryMailApp below. mail_counts is |
| +// deliberately not here: nothing writes it any more and |
| +// DropLegacyMailCounts removes it whole. |
| +var mailAppTables = []string{"mail_events", "mail_event_hooks", "mail_event_deliveries", "mail_sender_counts"} |
| // execer is what carryMailApp and forgetMailApp need from a transaction. |
| type execer interface { |
| @@ -132,7 +140,7 @@ func carryMailApp(ctx context.Context, tx execer, fromSqid, fromApp, toSqid, toA |
| continue |
| } |
| if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET account = ?, app = ? WHERE account = ? AND app = ? AND `+ |
| - mailAppTimeColumn(table)+` >= ?`, toSqid, toApp, fromSqid, fromApp, createdAt); err != nil { |
| + mailAppTimeColumn(table)+` >= ?`, toSqid, toApp, fromSqid, fromApp, mailAppTimeSince(table, createdAt)); err != nil { |
| return err |
| } |
| if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE account = ? AND app = ?`, fromSqid, fromApp); err != nil { |
| @@ -166,17 +174,33 @@ func forgetMailApp(ctx context.Context, tx execer, sqid, app string) error { |
| return nil |
| } |
| -// mailAppTimeColumn is the column that says when a row came to be. |
| +// mailAppTimeColumn is the column that says when a row came to be. Most are |
| +// unix seconds; mail_sender_counts keys on a UTC date string, which compares |
| +// correctly against another ISO date and against nothing else. |
| func mailAppTimeColumn(table string) string { |
| switch table { |
| case "mail_events": |
| return "received_at" |
| case "mail_event_hooks": |
| return "set_at" |
| + case "mail_sender_counts": |
| + return "day" |
| } |
| return "created_at" |
| } |
| +// mailAppTimeSince renders createdAt in whatever mailAppTimeColumn(table) |
| +// compares against: a unix stamp for a timestamp column, a UTC date string |
| +// for the one day column. Comparing a unix integer against a 'YYYY-MM-DD' |
| +// string in SQLite is not an error, it is simply always false, so getting |
| +// this wrong would silently carry nothing. |
| +func mailAppTimeSince(table string, createdAt int64) any { |
| + if mailAppTimeColumn(table) == "day" { |
| + return time.Unix(createdAt, 0).UTC().Format("2006-01-02") |
| + } |
| + return createdAt |
| +} |
| + |
| // pruneMailEvents trims the log to its retention window, at most once per |
| // mailEventsPruneEvery. The stamp is taken before the DELETE runs, so two |
| // notifications arriving together do not both prune. |
diff --git a/internal/console/mailevents_test.go b/internal/console/mailevents_test.go| index c7b3e0a20..c982a34d1 100644 |
| --- a/internal/console/mailevents_test.go |
| +++ b/internal/console/mailevents_test.go |
| @@ -477,3 +477,87 @@ func TestANotificationThroughTheDoorLandsInTheLog(t *testing.T) { |
| t.Errorf("notice = %q, want SNS's own MessageId", notice) |
| } |
| } |
| + |
| +// A sender's counts belong to the app, not to its name. A rename carries |
| +// them; a purge drops them, so the next app of that name starts at zero; |
| +// an account reap takes the lot. The comparison is on the day STRING, |
| +// because this table has no timestamp column for mailAppTimeColumn to name. |
| +func TestSenderCountsFollowTheAppAndDieWithIt(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| + yesterday := now.AddDate(0, 0, -1).UTC().Format("2006-01-02") |
| + today := now.UTC().Format("2006-01-02") |
| + |
| + seed := func(app, sender, day string) { |
| + t.Helper() |
| + if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, app, sender, day, mail.EventSend); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + countRows := func(app string) int { |
| + t.Helper() |
| + var n int |
| + if err := fx.st.db.QueryRow(`SELECT count(*) FROM mail_sender_counts WHERE account = ? AND app = ?`, |
| + release.OpsAccount, app).Scan(&n); err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| + } |
| + |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "jb-book", "bill@tito.io", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + seed("jb-book", mail.SenderAppID, today) |
| + seed("jb-book", "book.example.com", today) |
| + // A row from BEFORE the app row was created belongs to a predecessor of |
| + // the name and must not travel, the rule RenameAppIn already applies to |
| + // audit rows. |
| + seed("jb-book", mail.SenderAppID, yesterday) |
| + |
| + tx, err := fx.st.db.Begin() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := carryMailApp(ctx, tx, release.OpsAccount, "jb-book", release.OpsAccount, "jb-reader", now.Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.Commit(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := countRows("jb-reader"); got != 2 { |
| + t.Fatalf("the rename carried %d rows, want the 2 from today", got) |
| + } |
| + if got := countRows("jb-book"); got != 0 { |
| + t.Fatalf("%d rows were left under the old name", got) |
| + } |
| + |
| + tx, err = fx.st.db.Begin() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := forgetMailApp(ctx, tx, release.OpsAccount, "jb-reader"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.Commit(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := countRows("jb-reader"); got != 0 { |
| + t.Fatalf("a purge left %d rows for the next app of that name", got) |
| + } |
| + |
| + seed("jb-reader", mail.SenderAppID, today) |
| + tx, err = fx.st.db.Begin() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := forgetMailAccount(ctx, tx, release.OpsAccount); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.Commit(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := countRows("jb-reader"); got != 0 { |
| + t.Fatalf("the account reap left %d rows", got) |
| + } |
| +} |