carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev c74a539ad27c678dfb2516b4e7fbc30f3de4b2a0 parent ff18634
2 files changed, +111 −3
  • internal/console/mailevents.go +27 −3
  • internal/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)
+ }
+}