carlos / platform Public

console: email pause door — I1-I4 review fixes

I1: paused decodes as *bool, acted on only when present, so a
cap-raise-only body ({"daily_cap": N}) cannot decode a missing paused as
false and silently resume a tenant an earlier request paused — that would
have contradicted "nothing auto-resumes... resume is a decision"
(mailcounts.go), the rule that makes this door operator-only in the first
place.

I2: MailOperators stays fail-closed on empty, confirmed by test and by a
mutation that flips it to fail-open (caught).

I3: the pause door no longer uses apiAccountApp's member-then-allowlist
shape. It follows economicspass.go's handleAPIEconomicsBill instead:
cliUser -> store.IsOperator -> byte-identical http.NotFound, with the
account resolved afterward via AccountBySqid (viewer-independent, sqid
only, mirroring handleEconomicsTierSave) rather than membership.
Member-then-allowlist deadlocked the abuse brake in the exact scenario it
exists for: only an account's own owner can add a member, and an abusive
tenant has no reason to add the operator investigating them.
CARLOS_MAIL_OPERATORS (mailOperator) narrows the fleet-operator bit
further; the other eight doors are untouched by this — member-level is
still correct for them.

I4: cmd/console/main.go now wires CARLOS_MAIL_OPERATORS and
CARLOS_MAIL_REGION / CARLOS_MAIL_PLATFORM_SUFFIX / CARLOS_MAIL_DMARC_RUA /
CARLOS_MAIL_SNS_TOPIC_ARN into Config.MailOperators/MailDeployment,
following storeOperators()'s pattern. MailProvisioner and DNSWriter remain
unwired (separate infra work), so every email door still 404s in the
shipped binary until that lands — this at least means the operator list
and deployment coordinates don't become a second thing to remember the day
it does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 96291e586d36dcdcb6f4c6b9aa42974348c3fbce parent 0d02304
3 files changed, +345 −52
  • cmd/console/main.go +78 −0
  • internal/console/apimail.go +151 −41
  • internal/console/apimail_test.go +116 −11
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 75131c327..169e3f604 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -48,6 +48,16 @@
// member's own verb and destroy is the account owner's browser ceremony;
// it is read whether or not a backend is configured.
//
+// Tenant email (task 17) reads CARLOS_MAIL_OPERATORS (comma-separated
+// addresses; narrows the fleet-operator bit further for the pause/cap
+// door only — see mailOperators' doc comment), CARLOS_MAIL_REGION,
+// CARLOS_MAIL_PLATFORM_SUFFIX, CARLOS_MAIL_DMARC_RUA and
+// CARLOS_MAIL_SNS_TOPIC_ARN. All five are read regardless of whether a
+// mail Provisioner is wired (none is, yet — see mailDeployment's doc
+// comment): every email door 404s until that infra work lands, same
+// "config costs nothing to hold ahead of the backend" reasoning as
+// CARLOS_STORE_OPERATORS above.
+//
// grant/revoke/owners need only CONSOLE_DB.
package main
@@ -65,6 +75,7 @@ import (
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
@@ -258,6 +269,18 @@ func serve() {
// Deliberately independent of PLATFORM_APPS_DOMAIN (#167's blind
// spot), so it is read raw here rather than folded into appsDomain().
DelegationZone: os.Getenv("CARLOS_ACME_DELEGATION_ZONE"),
+ // MailDeployment/MailOperators (tenant-email task 17, review round
+ // 1 I4): MailProvisioner and DNSWriter are NOT wired here yet — a
+ // real AWS SES/IAM Provisioner and DNS writer are separate infra
+ // work this command does not yet do, so every email door still
+ // 404s in this binary (mailProvisionerGate) regardless of the two
+ // fields below. Wiring them now anyway is what lets an operator
+ // set CARLOS_MAIL_OPERATORS ahead of that work landing, same as
+ // storeOperators' own reasoning, and means the pause door's
+ // authority list and the mail deployment coordinates do not become
+ // a SECOND thing to remember to wire the day the Provisioner does.
+ MailDeployment: mailDeployment(),
+ MailOperators: mailOperators(),
}, st)
if err != nil {
log.Fatal(err)
@@ -450,6 +473,61 @@ func storeOperators() []string {
return out
}
+// mailOperators parses CARLOS_MAIL_OPERATORS (comma-separated addresses) —
+// who may drive the email pause/cap door (apimail.go's handleAPIEmailPause
+// and mailOperator's own doc comment explain why this is a SEPARATE list
+// from the fleet-operator bit console grant -operator sets, not a
+// replacement for it: pausing a tenant's shared sending reputation is a
+// narrower authority than "can see the fleet bill," and a deployment may
+// want to grant the two to different people).
+//
+// Same shape as storeOperators, for the same reason: unset is fail-closed,
+// not fail-open — no addresses means NO fleet operator may drive this
+// door, not "any fleet operator may." That is the right direction for a
+// list whose purpose is a per-tenant abuse brake, not merely a spending
+// gate.
+func mailOperators() []string {
+ var out []string
+ for _, a := range strings.Split(os.Getenv("CARLOS_MAIL_OPERATORS"), ",") {
+ if a = strings.TrimSpace(a); a != "" {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+// mailDeployment builds the tenant-email half of the console's config from
+// CARLOS_MAIL_* (tenant-email spec §7, §9): the region and platform suffix
+// every mail walk needs, plus the optional DMARC aggregate-report address
+// and SNS topic ARN that feed the bounce/complaint counts mailcounts.go
+// enforces caps against.
+//
+// DELIBERATELY INDEPENDENT OF PLATFORM_APPS_DOMAIN, the same caution
+// DelegationZone's own comment above already applies to a different zone
+// (#167's blind spot): PLATFORM_APPS_DOMAIN is the edge's routing zone,
+// and conflating it with the zone SES sending identities are verified
+// under would repeat the exact class of bug #167 already cost this
+// codebase once, just for a new pair of variables.
+//
+// NOTHING HERE IS FATAL, unlike storeBackend's coordinates. A store
+// deployment booted with the wrong region stamps that region into bucket
+// names and IAM policies it then actually creates — a real, misplaced
+// estate. A mail Deployment with a blank region or suffix cannot do that:
+// MailProvisioner is not wired by this command yet (see this function's
+// own doc comment history / the task-17 review), so
+// mailProvisionerGate's 404 already refuses every email door regardless
+// of what these env vars hold. Once a real Provisioner is wired, the
+// coordinates below become load-bearing the same way storeBackend's are —
+// that wiring should promote the same fields to fatal-if-missing then.
+func mailDeployment() mail.Deployment {
+ return mail.Deployment{
+ Region: strings.TrimSpace(os.Getenv("CARLOS_MAIL_REGION")),
+ PlatformSuffix: strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_MAIL_PLATFORM_SUFFIX"))),
+ DMARCRua: strings.TrimSpace(os.Getenv("CARLOS_MAIL_DMARC_RUA")),
+ SNSTopicARN: strings.TrimSpace(os.Getenv("CARLOS_MAIL_SNS_TOPIC_ARN")),
+ }
+}
+
// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the
// agent's domains reconcile loop reads, so the console's point-your-DNS-here
// guidance and the agent's DNS health checks can never name different
diff --git a/internal/console/apimail.go b/internal/console/apimail.go
index 4f3e61d70..d6a915d1e 100644
--- a/internal/console/apimail.go
+++ b/internal/console/apimail.go
@@ -20,10 +20,22 @@
// verb: declaring, bringing a domain into existence, verifying it, enabling,
// rotating and minting/revoking a standalone credential all self-serve at
// the account's tier cap (spec §10 — "free tier self-serves the platform
-// domain... paid tiers self-serve custom domains"). Only pausing/resuming and
-// raising a cap above what the tier grants need a deployment operator
-// (CARLOS_MAIL_OPERATORS) — the one door in this file with an allowlist gate,
-// run AFTER the member 404 exactly as store's grant door runs its own.
+// domain... paid tiers self-serve custom domains"). Only pausing/resuming
+// and raising a cap above what the tier grants need a deployment operator.
+//
+// THE PAUSE DOOR IS NOT apiAccountApp's SHAPE, and that is deliberate
+// (review round 1, I3). A pause is applied AGAINST a tenant, not asked for
+// by one — the abusive tenant has no reason to add the operator
+// investigating them as a member, and only that tenant's own owner can add
+// one, so member-then-allowlist (store's grant shape) would deadlock the
+// abuse brake in the exact scenario it exists for. handleAPIEmailPause
+// instead follows economicspass.go's handleAPIEconomicsBill shape: cliUser
+// -> store.IsOperator -> a bare, byte-identical http.NotFound, with the
+// account resolved afterward WITHOUT requiring membership
+// (mailPauseTarget, via AccountBySqid — viewer-independent, like
+// handleEconomicsTierSave's). CARLOS_MAIL_OPERATORS narrows the
+// fleet-operator bit further (mailOperator's doc comment says why); the
+// other eight doors never consult it.
//
// NOTHING SECRET CROSSES THESE DOORS except once. Every reply here echoes
// the record's IAM user, its key id (the SMTP USERNAME) and the domains'
@@ -149,12 +161,29 @@ func (s *Server) mailSealKey(w http.ResponseWriter) (*ecdh.PublicKey, bool) {
return pub, true
}
-// mailOperator reports whether addr may drive the pause/cap door. Matches
-// storeOperator's exact shape, including the reason it must NOT be
-// strings.EqualFold (see that function's doc comment): the address on a CLI
-// token is already ASCII-lowercased at every entry point, so the allowlist
-// entry is folded the same way and never Unicode-folded, which would let a
-// long-s lookalike claim someone else's entry.
+// mailOperator reports whether addr may drive the pause/cap door — a
+// NARROWING of store.IsOperator, not a replacement for it (review round 1,
+// I3). IsOperator is broad, fleet-wide authority: billing, every account's
+// economics, the alert watcher's recipient list. This door pauses a
+// tenant's SHARED SENDING REPUTATION specifically, and a deployment may
+// reasonably want to trust a smaller, different set of people with that
+// than with "can see the fleet bill" — so handleAPIEmailPause checks BOTH:
+// IsOperator first (cheap, and it is what makes the account resolution
+// membership-free — see the file comment), then this list.
+//
+// FAIL-CLOSED, like StoreOperators (that field's own doc comment): an
+// unset CARLOS_MAIL_OPERATORS means no fleet operator may drive this door,
+// not "any fleet operator may." That direction matters here more than for
+// store's grant — this list gates a brake against ABUSE, not merely
+// spending, and "everyone with the fleet-operator bit" is not the same
+// population as "everyone this deployment trusts with a tenant's sending
+// reputation."
+//
+// Matches storeOperator's exact shape otherwise, including the reason it
+// must NOT be strings.EqualFold (see that function's doc comment): the
+// address on a CLI token is already ASCII-lowercased at every entry point,
+// so the allowlist entry is folded the same way and never Unicode-folded,
+// which would let a long-s lookalike claim someone else's entry.
func (s *Server) mailOperator(addr string) bool {
for _, op := range s.cfg.MailOperators {
if strings.ToLower(strings.TrimSpace(op)) == addr {
@@ -164,6 +193,44 @@ func (s *Server) mailOperator(addr string) bool {
return false
}
+// mailPauseTarget resolves the pause door's {account}/{app} WITHOUT
+// requiring membership (review round 1, I3) — see the file comment for
+// why member-then-allowlist is backwards for a door applied AGAINST a
+// tenant. AccountBySqid is viewer-independent (it filters deleted_at, not
+// membership — store.go's own doc comment on it, and the same function
+// handleEconomicsTierSave uses to let an operator act on ANY account) and
+// sqid-ONLY, no name resolution — the same reason apiAccountApp's
+// `?exact=sqid` exists: the reference an operator reaches for here can
+// never itself be ambiguous.
+//
+// Every failure is a plain, byte-identical http.NotFound: an unknown
+// account, an app that account never claimed, and (via the caller, before
+// this is even reached) a non-operator's probe against a real one must all
+// read the same — the same non-probeability the member doors keep, just
+// earned a different way.
+func (s *Server) mailPauseTarget(w http.ResponseWriter, r *http.Request) (*Account, string, bool) {
+ acct, err := s.store.AccountBySqid(r.PathValue("account"))
+ if err != nil {
+ log.Printf("console: api email pause: account lookup: %v", err)
+ http.NotFound(w, r)
+ return nil, "", false
+ }
+ if acct == nil {
+ http.NotFound(w, r)
+ return nil, "", false
+ }
+ app := r.PathValue("app")
+ found, err := s.store.HasAppIn(acct.ID, app)
+ if err != nil {
+ log.Printf("console: api email pause: has-app %s/%s: %v", acct.Sqid, app, err)
+ }
+ if err != nil || !found {
+ http.NotFound(w, r)
+ return nil, "", false
+ }
+ return acct, app, true
+}
+
// mailBillingTier is the account's billing tier, as TierCap wants it.
//
// THERE IS NO BILLING-TIER FIELD ON Account YET (issue #226). The design
@@ -827,41 +894,63 @@ func (s *Server) handleAPIEmailCredentialsRevoke(w http.ResponseWriter, r *http.
// handleAPIEmailPause answers `carlos email pause`/`resume` AND an
// operator's cap raise — ONE door for both, per the F2 controller ruling.
//
-// OPERATOR-ONLY, and that is deliberate for pause/resume too, not just the
-// cap half: mailcounts.go's automatic abuse pause exists precisely so one
-// tenant's bounce rate cannot put the whole shared SES account under
-// review, and "NOTHING auto-resumes... resume is a decision" (that file's
-// own doc comment) would be hollow if the paused tenant could simply
-// resume itself through this door. The gate order is store's grant door's
-// exactly: member 404 FIRST (apiAccountApp), THEN the allowlist — an
-// operator is not automatically a member, and a stranger holding the
-// allowlist address must still get the byte-identical 404 for an account
-// they are not in.
+// FLEET-OPERATOR GATED via store.IsOperator, NOT apiAccountApp's
+// member-then-allowlist shape the other eight doors use (review round 1,
+// I3) — see the file comment for why. The order below matches
+// economicspass.go's handleAPIEconomicsBill exactly: cliUser -> IsOperator
+// -> a bare, byte-identical http.NotFound, before ANY account is even
+// looked up. CARLOS_MAIL_OPERATORS (mailOperator) narrows that further —
+// see its own doc comment for why a second, smaller list exists at all.
+// Both checks refuse with the SAME silent 404, never a speaking 403: a 403
+// here would be an oracle telling a non-operator prober "this route exists
+// and you lack authority for it," which is exactly the class of leak the
+// byte-identical 404 exists to prevent everywhere else in this file.
+//
+// I1 (review round 1): Paused is *bool, not bool, and SetPaused is called
+// ONLY when it is present. A body carrying just {"daily_cap": N} decoding
+// a MISSING paused as false would silently RESUME a tenant an earlier
+// request paused — directly contradicting "nothing auto-resumes... resume
+// is a decision" (mailcounts.go), the very rule that makes this whole door
+// operator-only in the first place.
//
// F2: a daily_cap in the body raises the cap with mail.ApplyGrant, source
// "operator" — mail.ApplyGrant itself already refuses to let a later
// tier-sourced grant (the enable door) overwrite an operator's raise; this
// door only has to pass the right source.
func (s *Server) handleAPIEmailPause(w http.ResponseWriter, r *http.Request) {
- addr, acct, app, ok := s.apiAccountApp(w, r)
+ addr, ok := s.cliUser(w, r)
if !ok {
return
}
- if !s.mailOperator(addr) {
- jsonError(w, http.StatusForbidden,
- "pausing, resuming or raising an email cap needs a deployment operator (CARLOS_MAIL_OPERATORS)")
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: api email pause: is-operator: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp || !s.mailOperator(addr) {
+ http.NotFound(w, r)
+ return
+ }
+ acct, app, ok := s.mailPauseTarget(w, r)
+ if !ok {
return
}
if !s.mailWriteGates(w, addr) {
return
}
var req struct {
- Paused bool `json:"paused"`
- DailyCap int `json:"daily_cap"`
+ Paused *bool `json:"paused"`
+ DailyCap int `json:"daily_cap"`
}
if !readJSON(w, r, &req) {
return
}
+ if req.Paused == nil && req.DailyCap <= 0 {
+ jsonError(w, http.StatusBadRequest,
+ "send paused (true/false) or a daily_cap to raise — this request would change nothing")
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), mailPauseTimeout)
defer cancel()
@@ -888,25 +977,46 @@ func (s *Server) handleAPIEmailPause(w http.ResponseWriter, r *http.Request) {
}
}
- rec, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, app, req.Paused, time.Now())
- if errors.Is(err, mail.ErrNotProvisioned) {
- jsonError(w, http.StatusConflict, "no provisioned email credential to pause — enable email first")
- return
- }
- if err != nil {
- log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusBadGateway, "could not change the pause state — try again")
- return
+ // I1: SetPaused runs ONLY when the caller actually asked for a pause or
+ // a resume. A cap-only raise reads the current record instead of
+ // writing one — it must not touch Active at all.
+ var rec *mail.Record
+ if req.Paused != nil {
+ rec, err = mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, app, *req.Paused, time.Now())
+ if errors.Is(err, mail.ErrNotProvisioned) {
+ jsonError(w, http.StatusConflict, "no provisioned email credential to pause — enable email first")
+ return
+ }
+ if err != nil {
+ log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not change the pause state — try again")
+ return
+ }
+ } else {
+ rec, _, err = mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
+ return
+ }
+ if rec == nil {
+ jsonError(w, http.StatusNotFound, "no email is declared for this app — declare it first")
+ return
+ }
}
- action := "resumed"
- if req.Paused {
- action = "paused"
+
+ var detail []string
+ if req.Paused != nil {
+ action := "resumed"
+ if *req.Paused {
+ action = "paused"
+ }
+ detail = append(detail, "email "+action)
}
- detail := "email " + action + " (cli)"
if req.DailyCap > 0 {
- detail += fmt.Sprintf("; daily cap raised to %d", req.DailyCap)
+ detail = append(detail, fmt.Sprintf("daily cap raised to %d", req.DailyCap))
}
- s.audit(acct, app, addr, "email-pause", detail)
+ s.audit(acct, app, addr, "email-pause", strings.Join(detail, "; ")+" (cli)")
// No bumpSerial: SetPaused and ApplyGrant both write only email.json,
// never the app's own delivered config.
s.mailWalkReply(ctx, w, acct, app, rec)
diff --git a/internal/console/apimail_test.go b/internal/console/apimail_test.go
index b2dfc7969..7b2295c1c 100644
--- a/internal/console/apimail_test.go
+++ b/internal/console/apimail_test.go
@@ -62,12 +62,16 @@ func emailArmed(t *testing.T, code string) (*consoleFixture, *mail.MemProvisione
return fx, fake, writer, token
}
-// emailOperatorToken mints a token for an address on MailOperators, adding
-// it as a member first — an operator is not automatically a member (the
-// pause door's own doc comment), so every operator test needs both.
+// emailOperatorToken mints a token for a genuine fleet operator (the
+// store.IsOperator bit set) on MailOperators — and DELIBERATELY does NOT
+// add them as a member of fx's account. I3: the pause door must be
+// drivable by an operator who is not a member of the account they are
+// pausing, which is the exact scenario the abuse brake exists for, so the
+// standard operator fixture proves that by construction rather than by a
+// separate test remembering to skip AddMember.
func emailOperatorToken(t *testing.T, fx *consoleFixture, code string) string {
t.Helper()
- if err := fx.st.AddMember(OpsAccountID, "ops@keymail.dev", RoleMember, time.Now()); err != nil {
+ if err := fx.st.SetOperator("ops@keymail.dev", true); err != nil {
t.Fatal(err)
}
return cliToken(t, fx, "ops@keymail.dev", code)
@@ -678,19 +682,46 @@ func TestAPIEmailCredentialsRevokeRemovesIt(t *testing.T) {
// ---- pause (operator) -------------------------------------------------------
// TestAPIEmailPauseNeedsAnOperator: a member of the account — even the one
-// who declared and enabled — cannot pause or raise a cap. The refusal
-// names CARLOS_MAIL_OPERATORS, and nothing is changed.
+// who declared and enabled — cannot pause or raise a cap. The refusal is
+// the family's byte-identical 404 (I3: this door never speaks a 403 —
+// that would be an oracle telling a non-operator prober the route
+// exists), and nothing is changed.
func TestAPIEmailPauseNeedsAnOperator(t *testing.T) {
fx, fake, _, token := emailArmed(t, "code-email-pause-operator")
app, domain := "titogo", "titogo.bab.oncarlos.com"
provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain)
- status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, token)
- if status != http.StatusForbidden {
- t.Fatalf("pause by a non-operator member = %d %v, want 403", status, out)
+ status, body := postRaw(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, token)
+ if status != http.StatusNotFound {
+ t.Fatalf("pause by a non-operator member = %d %q, want 404", status, body)
+ }
+ if body != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", body)
+ }
+ rec := emailRecord(t, fx, app)
+ if !rec.Provisioned.Active {
+ t.Fatal("a refused pause changed the record's active state")
+ }
+}
+
+// TestAPIEmailPauseOperatorNotOnMailOperatorsListIsRefused (I2): a genuine
+// fleet operator (store.IsOperator true) is STILL refused when
+// CARLOS_MAIL_OPERATORS does not name them — the list is fail-closed, not
+// "any fleet operator may." This is the invariant protecting the SHIPPED
+// deployment shape, where CARLOS_MAIL_OPERATORS is typically unset.
+func TestAPIEmailPauseOperatorNotOnMailOperatorsListIsRefused(t *testing.T) {
+ fx, fake, _, token := emailArmed(t, "code-email-pause-notlisted")
+ fx.srv.cfg.MailOperators = nil // the shipped-binary default
+ app, domain := "titogo", "titogo.bab.oncarlos.com"
+ provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain)
+ if err := fx.st.SetOperator("random-operator@keymail.dev", true); err != nil {
+ t.Fatal(err)
}
- if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_MAIL_OPERATORS") {
- t.Fatalf("error = %q, want the operator list named", msg)
+ randomOp := cliToken(t, fx, "random-operator@keymail.dev", "code-email-pause-notlisted-op")
+
+ status, body := postRaw(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, randomOp)
+ if status != http.StatusNotFound {
+ t.Fatalf("pause by a fleet operator not on MailOperators = %d %q, want 404", status, body)
}
rec := emailRecord(t, fx, app)
if !rec.Provisioned.Active {
@@ -698,6 +729,80 @@ func TestAPIEmailPauseNeedsAnOperator(t *testing.T) {
}
}
+// TestAPIEmailPauseOperatorNeedNotBeAMember (I3): the abuse brake's whole
+// reason for existing — an operator with NO membership in the account can
+// still pause it. Confirms non-membership explicitly, rather than relying
+// on emailOperatorToken never having called AddMember.
+func TestAPIEmailPauseOperatorNeedNotBeAMember(t *testing.T) {
+ fx, fake, _, token := emailArmed(t, "code-email-pause-nonmember")
+ app, domain := "titogo", "titogo.bab.oncarlos.com"
+ provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain)
+ ops := emailOperatorToken(t, fx, "code-email-pause-nonmember-ops")
+
+ role, err := fx.st.Role(OpsAccountID, "ops@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if role != "" {
+ t.Fatalf("role = %q, want the operator to hold NO role in this account", role)
+ }
+
+ status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops)
+ if status != http.StatusOK {
+ t.Fatalf("pause by a non-member operator = %d %v, want 200", status, out)
+ }
+ if out["active"] != false {
+ t.Fatalf("active = %v, want false after a non-member operator's pause", out["active"])
+ }
+}
+
+// TestAPIEmailPauseCapRaiseDoesNotResume (I1): a cap-raise-only request
+// ({"daily_cap": N}, no "paused" field) must not decode a missing Paused
+// as false and silently resume a tenant an earlier request paused.
+func TestAPIEmailPauseCapRaiseDoesNotResume(t *testing.T) {
+ fx, fake, _, token := emailArmed(t, "code-email-pause-capnoresume")
+ app, domain := "titogo", "titogo.bab.oncarlos.com"
+ provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain)
+ ops := emailOperatorToken(t, fx, "code-email-pause-capnoresume-ops")
+
+ if status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops); status != http.StatusOK {
+ t.Fatalf("pause = %d %v", status, out)
+ }
+ if emailRecord(t, fx, app).Provisioned.Active {
+ t.Fatal("sanity check failed: record is not paused after the pause call")
+ }
+
+ status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"daily_cap": 5000}, ops)
+ if status != http.StatusOK {
+ t.Fatalf("cap-raise-only = %d %v", status, out)
+ }
+ if out["active"] != false {
+ t.Fatalf("active = %v, want false — a cap-raise-only request must not resume", out["active"])
+ }
+ rec := emailRecord(t, fx, app)
+ if rec.Provisioned.Active {
+ t.Fatal("a cap-raise-only request resumed a paused tenant")
+ }
+ if rec.Grant.DailyCap != 5000 || rec.Grant.Source != "operator" {
+ t.Fatalf("grant = %+v, want the cap raise to still have applied", rec.Grant)
+ }
+}
+
+// TestAPIEmailPauseRefusesAnEmptyBody: neither paused nor a positive
+// daily_cap is a request that would change nothing — refused rather than
+// silently answering 200 having done nothing.
+func TestAPIEmailPauseRefusesAnEmptyBody(t *testing.T) {
+ fx, fake, _, token := emailArmed(t, "code-email-pause-empty")
+ app, domain := "titogo", "titogo.bab.oncarlos.com"
+ provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain)
+ ops := emailOperatorToken(t, fx, "code-email-pause-empty-ops")
+
+ status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{}, ops)
+ if status != http.StatusBadRequest {
+ t.Fatalf("empty pause body = %d %v, want 400", status, out)
+ }
+}
+
// TestAPIEmailPauseTogglesTheKeyAndRaisesTheCap: an operator can pause,
// resume and raise the cap through the same door. Raising the cap applies
// mail.ApplyGrant with source "operator" (F2), and that grant SURVIVES a