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>
2 files changed,
+52
−8
internal/console/mailevents_test.go+38 −0internal/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() |
| } |