carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev ff186347d71653c4b1e5e44a5af956612730319b parent 8099af1
6 files changed, +95 −31
  • internal/console/apimail.go +1 −1
  • internal/console/mailcounts.go +36 −21
  • internal/console/mailcounts_test.go +52 −3
  • internal/console/mailevents_test.go +3 −3
  • internal/console/sesevents.go +1 −1
  • internal/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)
}