console: mail counts are recorded and read per sender (plan 2, task 9)
RecordMailEvent and MailCounts now take a sender and read/write mail_sender_counts instead of mail_counts. An empty sender to MailCounts sums every sender for the app, which is what the status reply's top-level figures and enforceMailLimits still need today. All three production call sites pass mail.SenderAppID or "" as noted at each site; attribution is unchanged until task 11.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 files changed,
+95
−31
internal/console/apimail.go+1 −1internal/console/mailcounts.go+36 −21internal/console/mailcounts_test.go+52 −3internal/console/mailevents_test.go+3 −3internal/console/sesevents.go+1 −1internal/console/sesevents_test.go+2 −2
diff --git a/internal/console/apimail.go b/internal/console/apimail.go| index 773418753..20905304f 100644 |
| --- a/internal/console/apimail.go |
| +++ b/internal/console/apimail.go |
| @@ -400,7 +400,7 @@ func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec |
| missing = append(missing, got...) |
| } |
| day := time.Now().UTC().Format("2006-01-02") |
| - counts, err := s.store.MailCounts(ctx, acct.Sqid, app, day) |
| + counts, err := s.store.MailCounts(ctx, acct.Sqid, app, "", day) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/console/mailcounts.go b/internal/console/mailcounts.go| index 4fbccca63..f82013324 100644 |
| --- a/internal/console/mailcounts.go |
| +++ b/internal/console/mailcounts.go |
| @@ -11,7 +11,6 @@ package console |
| import ( |
| "context" |
| - "database/sql" |
| "fmt" |
| "log" |
| "time" |
| @@ -99,33 +98,46 @@ func mailCountColumn(evType string) (string, bool) { |
| } |
| // RecordMailEvent increments the one counter evType names for (acct, app, |
| -// day). An event type this function does not know (e.g. Delivery, or a |
| -// future SES type) is silently ignored — mirrored from mail.ParseEvent's own |
| -// tolerance of unknown types, for the same reason: SES adding a type must |
| -// never turn into a 500 that makes SNS retry forever. |
| -func (s *Store) RecordMailEvent(ctx context.Context, acct, app, day, evType string) error { |
| +// sender, day). An event type this function does not know is silently |
| +// ignored — mirrored from mail.ParseEvent's own tolerance of unknown types, |
| +// for the same reason: SES adding a type must never turn into a 500 that |
| +// makes SNS retry forever. |
| +// |
| +// Writes mail_sender_counts alone. mail_counts stays on disk, unwritten, |
| +// until DropLegacyMailCounts removes it a release later. |
| +func (s *Store) RecordMailEvent(ctx context.Context, acct, app, sender, day, evType string) error { |
| col, ok := mailCountColumn(evType) |
| if !ok { |
| return nil |
| } |
| - q := fmt.Sprintf(`INSERT INTO mail_counts (account, app, day, %s) |
| -VALUES (?, ?, ?, 1) |
| -ON CONFLICT(account, app, day) DO UPDATE SET %s = %s + 1`, col, col, col) |
| - _, err := s.db.ExecContext(ctx, q, acct, app, day) |
| + if sender == "" { |
| + sender = mail.SenderAppID |
| + } |
| + q := fmt.Sprintf(`INSERT INTO mail_sender_counts (account, app, sender, day, %s) |
| +VALUES (?, ?, ?, ?, 1) |
| +ON CONFLICT(account, app, sender, day) DO UPDATE SET %s = %s + 1`, col, col, col) |
| + _, err := s.db.ExecContext(ctx, q, acct, app, sender, day) |
| return err |
| } |
| -// MailCounts returns one app's counts for one UTC day. A day with no rows at |
| -// all is a legitimate zero, not an error. |
| -func (s *Store) MailCounts(ctx context.Context, acct, app, day string) (MailCounts, error) { |
| - var c MailCounts |
| - row := s.db.QueryRowContext(ctx, `SELECT sends, bounces, complaints, rejects, deliveries |
| -FROM mail_counts WHERE account = ? AND app = ? AND day = ?`, acct, app, day) |
| - err := row.Scan(&c.Sends, &c.Bounces, &c.Complaints, &c.Rejects, &c.Deliveries) |
| - if err == sql.ErrNoRows { |
| - return MailCounts{}, nil |
| +// MailCounts returns one sender's counts for one UTC day. An EMPTY sender |
| +// reads every sender the app has, which is what the status reply's |
| +// unchanged top-level figures mean and what a roll-up of an app in |
| +// per-instance mode needs. A day with no rows at all is a legitimate zero. |
| +func (s *Store) MailCounts(ctx context.Context, acct, app, sender, day string) (MailCounts, error) { |
| + q := `SELECT COALESCE(SUM(sends), 0), COALESCE(SUM(bounces), 0), COALESCE(SUM(complaints), 0), |
| + COALESCE(SUM(rejects), 0), COALESCE(SUM(deliveries), 0) |
| +FROM mail_sender_counts WHERE account = ? AND app = ? AND day = ?` |
| + args := []any{acct, app, day} |
| + if sender != "" { |
| + q += ` AND sender = ?` |
| + args = append(args, sender) |
| } |
| - if err != nil { |
| + var c MailCounts |
| + // An aggregate always returns exactly one row, so there is no ErrNoRows |
| + // branch to get wrong and a quiet day reads as zeroes by construction. |
| + if err := s.db.QueryRowContext(ctx, q, args...).Scan( |
| + &c.Sends, &c.Bounces, &c.Complaints, &c.Rejects, &c.Deliveries); err != nil { |
| return MailCounts{}, err |
| } |
| return c, nil |
| @@ -227,7 +239,10 @@ func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now ti |
| } |
| } |
| day := now.UTC().Format("2006-01-02") |
| - c, err := s.store.MailCounts(ctx, acct, app, day) |
| + // "" and not mail.SenderAppID: enforceMailLimits takes no sender yet, so |
| + // the only cap it can enforce is the app's whole day, same as before |
| + // this table split (task 12 gives this function a sender). |
| + c, err := s.store.MailCounts(ctx, acct, app, "", day) |
| if err != nil { |
| return false, "", err |
| } |
diff --git a/internal/console/mailcounts_test.go b/internal/console/mailcounts_test.go| index cf4d11970..34464dd82 100644 |
| --- a/internal/console/mailcounts_test.go |
| +++ b/internal/console/mailcounts_test.go |
| @@ -81,15 +81,15 @@ func seedCounts(t *testing.T, fx *consoleFixture, day string, c MailCounts) { |
| t.Helper() |
| ctx := context.Background() |
| for i := 0; i < c.Sends; i++ { |
| - if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", day, mail.EventSend); err != nil { |
| + if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil { |
| t.Fatal(err) |
| } |
| } |
| for i := 0; i < c.Bounces; i++ { |
| - fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", day, mail.EventBounce) |
| + fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventBounce) |
| } |
| for i := 0; i < c.Complaints; i++ { |
| - fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", day, mail.EventComplaint) |
| + fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventComplaint) |
| } |
| } |
| @@ -522,3 +522,52 @@ WHERE account = 'bag' AND app = 'harbourmail' AND sender = 'app' AND day = '2026 |
| t.Fatal(err) |
| } |
| } |
| + |
| +// Counts are per sender from here on. An empty sender reads the app's whole |
| +// day across every sender, which is what the status reply's top-level |
| +// figures and the roll-up both need. |
| +func TestMailCountsAreKeptAndReadPerSender(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + const day = "2026-09-15" |
| + for i := 0; i < 3; i++ { |
| + if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for i := 0; i < 2; i++ { |
| + if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventBounce); err != nil { |
| + t.Fatal(err) |
| + } |
| + app, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if app.Sends != 3 || app.Bounces != 0 { |
| + t.Fatalf("app sender = %+v, want 3 sends and no bounces", app) |
| + } |
| + one, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "book.example.com", day) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if one.Sends != 2 || one.Bounces != 1 { |
| + t.Fatalf("instance sender = %+v, want 2 sends and 1 bounce", one) |
| + } |
| + all, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "", day) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if all.Sends != 5 || all.Bounces != 1 { |
| + t.Fatalf("every sender = %+v, want 5 sends and 1 bounce", all) |
| + } |
| + // A day with no rows is a legitimate zero, not an error and not an |
| + // ErrNoRows the caller has to know about. |
| + quiet, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "", "2026-01-01") |
| + if err != nil || quiet != (MailCounts{}) { |
| + t.Fatalf("a quiet day = %+v %v", quiet, err) |
| + } |
| +} |
diff --git a/internal/console/mailevents_test.go b/internal/console/mailevents_test.go| index 7b807f3a8..c7b3e0a20 100644 |
| --- a/internal/console/mailevents_test.go |
| +++ b/internal/console/mailevents_test.go |
| @@ -162,7 +162,7 @@ func TestTheMailLogGoesToTheAppSESSaysSentIt(t *testing.T) { |
| } |
| day := now.UTC().Format("2006-01-02") |
| for _, app := range []string{"jb-book", "jb-site"} { |
| - if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, app, day); c.Bounces != 1 { |
| + if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, app, "", day); c.Bounces != 1 { |
| t.Errorf("%s bounces = %d — the counts' attribution is not this change's to narrow", app, c.Bounces) |
| } |
| } |
| @@ -303,7 +303,7 @@ func TestALateBounceCountsAgainstTheDayItWasSent(t *testing.T) { |
| fx.srv.recordAndEnforceMail(ctx, ev, "sns-late") |
| for day, want := range map[string]int{"2026-09-14": 1, "2026-09-15": 0} { |
| - c, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbour", day) |
| + c, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbour", "", day) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -415,7 +415,7 @@ func TestTheDomainFallbackNeedsAVerifiedDomain(t *testing.T) { |
| if got, _ := fx.st.MailEvents(ctx, release.OpsAccount, "squatter", MailEventQuery{}, time.Now()); len(got) != 0 { |
| t.Errorf("an unverified domain was given recipients' addresses: %+v", got) |
| } |
| - if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "squatter", now.UTC().Format("2006-01-02")); c.Bounces != 1 { |
| + if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "squatter", "", now.UTC().Format("2006-01-02")); c.Bounces != 1 { |
| t.Errorf("bounces = %d — the counts' matching is not this change's to narrow", c.Bounces) |
| } |
| } |
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go| index d33f8c5ac..094dfeaeb 100644 |
| --- a/internal/console/sesevents.go |
| +++ b/internal/console/sesevents.go |
| @@ -520,7 +520,7 @@ func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event, notice |
| if logHere { |
| s.recordMailMessage(ctx, a.Sqid, app, key, ev, false, now) |
| } |
| - if err := s.store.RecordMailEvent(ctx, a.Sqid, app, day, ev.Type); err != nil { |
| + if err := s.store.RecordMailEvent(ctx, a.Sqid, app, mail.SenderAppID, day, ev.Type); err != nil { |
| log.Printf("console: ses events: recording %s for %s/%s: %v", ev.Type, a.Sqid, app, err) |
| continue |
| } |
diff --git a/internal/console/sesevents_test.go b/internal/console/sesevents_test.go| index abb90406e..47f96b6a0 100644 |
| --- a/internal/console/sesevents_test.go |
| +++ b/internal/console/sesevents_test.go |
| @@ -292,7 +292,7 @@ func TestSESEventsCountsADelivery(t *testing.T) { |
| } |
| day := now.UTC().Format("2006-01-02") |
| - c, err := fx.st.MailCounts(ctx, release.OpsAccount, "seapointish", day) |
| + c, err := fx.st.MailCounts(ctx, release.OpsAccount, "seapointish", "", day) |
| if err != nil { |
| t.Fatalf("MailCounts: %v", err) |
| } |
| @@ -775,7 +775,7 @@ func TestRecordAndEnforceMailAttributesTheRewrittenMailFromEnvelope(t *testing.T |
| fx.srv.recordAndEnforceMail(ctx, ev, "") |
| day := now.UTC().Format("2006-01-02") |
| - counts, err := fx.srv.store.MailCounts(ctx, release.OpsAccount, "harbourmail", day) |
| + counts, err := fx.srv.store.MailCounts(ctx, release.OpsAccount, "harbourmail", "", day) |
| if err != nil { |
| t.Fatal(err) |
| } |