carlos / platform Public

mail, console: the cap, the pause and the day-roll resume are per sender (plan 2, task 12)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev eb0bb9d4680c5524c40fb3885af94fc45d69c5af parent 57e5520
6 files changed, +192 −92
  • internal/console/apimail.go +10 −3
  • internal/console/mailcounts.go +46 −42
  • internal/console/mailcounts_test.go +54 −19
  • internal/console/sesevents.go +2 −2
  • internal/mail/walk.go +48 −26
  • internal/mail/walk_lifecycle_test.go +32 −0
diff --git a/internal/console/apimail.go b/internal/console/apimail.go
index 20905304f..9ebbbb5a0 100644
--- a/internal/console/apimail.go
+++ b/internal/console/apimail.go
@@ -647,9 +647,16 @@ func (s *Server) handleAPIEmailStatus(w http.ResponseWriter, r *http.Request) {
// enforceMailLimits' day-roll check — a member running `carlos email
// status` is exactly the moment a rolled-over daily budget should
// clear. Shares resumeStaleCapPause with enforceMailLimits so the two
- // can never drift; a RATE pause or a kind-less pause is left untouched
- // by that function, fail-safe.
- rec, _, err = s.resumeStaleCapPause(ctx, acct.Sqid, app, rec, time.Now())
+ // can never drift; a RATE pause, a PER-INSTANCE pause or a kind-less
+ // pause is left untouched by that function, fail-safe.
+ sn, _, err := mail.GetSender(ctx, s.cfg.Releases, acct.Sqid, app, mail.SenderAppID)
+ if err == nil {
+ if _, resumed, rerr := s.resumeStaleCapPause(ctx, acct.Sqid, app, mail.SenderAppID, sn, time.Now()); rerr != nil {
+ err = rerr
+ } else if resumed {
+ rec, _, err = mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ }
+ }
if err != nil {
log.Printf("console: api email status resume %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
diff --git a/internal/console/mailcounts.go b/internal/console/mailcounts.go
index f82013324..eabf11d0b 100644
--- a/internal/console/mailcounts.go
+++ b/internal/console/mailcounts.go
@@ -157,52 +157,54 @@ FROM mail_sender_counts WHERE account = ? AND app = ? AND day = ?`
// Both callers share this one function so they can never drift.
//
// Fail-safe by construction, matching "RATE AND MANUAL PAUSES NEVER
-// AUTO-RESUME" below: resumes ONLY when rec is currently paused (Active ==
-// false), the pause is a CAP pause (PauseKind == mail.PauseKindCap — a
-// RATE pause or a kind-less manual/pre-existing pause, PauseKind == "",
-// both fall through untouched), and the pause was applied on a UTC day
-// STRICTLY EARLIER than now — same-day resumes would make the cap
-// meaningless.
+// AUTO-RESUME" below: resumes ONLY when sn is currently paused (Active ==
+// false), the pause is a CAP pause on a sender that is not PerInstance
+// (PauseKind == mail.PauseKindCap — a RATE pause, a PER-INSTANCE pause or a
+// kind-less manual/pre-existing pause, PauseKind == "", all fall through
+// untouched), and the pause was applied on a UTC day STRICTLY EARLIER than
+// now — same-day resumes would make the cap meaningless.
//
-// Returns the freshly re-read record when it resumes (rec's SetPaused
+// Returns the freshly re-read sender when it resumes (sn's SetSenderPaused
// write moves Active, PauseKind and At, so callers must not patch the
-// in-memory copy by hand) and whether a resume happened; rec is passed
+// in-memory copy by hand) and whether a resume happened; sn is passed
// straight back, and resumed is false, in every other case, including
-// rec == nil or rec.Provisioned == nil.
-func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app string, rec *mail.Record, now time.Time) (*mail.Record, bool, error) {
- if rec == nil || rec.Provisioned == nil || rec.Provisioned.Active {
- return rec, false, nil
+// sn == nil or sn.Provisioned == nil.
+func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app, sender string, sn *mail.Sender, now time.Time) (*mail.Sender, bool, error) {
+ if sn == nil || sn.Provisioned == nil || sn.Provisioned.Active {
+ return sn, false, nil
}
- // A RATE pause or a MANUAL (operator) pause — PauseKind is anything
- // other than mail.PauseKindCap, including "" — never auto-resumes: see
- // "RATE AND MANUAL PAUSES NEVER AUTO-RESUME" below.
- if rec.Provisioned.PauseKind != mail.PauseKindCap {
- return rec, false, nil
+ // A RATE pause, a MANUAL pause and a PER-INSTANCE pause all fall through
+ // untouched. The first two never auto-resume by design; the third must
+ // not, because resuming reactivates every key including the standalones
+ // and the app credential every instance still holds.
+ if sn.Provisioned.PauseKind != mail.PauseKindCap || sn.PerInstance {
+ return sn, false, nil
}
- pausedDay := rec.Provisioned.At.UTC().Format("2006-01-02")
+ pausedDay := sn.Provisioned.At.UTC().Format("2006-01-02")
today := now.UTC().Format("2006-01-02")
if pausedDay >= today {
// Paused earlier TODAY (or a clock oddity puts the pause in the
// future) — not a day roll yet.
- return rec, false, nil
+ return sn, false, nil
}
- if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, false, "", now); err != nil {
+ if _, err := mail.SetSenderPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, sender, false, "", now); err != nil {
return nil, false, err
}
if a, err := s.store.AccountBySqid(acct); err != nil {
log.Printf("console: mail resume audit: resolving account %s: %v", acct, err)
} else if a != nil {
- s.audit(a, app, "ses", "mail.resumed", fmt.Sprintf("%s: daily cap pause cleared on the UTC day roll", app))
+ s.audit(a, app, "ses", "mail.resumed", fmt.Sprintf("%s/%s: daily cap pause cleared on the UTC day roll", app, sender))
}
- fresh, _, err := mail.Get(ctx, s.cfg.Releases, acct, app)
+ fresh, _, err := mail.GetSender(ctx, s.cfg.Releases, acct, app, sender)
if err != nil {
return nil, false, err
}
return fresh, true, nil
}
-// enforceMailLimits pauses an app that has exceeded its daily cap or is past
-// either SES threshold, and reports why.
+// enforceMailLimits pauses a sender that has exceeded its daily cap or is
+// past either SES threshold, and reports why. The cap and the pause are the
+// sender's own, so one sender's breach leaves its siblings sending.
//
// The rate thresholds are applied only once MinSendsForRates messages have
// gone out that day: without that floor, one bounce on a two-message morning
@@ -213,42 +215,44 @@ func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app string, rec
// counts that caused it and surfaced on the app page — a silent pause
// presents to the member as "email stopped working", which is the failure
// this whole mechanism is supposed to prevent someone else's tenant causing.
-func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now time.Time) (bool, string, error) {
- rec, _, err := mail.Get(ctx, s.cfg.Releases, acct, app)
+func (s *Server) enforceMailLimits(ctx context.Context, acct, app, sender string, now time.Time) (bool, string, error) {
+ sn, _, err := mail.GetSender(ctx, s.cfg.Releases, acct, app, sender)
if err != nil {
return false, "", err
}
- if rec == nil || rec.Provisioned == nil {
+ if sn == nil || sn.Provisioned == nil {
return false, "", nil
}
- if !rec.Provisioned.Active {
- fresh, resumed, err := s.resumeStaleCapPause(ctx, acct, app, rec, now)
+ if !sn.Provisioned.Active {
+ fresh, resumed, err := s.resumeStaleCapPause(ctx, acct, app, sender, sn, now)
if err != nil {
return false, "", err
}
if !resumed {
return false, "", nil
}
- // Re-read via resumeStaleCapPause: SetPaused just wrote a fresh
- // record (Active, PauseKind and At all moved), and everything
- // below reads rec fresh rather than patching the in-memory copy by
+ // Re-read via resumeStaleCapPause: SetSenderPaused just wrote a
+ // fresh sender (Active, PauseKind and At all moved), and everything
+ // below reads sn fresh rather than patching the in-memory copy by
// hand.
- rec = fresh
- if rec == nil || rec.Provisioned == nil || !rec.Provisioned.Active {
+ sn = fresh
+ if sn == nil || sn.Provisioned == nil || !sn.Provisioned.Active {
return false, "", nil
}
}
day := now.UTC().Format("2006-01-02")
- // "" 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)
+ // The real sender, not "": MailCounts treats "" as every sender the app
+ // has, and this cap belongs to ONE sender — summing every sibling's
+ // traffic into it would let one sender's cap trip on another sender's
+ // mail, which is exactly what keying the cap to the sender is meant to
+ // stop.
+ c, err := s.store.MailCounts(ctx, acct, app, sender, day)
if err != nil {
return false, "", err
}
cap := TierCap("")
- if rec.Grant != nil && rec.Grant.DailyCap > 0 {
- cap = rec.Grant.DailyCap
+ if sn.Grant != nil && sn.Grant.DailyCap > 0 {
+ cap = sn.Grant.DailyCap
}
var reason, kind string
@@ -270,7 +274,7 @@ func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now ti
return false, "", nil
}
- if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, true, kind, now); err != nil {
+ if _, err := mail.SetSenderPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, sender, true, kind, now); err != nil {
return false, "", err
}
// Audit AFTER the write it announces, and never before — a pause the
@@ -279,7 +283,7 @@ func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now ti
if a, err := s.store.AccountBySqid(acct); err != nil {
log.Printf("console: mail pause audit: resolving account %s: %v", acct, err)
} else if a != nil {
- s.audit(a, app, "ses", "mail.paused", fmt.Sprintf("%s: %s", app, reason))
+ s.audit(a, app, "ses", "mail.paused", fmt.Sprintf("%s/%s: %s", app, sender, reason))
}
return true, reason, nil
}
diff --git a/internal/console/mailcounts_test.go b/internal/console/mailcounts_test.go
index 34464dd82..1c0ea4663 100644
--- a/internal/console/mailcounts_test.go
+++ b/internal/console/mailcounts_test.go
@@ -96,7 +96,7 @@ func seedCounts(t *testing.T, fx *consoleFixture, day string, c MailCounts) {
func TestEnforcePausesOnCapBreach(t *testing.T) {
fx, now := mailFixture(t)
seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50})
- paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now)
+ paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
if err != nil {
t.Fatal(err)
}
@@ -117,7 +117,7 @@ func TestEnforcePausesOnCapBreach(t *testing.T) {
func TestEnforceIgnoresRatesBelowTheSendFloor(t *testing.T) {
fx, now := mailFixture(t)
seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 2, Bounces: 1})
- paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now)
+ paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
if err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestEnforcePausesOnBounceRate(t *testing.T) {
mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "harbourmail",
[]string{"x.com"}, 100000, "operator", "owner@example.test", now)
seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 1000, Bounces: 60})
- paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now)
+ paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
if err != nil {
t.Fatal(err)
}
@@ -167,7 +167,7 @@ func TestEnforcePausesOnComplaintRate(t *testing.T) {
mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "harbourmail",
[]string{"x.com"}, 100000, "operator", "owner@example.test", now)
seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 1000, Complaints: 5})
- paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now)
+ paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
if err != nil {
t.Fatal(err)
}
@@ -207,7 +207,7 @@ func TestSESThresholdConstants(t *testing.T) {
func TestEnforceCapPauseResumesOnTheNextUTCDay(t *testing.T) {
fx, now := mailFixture(t)
seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50})
- if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now); err != nil {
+ if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now); err != nil {
t.Fatal(err)
}
rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
@@ -219,7 +219,7 @@ func TestEnforceCapPauseResumesOnTheNextUTCDay(t *testing.T) {
}
next := now.AddDate(0, 0, 1) // a fresh UTC day, no counts at all
- paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", next)
+ paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, next)
if err != nil {
t.Fatal(err)
}
@@ -262,7 +262,7 @@ func TestEnforceRatePauseNeverAutoResumes(t *testing.T) {
t.Fatal(err)
}
seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 1000, Bounces: 60})
- if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now); err != nil {
+ if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now); err != nil {
t.Fatal(err)
}
rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
@@ -274,7 +274,7 @@ func TestEnforceRatePauseNeverAutoResumes(t *testing.T) {
}
next := now.AddDate(0, 0, 1) // a fresh UTC day
- paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", next)
+ paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, next)
if err != nil {
t.Fatal(err)
}
@@ -302,14 +302,14 @@ func TestEnforceAlreadyPausedDoesNotReprocess(t *testing.T) {
fx, now := mailFixture(t)
day := now.UTC().Format("2006-01-02")
seedCounts(t, fx, day, MailCounts{Sends: 50})
- if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now); err != nil {
+ if _, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now); err != nil {
t.Fatal(err)
}
// More sends land on the SAME day, well past the cap — a re-evaluating
// enforce would trip again.
seedCounts(t, fx, day, MailCounts{Sends: 25})
- paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now)
+ paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
if err != nil {
t.Fatal(err)
}
@@ -339,7 +339,7 @@ func TestEnforceAlreadyPausedDoesNotReprocess(t *testing.T) {
func TestEnforceRecordsTheReasonInTheAuditLog(t *testing.T) {
fx, now := mailFixture(t)
seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50})
- fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", now)
+ fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, now)
entries, err := fx.st.Events(OpsAccountID, "harbourmail", 10)
if err != nil {
@@ -379,11 +379,11 @@ func pauseDirectly(t *testing.T, fx *consoleFixture, app, kind string, at time.T
func TestResumeStaleCapPauseClearsACapPauseFromYesterday(t *testing.T) {
fx, now := mailFixture(t)
pauseDirectly(t, fx, "harbourmail", mail.PauseKindCap, now.AddDate(0, 0, -1))
- rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
+ sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
if err != nil {
t.Fatal(err)
}
- got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", rec, now)
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
if err != nil {
t.Fatal(err)
}
@@ -401,11 +401,11 @@ func TestResumeStaleCapPauseClearsACapPauseFromYesterday(t *testing.T) {
func TestResumeStaleCapPauseLeavesATodayCapPauseAlone(t *testing.T) {
fx, now := mailFixture(t)
pauseDirectly(t, fx, "harbourmail", mail.PauseKindCap, now) // paused earlier the SAME UTC day
- rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
+ sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
if err != nil {
t.Fatal(err)
}
- got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", rec, now)
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
if err != nil {
t.Fatal(err)
}
@@ -420,11 +420,11 @@ func TestResumeStaleCapPauseLeavesATodayCapPauseAlone(t *testing.T) {
func TestResumeStaleCapPauseNeverTouchesARatePause(t *testing.T) {
fx, now := mailFixture(t)
pauseDirectly(t, fx, "harbourmail", mail.PauseKindRate, now.AddDate(0, 0, -1))
- rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
+ sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
if err != nil {
t.Fatal(err)
}
- got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", rec, now)
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
if err != nil {
t.Fatal(err)
}
@@ -442,11 +442,11 @@ func TestResumeStaleCapPauseNeverTouchesARatePause(t *testing.T) {
func TestResumeStaleCapPauseNeverTouchesAKindlessPause(t *testing.T) {
fx, now := mailFixture(t)
pauseDirectly(t, fx, "harbourmail", "", now.AddDate(0, 0, -1))
- rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "harbourmail")
+ sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
if err != nil {
t.Fatal(err)
}
- got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", rec, now)
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
if err != nil {
t.Fatal(err)
}
@@ -571,3 +571,38 @@ func TestMailCountsAreKeptAndReadPerSender(t *testing.T) {
t.Fatalf("a quiet day = %+v %v", quiet, err)
}
}
+
+// The cap applies per sender, so one sender's breach pauses that sender and
+// leaves its siblings sending.
+func TestTheCapIsEnforcedPerSender(t *testing.T) {
+ fx, now := mailFixture(t)
+ ctx := context.Background()
+ day := now.UTC().Format("2006-01-02")
+ for i := 0; i < 50; i++ {
+ if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // A second sender's traffic does not count towards the first one's cap.
+ for i := 0; i < 50; i++ {
+ if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
+ t.Fatal(err)
+ }
+ }
+ paused, reason, err := fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !paused || !strings.Contains(reason, "daily cap 50") {
+ t.Fatalf("app sender = %v %q, want a cap pause", paused, reason)
+ }
+ sn, _, err := mail.GetSender(ctx, fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
+ if err != nil || sn.Provisioned.Active || sn.Provisioned.PauseKind != mail.PauseKindCap {
+ t.Fatalf("the app sender's record = %+v %v", sn.Provisioned, err)
+ }
+ // The day roll clears a cap pause, still, and only a cap pause.
+ fresh, resumed, err := fx.srv.resumeStaleCapPause(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now.AddDate(0, 0, 1))
+ if err != nil || !resumed || fresh == nil || !fresh.Provisioned.Active {
+ t.Fatalf("the day roll did not clear the cap pause: %+v %v %v", fresh, resumed, err)
+ }
+}
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go
index e00233481..a4c3f5c4a 100644
--- a/internal/console/sesevents.go
+++ b/internal/console/sesevents.go
@@ -521,7 +521,7 @@ func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event, notice
log.Printf("console: ses events: recording %s for %s/%s/%s -> %v", ev.Type, callerAcct, callerApp, callerSender, err)
return
}
- if _, reason, err := s.enforceMailLimits(ctx, callerAcct, callerApp, at); err != nil {
+ if _, reason, err := s.enforceMailLimits(ctx, callerAcct, callerApp, callerSender, at); err != nil {
log.Printf("console: ses events: enforcing limits for %s/%s/%s -> %v", callerAcct, callerApp, callerSender, err)
} else if reason != "" {
log.Printf("console: ses events: paused %s/%s/%s -> %s", callerAcct, callerApp, callerSender, reason)
@@ -568,7 +568,7 @@ func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event, notice
log.Printf("console: ses events: recording %s for %s/%s: %v", ev.Type, a.Sqid, app, err)
continue
}
- if _, reason, err := s.enforceMailLimits(ctx, a.Sqid, app, at); err != nil {
+ if _, reason, err := s.enforceMailLimits(ctx, a.Sqid, app, mail.SenderAppID, at); err != nil {
log.Printf("console: ses events: enforcing limits for %s/%s: %v", a.Sqid, app, err)
} else if reason != "" {
log.Printf("console: ses events: paused %s/%s: %s", a.Sqid, app, reason)
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index e43b745cf..b250b831d 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -967,20 +967,35 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app
return rec, nil
}
-// SetPaused stops or resumes sending — every principal this app owns, not
-// just the delivered one.
-//
-// It toggles the KEY, never the policy. A policy rewrite would have to be
-// undone correctly to resume, and a half-failed rewrite leaves a grant that
-// is neither the old one nor the new one. Deactivating a key is atomic at
-// IAM and trivially reversible.
+// ErrPerInstance refuses a resume aimed at an app sender whose app has moved
+// to per-instance senders (design section 6.3). Nothing sets PerInstance in this
+// release; the refusal exists so that nothing can set it later without this
+// guard already being in place and tested.
+var ErrPerInstance = errors.New("mail: the app sender is in per-instance mode")
+
+// SetPaused stops or resumes the APP sender, which is every sender an app
+// has until instance senders exist. Its signature is unchanged so the
+// operator door and the limiter keep calling it exactly as they did.
+func SetPaused(ctx context.Context, s release.Store, p Provisioner,
+ acct, app string, paused bool, kind string, now time.Time) (*Record, error) {
+
+ sn, err := SetSenderPaused(ctx, s, p, acct, app, SenderAppID, paused, kind, now)
+ if err != nil {
+ return nil, err
+ }
+ return sn.record(app), nil
+}
+
+// SetSenderPaused toggles one sender's keys, never its policy. A policy
+// rewrite would have to be undone correctly to resume, and a half-failed
+// rewrite leaves a grant that is neither the old one nor the new one;
+// deactivating a key is atomic at IAM and trivially reversible.
//
-// BOTH of the delivered user's keys are toggled: an app mid-rotation still
-// holds the previous credential, and pausing only the current one would
-// leave it sending. Every standalone credential (I2) is toggled too — each
-// is its OWN IAM user, so there is no two-key ceiling interaction, and
-// skipping them would leave a tenant paused for abuse still sending from the
-// same verified domain through, say, a laptop credential.
+// BOTH of the delivered user's keys are toggled, because an app
+// mid-rotation still holds the previous credential, and every standalone
+// credential of this sender is toggled too — each is its own IAM user, so
+// there is no two-key ceiling interaction, and skipping them would leave a
+// tenant paused for abuse still sending through a laptop credential.
//
// kind records WHICH automatic pause this is (mail.PauseKindCap or
// mail.PauseKindRate) so mailcounts.go's enforceMailLimits can tell a
@@ -990,18 +1005,25 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app
// (paused == false): PauseKind is always cleared back to "" on resume,
// whatever resumed it, so a record never carries a stale kind from a pause
// that is already lifted.
-func SetPaused(ctx context.Context, s release.Store, p Provisioner,
- acct, app string, paused bool, kind string, now time.Time) (*Record, error) {
+//
+// A resume is refused while PerInstance is set: the app's instances still
+// hold this credential, and re-arming it is exactly what per-instance mode
+// exists to prevent. A pause is always allowed.
+func SetSenderPaused(ctx context.Context, s release.Store, p Provisioner,
+ acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error) {
- rec, etag, err := Get(ctx, s, acct, app)
+ sn, etag, err := GetSender(ctx, s, acct, app, sender)
if err != nil {
return nil, err
}
- if rec == nil || rec.Provisioned == nil {
+ if sn == nil || sn.Provisioned == nil {
return nil, ErrNotProvisioned
}
- user := rec.Provisioned.IAMUser
- for _, id := range []string{rec.Provisioned.KeyID, rec.Provisioned.PrevKeyID} {
+ if !paused && sn.PerInstance {
+ return nil, fmt.Errorf("mail: %s/%s: resume its instance senders instead: %w", acct, app, ErrPerInstance)
+ }
+ user := sn.Provisioned.IAMUser
+ for _, id := range []string{sn.Provisioned.KeyID, sn.Provisioned.PrevKeyID} {
if id == "" {
continue
}
@@ -1009,22 +1031,22 @@ func SetPaused(ctx context.Context, s release.Store, p Provisioner,
return nil, err
}
}
- for _, sa := range rec.Standalone {
+ for _, sa := range sn.Standalone {
if err := p.SetKeyActive(ctx, sa.IAMUser, sa.KeyID, !paused); err != nil {
return nil, err
}
}
- rec.Provisioned.Active = !paused
+ sn.Provisioned.Active = !paused
if paused {
- rec.Provisioned.PauseKind = kind
+ sn.Provisioned.PauseKind = kind
} else {
- rec.Provisioned.PauseKind = ""
+ sn.Provisioned.PauseKind = ""
}
- rec.Provisioned.At = now
- if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ sn.Provisioned.At = now
+ if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
return nil, err
}
- return rec, nil
+ return sn, nil
}
// CreateStandalone mints a displayed credential on its OWN IAM principal.
diff --git a/internal/mail/walk_lifecycle_test.go b/internal/mail/walk_lifecycle_test.go
index 2b48de825..cce966e1f 100644
--- a/internal/mail/walk_lifecycle_test.go
+++ b/internal/mail/walk_lifecycle_test.go
@@ -578,4 +578,36 @@ func TestTeardownDeletesTheLegacyRecordToo(t *testing.T) {
}
}
+// An app sender in per-instance mode refuses to resume. Nothing sets the
+// flag in this release, but the refusal has to be in place before anything
+// can: resumeStaleCapPause reactivates every key it touches, including every
+// standalone, so a cap-paused app sender would otherwise re-arm on the next
+// UTC day roll the credential every instance still holds.
+func TestPerInstanceModeRefusesAResume(t *testing.T) {
+ ctx, s, p, pub, now := enableFixture(t)
+ if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindCap, now); err != nil {
+ t.Fatal(err)
+ }
+ sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sn.PerInstance = true
+ sn.Provisioned.PauseKind = PauseKindPerInstance
+ if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", false, "", now); !errors.Is(err, ErrPerInstance) {
+ t.Fatalf("resume in per-instance mode = %v, want ErrPerInstance", err)
+ }
+ // Pausing harder is still allowed: the refusal is about re-arming a
+ // credential, not about turning one off.
+ if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindRate, now); err != nil {
+ t.Fatalf("pausing an app sender in per-instance mode was refused: %v", err)
+ }
+}
+
func memWriterForTest() dnsrec.Writer { return dnsrec.NewMemWriter() }