carlos / platform Public

tenant email: six residual fixes from the final whole-branch review

R1: Enable's repair path dropped PauseKind on rewrite, silently turning a
CAP pause kind-less (nothing then auto-resumes it, and the CLI describes
it as needing an operator) — now carried forward alongside Active.

R2: enforceMailLimits' day-roll resume only ever ran on the SES event
path, which a paused app (deactivated IAM key) never generates traffic
for — an app alone on its own domain saw its cap pause as permanent.
Extracted the check into resumeStaleCapPause, shared by enforceMailLimits
and the new call in handleAPIEmailStatus, so a member reading `carlos
email status` after the UTC day roll clears a stale cap pause itself. CLI
copy updated to say so; a rate or kind-less pause is untouched either way.

R3: appsettings.go's store-destroy door no longer runs mail.Teardown.
Its confirm word is the bucket name and its flash names only the object
store — consent for destroying the SES identity, IAM principal, delivered
credentials, DNS and the global domain claim was never obtained there.
ownership.go's handlePurgeApp (the whole-app delete) keeps the call.

R4: moved domainMatchesEvent (with its own doc comment) back above
recordAndEnforceMail's doc comment in sesevents.go, so the comment sits
over the function it documents again.

R5: hoisted the "email estate but no MailProvisioner" refusal in
handlePurgeApp above objstore.Teardown, so a doomed purge refuses before
destroying the store, matching appsettings.go's ordering.

R6: spec §9 amended to match — Provisioned carries PauseKind now, and the
cap-vs-rate resume rule (cap clears at the UTC day roll, checked on both
the event and status-read paths; rate/kind-less never auto-resume) is
recorded.

Mutation-tested R1, R2, R3 (and R5, though not required): reverted each
fix in turn and confirmed a test fails before restoring it. Full report
in .superpowers/sdd/2026-08-19-tenant-email/residuals-report.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev bbaf09cea626625139026d8809ed9007a8928825 parent ccc5d93
13 files changed, +486 −123
  • docs/superpowers/specs/2026-08-19-tenant-email-design.md +32 −5
  • internal/console/apimail.go +14 −0
  • internal/console/apimail_test.go +77 −0
  • internal/console/appsettings.go +8 −41
  • internal/console/appsettings_test.go +63 −0
  • internal/console/deletion_test.go +47 −0
  • internal/console/mailcounts.go +67 −32
  • internal/console/mailcounts_test.go +105 −0
  • internal/console/ownership.go +32 −22
  • internal/console/sesevents.go +14 −14
  • internal/mail/walk.go +14 −7
  • internal/mail/walk_enable_test.go +6 −0
  • mail_cli.go +7 −2
diff --git a/docs/superpowers/specs/2026-08-19-tenant-email-design.md b/docs/superpowers/specs/2026-08-19-tenant-email-design.md
index 96b8941c8..0b5668996 100644
--- a/docs/superpowers/specs/2026-08-19-tenant-email-design.md
+++ b/docs/superpowers/specs/2026-08-19-tenant-email-design.md
@@ -555,11 +555,38 @@ complains. So a pause MUST surface at the moment it happens, with the
counts that caused it. A silent pause is a bug.
**Amended 2026-08-20 on where the reason lives.** The reason string reaches
-the **audit log** and nothing else: `Provisioned` carries only `Active`, so
-`carlos email status` shows the pause, the counts and the cap, and points at
-the audit log for the reason rather than printing it. Adding
-`Provisioned.Reason` is additive and small; until it exists this document
-must not claim the status surface carries it.
+the **audit log** and nothing else: `Provisioned` carries `Active` and
+`PauseKind`, not a free-text reason, so `carlos email status` shows the
+pause, the counts and the cap, and points at the audit log for the reason
+rather than printing it. Adding `Provisioned.Reason` is additive and small;
+until it exists this document must not claim the status surface carries it.
+
+**Amended 2026-08-20 on the two kinds of pause, and which one clears
+itself.** `Provisioned.PauseKind` distinguishes a **cap** pause (the daily
+budget spent) from a **rate** pause (a bounce/complaint threshold breached
+— abuse). The difference is load-bearing, because only one of the two is
+safe to lift on a timer:
+
+- A **cap** pause is a PER-DAY limit, computed from counts that reset every
+ UTC day, so it clears itself once a day has rolled over since it was
+ applied — same-day never clears, that would make the cap meaningless.
+ The check runs in two places, sharing one function so they cannot drift:
+ on the SES event path (`enforceMailLimits`, which is also where a cap
+ pause is first applied), and on the `carlos email status` READ path.
+ The second place exists because a paused app's IAM key is deactivated —
+ it sends nothing, so no SES event ever arrives to run the event-path
+ check — so an app alone on its own domain would otherwise see its cap
+ pause as permanent; reading status is the moment that resume actually
+ happens for it.
+- A **rate** pause never auto-resumes, day roll or not — SES's
+ account-review thresholds are not a daily allowance, and lifting the
+ brake on a schedule would let an abusive tenant breach it again every
+ day forever. It needs a deployment operator, through `carlos email
+ resume`.
+- A pause with no kind recorded (`PauseKind == ""` — every record written
+ before this split, and every manual operator pause) is treated the same
+ as a rate pause for this purpose: it never auto-resumes either. Fail
+ safe: the default is "needs a human," not "clears itself."
**Suppression.** SES's account-level suppression list does the enforcing.
CARLOS ingests suppression events so a tenant can see and clear its own
diff --git a/internal/console/apimail.go b/internal/console/apimail.go
index 948cc0093..6f52f4c21 100644
--- a/internal/console/apimail.go
+++ b/internal/console/apimail.go
@@ -585,6 +585,20 @@ func (s *Server) handleAPIEmailStatus(w http.ResponseWriter, r *http.Request) {
})
return
}
+ // R2 (review follow-up): resume a stale CAP pause on the status READ
+ // path, not only on the SES event path. A paused app's IAM key is
+ // deactivated, so it sends nothing, so no events ever arrive to run
+ // 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())
+ 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")
+ return
+ }
out, err := s.emailReply(ctx, acct, app, rec)
if err != nil {
log.Printf("console: api email delivery %s/%s: %v", acct.Sqid, app, err)
diff --git a/internal/console/apimail_test.go b/internal/console/apimail_test.go
index bc5225c6a..2322f56d5 100644
--- a/internal/console/apimail_test.go
+++ b/internal/console/apimail_test.go
@@ -379,6 +379,83 @@ func TestAPIEmailStatusDefaultHostEmptyWithNoPlatformSuffix(t *testing.T) {
}
}
+// TestAPIEmailStatusResumesAStaleCapPause: R2, review follow-up. The SES
+// event path (enforceMailLimits) is enforceMailLimits' only production
+// caller, and a paused app's IAM key is deactivated — it sends nothing, so
+// no SES event ever arrives to run that check. The status READ door must
+// run the same day-roll check itself, or an app alone on its own domain
+// would see its cap pause as permanent, contradicting what the CLI tells
+// the member. Proves the wiring end to end, over HTTP — not just the
+// shared function in isolation.
+func TestAPIEmailStatusResumesAStaleCapPause(t *testing.T) {
+ fx, fake, _, token := emailArmed(t, "code-email-status-resume")
+ app, domain := "titogo", "titogo.bab.oncarlos.com"
+ provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain)
+
+ rec, etag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Provisioned.Active = false
+ rec.Provisioned.PauseKind = mail.PauseKindCap
+ rec.Provisioned.At = time.Now().AddDate(0, 0, -1) // yesterday, UTC
+ if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, etag); err != nil {
+ t.Fatal(err)
+ }
+
+ status, out := getJSON(t, emailURL(fx, app), token)
+ if status != http.StatusOK {
+ t.Fatalf("status = %d %v", status, out)
+ }
+ if out["active"] != true {
+ t.Fatalf("active = %v, want true — a cap pause from yesterday must clear on a status read", out["active"])
+ }
+
+ got := emailRecord(t, fx, app)
+ if !got.Provisioned.Active {
+ t.Fatal("the record on disk is still paused after the status read resumed it")
+ }
+ if got.Provisioned.PauseKind != "" {
+ t.Fatalf("PauseKind = %q on disk after resume, want cleared", got.Provisioned.PauseKind)
+ }
+}
+
+// TestAPIEmailStatusLeavesARatePauseAlone: the same door must NEVER resume
+// a RATE pause — that pause exists because the tenant is abusive, not
+// because a budget rolled over, and it needs a human.
+func TestAPIEmailStatusLeavesARatePauseAlone(t *testing.T) {
+ fx, fake, _, token := emailArmed(t, "code-email-status-no-resume-rate")
+ app, domain := "titogo", "titogo.bab.oncarlos.com"
+ provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain)
+
+ rec, etag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Provisioned.Active = false
+ rec.Provisioned.PauseKind = mail.PauseKindRate
+ rec.Provisioned.At = time.Now().AddDate(0, 0, -1) // yesterday, UTC
+ if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, etag); err != nil {
+ t.Fatal(err)
+ }
+
+ status, out := getJSON(t, emailURL(fx, app), token)
+ if status != http.StatusOK {
+ t.Fatalf("status = %d %v", status, out)
+ }
+ if out["active"] != false {
+ t.Fatalf("active = %v, want false — a rate pause must never auto-resume", out["active"])
+ }
+
+ got := emailRecord(t, fx, app)
+ if got.Provisioned.Active {
+ t.Fatal("the record on disk was resumed — a rate pause must never auto-resume")
+ }
+ if got.Provisioned.PauseKind != mail.PauseKindRate {
+ t.Fatalf("PauseKind = %q on disk, want it to survive as %q", got.Provisioned.PauseKind, mail.PauseKindRate)
+ }
+}
+
// TestAPIEmailDeclareRefusesAnUnaddressedFrom: a From with no "@" is refused
// before anything is written.
func TestAPIEmailDeclareRefusesAnUnaddressedFrom(t *testing.T) {
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index d5ada86bb..2d1755265 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -38,7 +38,6 @@ import (
"time"
"github.com/carlosframework/platform/internal/domains"
- "github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
)
@@ -600,37 +599,17 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) {
"this deployment has to tear it down.")
return
}
- // An email estate is torn down alongside the object store here too (I2,
- // review follow-up): mail.Teardown is the ONLY place that releases this
- // app's global domain claims (spec §4a), and this button — like
- // ownership.go's handlePurgeApp, which already does the same — is one
- // of the two backend-destroying surfaces objstore.Teardown was called
- // from with no email counterpart. Checked BEFORE requireFreshAuth, same
- // as the store's own backend check above: a doomed request must not
- // spend the passkey.
- mrec, _, mrerr := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
- if mrerr != nil {
- log.Printf("console: store destroy mail read %s/%s: %v", acct.Sqid, app, mrerr)
- fail("Could not read “" + app + "”'s email record — nothing was destroyed. Try again.")
- return
- }
- mailEstate := mrec != nil && (mrec.Provisioned != nil || len(mrec.Standalone) > 0)
- if !mailEstate {
- // A domain claim can exist with no delivered credential at all —
- // `carlos email domains add` alone, never followed by `enable` —
- // and mail.Teardown releases that claim too.
- if doms, derr := s.mailAppDomains(ctx, acct, app); derr == nil && len(doms) > 0 {
- mailEstate = true
- }
- }
- if mailEstate && s.cfg.MailProvisioner == nil {
- fail("This console has no SES backend configured, so it cannot delete “" + app + "”'s email " +
- "credentials — nothing was destroyed. An operator of this deployment has to tear it down.")
- return
- }
if !s.requireFreshAuth(w, r, addr, raw) {
return
}
+ // R3 (review follow-up): this door tears down ONLY the object store.
+ // It used to also tear down the app's email estate — the SES identity,
+ // the IAM principal, delivered credentials, published DNS and,
+ // irreversibly and globally, the domain claim — but consent for THAT
+ // was never obtained here: the confirm word is the bucket name, the
+ // button says "Destroy store", and the success flash below names only
+ // the object store. Email teardown belongs to ownership.go's
+ // handlePurgeApp, which destroys the whole app and says so.
if err := objstore.Teardown(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app); err != nil {
log.Printf("console: store destroy %s/%s: %v", acct.Sqid, app, err)
if errors.Is(err, release.ErrCASConflict) {
@@ -641,18 +620,6 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) {
"backend is reachable.")
return
}
- if s.cfg.MailProvisioner != nil {
- mdep, mwriter := s.mailDeploymentFor()
- if terr := mail.Teardown(ctx, s.cfg.Releases, s.cfg.MailProvisioner, mwriter, mdep, acct.Sqid, app); terr != nil {
- log.Printf("console: store destroy mail teardown %s/%s: %v", acct.Sqid, app, terr)
- fail("Destroyed the object store, but could not tear down “" + app + "”'s email estate — " +
- "the store is already gone; try again to finish tearing down email.")
- return
- }
- if mailEstate {
- s.audit(acct, app, addr, "email-teardown", "tore down the email estate alongside the object store")
- }
- }
what, flash := "the store declaration", "Withdrew “"+app+"”'s store declaration."
if estate {
what = "bucket " + want
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index 5440b1a24..9c1985f31 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -18,6 +18,8 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/dnsrec"
+ "github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -818,6 +820,67 @@ func TestStoreDestroyDestroysTheEstateAndAnnounces(t *testing.T) {
}
}
+// TestStoreDestroyLeavesTheEmailEstateAlone: R3, review follow-up. A
+// previous fix wave wired mail.Teardown into THIS door — but this door's
+// confirm word is the bucket name, its button says "Destroy store", and its
+// success flash (asserted above) names only the object store. Nothing here
+// ever told the owner that confirming would also destroy the SES identity,
+// the IAM principal, delivered credentials, published DNS and — globally,
+// irreversibly — the domain claim. Consent for the store is not consent for
+// email; ownership.go's handlePurgeApp (the whole-app delete) is the door
+// that destroys both, and says so.
+//
+// The proof is the claim's own semantics (mirroring
+// TestPurgeReleasesTheDomainClaim in deletion_test.go), not an internal peek
+// at bucket keys: a SECOND, unrelated account trying to claim the SAME
+// domain is refused with ErrDomainClaimed both before and after the store
+// destroy — if this door released it, the second account's EnsureDomain
+// would start succeeding.
+func TestStoreDestroyLeavesTheEmailEstateAlone(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+
+ mfake := mail.NewMemProvisioner("1")
+ writer := dnsrec.NewMemWriter()
+ fx.srv.cfg.MailProvisioner = mfake
+ fx.srv.cfg.DNSWriter = writer
+ dep := mail.Deployment{Region: "eu-west-1"} // no PlatformSuffix, matching
+ // TestPurgeReleasesTheDomainClaim: keeps this about the claim gate
+ // alone, not the separate platform-name-ownership gate.
+ ctx := context.Background()
+ now := time.Now()
+ domain := "store-destroy-claim.example.com"
+ // Deliberately no mail.Declare/Enable — kass has a domain record (and
+ // the claim it holds) but no Provisioned credential, exactly the shape
+ // `carlos email domains add` alone leaves. If this door's old
+ // mail.Teardown wiring were still here, it would find and release this
+ // claim too — mail.Teardown traces the domain-referrer set, not just a
+ // delivered credential.
+ if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, fx.sqid, "kass", domain, now); err != nil {
+ t.Fatalf("EnsureDomain for kass: %v", err)
+ }
+ if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) {
+ t.Fatalf("a second account's EnsureDomain before the destroy = %v, want ErrDomainClaimed", err)
+ }
+
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
+ url.Values{"csrf": {csrf}, "confirm": {bucket}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("store destroy = %d -> %q:\n%s", status, loc, body)
+ }
+ estateGone(t, fake, "a store destroy that must leave email alone")
+ if rec := storedRecord(t, fx, "kass"); rec != nil {
+ t.Fatalf("the store record survived its own destruction: %+v", rec)
+ }
+
+ if _, _, err := mail.EnsureDomain(ctx, fx.rel, mfake, writer, dep, "zzzz", "other", domain, now); !errors.Is(err, mail.ErrDomainClaimed) {
+ t.Fatalf("the domain claim was released by a store destroy: err = %v, want it still ErrDomainClaimed", err)
+ }
+}
+
// A declaration is a request nobody has acted on: no bucket, no key, nothing to
// spend backend authority on. So a console with no backend at all may still
// withdraw it — and the word to type is the APP's name, because that is the
diff --git a/internal/console/deletion_test.go b/internal/console/deletion_test.go
index b7805cdad..824ff7294 100644
--- a/internal/console/deletion_test.go
+++ b/internal/console/deletion_test.go
@@ -592,6 +592,53 @@ func TestPurgeRefusesAStoreThisConsoleCannotTearDown(t *testing.T) {
}
}
+// TestPurgeRefusesNoMailBackendBeforeTouchingTheStore: R5, review
+// follow-up. The "this app has an email estate but no MailProvisioner"
+// refusal used to sit AFTER objstore.Teardown had already destroyed the
+// object store, mirroring the very bug
+// TestPurgeRefusesAStoreThisConsoleCannotTearDown above already guards
+// against on the store side — a purge that was always going to be refused
+// destroyed the store first. Same shape, but for the email-side refusal:
+// the store must still be standing after it.
+func TestPurgeRefusesNoMailBackendBeforeTouchingTheStore(t *testing.T) {
+ fx, c, csrf := trashPageFixture(t)
+ fake := armStoreBackend(t, fx)
+ bucket := seedStore(t, fx, fake, "kass", false)
+
+ // An email estate with no MailProvisioner configured — this deployment
+ // cannot tear it down, so the whole purge must refuse before it
+ // touches anything.
+ now := time.Now()
+ if _, err := mail.Declare(context.Background(), fx.rel, release.OpsAccount, "kass",
+ "KASS_SMTP", "noreply@kass.example.com", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ mrec, metag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ mrec.Provisioned = &mail.Provisioned{IAMUser: "u", KeyID: "AKIA1", Active: true, At: now}
+ if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, "kass", mrec, metag); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.cfg.MailProvisioner = nil
+
+ status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"),
+ url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("purge with no mail backend = %d:\n%s", status, body)
+ }
+ // R5: the store must be untouched — the refusal must have fired BEFORE
+ // objstore.Teardown, not after.
+ estateStanding(t, fx, fake, "kass", bucket, "a purge refused for lacking a mail backend")
+ if rec := storedRecord(t, fx, "kass"); rec == nil || rec.Provisioned == nil {
+ t.Fatalf("the store record did not survive the refusal: %+v", rec)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "no SES backend configured") {
+ t.Fatalf("the refusal does not name the missing backend:\n%s", page)
+ }
+}
+
// The whole point of the door: the backend estate comes down FIRST, and only
// then is the deployment prefix swept — store.json included, now that it names
// nothing.
diff --git a/internal/console/mailcounts.go b/internal/console/mailcounts.go
index 2d3ceebbb..60e4bd265 100644
--- a/internal/console/mailcounts.go
+++ b/internal/console/mailcounts.go
@@ -124,6 +124,64 @@ FROM mail_counts WHERE account = ? AND app = ? AND day = ?`, acct, app, day)
return c, nil
}
+// resumeStaleCapPause is the ONE place the day-roll resume check lives
+// (R2, review follow-up). enforceMailLimits' only production caller is the
+// SES event path (recordAndEnforceMail): a paused app's IAM key is
+// deactivated, so it sends nothing, so no events ever arrive to run that
+// check, so an app alone on its own domain saw its cap pause as permanent
+// as before the cap/rate split — while the CLI now promises it clears
+// automatically. The email-status READ path (handleAPIEmailStatus) is the
+// other caller: a member running `carlos email status` is exactly the
+// moment a rolled-over daily budget should clear, and it is a moment that
+// happens without SES traffic.
+//
+// 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.
+//
+// Returns the freshly re-read record when it resumes (rec's SetPaused
+// 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
+// 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
+ }
+ // 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
+ }
+ pausedDay := rec.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
+ }
+ if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, 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))
+ }
+ fresh, _, err := mail.Get(ctx, s.cfg.Releases, acct, app)
+ 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.
//
@@ -145,41 +203,18 @@ func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now ti
return false, "", nil
}
if !rec.Provisioned.Active {
- // I3 (review follow-up): a CAP pause is a PER-DAY limit, because
- // the counts it was computed from are per-day — it MUST clear on
- // the UTC day roll, or a free-tier member's ordinary first day
- // over 50 messages stops their email forever, with resume gated
- // behind an operator allowlist that ships empty
- // (CARLOS_MAIL_OPERATORS, fail-closed). A RATE pause or a MANUAL
- // (operator) pause — PauseKind is anything other than
- // mail.PauseKindCap, including "" — still never auto-resumes: see
- // "RATE AND MANUAL PAUSES NEVER AUTO-RESUME" below, which still
- // holds for those two.
- if rec.Provisioned.PauseKind != mail.PauseKindCap {
- return false, "", nil
- }
- pausedDay := rec.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 false, "", nil
- }
- if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, false, "", now); err != nil {
- return 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))
- }
- // Re-read: 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 hand.
- rec, _, err = mail.Get(ctx, s.cfg.Releases, acct, app)
+ fresh, resumed, err := s.resumeStaleCapPause(ctx, acct, app, rec, 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
+ // hand.
+ rec = fresh
if rec == nil || rec.Provisioned == nil || !rec.Provisioned.Active {
return false, "", nil
}
diff --git a/internal/console/mailcounts_test.go b/internal/console/mailcounts_test.go
index ac27f01fe..ed54dc1dc 100644
--- a/internal/console/mailcounts_test.go
+++ b/internal/console/mailcounts_test.go
@@ -351,3 +351,108 @@ func TestEnforceRecordsTheReasonInTheAuditLog(t *testing.T) {
}
t.Fatalf("no audit entry naming the pause reason: %+v", entries)
}
+
+// pauseDirectly writes rec.Provisioned's Active/PauseKind/At by hand — the
+// way SetPaused already would, without needing to breach a threshold
+// first. Used by the resumeStaleCapPause tests below, which pin the shared
+// day-roll function's contract directly (R2, review follow-up) rather than
+// only through enforceMailLimits' caller.
+func pauseDirectly(t *testing.T, fx *consoleFixture, app, kind string, at time.Time) {
+ t.Helper()
+ rec, etag, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Provisioned.Active = false
+ rec.Provisioned.PauseKind = kind
+ rec.Provisioned.At = at
+ if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// R2: resumeStaleCapPause is the ONE function both enforceMailLimits (the
+// SES event path) and handleAPIEmailStatus (the status READ path) share,
+// so these four cases pin its contract directly.
+
+func TestResumeStaleCapPauseClearsACapPauseFromYesterday(t *testing.T) {
+ fx, now := mailFixture(t)
+ pauseDirectly(t, fx, "seapointish", mail.PauseKindCap, now.AddDate(0, 0, -1))
+ rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "seapointish", rec, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !resumed {
+ t.Fatal("did not resume a cap pause applied on an earlier UTC day")
+ }
+ if !got.Provisioned.Active {
+ t.Fatal("resumed record still reports Active == false")
+ }
+ if got.Provisioned.PauseKind != "" {
+ t.Fatalf("PauseKind = %q after resume, want cleared", got.Provisioned.PauseKind)
+ }
+}
+
+func TestResumeStaleCapPauseLeavesATodayCapPauseAlone(t *testing.T) {
+ fx, now := mailFixture(t)
+ pauseDirectly(t, fx, "seapointish", mail.PauseKindCap, now) // paused earlier the SAME UTC day
+ rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "seapointish", rec, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resumed {
+ t.Fatal("resumed a cap pause applied the SAME UTC day — that makes the cap meaningless")
+ }
+ if got.Provisioned.Active {
+ t.Fatal("same-day cap pause was lifted")
+ }
+}
+
+func TestResumeStaleCapPauseNeverTouchesARatePause(t *testing.T) {
+ fx, now := mailFixture(t)
+ pauseDirectly(t, fx, "seapointish", mail.PauseKindRate, now.AddDate(0, 0, -1))
+ rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "seapointish", rec, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resumed {
+ t.Fatal("resumed a RATE pause — a rate pause must never auto-resume, day roll or not")
+ }
+ if got.Provisioned.Active {
+ t.Fatal("a rate pause from yesterday was lifted")
+ }
+ if got.Provisioned.PauseKind != mail.PauseKindRate {
+ t.Fatalf("PauseKind = %q, want it to survive as %q", got.Provisioned.PauseKind, mail.PauseKindRate)
+ }
+}
+
+func TestResumeStaleCapPauseNeverTouchesAKindlessPause(t *testing.T) {
+ fx, now := mailFixture(t)
+ pauseDirectly(t, fx, "seapointish", "", now.AddDate(0, 0, -1))
+ rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "seapointish", rec, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resumed {
+ t.Fatal("resumed a kind-less pause (pre-existing record or manual pause) — fail safe means these never auto-resume")
+ }
+ if got.Provisioned.Active {
+ t.Fatal("a kind-less pause from yesterday was lifted")
+ }
+}
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index 4d2a05aec..9889757c9 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -340,6 +340,36 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) {
fail(purgeAdvice(err, app))
return
}
+ // R5 (review follow-up): this refusal — "there is an email estate but
+ // no MailProvisioner to tear it down with" — is read and checked BEFORE
+ // the store teardown below, not after. It used to sit next to the
+ // mail.Teardown call further down, which is AFTER objstore.Teardown
+ // already destroys the object store — so a purge that was always going
+ // to be refused destroyed the store first. Hoisted here, matching how
+ // appsettings.go's store-destroy door already orders its own
+ // backend-missing checks before either destructive call. mrec and
+ // mailEstate are reused below, at the actual mail.Teardown call, so the
+ // two can never disagree about what estate exists.
+ mrec, _, mrerr := mail.Get(r.Context(), s.cfg.Releases, acct.Sqid, app)
+ if mrerr != nil {
+ log.Printf("console: purge mail read %s/%s: %v", acct.Sqid, app, mrerr)
+ fail("Could not read “" + app + "”'s email record — nothing was deleted; try again.")
+ return
+ }
+ mailEstate := mrec != nil && (mrec.Provisioned != nil || len(mrec.Standalone) > 0)
+ if !mailEstate {
+ // A domain claim can exist with no delivered credential at all —
+ // `carlos email domains add` alone, never followed by `enable` —
+ // and mail.Teardown releases that claim too. The audit trail
+ // below should say so.
+ if doms, derr := s.mailAppDomains(r.Context(), acct, app); derr == nil && len(doms) > 0 {
+ mailEstate = true
+ }
+ }
+ if mailEstate && s.cfg.MailProvisioner == nil {
+ fail("“" + app + "” has a provisioned email credential, and this console has no SES backend configured — it cannot be deleted here until email is torn down.")
+ return
+ }
// A provisioned store is torn down BEFORE the prefix sweep — this door
// is the only purge path with backend authority (objstore spec §9;
// box-side auto-purge refuses via release.ErrStoreProvisioned). Failure
@@ -393,28 +423,8 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) {
// records and this app's ORPHANED claims even when the app never
// declared email at all — its own doc comment), so it runs whenever a
// MailProvisioner is configured, not only when a Provisioned/Standalone
- // estate is already known to exist; the estate check below only decides
- // whether a MISSING provisioner has to refuse the whole delete.
- mrec, _, mrerr := mail.Get(r.Context(), s.cfg.Releases, acct.Sqid, app)
- if mrerr != nil {
- log.Printf("console: purge mail read %s/%s: %v", acct.Sqid, app, mrerr)
- fail("Could not read “" + app + "”'s email record — nothing was deleted; try again.")
- return
- }
- mailEstate := mrec != nil && (mrec.Provisioned != nil || len(mrec.Standalone) > 0)
- if !mailEstate {
- // A domain claim can exist with no delivered credential at all —
- // `carlos email domains add` alone, never followed by `enable` —
- // and mail.Teardown releases that claim too. The audit trail
- // below should say so.
- if doms, derr := s.mailAppDomains(r.Context(), acct, app); derr == nil && len(doms) > 0 {
- mailEstate = true
- }
- }
- if mailEstate && s.cfg.MailProvisioner == nil {
- fail("“" + app + "” has a provisioned email credential, and this console has no SES backend configured — it cannot be deleted here until email is torn down.")
- return
- }
+ // estate is already known to exist. mrec and mailEstate were already
+ // computed and refused on above (R5), before the store teardown ran.
if s.cfg.MailProvisioner != nil {
mctx, mcancel := context.WithTimeout(r.Context(), storeTeardownTimeout)
defer mcancel()
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go
index c9618b0a8..0aaa0b2c7 100644
--- a/internal/console/sesevents.go
+++ b/internal/console/sesevents.go
@@ -413,20 +413,6 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) {
}
}
-// recordAndEnforceMail resolves ev.Domain to the account(s)/app(s) that own
-// it and, for each, records the count and re-evaluates the cap and rate
-// thresholds (Task 16, mailcounts.go). Best-effort throughout: a lookup or
-// storage failure here is logged and swallowed, never turned into a non-2xx
-// — the caller has already committed to answering 200 for every recognised
-// notification, retried or not, and losing one count must not become an
-// unbounded SNS retry flood.
-//
-// Domain identity (mail.DomainRecord) is ACCOUNT-scoped, and there is no
-// reverse index from a bare domain to the account that owns it — so every
-// live console account is walked. Console deployments run a small,
-// bounded number of accounts; this is the same shape ListDomains' own
-// callers (mail.Teardown, mail.grantedDomains) already accept for a single
-// account, one level up.
// domainMatchesEvent reports whether ev's domain attributes to d — either
// the identity's own domain (the sender-supplied From SES usually reports
// as mail.source) or, for ANY region, d's recorded custom MAIL FROM
@@ -450,6 +436,20 @@ func domainMatchesEvent(d *mail.DomainRecord, domain string) bool {
return false
}
+// recordAndEnforceMail resolves ev.Domain to the account(s)/app(s) that own
+// it and, for each, records the count and re-evaluates the cap and rate
+// thresholds (Task 16, mailcounts.go). Best-effort throughout: a lookup or
+// storage failure here is logged and swallowed, never turned into a non-2xx
+// — the caller has already committed to answering 200 for every recognised
+// notification, retried or not, and losing one count must not become an
+// unbounded SNS retry flood.
+//
+// Domain identity (mail.DomainRecord) is ACCOUNT-scoped, and there is no
+// reverse index from a bare domain to the account that owns it — so every
+// live console account is walked. Console deployments run a small,
+// bounded number of accounts; this is the same shape ListDomains' own
+// callers (mail.Teardown, mail.grantedDomains) already accept for a single
+// account, one level up.
func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event) {
accts, err := s.store.Accounts()
if err != nil {
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index 6ba3cf7c6..f7e7c5aca 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -546,15 +546,22 @@ func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P
}
}
- // Active is carried forward, not reset. A re-walk that repairs missing
- // delivery for a paused app is legitimate; silently un-pausing it is
- // not — Active only ever moves from false to true through the pause
- // verb itself (Task 13), never as a side effect of this walk running.
- // A first-time enable (no prior Provisioned block) starts active.
- active, prev := true, ""
+ // Active and PauseKind are carried forward, not reset. A re-walk that
+ // repairs missing delivery for a paused app is legitimate; silently
+ // un-pausing it is not — Active only ever moves from false to true
+ // through the pause verb itself (Task 13), never as a side effect of
+ // this walk running. PauseKind must travel with Active: dropping it
+ // here would turn a CAP pause into a kind-less one on the very next
+ // repair walk, which enforceMailLimits and the status-read auto-resume
+ // (R2) never resume, and which the CLI describes as needing an
+ // operator — resurrecting the exact defect PauseKind was added to fix,
+ // through this door instead of SetPaused. A first-time enable (no
+ // prior Provisioned block) starts active with no pause kind.
+ active, prev, pauseKind := true, "", ""
if rec.Provisioned != nil {
prev = rec.Provisioned.PrevKeyID
active = rec.Provisioned.Active
+ pauseKind = rec.Provisioned.PauseKind
}
// Reap before minting: reaching here means delivery is missing, so any
@@ -586,7 +593,7 @@ func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P
}
rec.Provisioned = &Provisioned{
- IAMUser: user, UserARN: arn, KeyID: keyID, PrevKeyID: prev, Active: active, At: now,
+ IAMUser: user, UserARN: arn, KeyID: keyID, PrevKeyID: prev, Active: active, PauseKind: pauseKind, At: now,
}
if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
return nil, err
diff --git a/internal/mail/walk_enable_test.go b/internal/mail/walk_enable_test.go
index 9e861d953..488d6083b 100644
--- a/internal/mail/walk_enable_test.go
+++ b/internal/mail/walk_enable_test.go
@@ -223,6 +223,7 @@ func TestEnableCarriesAPausedActiveForwardThroughARewalk(t *testing.T) {
t.Fatalf("Get: %v", err)
}
got.Provisioned.Active = false
+ got.Provisioned.PauseKind = PauseKindCap
if _, err := Put(ctx, s, "bag", "seapointish", got, etag); err != nil {
t.Fatalf("Put: %v", err)
}
@@ -240,6 +241,11 @@ func TestEnableCarriesAPausedActiveForwardThroughARewalk(t *testing.T) {
if second.Provisioned.Active {
t.Fatal("a re-walk silently resumed a paused credential")
}
+ // R1: PauseKind must survive the rewrite alongside Active — dropping it
+ // turns a CAP pause into a kind-less one, which nothing auto-resumes.
+ if second.Provisioned.PauseKind != PauseKindCap {
+ t.Fatalf("PauseKind = %q, want %q to survive the re-walk", second.Provisioned.PauseKind, PauseKindCap)
+ }
}
// If the record ever names a key that is not what was actually delivered
diff --git a/mail_cli.go b/mail_cli.go
index b4b123557..34707b2d4 100644
--- a/mail_cli.go
+++ b/mail_cli.go
@@ -846,13 +846,18 @@ func printEmailStatus(w io.Writer, res apiEmailReply) {
// and 404s for an ordinary member — pointing every paused
// member at it, regardless of why, was actively wrong advice.
// A CAP pause (PauseKindCap) clears itself on the next UTC day
- // roll (mailcounts.go's enforceMailLimits); nothing to run. A
+ // roll — evaluated both on the SES event path
+ // (mailcounts.go's enforceMailLimits) and on THIS status read
+ // itself (R2, review follow-up: a paused app sends nothing, so
+ // no SES event ever arrives to trigger the event-path check —
+ // running `carlos email status` after the roll is what
+ // actually clears it for an app alone on its own domain). A
// RATE pause, or a manual pause this reply carries no kind
// for, does not clear on its own and genuinely needs an
// operator.
switch res.Provisioned.PauseKind {
case "cap":
- mutedf(w, " daily cap reached — this clears automatically at the next UTC day roll; no action needed")
+ mutedf(w, " daily cap reached — this clears at the next UTC day roll; running `carlos email status` after that will clear it")
case "rate":
mutedf(w, " bounce/complaint rate over threshold — this does not clear on its own; ask a deployment operator to resume it")
default: