// The member-facing email API (tenant-email spec, task 17): declare that an // app sends mail, bring a sending domain into existence and verify it, // enable delivery, rotate or pause the delivered credential, and mint or // revoke a standalone one for something not running on CARLOS. Every door // here sits behind the same apiAccountApp gate as store/config/domains — an // unknown account, an unknown app and someone else's account are one // byte-identical 404 — and the file is deliberately apistore.go's sibling: // same doorRefusal shaping, same audit-after-write discipline, same serial // bumping gated on an actual write. // // ONE DEPARTURE FROM apistore.go, DELIBERATE: a nil MailProvisioner answers // 404, not 503. Object storage's "no backend" is a state a member is told // about (store's `backend` field, storeNoBucket's 503) because an operator // may configure one later and the declaration is queued for that. Email has // no such queued-operator step — self-serve is the whole design (spec §10) — // so a deployment with no SES estate at all is better read as "this API does // not exist here" than "it exists but is broken". See mailProvisionerGate. // // TWO AUTHORITIES, NOT THREE. Unlike store, most of this surface is a MEMBER // 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. // // 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' // DKIM tokens — none of that is a secret; mail.Provisioned's own doc comment // says so. The ONE exception is handleAPIEmailCredentialsCreate: a // standalone credential is displayed once, at mint time, because nothing // else ever will — see that handler's doc comment. // // Spec: docs/superpowers/specs/2026-08-19-tenant-email-design.md §9, §10. package console import ( "context" "crypto/ecdh" "errors" "fmt" "log" "net/http" "sort" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/schedule" "github.com/carlosframework/platform/internal/svcrole" ) // mailTimeout bounds a single round trip: declare, status, and a domain // verify (one SES call). Matches storeTimeout for the same reason — a // wedged bucket or a wedged SES call must cost one request this long, not a // console goroutine forever. const mailTimeout = 15 * time.Second // mailDomainTimeout bounds EnsureDomain: an SES identity, an optional // config set, and — for a platform domain — several DNS record writes. // Longer than mailTimeout for the same reason storeGrantTimeout is longer // than storeTimeout: this is not one round trip. const mailDomainTimeout = 60 * time.Second // mailEnableTimeout and mailStandaloneTimeout bound Enable and // CreateStandalone: both ensure an IAM user, reap stray keys, mint a fresh // one and (Enable only) deliver it into config. Same shape and ceiling as // storeGrantTimeout. const ( mailEnableTimeout = 60 * time.Second mailStandaloneTimeout = 60 * time.Second ) // mailRotateTimeout bounds Rotate, RotateFinish and RevokeStandalone: all // three act on an estate that already exists (delete, reap, mint, deliver, // or just delete), so shorter than a mint-from-nothing walk but still well // past a single object write. Matches storeRotateTimeout. const mailRotateTimeout = 30 * time.Second // mailPauseTimeout bounds SetPaused, which toggles every key this app's // principals hold — the delivered pair and every standalone — plus, when a // cap is being raised in the same request, one extra record write. const mailPauseTimeout = 30 * time.Second // mailNoBucket is the 503 every door gives when this console has no // deployment bucket. Matches storeNoBucket exactly, for the same reason: // the record IS a bucket object, and answering as if email were merely // undeclared would be a lie a member could act on. func mailNoBucket(w http.ResponseWriter) { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot serve email") } // mailProvisionerGate is the ONE departure from apistore.go's shape — see // the file comment. A nil MailProvisioner reads as "this deployment has no // email API" (404), not "email exists but its backend is unset" (503). func (s *Server) mailProvisionerGate(w http.ResponseWriter) bool { if s.cfg.MailProvisioner == nil { jsonError(w, http.StatusNotFound, "email is not available on this deployment") return false } return true } // mailAvailable is every email door's shape gate: a bucket to record into, // and a backend to act through. Run AFTER the member gate (and, on the pause // door, the operator gate) so neither becomes an oracle for a stranger. func (s *Server) mailAvailable(w http.ResponseWriter) bool { if s.cfg.Releases == nil { mailNoBucket(w) return false } return s.mailProvisionerGate(w) } // mailWriteGates is mailAvailable plus the shared write budget, keyed on the // ADDRESS like every other write door here — a budget belongs to the person, // not the terminal they typed on. func (s *Server) mailWriteGates(w http.ResponseWriter, addr string) bool { if !s.cli.write.allow(addr, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return false } return s.mailAvailable(w) } // mailSealKey is the gate that says: no sealing key, no minting. Matches // storeSealKey exactly — a credential this console cannot seal is one it // cannot deliver, and a key minted without delivery is the worst state in // the system (see storeSealKey's own doc comment for why). Enable and // Rotate need it; CreateStandalone does not, because a standalone // credential is displayed, never sealed into config (mail.StandaloneCreds' // doc comment). func (s *Server) mailSealKey(w http.ResponseWriter) (*ecdh.PublicKey, bool) { if s.cfg.SecretsPub == "" { jsonError(w, http.StatusServiceUnavailable, "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver an email credential, so it will not enable sending") return nil, false } pub, err := appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY") if err != nil { log.Printf("console: api email seal key: %v", err) jsonError(w, http.StatusServiceUnavailable, "this console's secrets key (CARLOS_SECRETS_PUBKEY) cannot be read — it cannot deliver an email credential, so it will not enable sending") return nil, false } return pub, true } // 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 { return true } } 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 // spec is explicit that this ships before that work lands: "an account with // no tier defaults to free, so this ships whether or not [#226] lands" // (design doc §10). Every account therefore reads as "free" until #226 // wires a real value through here — acct is already the right shape to // extend this with when it does, so no caller has to change. func mailBillingTier(acct *Account) string { _ = acct return "free" } // mailEffectivePrefix mirrors storeEffectivePrefix: the prefix a record's // variables are delivered under, falling back to the default only when no // declaration has named one. func mailEffectivePrefix(rec *mail.Record) string { if rec != nil && rec.Declared != nil && rec.Declared.EnvPrefix != "" { return rec.Declared.EnvPrefix } return mail.DefaultEnvPrefix } // mailDefaultHost is the app's own host — .. — // the default identity spec §3 promises (the same shape // internal/domains/reconcile.go mints a route for) and the one thing that // makes `carlos email enable` with no -from a one-command story (I4, // review follow-up). The CLI cannot compute this itself: it never learns // CARLOS_MAIL_PLATFORM_SUFFIX, only this console does — so it has to ride // along in the reply. Empty when this deployment has no platform suffix // configured; the CLI keeps its explicit "no From address" refusal in that // case, exactly as before this fix. // // DELIBERATELY INDEPENDENT OF Config.AppsDomain, the same caution // mailDeployment's own doc comment (cmd/console/main.go) already applies: // conflating the edge's routing zone with the zone SES identities are // verified under would repeat the class of bug #167 already cost this // codebase once. This uses ONLY s.cfg.MailDeployment.PlatformSuffix. func (s *Server) mailDefaultHost(acct *Account, app string) string { suffix := s.cfg.MailDeployment.PlatformSuffix if suffix == "" { return "" } return app + "." + acct.Sqid + "." + suffix } // mailEffectiveCap mirrors mailcounts.go's enforceMailLimits exactly — the // cap a status reply names must be the SAME cap the limiter enforces, or a // member reading "cap: 1000" while running on the free tier's 50 would have // no way to know their own console lied to them. func mailEffectiveCap(rec *mail.Record) int { if rec == nil { return mailCapFor(nil) } return mailCapFor(rec.Grant) } // mailCapFor is mailEffectiveCap for one sender's grant. Both read the same // rule, because the cap a status reply names must be the cap the limiter // enforces (mailcounts.go's enforceMailLimits). func mailCapFor(g *mail.GrantBlock) int { if g != nil && g.DailyCap > 0 { return g.DailyCap } return TierCap("") } // mailAppDomains is every domain record in acct's account that names app as // a referrer — the app-scoped view of an account-scoped resource (see // domain.go's package comment for why domains live one level up). Used for // the status/enable replies and for computing ApplyGrant's domain list. func (s *Server) mailAppDomains(ctx context.Context, acct *Account, app string) ([]*mail.DomainRecord, error) { all, err := mail.ListDomains(ctx, s.cfg.Releases, acct.Sqid) if err != nil { return nil, err } var out []*mail.DomainRecord for _, d := range all { for _, a := range d.Apps { if a == app { out = append(out, d) break } } } return out, nil } // mailDeploymentFor adapts s.cfg.MailDeployment and s.cfg.DNSWriter for one // EnsureDomain (or Teardown) call. // // Config.DNSWriter's own doc comment promises: "nil means platform domains // behave like custom ones: records are emitted for the member instead of // published." The FIRST cut of this adaptation (this task, before review) // read that promise too broadly and cleared d.PlatformSuffix whenever // DNSWriter was nil — which also, silently, turned off C1's platform-name // ownership check (mail.platformNameOwnedBy only ever runs when // DomainKindFor answers KindPlatform, and DomainKindFor can only answer // that when PlatformSuffix is set). In a deployment with no DNSWriter // configured, ANY account could then take the global claim on ANY other // account's platform name — no DKIM gets published so it is denial-of-name // rather than a send capability, but the victim still gets 409'd off their // own name (I4, review round 2). // // The fix keeps the two questions separate, because they ARE separate: // "is this domain platform-kind, and whose name is it" (decided from the // CONFIGURED suffix, unconditionally) versus "do we have somewhere to // actually publish DNS" (decided from DNSWriter alone). d.PlatformSuffix // is therefore left untouched here; mail.EnsureDomain and mail.Teardown // gate their record-writing calls on w != nil themselves, not on Kind // alone, so a nil writer degrades ONLY publishing to the documented // pending/no-op behaviour and never touches ownership. func (s *Server) mailDeploymentFor() (mail.Deployment, dnsrec.Writer) { return s.cfg.MailDeployment, s.cfg.DNSWriter } // dnsRecordOut renders a dnsrec.Record in this API's lowercase-key style — // dnsrec.Record itself carries no json tags, since nothing before this // task ever put one on the wire. type dnsRecordOut struct { Name string `json:"name"` Type string `json:"type"` Values []string `json:"values"` TTL int64 `json:"ttl"` } func dnsRecordsOut(recs []dnsrec.Record) []dnsRecordOut { out := make([]dnsRecordOut, 0, len(recs)) for _, r := range recs { out = append(out, dnsRecordOut{Name: r.Name, Type: r.Type, Values: r.Values, TTL: r.TTL}) } return out } // emailReply is the ONE answer shape every door in this file that returns a // whole record gives — storeReply's counterpart. Kept in a single function // so enable, rotate and status can never drift into describing the same // record differently. // // standalone, domains and senders are NEVER null, matching storeReply's // env_names. a client reads them rather than testing for absence. Neither // carries a password: mail.Standalone stores a name, an IAM user and a key // id, never the secret (see that type's doc comment); a domain record // carries DKIM tokens and ARNs, not credentials. // // counts/daily_cap/active are computed fresh on every call — never latched // on the record — because they are exactly the sight a member with no box // access and no AWS console has of what the automatic limiter (mailcounts.go) // is about to do to their app. // // senders (senders design §8) is the per-sender view of the same sight, // app sender first. Every top-level field above keeps describing the APP // sender and the app's whole day unchanged, so an older CLI reading only // those fields still works. func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec *mail.Record) (map[string]any, error) { domains, err := s.mailAppDomains(ctx, acct, app) if err != nil { return nil, err } if domains == nil { domains = []*mail.DomainRecord{} } prefix := mailEffectivePrefix(rec) host, port, user, pass, from := mail.EnvNames(prefix) names := []string{host, port, user, pass, from} missing := []string{} if rec.Provisioned != nil { wantKeyID := rec.Provisioned.KeyID // rec.Declared is guaranteed non-nil here: Provisioned is only ever // set by Enable, which itself requires Declared != nil — so // rec.Declared.From is always the record's CURRENT From (I1, // review follow-up: EnvDelivered compares _FROM's delivered value // against it the same way _USER is compared against KeyID). got, err := mail.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantKeyID, rec.Declared.From) if err != nil { return nil, err } missing = append(missing, got...) } day := time.Now().UTC().Format("2006-01-02") counts, err := s.store.MailCounts(ctx, acct.Sqid, app, "", day) if err != nil { return nil, err } standalone := rec.Standalone if standalone == nil { standalone = []*mail.Standalone{} } // The senders array (senders design §8). The top-level fields above keep // describing the APP sender and the app's whole day, so an older CLI // still works; this is the per-sender view beside them. all, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { return nil, err } senders := make([]map[string]any, 0, len(all)) for _, sn := range all { sc, err := s.store.MailCounts(ctx, acct.Sqid, app, sn.ID, day) if err != nil { return nil, err } entry := map[string]any{ "id": sn.ID, "scope": sn.Scope, "source": sn.Source, "active": sn.Provisioned != nil && sn.Provisioned.Active, "daily_cap": mailCapFor(sn.Grant), "counts": map[string]any{ "sends": sc.Sends, "bounces": sc.Bounces, "complaints": sc.Complaints, "rejects": sc.Rejects, "deliveries": sc.Deliveries, }, } if sn.Host != "" { entry["host"] = sn.Host } if sn.Address != "" { entry["address"] = sn.Address } if sn.Provisioned != nil { entry["key_id"] = sn.Provisioned.KeyID if sn.Provisioned.PauseKind != "" { entry["pause_kind"] = sn.Provisioned.PauseKind } } senders = append(senders, entry) } return map[string]any{ "app": app, "declared": rec.Declared, "grant": rec.Grant, "provisioned": rec.Provisioned, "standalone": standalone, "default_host": s.mailDefaultHost(acct, app), "domains": domains, "env_names": names, // A rotation that minted a new key but has not yet deleted the old // one — the member-visible half of the two-key overlap. "pending_finish": rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "", "missing_vars": missing, "daily_cap": mailEffectiveCap(rec), "active": rec.Provisioned != nil && rec.Provisioned.Active, "counts": map[string]any{ "sends": counts.Sends, "bounces": counts.Bounces, "complaints": counts.Complaints, "rejects": counts.Rejects, "deliveries": counts.Deliveries, }, "senders": senders, }, nil } // mailWalkReply is the shared tail of every walk door that hands back a // whole record: render the same answer shape the status door gives, from // the record the walk just wrote. Matches storeWalkReply. func (s *Server) mailWalkReply(ctx context.Context, w http.ResponseWriter, acct *Account, app string, rec *mail.Record) { out, err := s.emailReply(ctx, acct, app, rec) if err != nil { log.Printf("console: api email reply %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "email changed, but its status could not be read — run `carlos email status`") return } writeJSON(w, http.StatusOK, out) } // registerEmailRoutes wires this file's ten doors onto s.mux, through the // role gate (cliRoute) like every other /api/cli/ door. It stays a separate // function from registerCLIRoutes only so the email family's reasoning lives // beside the handlers it describes; svcroutes_test's AST pass is // package-wide precisely so a second registration site is still covered. // // THE ROLE TABLE, and why each door reads as it does. // // The seven OPEN doors take Allow(Admin), matching the store family verb // for verb — declare, ensure, verify, REMOVE, enable, rotate all move an // account's backend estate, and `enable` mints a path-scoped IAM user // exactly as `store grant` does. Admin is the union role, so // Publish/Instance/Operate are refused: a credential that may deploy an app // must not thereby be able to give it a sending identity on a domain the // account owns. // // `domains rm` is Admin because `domains add` is: the same authority, run // backwards. It is deliberately NOT closed or operator-gated — the whole // defect it exists to fix is that releasing a claim required an authority // (permanent app deletion) far larger than claiming one ever did, which is // what left real deployments with a domain nobody could ever free. // // GET .../email is Admin rather than a read role for the same reason GET // .../store is: the status reply names the IAM user, the key id and the // verified domains — the estate, not just the app. // // GET .../email/events is Admin too, and for a different reason: it names // the people the app wrote to. A credential that may read an app's routes // or deploy it has no need of its recipients' addresses. // // The three .../email/events/push doors are Admin for the same reason: turning // the push on is what starts recipients' addresses leaving the console, and // the status names them in its last failure. The addresses only ever go to // the app's own platform host (mailpush.go), so this is not a grant to send // them anywhere — but it is a decision about them, and Admin makes it. // // The three CLOSED doors (svcrole.Set{} — closed to every role, admin // included) are each closed for their own reason: // // - credentials create is the ONLY door in either family that hands a // plaintext sending secret back to its caller. Store never does: its // secret goes into sealed config and the console keeps no key to open // it. This one must, because the whole point of a standalone credential // is a human copying it into an off-platform MTA (spec §6b). An // on-platform app never needs this door — it is delivered its own // credentials as env by `enable`. So the one caller this door exists // for is a person, and a service credential reaching it would turn // "may deploy" into "may send as this domain, indefinitely". // - credentials revoke is closed with it. Nothing a service could have // minted here exists for it to clean up, and the services family closes // create and revoke together for the same symmetry. // - pause already resolves an ADDRESS against CARLOS_MAIL_OPERATORS // (mailOperator), which no service principal can ever hold. Naming a // role here would be a registration that lies about who gets in — the // objection authcli.go raises against exactly that shape. It also // RAISES THE CAP, which is an authority grant, not a runtime toggle. // // All three are pinned at the intent level too (mintDoors, policyDoors in // svcroutes_test.go), so reopening one takes an edit that argues with the // words next to it, not just a line moved in closedDoors. func (s *Server) registerEmailRoutes() { s.cliRoute("POST /api/cli/apps/{account}/{app}/email", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDeclare) s.cliRoute("GET /api/cli/apps/{account}/{app}/email", svcrole.Allow(svcrole.Admin), s.handleAPIEmailStatus) s.cliRoute("GET /api/cli/apps/{account}/{app}/email/events", svcrole.Allow(svcrole.Admin), s.handleAPIEmailEvents) s.cliRoute("GET /api/cli/apps/{account}/{app}/email/events/push", svcrole.Allow(svcrole.Admin), s.handleAPIEmailPushStatus) s.cliRoute("PUT /api/cli/apps/{account}/{app}/email/events/push", svcrole.Allow(svcrole.Admin), s.handleAPIEmailPushSet) s.cliRoute("DELETE /api/cli/apps/{account}/{app}/email/events/push", svcrole.Allow(svcrole.Admin), s.handleAPIEmailPushOff) s.cliRoute("POST /api/cli/apps/{account}/{app}/email/domains", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainEnsure) s.cliRoute("POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainVerify) s.cliRoute("DELETE /api/cli/apps/{account}/{app}/email/domains/{domain}", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainRemove) s.cliRoute("POST /api/cli/apps/{account}/{app}/email/enable", svcrole.Allow(svcrole.Admin), s.handleAPIEmailEnable) s.cliRoute("POST /api/cli/apps/{account}/{app}/email/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIEmailRotate) s.cliRoute("POST /api/cli/apps/{account}/{app}/email/credentials", svcrole.Set{}, s.handleAPIEmailCredentialsCreate) s.cliRoute("DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}", svcrole.Set{}, s.handleAPIEmailCredentialsRevoke) s.cliRoute("POST /api/cli/apps/{account}/{app}/email/pause", svcrole.Set{}, s.handleAPIEmailPause) } // ---- declare ------------------------------------------------------------ // handleAPIEmailDeclare answers the member's first step: record the env // prefix and From address an app wants to send under. A REQUEST, like // store's create door — nothing is provisioned when this returns 200. // // The doors, in order: token (cliUser's 401) — account+app 404 // (apiAccountApp) — rate limit (the shared write budget) — mailAvailable // (bucket, then backend) — the body's own validation, refused before any // record read so a corrected retry starts clean. // // CHANGED-GATING (#151's rule, this task's own extension of it): // mail.Declare itself always refreshes At/By and always writes — it has no // concept of a no-op. This door decides "changed" itself, from a pre-read, // so a re-declare of the SAME prefix and From does not bury the tenant's // audit log under no-ops. Declaring never bumps the serial regardless — // nothing a box reads has changed either way. func (s *Server) handleAPIEmailDeclare(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.refuseTemporary(w, acct, app, "give it email") { return } if !s.mailWriteGates(w, addr) { return } var req struct { EnvPrefix string `json:"env_prefix"` From string `json:"from"` } if !readJSON(w, r, &req) { return } prefix := strings.TrimSpace(req.EnvPrefix) if prefix == "" { prefix = mail.DefaultEnvPrefix } if !mail.ValidEnvPrefix(prefix) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$ (e.g. %s)", req.EnvPrefix, mail.DefaultEnvPrefix)) return } from := strings.TrimSpace(req.From) if !strings.Contains(from, "@") { jsonError(w, http.StatusBadRequest, fmt.Sprintf("From %q is not an address", req.From)) return } if err := appconfig.ValidVarValue(from); err != nil { jsonError(w, http.StatusBadRequest, fmt.Sprintf("From %q is not usable: %v", req.From, err)) return } ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) defer cancel() rec0, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api email read %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the email record — try again") return } // THE ENV PREFIX IS FIXED ONCE DELIVERED — mail.Declare enforces this // too, but checking it here first keeps the refusal a clean 409 naming // the reason, instead of a stringified backend error. if rec0 != nil && rec0.Declared != nil && rec0.Provisioned != nil && rec0.Declared.EnvPrefix != prefix { jsonError(w, http.StatusConflict, fmt.Sprintf("the env prefix is %q and cannot move after delivery", rec0.Declared.EnvPrefix)) return } changed := rec0 == nil || rec0.Declared == nil || rec0.Declared.EnvPrefix != prefix || rec0.Declared.From != from rec, err := mail.Declare(ctx, s.claimMailStore(), acct.Sqid, app, prefix, from, addr, time.Now()) if err != nil { if errors.Is(err, release.ErrCASConflict) { jsonError(w, http.StatusConflict, "the email record changed underneath you — try again") return } log.Printf("console: api email declare %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not record the declaration — nothing was changed; try again") return } if changed { s.audit(acct, app, addr, "email-declare", "declared email with env prefix "+prefix+" from "+from+" (cli)") } // No bumpSerial: a declaration touches nothing a box reads. out, err := s.emailReply(ctx, acct, app, rec) if err != nil { log.Printf("console: api email reply %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the email was declared, but its status could not be read — try `carlos email status`") return } writeJSON(w, http.StatusOK, out) } // ---- status --------------------------------------------------------------- // handleAPIEmailStatus answers `carlos email status`: the whole record plus // today's counts against the cap and which delivered variables have not // arrived — the same sight store's status door gives, for the same reason // (a member with no box access and no AWS console has no other way to see // it). Not rate limited, matching every other read door: an exhausted write // budget must not blind a member to the state of what they already asked // for. func (s *Server) handleAPIEmailStatus(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailAvailable(w) { return } ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) defer cancel() rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api email status %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the email record — try again") return } if rec == nil { // Still carries default_host (I4, review follow-up), even on this // 404: `carlos email enable` with no -from makes its FIRST call // here, before anything has ever been declared, and that is // exactly when the app's default sending host needs to reach the // CLI for the one-command story to work at all. writeJSON(w, http.StatusNotFound, map[string]any{ "error": "no email is declared for this app — declare it first", "default_host": s.mailDefaultHost(acct, app), }) return } // R2 (review follow-up): resume a stale CAP pause on the status READ // path, not only on the SES event path. A paused sender's IAM key is // deactivated, so it sends nothing, so no events ever arrive to run // enforceMailLimits' day-roll check for it either. 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, a PER-INSTANCE pause or a kind-less // pause is left untouched by that function, fail-safe. // // EVERY sender gets this, not only the app sender (review round 2, task // 13). This reply now names every sender's active and pause_kind, and a // cap-paused non-app sender sends nothing either, so it never generates // the SES event that would otherwise resume it. step 5's per-customer // instance senders would each pause at their own cap and stay paused // forever without this. senders, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app) appResumed := false if err == nil { for _, sn := range senders { _, resumed, rerr := s.resumeStaleCapPause(ctx, acct.Sqid, app, sn.ID, sn, time.Now()) if rerr != nil { err = rerr break } if resumed && sn.ID == mail.SenderAppID { appResumed = true } } } if err == nil && appResumed { // The top-level fields describe the app sender's own record, so a // resume of it (not a sibling) has to be re-read before emailReply // renders them. resumeStaleCapPause hands back a fresh sender, not a // patched copy, and rec is the Record view of that same object. 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") 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) jsonError(w, http.StatusBadGateway, "could not read the email's config delivery — try again") return } writeJSON(w, http.StatusOK, out) } // ---- events ---------------------------------------------------------------- // handleAPIEmailEvents answers `carlos email events`: what happened to the // messages the app sent — delivered, bounced, complained about, delayed — // newest first, from the per-message log (mailevents.go). Not rate limited, // like every read door. // // It deliberately does NOT require the email record to exist: an app whose // email was torn down still has thirty days of history, and "what happened // to the last thing we sent" is exactly the question after a teardown. Nor // does it need the bucket — the log is the console's own table. What it // keeps from the family is the provisioner gate: a deployment with no email // answers 404 here as on every other email door. // // Query parameters, all optional: since (RFC 3339), message (an SES message // id), to (an address), limit (1–1000, default 100). func (s *Server) handleAPIEmailEvents(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailProvisionerGate(w) { return } q := MailEventQuery{ MessageID: strings.TrimSpace(r.URL.Query().Get("message")), Recipient: strings.TrimSpace(r.URL.Query().Get("to")), } if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" { since, err := time.Parse(time.RFC3339, raw) if err != nil { jsonError(w, http.StatusBadRequest, "since must be an RFC 3339 time, like 2026-09-15T08:00:00Z") return } q.Since = since } if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 1 || n > mailEventsMaxLimit { jsonError(w, http.StatusBadRequest, fmt.Sprintf("limit must be a whole number from 1 to %d", mailEventsMaxLimit)) return } q.Limit = n } ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) defer cancel() events, err := s.store.MailEvents(ctx, acct.Sqid, app, q, time.Now()) if err != nil { log.Printf("console: api email events %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the email log — try again") return } if events == nil { events = []MailEvent{} } writeJSON(w, http.StatusOK, map[string]any{ "events": events, "retention_days": int(mailEventsRetention / (24 * time.Hour)), }) } // ---- events push ------------------------------------------------------------ // emailPushReply is what all three push doors answer: where events go, the // key an app verifies them with, and how delivery has been going. func (s *Server) emailPushReply(ctx context.Context, acct *Account, app string) (map[string]any, error) { hook, err := s.store.MailEventHookFor(ctx, acct.Sqid, app) if err != nil { return nil, err } var since time.Time if hook != nil { since = hook.SetAt } st, err := s.store.MailEventPushStatusFor(ctx, acct.Sqid, app, since) if err != nil { return nil, err } out := map[string]any{"on": hook != nil, "key_env": MailEventsKeyEnv, "status": st} if hook == nil { return out, nil } if hook.Suspended() { out["suspended_at"] = hook.SuspendedAt out["suspended_why"] = hook.SuspendedWhy } key, err := s.store.MailEventsSigningKey() if err != nil { return nil, err } out["path"] = hook.Path out["set_by"] = hook.SetBy out["set_at"] = hook.SetAt out["public_key"] = mailEventsPublicKey(key) if url, err := s.mailPushURL(acct.Sqid, app, hook.Path); err == nil { out["url"] = url } return out, nil } // handleAPIEmailPushStatus answers `carlos email events push --app ` with // no change asked for. func (s *Server) handleAPIEmailPushStatus(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailProvisionerGate(w) { return } ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) defer cancel() out, err := s.emailPushReply(ctx, acct, app) if err != nil { log.Printf("console: api email push status %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the email events push — try again") return } writeJSON(w, http.StatusOK, out) } // handleAPIEmailPushSet turns the push on, or moves its path. // // The verifying key is delivered BEFORE the hook is recorded, so there is no // window in which events flow to an app that has nothing to check them // with. A failure between the two leaves the key delivered and the push off, // which a retry finishes. func (s *Server) handleAPIEmailPushSet(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailWriteGates(w, addr) { return } var req struct { Path string `json:"path"` } if !readJSON(w, r, &req) { return } path := strings.TrimSpace(req.Path) if err := schedule.ValidPath(path); err != nil { jsonError(w, http.StatusBadRequest, fmt.Sprintf("path %q: %v", req.Path, err)) return } if _, err := s.mailPushURL(acct.Sqid, app, path); err != nil { jsonError(w, http.StatusServiceUnavailable, err.Error()) return } ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) defer cancel() before, err := s.store.MailEventHookFor(ctx, acct.Sqid, app) if err != nil { log.Printf("console: api email push read %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the email events push — nothing was changed; try again") return } key, err := s.store.MailEventsSigningKey() if err != nil { log.Printf("console: api email push key: %v", err) jsonError(w, http.StatusBadGateway, "could not read the signing key — nothing was changed; try again") return } sets := map[string]appconfig.Var{MailEventsKeyEnv: {Kind: "env", Value: mailEventsPublicKey(key)}} if _, err := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, sets, nil); err != nil { s.configStoreError(w, fmt.Sprintf("api email push key %s/%s", acct.Sqid, app), err) return } s.bumpSerial(ctx) if err := s.store.SetMailEventHook(ctx, acct.Sqid, app, path, addr, time.Now()); err != nil { log.Printf("console: api email push set %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the verifying key was delivered, but the push could not be turned on — try again") return } if before == nil || before.Path != path { s.audit(acct, app, addr, "email-events-push", "pushing email events to "+path+" (cli)") } out, err := s.emailPushReply(ctx, acct, app) if err != nil { log.Printf("console: api email push reply %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the push is on, but its status could not be read — try `carlos email events push`") return } writeJSON(w, http.StatusOK, out) } // handleAPIEmailPushOff turns the push off: the hook goes, whatever is still // queued is given up on, and the key variable is taken back out of the // app's config. func (s *Server) handleAPIEmailPushOff(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailWriteGates(w, addr) { return } ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) defer cancel() was, err := s.store.DeleteMailEventHook(ctx, acct.Sqid, app, time.Now()) if err != nil { log.Printf("console: api email push off %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not turn the email events push off — try again") return } // The key is taken out whether or not a push was on: turning it on // delivers the key before recording the hook, so a failure between the // two leaves a key with no push, and --off is how that is cleaned up. if removed, err := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, nil, []string{MailEventsKeyEnv}); err != nil { // A key left in the environment verifies nothing that will ever // arrive. Said, not failed. log.Printf("console: api email push off %s/%s: unsetting %s: %v", acct.Sqid, app, MailEventsKeyEnv, err) } else if removed > 0 { s.bumpSerial(ctx) } if was { s.audit(acct, app, addr, "email-events-push-off", "stopped pushing email events (cli)") } out, err := s.emailPushReply(ctx, acct, app) if err != nil { log.Printf("console: api email push reply %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the push is off, but its status could not be read") return } writeJSON(w, http.StatusOK, out) } // ---- domains ---------------------------------------------------------------- // handleAPIEmailDomainEnsure answers `carlos email domains add`: bring a // sending domain into existence for this app, publishing its DNS itself // when this deployment owns the zone, or handing back the record set for // the member to publish when it does not. // // A MEMBER VERB (spec §10) — no operator gate. It spends nothing that // exceeds the account's tier; only raising the CAP above a tier's default // needs an operator, through the pause door. func (s *Server) handleAPIEmailDomainEnsure(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.refuseTemporary(w, acct, app, "give it email") { return } if !s.mailWriteGates(w, addr) { return } var req struct { Domain string `json:"domain"` } if !readJSON(w, r, &req) { return } domain := strings.ToLower(strings.TrimSpace(req.Domain)) if !mail.ValidDomain(domain) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid domain %q", req.Domain)) return } ctx, cancel := context.WithTimeout(r.Context(), mailDomainTimeout) defer cancel() d, writer := s.mailDeploymentFor() rec, pending, err := mail.EnsureDomain(ctx, s.claimMailStore(), s.cfg.MailProvisioner, writer, d, acct.Sqid, app, domain, time.Now()) if err != nil { // I3 (Task 17a review follow-up): ErrDomainClaimed is a refusal a // retry cannot change, not a transient failure. Rendering it as the // generic 502 below told a member to retry, with a server-error // status for a client-side condition. 409 names the real shape (a // conflict with existing state). // // The message is the neutral one (#216), in the shape of the // hostname door's hostUnavailableMsg: it used to say "already // claimed by another CARLOS account", which confirmed to anybody // that some tenant holds the name. if errors.Is(err, mail.ErrDomainClaimed) { jsonError(w, http.StatusConflict, mailDomainUnavailableMsg) return } // ErrIdentityNotOwned (#216): SES already has an identity for this // domain that this account's claim did not create. The member is // told exactly how to prove control, because the proof is the only // way in and nobody can guess the record. if errors.Is(err, mail.ErrIdentityNotOwned) { jsonError(w, http.StatusConflict, fmt.Sprintf( "%s already has a sending identity this account did not create. To use it, prove you control the domain: "+ "publish a TXT record at %s with the value %s, then add the domain again.", domain, mail.ProofRecordName(domain), mail.ProofRecordValue(acct.Sqid))) return } // Same reasoning for ErrNotOwnPlatformName: refused because of what // the domain NAME is, not because of any backend state, so a retry // changes nothing either. 400, not 409/502 — this is a malformed // request for THIS account, not a conflict with someone else's // state, and naming the caller's own account here leaks nothing // (mail.EnsureDomain's error already does, deliberately — see // ErrNotOwnPlatformName's doc comment). if errors.Is(err, mail.ErrNotOwnPlatformName) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a platform name this account owns", domain)) return } log.Printf("console: api email domain ensure %s/%s (%s): %v", acct.Sqid, app, domain, err) jsonError(w, http.StatusBadGateway, "could not ensure the sending domain — nothing unrecorded was left behind; re-run to resume") return } s.audit(acct, app, addr, "email-domain-ensure", domain+" ("+rec.Kind+") (cli)") // No bumpSerial: nothing in the app's own config changed — only the // domain record and, for a platform domain, DNS this console published // itself. writeJSON(w, http.StatusOK, map[string]any{ "domain": rec, "pending": dnsRecordsOut(pending), }) } // mailDomainUnavailableMsg is the one refusal for a sending domain another // account holds, in the hostname door's neutral shape (hostUnavailableMsg): // it says no, and not who or whether anyone does. const mailDomainUnavailableMsg = "sending domain unavailable" // handleAPIEmailDomainVerify answers `carlos email domains verify`: // re-check SES's own verdict for this deployment's region and record it. // Account-scoped, like the domain record itself (domain.go's package // comment) — {app} in the path is only the membership gate, not an // ownership filter, so any member of the account may verify any of its // domains. func (s *Server) handleAPIEmailDomainVerify(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailWriteGates(w, addr) { return } domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) if !mail.ValidDomain(domain) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid domain %q", r.PathValue("domain"))) return } ctx, cancel := context.WithTimeout(r.Context(), mailTimeout) defer cancel() existing, _, err := mail.GetDomain(ctx, s.cfg.Releases, acct.Sqid, domain) if err != nil { log.Printf("console: api email domain read %s/%s: %v", acct.Sqid, domain, err) jsonError(w, http.StatusBadGateway, "could not read the domain record — try again") return } if existing == nil { jsonError(w, http.StatusNotFound, fmt.Sprintf("no sending domain %q for this account — run \"carlos email domains add\" first", domain)) return } region := s.cfg.MailDeployment.Region // The deployment goes in because verify RECONCILES the identity's // configuration set against it (#272): the binding SES should have is a // function of whether this deployment has an SNS topic, not of what the // record last recorded. rec, err := mail.VerifyDomain(ctx, s.claimMailStore(), s.cfg.MailProvisioner, s.cfg.MailDeployment, acct.Sqid, domain, region, time.Now()) if errors.Is(err, mail.ErrDomainClaimed) { // The claim lapsed unverified and another account took the domain // (#216). The same neutral words as the add door. jsonError(w, http.StatusConflict, mailDomainUnavailableMsg) return } if err != nil { log.Printf("console: api email domain verify %s/%s: %v", acct.Sqid, domain, err) jsonError(w, http.StatusBadGateway, "could not check verification — try again") return } status := "" if st, ok := rec.Regions[region]; ok { status = st.Status } s.audit(acct, app, addr, "email-domain-verify", domain+": "+status+" (cli)") // No bumpSerial: nothing in the app's own config changed. writeJSON(w, http.StatusOK, map[string]any{"domain": rec}) } // handleAPIEmailDomainRemove answers `carlos email domains rm`: stop this // app sending from a domain and, when it was the last app doing so, give // the domain's GLOBAL claim back. // // THE DOOR THE CLAIM ALWAYS NEEDED. The claim (internal/mail/domainclaim.go) // is deliberately a create-only, globally-keyed object so two accounts // cannot both hold one domain — right, and unchanged by this door. What was // missing was the way out: until this existed, mail.Teardown, reached only // by PERMANENTLY DELETING the whole app, was the one caller that could ever // release a claim, so a domain claimed by the wrong app stayed unreachable // to every other account — including the account that actually controls its // DNS — for the life of the deployment. // // A MEMBER VERB, Admin like `domains add`, for the same reason: it spends // nothing that exceeds the account's tier, and it is the exact inverse of a // door an admin already has. It can never touch an estate outside the // caller's own account — mail.RemoveDomain reads and releases only through // the owner-gated releaseDomainClaim, and a claim held by anyone else is // refused in the same words as a domain that does not exist. // // Account-scoped like the record itself (domain.go's package comment), so // {app} is the membership gate AND — unlike verify — the referrer actually // being removed. The one exception is a claim with no record at all, where // there is no referrer set to be in; mail.removeOrphanedClaim documents why // the gate reading is the right one there. func (s *Server) handleAPIEmailDomainRemove(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailWriteGates(w, addr) { return } domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) if !mail.ValidDomain(domain) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid domain %q", r.PathValue("domain"))) return } ctx, cancel := context.WithTimeout(r.Context(), mailDomainTimeout) defer cancel() d, writer := s.mailDeploymentFor() out, err := mail.RemoveDomain(ctx, s.claimMailStore(), s.cfg.MailProvisioner, writer, d, acct.Sqid, app, domain) if err != nil { // The From refusal is a 409, not a 400: it is a conflict with state // the caller can change (move the From, then remove the domain), // which is precisely what this message has to say. Contrast the // ensure door's 409, which is permanent — nothing the caller does // clears another account's claim. if errors.Is(err, mail.ErrDomainIsFrom) { jsonError(w, http.StatusConflict, s.mailFromConflictMessage(ctx, acct, app, domain)) return } // 404 covers both "this app does not use that domain" and "that // domain's claim belongs to another account", deliberately in the // same words — see mail.ErrDomainNotReferenced. if errors.Is(err, mail.ErrDomainNotReferenced) { jsonError(w, http.StatusNotFound, fmt.Sprintf( "no sending domain %q for this app — `carlos email status --app %s` lists the ones it has", domain, app)) return } if out != nil { // The removal LANDED; only the departing app's IAM policy // shrink failed (mail.RemoveDomain's own doc comment). Saying // "re-run" here would be wrong — a re-run answers 404 now — // so name the verb that actually repays the debt. log.Printf("console: api email domain remove %s/%s (%s): policy refresh: %v", acct.Sqid, app, domain, err) s.audit(acct, app, addr, "email-domain-remove", domain+" (cli; policy refresh failed)") jsonError(w, http.StatusBadGateway, "the domain was removed, but this app's sending policy could not be rewritten — run `carlos email enable --app "+app+"` to repair it") return } log.Printf("console: api email domain remove %s/%s (%s): %v", acct.Sqid, app, domain, err) jsonError(w, http.StatusBadGateway, "could not remove the sending domain — nothing unrecorded was left behind; re-run to resume") return } detail := domain + " (" + out.Kind + ")" switch { case out.ClaimOnly: detail += " released a claim with no domain record" case out.TornDown: detail += " torn down, claim released" default: detail += " still used by " + strings.Join(out.Remaining, ", ") } s.audit(acct, app, addr, "email-domain-remove", detail+" (cli)") // No bumpSerial: nothing in the app's own config changed — the delivered // SMTP variables are untouched by a domain leaving the granted set, and // the app's From is guaranteed by the refusal above to be somewhere // else. writeJSON(w, http.StatusOK, map[string]any{ "app": app, "domain": out.Domain, "kind": out.Kind, "torn_down": out.TornDown, "claim_only": out.ClaimOnly, "remaining_apps": out.Remaining, "orphaned": dnsRecordsOut(out.Orphaned), }) } // mailFromConflictMessage names the From that blocks a domain removal, and // the command that moves it. Re-reading the record on this one error path // costs nothing and is the difference between a member knowing what to do // next and being told only that they may not. func (s *Server) mailFromConflictMessage(ctx context.Context, acct *Account, app, domain string) string { msg := fmt.Sprintf("this app still sends from %s", domain) if rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app); err == nil && rec != nil && rec.Declared != nil { msg = fmt.Sprintf("this app still sends as %s", rec.Declared.From) } return msg + fmt.Sprintf(" — move its From to another verified domain first"+ " (`carlos email enable --app %s --from `), then remove %s", app, domain) } // ---- enable ----------------------------------------------------------------- // handleAPIEmailEnable answers `carlos email enable`: provision the // declared app's credential against its verified domains and deliver it. // // F2 (controller ruling): the tier's cap is applied via mail.ApplyGrant // BEFORE mail.Enable runs, never after. enforceMailLimits (mailcounts.go) // reads rec.Grant.DailyCap on every SES event this app generates — if // Enable delivered a credential before a grant existed, the FIRST message // this app could ever send would land with no cap on record, and // enforceMailLimits's own fallback (TierCap("")) happens to be the same // number today only by coincidence. Applying the grant first makes the cap // unconditionally present before delivery, not merely usually present. func (s *Server) handleAPIEmailEnable(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.refuseTemporary(w, acct, app, "give it email") { return } if !s.mailWriteGates(w, addr) { return } pub, ok := s.mailSealKey(w) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), mailEnableTimeout) defer cancel() tier := mailBillingTier(acct) domains, err := s.mailAppDomains(ctx, acct, app) if err != nil { log.Printf("console: api email enable %s/%s: listing domains: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read this app's domains — try again") return } names := make([]string, 0, len(domains)) for _, d := range domains { names = append(names, d.Domain) } sort.Strings(names) if _, err := mail.ApplyGrant(ctx, s.claimMailStore(), acct.Sqid, app, names, TierCap(tier), "tier:"+tier, addr, time.Now()); err != nil { if errors.Is(err, mail.ErrNotDeclared) { jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first") return } log.Printf("console: api email enable %s/%s: apply grant: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not authorise sending for this app — try again") return } rec, err := mail.Enable(ctx, s.claimMailStore(), s.cfg.MailProvisioner, pub, s.cfg.MailDeployment, acct.Sqid, app, time.Now()) switch { case errors.Is(err, mail.ErrNotDeclared): jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first") return case errors.Is(err, mail.ErrNoDomains): jsonError(w, http.StatusConflict, "no verified sending domain for this app — verify a domain first") return case err != nil && strings.Contains(err.Error(), "is not at any verified domain"): // Not a backend failure: the member's own declared From address // does not match any domain this app can send from yet. Safe to // hand back verbatim — it names only data the caller already gave. jsonError(w, http.StatusConflict, err.Error()) return case err != nil: log.Printf("console: api email enable %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not enable email — nothing unrecorded was left behind; re-run to resume") return } s.audit(acct, app, addr, "email-enable", "enabled email (cli)") // The app's config bundle changed: boxes read config only when the // serial moves. s.bumpSerial(ctx) s.mailWalkReply(ctx, w, acct, app, rec) } // ---- rotate ----------------------------------------------------------------- // handleAPIEmailRotate answers `carlos email rotate` and, with // `?finish=1`, `carlos email rotate -finish`. A MEMBER VERB throughout // (spec §8's store precedent): rotation cycles a credential inside an // estate already granted, and the person who needs it most is a member who // has just leaked a key. func (s *Server) handleAPIEmailRotate(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailWriteGates(w, addr) { return } if r.URL.Query().Get("finish") == "1" { ctx, cancel := context.WithTimeout(r.Context(), mailRotateTimeout) defer cancel() rec, err := mail.RotateFinish(ctx, s.claimMailStore(), s.cfg.MailProvisioner, acct.Sqid, app) if errors.Is(err, mail.ErrNotProvisioned) { jsonError(w, http.StatusConflict, "no provisioned email credential to finish rotating — enable email first") return } if err != nil { log.Printf("console: api email rotate finish %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not finish the rotation — the previous key may still be live; re-run to resume") return } s.audit(acct, app, addr, "email-rotate-finish", "finished the email credential rotation (cli)") // No bumpSerial: delivery is untouched — only PrevKeyID moved. s.mailWalkReply(ctx, w, acct, app, rec) return } pub, ok := s.mailSealKey(w) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), mailRotateTimeout) defer cancel() rec, err := mail.Rotate(ctx, s.claimMailStore(), s.cfg.MailProvisioner, pub, s.cfg.MailDeployment, acct.Sqid, app, time.Now()) switch { case errors.Is(err, mail.ErrNotDeclared): jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first") return case errors.Is(err, mail.ErrNotProvisioned): jsonError(w, http.StatusConflict, "no provisioned email credential to rotate — enable email first") return case err != nil: log.Printf("console: api email rotate %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not rotate the email credential — nothing unrecorded was left behind; re-run the rotation to resume") return } s.audit(acct, app, addr, "email-rotate", "rotated the email credential (cli)") s.bumpSerial(ctx) s.mailWalkReply(ctx, w, acct, app, rec) } // ---- standalone credentials --------------------------------------------- // handleAPIEmailCredentialsCreate answers `carlos email credentials // create`: mint a displayed credential for something not running on CARLOS. // // THE ONLY REPLY IN THIS FILE THAT CARRIES A PASSWORD. mail.CreateStandalone // derives the password and hands it straight back; nothing stores it — // mail.Standalone's own fields are Name, IAMUser, KeyID, At, By, and no // more. writeJSON sets Cache-Control: no-store on every reply in this // console (authcli.go), which is what makes handing the plaintext straight // back here safe rather than requiring a second, cacheable fetch. func (s *Server) handleAPIEmailCredentialsCreate(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.refuseTemporary(w, acct, app, "give it email") { return } if !s.mailWriteGates(w, addr) { return } var req struct { Name string `json:"name"` } if !readJSON(w, r, &req) { return } name := strings.TrimSpace(req.Name) ctx, cancel := context.WithTimeout(r.Context(), mailStandaloneTimeout) defer cancel() creds, err := mail.CreateStandalone(ctx, s.claimMailStore(), s.cfg.MailProvisioner, s.cfg.MailDeployment, acct.Sqid, app, name, addr, time.Now()) switch { case errors.Is(err, mail.ErrNotDeclared): jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first") return case errors.Is(err, mail.ErrNoDomains): jsonError(w, http.StatusConflict, "no verified sending domain for this app — verify a domain first") return case err != nil && strings.Contains(err.Error(), "invalid credential name"): jsonError(w, http.StatusBadRequest, err.Error()+" — want ^[a-z0-9][a-z0-9-]{0,30}$") return case err != nil: log.Printf("console: api email credentials create %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not mint the credential — try again") return } // Audited WITHOUT the password: the name and the minted key id, never // the secret the reply below is about to carry. s.audit(acct, app, addr, "email-credentials-create", "minted a standalone credential "+name+" (key "+creds.Username+") (cli)") // No bumpSerial: a standalone credential is never delivered into the // app's own config. writeJSON(w, http.StatusOK, map[string]any{ "app": app, "name": name, "host": creds.Host, "port": creds.Port, "username": creds.Username, "password": creds.Password, "from": creds.From, }) } // handleAPIEmailCredentialsRevoke answers `carlos email credentials // revoke`: delete one displayed credential and its own IAM principal. It // cannot disturb delivery — see mail.Standalone's doc comment on why a // standalone credential gets its own principal. func (s *Server) handleAPIEmailCredentialsRevoke(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if !s.mailWriteGates(w, addr) { return } name := r.PathValue("name") ctx, cancel := context.WithTimeout(r.Context(), mailRotateTimeout) defer cancel() rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api email credentials read %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the email record — try again") return } found := false if rec != nil { for _, sa := range rec.Standalone { if sa.Name == name { found = true break } } } if !found { jsonError(w, http.StatusNotFound, fmt.Sprintf("no credential named %q for this app", name)) return } if err := mail.RevokeStandalone(ctx, s.claimMailStore(), s.cfg.MailProvisioner, acct.Sqid, app, name); err != nil { log.Printf("console: api email credentials revoke %s/%s (%s): %v", acct.Sqid, app, name, err) jsonError(w, http.StatusBadGateway, "could not revoke the credential — try again") return } s.audit(acct, app, addr, "email-credentials-revoke", "revoked the standalone credential "+name+" (cli)") // No bumpSerial: revoking a standalone credential never touched the // app's own delivered config. writeJSON(w, http.StatusOK, map[string]any{"app": app, "name": name, "revoked": true}) } // ---- pause (operator) ------------------------------------------------------- // handleAPIEmailPause answers `carlos email pause`/`resume` AND an // operator's cap raise — ONE door for both, per the F2 controller ruling. // // 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, ok := s.cliUser(w, r) if !ok { return } 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"` } 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() if req.DailyCap > 0 { domains, err := s.mailAppDomains(ctx, acct, app) if err != nil { log.Printf("console: api email pause %s/%s: listing domains: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read this app's domains — try again") return } names := make([]string, 0, len(domains)) for _, d := range domains { names = append(names, d.Domain) } sort.Strings(names) if _, err := mail.ApplyGrant(ctx, s.claimMailStore(), acct.Sqid, app, names, req.DailyCap, "operator", addr, time.Now()); err != nil { if errors.Is(err, mail.ErrNotDeclared) { jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first") return } log.Printf("console: api email pause %s/%s: apply grant: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not raise the cap — 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 { // "" — this is the MANUAL (operator) pause/resume door, not an // automatic one; mail.PauseKindCap/PauseKindRate are stamped only // by mailcounts.go's enforceMailLimits (I3, review follow-up). An // empty PauseKind is exactly what makes a manual pause never // auto-resume on the UTC day roll, same as a rate pause. rec, err = mail.SetPaused(ctx, s.claimMailStore(), 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 } // A permanent refusal, not a transient one. A retry never succeeds // on its own: the app has moved to per-instance senders, and the // pause an operator wants belongs to an instance sender, not the // app sender this door still resumes. if errors.Is(err, mail.ErrPerInstance) { jsonError(w, http.StatusConflict, "the app is in per-instance mode. resume its instance senders instead of the app sender.") 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 } } var detail []string if req.Paused != nil { action := "resumed" if *req.Paused { action = "paused" } detail = append(detail, "email "+action) } if req.DailyCap > 0 { detail = append(detail, fmt.Sprintf("daily cap raised to %d", req.DailyCap)) } s.audit(acct, app, addr, "email-pause", strings.Join(detail, "; ")+" (cli)") // No bumpSerial: SetPaused and ApplyGrant both write only the sender // record (email/senders/app.json, with an email.json read fallback), // never the app's own delivered config. s.mailWalkReply(ctx, w, acct, app, rec) }