carlos / platform Public

email events: count an event against the day its message was sent

The previous commit dated events by what they report — a bounce by the
bounce — and recordAndEnforceMail took that same timestamp to choose which
day's counters to bump and which day to enforce. The rate limiter divides
a day's bounces and complaints by that day's sends, so a late event moved
to a day that does not hold its send: 1,000 messages at 23:50 UTC with a
healthy 3% bounce rate, 30 bounces at 00:05, and the new day reads 30/50 =
60% once it has fifty sends of its own — a pause. A complaint arriving
days later reads as 2% against a 0.1% threshold.

The counts and the enforcement now use ev.SentAt (mail.timestamp), as they
did before the event's own time was read; ev.At stays for the log. Caught
in re-review of the review fixes. The test pins a bounce sent at 23:50 and
arriving at 00:05 to the send's day, and fails with the old line restored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bill Horsman pushed by bill@keymail.dev 114a915f4065bf1ddc94fe4ab35c7293c32c6446 parent f497a4d
2 files changed, +52 −8
  • internal/console/mailevents_test.go +38 −0
  • internal/console/sesevents.go +14 −8
diff --git a/internal/console/mailevents_test.go b/internal/console/mailevents_test.go
index 99c5fc25f..29a6fa90a 100644
--- a/internal/console/mailevents_test.go
+++ b/internal/console/mailevents_test.go
@@ -271,3 +271,41 @@ func TestTheMailLogBelongsToTheAppNotItsName(t *testing.T) {
t.Errorf("after a move: dest=%d ops=%d, want 1 and 0", logOf("dest", "moving"), logOf(release.OpsAccount, "moving"))
}
}
+
+// A bounce, a complaint or a delivery can arrive long after its message was
+// sent, and the rate limiter divides a day's bounces by that same day's
+// sends. So the counts file an event under the day it was SENT, whatever
+// day it arrives — a late bounce landing on the new day would read as a
+// bounce rate over a handful of sends and pause a healthy tenant.
+func TestALateBounceCountsAgainstTheDayItWasSent(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "harbour", "bill@tito.io", now); err != nil {
+ t.Fatal(err)
+ }
+ rec := &mail.DomainRecord{
+ Domain: "harbour.example", Kind: mail.KindCustom, Apps: []string{"harbour"},
+ Regions: map[string]*mail.RegionState{"eu-west-1": {
+ IdentityARN: "arn:aws:ses:eu-west-1:1:identity/harbour.example", Status: mail.StatusVerified,
+ }},
+ CreatedAt: now,
+ }
+ if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
+ t.Fatal(err)
+ }
+ sent := time.Date(2026, 9, 14, 23, 50, 0, 0, time.UTC)
+ ev := bounceTo("ann@example.com", "m-late", sent.Add(15*time.Minute)) // bounces at 00:05 the next day
+ ev.Domain, ev.SentAt = "harbour.example", sent
+ 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)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Bounces != want {
+ t.Errorf("bounces on %s = %d, want %d — counted against the day it arrived, not the day it was sent", day, c.Bounces, want)
+ }
+ }
+}
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go
index 5b8c4c386..c4a90683c 100644
--- a/internal/console/sesevents.go
+++ b/internal/console/sesevents.go
@@ -465,14 +465,20 @@ func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event, notice
log.Printf("console: ses events: listing accounts for %s: %v", ev.Domain, err)
return
}
- // The event's own timestamp decides which day's counters it lands in;
- // a missing one (mail.ParseEvent leaves At zero when SES omits or
- // mis-shapes mail.timestamp) falls back to now rather than bucketing
- // into 0001-01-01. The SAME instant is then reused as enforceMailLimits'
- // "now", so the cap/rate check reads the same day the count just landed
- // in — using time.Now() there instead could evaluate a different UTC day
- // than the one just written, right at midnight.
- at := ev.At
+ // The SEND decides which day's counters an event lands in — never the
+ // bounce, the complaint or the delivery, which can arrive hours or days
+ // later. The rate limiter divides a day's bounces and complaints by that
+ // same day's sends, so an event must count against the day its message
+ // was sent: a bounce at 00:05 for a message sent at 23:50 counted on the
+ // new day reads as a bounce rate over a handful of sends, and pauses a
+ // healthy tenant. (ev.At is the event's own time, for the log; ev.SentAt
+ // is mail.timestamp.) A missing send time (mail.ParseEvent leaves it
+ // zero when SES omits or mis-shapes it) falls back to now rather than
+ // bucketing into 0001-01-01. The SAME instant is then reused as
+ // enforceMailLimits' "now", so the cap/rate check reads the same day the
+ // count just landed in — using time.Now() there instead could evaluate a
+ // different UTC day than the one just written, right at midnight.
+ at := ev.SentAt
if at.IsZero() {
at = time.Now()
}