carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The member-facing email API (tenant-email spec, task 17): declare that an
2// app sends mail, bring a sending domain into existence and verify it,
3// enable delivery, rotate or pause the delivered credential, and mint or
4// revoke a standalone one for something not running on CARLOS. Every door
5// here sits behind the same apiAccountApp gate as store/config/domains — an
6// unknown account, an unknown app and someone else's account are one
7// byte-identical 404 — and the file is deliberately apistore.go's sibling:
8// same doorRefusal shaping, same audit-after-write discipline, same serial
9// bumping gated on an actual write.
10//
11// ONE DEPARTURE FROM apistore.go, DELIBERATE: a nil MailProvisioner answers
12// 404, not 503. Object storage's "no backend" is a state a member is told
13// about (store's `backend` field, storeNoBucket's 503) because an operator
14// may configure one later and the declaration is queued for that. Email has
15// no such queued-operator step — self-serve is the whole design (spec §10) —
16// so a deployment with no SES estate at all is better read as "this API does
17// not exist here" than "it exists but is broken". See mailProvisionerGate.
18//
19// TWO AUTHORITIES, NOT THREE. Unlike store, most of this surface is a MEMBER
20// verb: declaring, bringing a domain into existence, verifying it, enabling,
21// rotating and minting/revoking a standalone credential all self-serve at
22// the account's tier cap (spec §10 — "free tier self-serves the platform
23// domain... paid tiers self-serve custom domains"). Only pausing/resuming
24// and raising a cap above what the tier grants need a deployment operator.
25//
26// THE PAUSE DOOR IS NOT apiAccountApp's SHAPE, and that is deliberate
27// (review round 1, I3). A pause is applied AGAINST a tenant, not asked for
28// by one — the abusive tenant has no reason to add the operator
29// investigating them as a member, and only that tenant's own owner can add
30// one, so member-then-allowlist (store's grant shape) would deadlock the
31// abuse brake in the exact scenario it exists for. handleAPIEmailPause
32// instead follows economicspass.go's handleAPIEconomicsBill shape: cliUser
33// -> store.IsOperator -> a bare, byte-identical http.NotFound, with the
34// account resolved afterward WITHOUT requiring membership
35// (mailPauseTarget, via AccountBySqid — viewer-independent, like
36// handleEconomicsTierSave's). CARLOS_MAIL_OPERATORS narrows the
37// fleet-operator bit further (mailOperator's doc comment says why); the
38// other eight doors never consult it.
39//
40// NOTHING SECRET CROSSES THESE DOORS except once. Every reply here echoes
41// the record's IAM user, its key id (the SMTP USERNAME) and the domains'
42// DKIM tokens — none of that is a secret; mail.Provisioned's own doc comment
43// says so. The ONE exception is handleAPIEmailCredentialsCreate: a
44// standalone credential is displayed once, at mint time, because nothing
45// else ever will — see that handler's doc comment.
46//
47// Spec: docs/superpowers/specs/2026-08-19-tenant-email-design.md §9, §10.
48package console
49
50import (
51 "context"
52 "crypto/ecdh"
53 "errors"
54 "fmt"
55 "log"
56 "net/http"
57 "sort"
58 "strconv"
59 "strings"
60 "time"
61
62 "github.com/carlosframework/platform/internal/appconfig"
63 "github.com/carlosframework/platform/internal/dnsrec"
64 "github.com/carlosframework/platform/internal/mail"
65 "github.com/carlosframework/platform/internal/release"
66 "github.com/carlosframework/platform/internal/schedule"
67 "github.com/carlosframework/platform/internal/svcrole"
68)
69
70// mailTimeout bounds a single round trip: declare, status, and a domain
71// verify (one SES call). Matches storeTimeout for the same reason — a
72// wedged bucket or a wedged SES call must cost one request this long, not a
73// console goroutine forever.
74const mailTimeout = 15 * time.Second
75
76// mailDomainTimeout bounds EnsureDomain: an SES identity, an optional
77// config set, and — for a platform domain — several DNS record writes.
78// Longer than mailTimeout for the same reason storeGrantTimeout is longer
79// than storeTimeout: this is not one round trip.
80const mailDomainTimeout = 60 * time.Second
81
82// mailEnableTimeout and mailStandaloneTimeout bound Enable and
83// CreateStandalone: both ensure an IAM user, reap stray keys, mint a fresh
84// one and (Enable only) deliver it into config. Same shape and ceiling as
85// storeGrantTimeout.
86const (
87 mailEnableTimeout = 60 * time.Second
88 mailStandaloneTimeout = 60 * time.Second
89)
90
91// mailRotateTimeout bounds Rotate, RotateFinish and RevokeStandalone: all
92// three act on an estate that already exists (delete, reap, mint, deliver,
93// or just delete), so shorter than a mint-from-nothing walk but still well
94// past a single object write. Matches storeRotateTimeout.
95const mailRotateTimeout = 30 * time.Second
96
97// mailPauseTimeout bounds SetPaused, which toggles every key this app's
98// principals hold — the delivered pair and every standalone — plus, when a
99// cap is being raised in the same request, one extra record write.
100const mailPauseTimeout = 30 * time.Second
101
102// mailNoBucket is the 503 every door gives when this console has no
103// deployment bucket. Matches storeNoBucket exactly, for the same reason:
104// the record IS a bucket object, and answering as if email were merely
105// undeclared would be a lie a member could act on.
106func mailNoBucket(w http.ResponseWriter) {
107 jsonError(w, http.StatusServiceUnavailable,
108 "no deployment bucket on this host — this console cannot serve email")
109}
110
111// mailProvisionerGate is the ONE departure from apistore.go's shape — see
112// the file comment. A nil MailProvisioner reads as "this deployment has no
113// email API" (404), not "email exists but its backend is unset" (503).
114func (s *Server) mailProvisionerGate(w http.ResponseWriter) bool {
115 if s.cfg.MailProvisioner == nil {
116 jsonError(w, http.StatusNotFound, "email is not available on this deployment")
117 return false
118 }
119 return true
120}
121
122// mailAvailable is every email door's shape gate: a bucket to record into,
123// and a backend to act through. Run AFTER the member gate (and, on the pause
124// door, the operator gate) so neither becomes an oracle for a stranger.
125func (s *Server) mailAvailable(w http.ResponseWriter) bool {
126 if s.cfg.Releases == nil {
127 mailNoBucket(w)
128 return false
129 }
130 return s.mailProvisionerGate(w)
131}
132
133// mailWriteGates is mailAvailable plus the shared write budget, keyed on the
134// ADDRESS like every other write door here — a budget belongs to the person,
135// not the terminal they typed on.
136func (s *Server) mailWriteGates(w http.ResponseWriter, addr string) bool {
137 if !s.cli.write.allow(addr, time.Now()) {
138 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
139 return false
140 }
141 return s.mailAvailable(w)
142}
143
144// mailSealKey is the gate that says: no sealing key, no minting. Matches
145// storeSealKey exactly — a credential this console cannot seal is one it
146// cannot deliver, and a key minted without delivery is the worst state in
147// the system (see storeSealKey's own doc comment for why). Enable and
148// Rotate need it; CreateStandalone does not, because a standalone
149// credential is displayed, never sealed into config (mail.StandaloneCreds'
150// doc comment).
151func (s *Server) mailSealKey(w http.ResponseWriter) (*ecdh.PublicKey, bool) {
152 if s.cfg.SecretsPub == "" {
153 jsonError(w, http.StatusServiceUnavailable,
154 "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver an email credential, so it will not enable sending")
155 return nil, false
156 }
157 pub, err := appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY")
158 if err != nil {
159 log.Printf("console: api email seal key: %v", err)
160 jsonError(w, http.StatusServiceUnavailable,
161 "this console's secrets key (CARLOS_SECRETS_PUBKEY) cannot be read — it cannot deliver an email credential, so it will not enable sending")
162 return nil, false
163 }
164 return pub, true
165}
166
167// mailOperator reports whether addr may drive the pause/cap door — a
168// NARROWING of store.IsOperator, not a replacement for it (review round 1,
169// I3). IsOperator is broad, fleet-wide authority: billing, every account's
170// economics, the alert watcher's recipient list. This door pauses a
171// tenant's SHARED SENDING REPUTATION specifically, and a deployment may
172// reasonably want to trust a smaller, different set of people with that
173// than with "can see the fleet bill" — so handleAPIEmailPause checks BOTH:
174// IsOperator first (cheap, and it is what makes the account resolution
175// membership-free — see the file comment), then this list.
176//
177// FAIL-CLOSED, like StoreOperators (that field's own doc comment): an
178// unset CARLOS_MAIL_OPERATORS means no fleet operator may drive this door,
179// not "any fleet operator may." That direction matters here more than for
180// store's grant — this list gates a brake against ABUSE, not merely
181// spending, and "everyone with the fleet-operator bit" is not the same
182// population as "everyone this deployment trusts with a tenant's sending
183// reputation."
184//
185// Matches storeOperator's exact shape otherwise, including the reason it
186// must NOT be strings.EqualFold (see that function's doc comment): the
187// address on a CLI token is already ASCII-lowercased at every entry point,
188// so the allowlist entry is folded the same way and never Unicode-folded,
189// which would let a long-s lookalike claim someone else's entry.
190func (s *Server) mailOperator(addr string) bool {
191 for _, op := range s.cfg.MailOperators {
192 if strings.ToLower(strings.TrimSpace(op)) == addr {
193 return true
194 }
195 }
196 return false
197}
198
199// mailPauseTarget resolves the pause door's {account}/{app} WITHOUT
200// requiring membership (review round 1, I3) — see the file comment for
201// why member-then-allowlist is backwards for a door applied AGAINST a
202// tenant. AccountBySqid is viewer-independent (it filters deleted_at, not
203// membership — store.go's own doc comment on it, and the same function
204// handleEconomicsTierSave uses to let an operator act on ANY account) and
205// sqid-ONLY, no name resolution — the same reason apiAccountApp's
206// `?exact=sqid` exists: the reference an operator reaches for here can
207// never itself be ambiguous.
208//
209// Every failure is a plain, byte-identical http.NotFound: an unknown
210// account, an app that account never claimed, and (via the caller, before
211// this is even reached) a non-operator's probe against a real one must all
212// read the same — the same non-probeability the member doors keep, just
213// earned a different way.
214func (s *Server) mailPauseTarget(w http.ResponseWriter, r *http.Request) (*Account, string, bool) {
215 acct, err := s.store.AccountBySqid(r.PathValue("account"))
216 if err != nil {
217 log.Printf("console: api email pause: account lookup: %v", err)
218 http.NotFound(w, r)
219 return nil, "", false
220 }
221 if acct == nil {
222 http.NotFound(w, r)
223 return nil, "", false
224 }
225 app := r.PathValue("app")
226 found, err := s.store.HasAppIn(acct.ID, app)
227 if err != nil {
228 log.Printf("console: api email pause: has-app %s/%s: %v", acct.Sqid, app, err)
229 }
230 if err != nil || !found {
231 http.NotFound(w, r)
232 return nil, "", false
233 }
234 return acct, app, true
235}
236
237// mailBillingTier is the account's billing tier, as TierCap wants it.
238//
239// THERE IS NO BILLING-TIER FIELD ON Account YET (issue #226). The design
240// spec is explicit that this ships before that work lands: "an account with
241// no tier defaults to free, so this ships whether or not [#226] lands"
242// (design doc §10). Every account therefore reads as "free" until #226
243// wires a real value through here — acct is already the right shape to
244// extend this with when it does, so no caller has to change.
245func mailBillingTier(acct *Account) string {
246 _ = acct
247 return "free"
248}
249
250// mailEffectivePrefix mirrors storeEffectivePrefix: the prefix a record's
251// variables are delivered under, falling back to the default only when no
252// declaration has named one.
253func mailEffectivePrefix(rec *mail.Record) string {
254 if rec != nil && rec.Declared != nil && rec.Declared.EnvPrefix != "" {
255 return rec.Declared.EnvPrefix
256 }
257 return mail.DefaultEnvPrefix
258}
259
260// mailDefaultHost is the app's own host — <app>.<sqid>.<platform-suffix> —
261// the default identity spec §3 promises (the same shape
262// internal/domains/reconcile.go mints a route for) and the one thing that
263// makes `carlos email enable` with no -from a one-command story (I4,
264// review follow-up). The CLI cannot compute this itself: it never learns
265// CARLOS_MAIL_PLATFORM_SUFFIX, only this console does — so it has to ride
266// along in the reply. Empty when this deployment has no platform suffix
267// configured; the CLI keeps its explicit "no From address" refusal in that
268// case, exactly as before this fix.
269//
270// DELIBERATELY INDEPENDENT OF Config.AppsDomain, the same caution
271// mailDeployment's own doc comment (cmd/console/main.go) already applies:
272// conflating the edge's routing zone with the zone SES identities are
273// verified under would repeat the class of bug #167 already cost this
274// codebase once. This uses ONLY s.cfg.MailDeployment.PlatformSuffix.
275func (s *Server) mailDefaultHost(acct *Account, app string) string {
276 suffix := s.cfg.MailDeployment.PlatformSuffix
277 if suffix == "" {
278 return ""
279 }
280 return app + "." + acct.Sqid + "." + suffix
281}
282
283// mailEffectiveCap mirrors mailcounts.go's enforceMailLimits exactly — the
284// cap a status reply names must be the SAME cap the limiter enforces, or a
285// member reading "cap: 1000" while running on the free tier's 50 would have
286// no way to know their own console lied to them.
287func mailEffectiveCap(rec *mail.Record) int {
288 if rec == nil {
289 return mailCapFor(nil)
290 }
291 return mailCapFor(rec.Grant)
292}
293
294// mailCapFor is mailEffectiveCap for one sender's grant. Both read the same
295// rule, because the cap a status reply names must be the cap the limiter
296// enforces (mailcounts.go's enforceMailLimits).
297func mailCapFor(g *mail.GrantBlock) int {
298 if g != nil && g.DailyCap > 0 {
299 return g.DailyCap
300 }
301 return TierCap("")
302}
303
304// mailAppDomains is every domain record in acct's account that names app as
305// a referrer — the app-scoped view of an account-scoped resource (see
306// domain.go's package comment for why domains live one level up). Used for
307// the status/enable replies and for computing ApplyGrant's domain list.
308func (s *Server) mailAppDomains(ctx context.Context, acct *Account, app string) ([]*mail.DomainRecord, error) {
309 all, err := mail.ListDomains(ctx, s.cfg.Releases, acct.Sqid)
310 if err != nil {
311 return nil, err
312 }
313 var out []*mail.DomainRecord
314 for _, d := range all {
315 for _, a := range d.Apps {
316 if a == app {
317 out = append(out, d)
318 break
319 }
320 }
321 }
322 return out, nil
323}
324
325// mailDeploymentFor adapts s.cfg.MailDeployment and s.cfg.DNSWriter for one
326// EnsureDomain (or Teardown) call.
327//
328// Config.DNSWriter's own doc comment promises: "nil means platform domains
329// behave like custom ones: records are emitted for the member instead of
330// published." The FIRST cut of this adaptation (this task, before review)
331// read that promise too broadly and cleared d.PlatformSuffix whenever
332// DNSWriter was nil — which also, silently, turned off C1's platform-name
333// ownership check (mail.platformNameOwnedBy only ever runs when
334// DomainKindFor answers KindPlatform, and DomainKindFor can only answer
335// that when PlatformSuffix is set). In a deployment with no DNSWriter
336// configured, ANY account could then take the global claim on ANY other
337// account's platform name — no DKIM gets published so it is denial-of-name
338// rather than a send capability, but the victim still gets 409'd off their
339// own name (I4, review round 2).
340//
341// The fix keeps the two questions separate, because they ARE separate:
342// "is this domain platform-kind, and whose name is it" (decided from the
343// CONFIGURED suffix, unconditionally) versus "do we have somewhere to
344// actually publish DNS" (decided from DNSWriter alone). d.PlatformSuffix
345// is therefore left untouched here; mail.EnsureDomain and mail.Teardown
346// gate their record-writing calls on w != nil themselves, not on Kind
347// alone, so a nil writer degrades ONLY publishing to the documented
348// pending/no-op behaviour and never touches ownership.
349func (s *Server) mailDeploymentFor() (mail.Deployment, dnsrec.Writer) {
350 return s.cfg.MailDeployment, s.cfg.DNSWriter
351}
352
353// dnsRecordOut renders a dnsrec.Record in this API's lowercase-key style —
354// dnsrec.Record itself carries no json tags, since nothing before this
355// task ever put one on the wire.
356type dnsRecordOut struct {
357 Name string `json:"name"`
358 Type string `json:"type"`
359 Values []string `json:"values"`
360 TTL int64 `json:"ttl"`
361}
362
363func dnsRecordsOut(recs []dnsrec.Record) []dnsRecordOut {
364 out := make([]dnsRecordOut, 0, len(recs))
365 for _, r := range recs {
366 out = append(out, dnsRecordOut{Name: r.Name, Type: r.Type, Values: r.Values, TTL: r.TTL})
367 }
368 return out
369}
370
371// emailReply is the ONE answer shape every door in this file that returns a
372// whole record gives — storeReply's counterpart. Kept in a single function
373// so enable, rotate and status can never drift into describing the same
374// record differently.
375//
376// standalone, domains and senders are NEVER null, matching storeReply's
377// env_names. a client reads them rather than testing for absence. Neither
378// carries a password: mail.Standalone stores a name, an IAM user and a key
379// id, never the secret (see that type's doc comment); a domain record
380// carries DKIM tokens and ARNs, not credentials.
381//
382// counts/daily_cap/active are computed fresh on every call — never latched
383// on the record — because they are exactly the sight a member with no box
384// access and no AWS console has of what the automatic limiter (mailcounts.go)
385// is about to do to their app.
386//
387// senders (senders design §8) is the per-sender view of the same sight,
388// app sender first. Every top-level field above keeps describing the APP
389// sender and the app's whole day unchanged, so an older CLI reading only
390// those fields still works.
391func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec *mail.Record) (map[string]any, error) {
392 domains, err := s.mailAppDomains(ctx, acct, app)
393 if err != nil {
394 return nil, err
395 }
396 if domains == nil {
397 domains = []*mail.DomainRecord{}
398 }
399 prefix := mailEffectivePrefix(rec)
400 host, port, user, pass, from := mail.EnvNames(prefix)
401 names := []string{host, port, user, pass, from}
402 missing := []string{}
403 if rec.Provisioned != nil {
404 wantKeyID := rec.Provisioned.KeyID
405 // rec.Declared is guaranteed non-nil here: Provisioned is only ever
406 // set by Enable, which itself requires Declared != nil — so
407 // rec.Declared.From is always the record's CURRENT From (I1,
408 // review follow-up: EnvDelivered compares _FROM's delivered value
409 // against it the same way _USER is compared against KeyID).
410 got, err := mail.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantKeyID, rec.Declared.From)
411 if err != nil {
412 return nil, err
413 }
414 missing = append(missing, got...)
415 }
416 day := time.Now().UTC().Format("2006-01-02")
417 counts, err := s.store.MailCounts(ctx, acct.Sqid, app, "", day)
418 if err != nil {
419 return nil, err
420 }
421 standalone := rec.Standalone
422 if standalone == nil {
423 standalone = []*mail.Standalone{}
424 }
425 // The senders array (senders design §8). The top-level fields above keep
426 // describing the APP sender and the app's whole day, so an older CLI
427 // still works; this is the per-sender view beside them.
428 all, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app)
429 if err != nil {
430 return nil, err
431 }
432 senders := make([]map[string]any, 0, len(all))
433 for _, sn := range all {
434 sc, err := s.store.MailCounts(ctx, acct.Sqid, app, sn.ID, day)
435 if err != nil {
436 return nil, err
437 }
438 entry := map[string]any{
439 "id": sn.ID,
440 "scope": sn.Scope,
441 "source": sn.Source,
442 "active": sn.Provisioned != nil && sn.Provisioned.Active,
443 "daily_cap": mailCapFor(sn.Grant),
444 "counts": map[string]any{
445 "sends": sc.Sends, "bounces": sc.Bounces,
446 "complaints": sc.Complaints, "rejects": sc.Rejects,
447 "deliveries": sc.Deliveries,
448 },
449 }
450 if sn.Host != "" {
451 entry["host"] = sn.Host
452 }
453 if sn.Address != "" {
454 entry["address"] = sn.Address
455 }
456 if sn.Provisioned != nil {
457 entry["key_id"] = sn.Provisioned.KeyID
458 if sn.Provisioned.PauseKind != "" {
459 entry["pause_kind"] = sn.Provisioned.PauseKind
460 }
461 }
462 senders = append(senders, entry)
463 }
464 return map[string]any{
465 "app": app,
466 "declared": rec.Declared,
467 "grant": rec.Grant,
468 "provisioned": rec.Provisioned,
469 "standalone": standalone,
470 "default_host": s.mailDefaultHost(acct, app),
471 "domains": domains,
472 "env_names": names,
473 // A rotation that minted a new key but has not yet deleted the old
474 // one — the member-visible half of the two-key overlap.
475 "pending_finish": rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "",
476 "missing_vars": missing,
477 "daily_cap": mailEffectiveCap(rec),
478 "active": rec.Provisioned != nil && rec.Provisioned.Active,
479 "counts": map[string]any{
480 "sends": counts.Sends, "bounces": counts.Bounces,
481 "complaints": counts.Complaints, "rejects": counts.Rejects,
482 "deliveries": counts.Deliveries,
483 },
484 "senders": senders,
485 }, nil
486}
487
488// mailWalkReply is the shared tail of every walk door that hands back a
489// whole record: render the same answer shape the status door gives, from
490// the record the walk just wrote. Matches storeWalkReply.
491func (s *Server) mailWalkReply(ctx context.Context, w http.ResponseWriter, acct *Account, app string, rec *mail.Record) {
492 out, err := s.emailReply(ctx, acct, app, rec)
493 if err != nil {
494 log.Printf("console: api email reply %s/%s: %v", acct.Sqid, app, err)
495 jsonError(w, http.StatusBadGateway,
496 "email changed, but its status could not be read — run `carlos email status`")
497 return
498 }
499 writeJSON(w, http.StatusOK, out)
500}
501
502// registerEmailRoutes wires this file's ten doors onto s.mux, through the
503// role gate (cliRoute) like every other /api/cli/ door. It stays a separate
504// function from registerCLIRoutes only so the email family's reasoning lives
505// beside the handlers it describes; svcroutes_test's AST pass is
506// package-wide precisely so a second registration site is still covered.
507//
508// THE ROLE TABLE, and why each door reads as it does.
509//
510// The seven OPEN doors take Allow(Admin), matching the store family verb
511// for verb — declare, ensure, verify, REMOVE, enable, rotate all move an
512// account's backend estate, and `enable` mints a path-scoped IAM user
513// exactly as `store grant` does. Admin is the union role, so
514// Publish/Instance/Operate are refused: a credential that may deploy an app
515// must not thereby be able to give it a sending identity on a domain the
516// account owns.
517//
518// `domains rm` is Admin because `domains add` is: the same authority, run
519// backwards. It is deliberately NOT closed or operator-gated — the whole
520// defect it exists to fix is that releasing a claim required an authority
521// (permanent app deletion) far larger than claiming one ever did, which is
522// what left real deployments with a domain nobody could ever free.
523//
524// GET .../email is Admin rather than a read role for the same reason GET
525// .../store is: the status reply names the IAM user, the key id and the
526// verified domains — the estate, not just the app.
527//
528// GET .../email/events is Admin too, and for a different reason: it names
529// the people the app wrote to. A credential that may read an app's routes
530// or deploy it has no need of its recipients' addresses.
531//
532// The three .../email/events/push doors are Admin for the same reason: turning
533// the push on is what starts recipients' addresses leaving the console, and
534// the status names them in its last failure. The addresses only ever go to
535// the app's own platform host (mailpush.go), so this is not a grant to send
536// them anywhere — but it is a decision about them, and Admin makes it.
537//
538// The three CLOSED doors (svcrole.Set{} — closed to every role, admin
539// included) are each closed for their own reason:
540//
541// - credentials create is the ONLY door in either family that hands a
542// plaintext sending secret back to its caller. Store never does: its
543// secret goes into sealed config and the console keeps no key to open
544// it. This one must, because the whole point of a standalone credential
545// is a human copying it into an off-platform MTA (spec §6b). An
546// on-platform app never needs this door — it is delivered its own
547// credentials as env by `enable`. So the one caller this door exists
548// for is a person, and a service credential reaching it would turn
549// "may deploy" into "may send as this domain, indefinitely".
550// - credentials revoke is closed with it. Nothing a service could have
551// minted here exists for it to clean up, and the services family closes
552// create and revoke together for the same symmetry.
553// - pause already resolves an ADDRESS against CARLOS_MAIL_OPERATORS
554// (mailOperator), which no service principal can ever hold. Naming a
555// role here would be a registration that lies about who gets in — the
556// objection authcli.go raises against exactly that shape. It also
557// RAISES THE CAP, which is an authority grant, not a runtime toggle.
558//
559// All three are pinned at the intent level too (mintDoors, policyDoors in
560// svcroutes_test.go), so reopening one takes an edit that argues with the
561// words next to it, not just a line moved in closedDoors.
562func (s *Server) registerEmailRoutes() {
563 s.cliRoute("POST /api/cli/apps/{account}/{app}/email", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDeclare)
564 s.cliRoute("GET /api/cli/apps/{account}/{app}/email", svcrole.Allow(svcrole.Admin), s.handleAPIEmailStatus)
565 s.cliRoute("GET /api/cli/apps/{account}/{app}/email/events", svcrole.Allow(svcrole.Admin), s.handleAPIEmailEvents)
566 s.cliRoute("GET /api/cli/apps/{account}/{app}/email/events/push", svcrole.Allow(svcrole.Admin), s.handleAPIEmailPushStatus)
567 s.cliRoute("PUT /api/cli/apps/{account}/{app}/email/events/push", svcrole.Allow(svcrole.Admin), s.handleAPIEmailPushSet)
568 s.cliRoute("DELETE /api/cli/apps/{account}/{app}/email/events/push", svcrole.Allow(svcrole.Admin), s.handleAPIEmailPushOff)
569 s.cliRoute("POST /api/cli/apps/{account}/{app}/email/domains", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainEnsure)
570 s.cliRoute("POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainVerify)
571 s.cliRoute("DELETE /api/cli/apps/{account}/{app}/email/domains/{domain}", svcrole.Allow(svcrole.Admin), s.handleAPIEmailDomainRemove)
572 s.cliRoute("POST /api/cli/apps/{account}/{app}/email/enable", svcrole.Allow(svcrole.Admin), s.handleAPIEmailEnable)
573 s.cliRoute("POST /api/cli/apps/{account}/{app}/email/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIEmailRotate)
574 s.cliRoute("POST /api/cli/apps/{account}/{app}/email/credentials", svcrole.Set{}, s.handleAPIEmailCredentialsCreate)
575 s.cliRoute("DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}", svcrole.Set{}, s.handleAPIEmailCredentialsRevoke)
576 s.cliRoute("POST /api/cli/apps/{account}/{app}/email/pause", svcrole.Set{}, s.handleAPIEmailPause)
577}
578
579// ---- declare ------------------------------------------------------------
580
581// handleAPIEmailDeclare answers the member's first step: record the env
582// prefix and From address an app wants to send under. A REQUEST, like
583// store's create door — nothing is provisioned when this returns 200.
584//
585// The doors, in order: token (cliUser's 401) — account+app 404
586// (apiAccountApp) — rate limit (the shared write budget) — mailAvailable
587// (bucket, then backend) — the body's own validation, refused before any
588// record read so a corrected retry starts clean.
589//
590// CHANGED-GATING (#151's rule, this task's own extension of it):
591// mail.Declare itself always refreshes At/By and always writes — it has no
592// concept of a no-op. This door decides "changed" itself, from a pre-read,
593// so a re-declare of the SAME prefix and From does not bury the tenant's
594// audit log under no-ops. Declaring never bumps the serial regardless —
595// nothing a box reads has changed either way.
596func (s *Server) handleAPIEmailDeclare(w http.ResponseWriter, r *http.Request) {
597 addr, acct, app, ok := s.apiAccountApp(w, r)
598 if !ok {
599 return
600 }
601 if s.refuseTemporary(w, acct, app, "give it email") {
602 return
603 }
604 if !s.mailWriteGates(w, addr) {
605 return
606 }
607 var req struct {
608 EnvPrefix string `json:"env_prefix"`
609 From string `json:"from"`
610 }
611 if !readJSON(w, r, &req) {
612 return
613 }
614 prefix := strings.TrimSpace(req.EnvPrefix)
615 if prefix == "" {
616 prefix = mail.DefaultEnvPrefix
617 }
618 if !mail.ValidEnvPrefix(prefix) {
619 jsonError(w, http.StatusBadRequest,
620 fmt.Sprintf("invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$ (e.g. %s)", req.EnvPrefix, mail.DefaultEnvPrefix))
621 return
622 }
623 from := strings.TrimSpace(req.From)
624 if !strings.Contains(from, "@") {
625 jsonError(w, http.StatusBadRequest, fmt.Sprintf("From %q is not an address", req.From))
626 return
627 }
628 if err := appconfig.ValidVarValue(from); err != nil {
629 jsonError(w, http.StatusBadRequest, fmt.Sprintf("From %q is not usable: %v", req.From, err))
630 return
631 }
632 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
633 defer cancel()
634 rec0, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
635 if err != nil {
636 log.Printf("console: api email read %s/%s: %v", acct.Sqid, app, err)
637 jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
638 return
639 }
640 // THE ENV PREFIX IS FIXED ONCE DELIVERED — mail.Declare enforces this
641 // too, but checking it here first keeps the refusal a clean 409 naming
642 // the reason, instead of a stringified backend error.
643 if rec0 != nil && rec0.Declared != nil && rec0.Provisioned != nil && rec0.Declared.EnvPrefix != prefix {
644 jsonError(w, http.StatusConflict,
645 fmt.Sprintf("the env prefix is %q and cannot move after delivery", rec0.Declared.EnvPrefix))
646 return
647 }
648 changed := rec0 == nil || rec0.Declared == nil || rec0.Declared.EnvPrefix != prefix || rec0.Declared.From != from
649 rec, err := mail.Declare(ctx, s.claimMailStore(), acct.Sqid, app, prefix, from, addr, time.Now())
650 if err != nil {
651 if errors.Is(err, release.ErrCASConflict) {
652 jsonError(w, http.StatusConflict, "the email record changed underneath you — try again")
653 return
654 }
655 log.Printf("console: api email declare %s/%s: %v", acct.Sqid, app, err)
656 jsonError(w, http.StatusBadGateway, "could not record the declaration — nothing was changed; try again")
657 return
658 }
659 if changed {
660 s.audit(acct, app, addr, "email-declare", "declared email with env prefix "+prefix+" from "+from+" (cli)")
661 }
662 // No bumpSerial: a declaration touches nothing a box reads.
663 out, err := s.emailReply(ctx, acct, app, rec)
664 if err != nil {
665 log.Printf("console: api email reply %s/%s: %v", acct.Sqid, app, err)
666 jsonError(w, http.StatusBadGateway, "the email was declared, but its status could not be read — try `carlos email status`")
667 return
668 }
669 writeJSON(w, http.StatusOK, out)
670}
671
672// ---- status ---------------------------------------------------------------
673
674// handleAPIEmailStatus answers `carlos email status`: the whole record plus
675// today's counts against the cap and which delivered variables have not
676// arrived — the same sight store's status door gives, for the same reason
677// (a member with no box access and no AWS console has no other way to see
678// it). Not rate limited, matching every other read door: an exhausted write
679// budget must not blind a member to the state of what they already asked
680// for.
681func (s *Server) handleAPIEmailStatus(w http.ResponseWriter, r *http.Request) {
682 _, acct, app, ok := s.apiAccountApp(w, r)
683 if !ok {
684 return
685 }
686 if !s.mailAvailable(w) {
687 return
688 }
689 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
690 defer cancel()
691 rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
692 if err != nil {
693 log.Printf("console: api email status %s/%s: %v", acct.Sqid, app, err)
694 jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
695 return
696 }
697 if rec == nil {
698 // Still carries default_host (I4, review follow-up), even on this
699 // 404: `carlos email enable` with no -from makes its FIRST call
700 // here, before anything has ever been declared, and that is
701 // exactly when the app's default sending host needs to reach the
702 // CLI for the one-command story to work at all.
703 writeJSON(w, http.StatusNotFound, map[string]any{
704 "error": "no email is declared for this app — declare it first",
705 "default_host": s.mailDefaultHost(acct, app),
706 })
707 return
708 }
709 // R2 (review follow-up): resume a stale CAP pause on the status READ
710 // path, not only on the SES event path. A paused sender's IAM key is
711 // deactivated, so it sends nothing, so no events ever arrive to run
712 // enforceMailLimits' day-roll check for it either. a member running
713 // `carlos email status` is exactly the moment a rolled-over daily budget
714 // should clear. Shares resumeStaleCapPause with enforceMailLimits so the
715 // two can never drift; a RATE pause, a PER-INSTANCE pause or a kind-less
716 // pause is left untouched by that function, fail-safe.
717 //
718 // EVERY sender gets this, not only the app sender (review round 2, task
719 // 13). This reply now names every sender's active and pause_kind, and a
720 // cap-paused non-app sender sends nothing either, so it never generates
721 // the SES event that would otherwise resume it. step 5's per-customer
722 // instance senders would each pause at their own cap and stay paused
723 // forever without this.
724 senders, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app)
725 appResumed := false
726 if err == nil {
727 for _, sn := range senders {
728 _, resumed, rerr := s.resumeStaleCapPause(ctx, acct.Sqid, app, sn.ID, sn, time.Now())
729 if rerr != nil {
730 err = rerr
731 break
732 }
733 if resumed && sn.ID == mail.SenderAppID {
734 appResumed = true
735 }
736 }
737 }
738 if err == nil && appResumed {
739 // The top-level fields describe the app sender's own record, so a
740 // resume of it (not a sibling) has to be re-read before emailReply
741 // renders them. resumeStaleCapPause hands back a fresh sender, not a
742 // patched copy, and rec is the Record view of that same object.
743 rec, _, err = mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
744 }
745 if err != nil {
746 log.Printf("console: api email status resume %s/%s: %v", acct.Sqid, app, err)
747 jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
748 return
749 }
750 out, err := s.emailReply(ctx, acct, app, rec)
751 if err != nil {
752 log.Printf("console: api email delivery %s/%s: %v", acct.Sqid, app, err)
753 jsonError(w, http.StatusBadGateway, "could not read the email's config delivery — try again")
754 return
755 }
756 writeJSON(w, http.StatusOK, out)
757}
758
759// ---- events ----------------------------------------------------------------
760
761// handleAPIEmailEvents answers `carlos email events`: what happened to the
762// messages the app sent — delivered, bounced, complained about, delayed —
763// newest first, from the per-message log (mailevents.go). Not rate limited,
764// like every read door.
765//
766// It deliberately does NOT require the email record to exist: an app whose
767// email was torn down still has thirty days of history, and "what happened
768// to the last thing we sent" is exactly the question after a teardown. Nor
769// does it need the bucket — the log is the console's own table. What it
770// keeps from the family is the provisioner gate: a deployment with no email
771// answers 404 here as on every other email door.
772//
773// Query parameters, all optional: since (RFC 3339), message (an SES message
774// id), to (an address), limit (1–1000, default 100).
775func (s *Server) handleAPIEmailEvents(w http.ResponseWriter, r *http.Request) {
776 _, acct, app, ok := s.apiAccountApp(w, r)
777 if !ok {
778 return
779 }
780 if !s.mailProvisionerGate(w) {
781 return
782 }
783 q := MailEventQuery{
784 MessageID: strings.TrimSpace(r.URL.Query().Get("message")),
785 Recipient: strings.TrimSpace(r.URL.Query().Get("to")),
786 }
787 if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" {
788 since, err := time.Parse(time.RFC3339, raw)
789 if err != nil {
790 jsonError(w, http.StatusBadRequest, "since must be an RFC 3339 time, like 2026-09-15T08:00:00Z")
791 return
792 }
793 q.Since = since
794 }
795 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
796 n, err := strconv.Atoi(raw)
797 if err != nil || n < 1 || n > mailEventsMaxLimit {
798 jsonError(w, http.StatusBadRequest, fmt.Sprintf("limit must be a whole number from 1 to %d", mailEventsMaxLimit))
799 return
800 }
801 q.Limit = n
802 }
803 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
804 defer cancel()
805 events, err := s.store.MailEvents(ctx, acct.Sqid, app, q, time.Now())
806 if err != nil {
807 log.Printf("console: api email events %s/%s: %v", acct.Sqid, app, err)
808 jsonError(w, http.StatusBadGateway, "could not read the email log — try again")
809 return
810 }
811 if events == nil {
812 events = []MailEvent{}
813 }
814 writeJSON(w, http.StatusOK, map[string]any{
815 "events": events,
816 "retention_days": int(mailEventsRetention / (24 * time.Hour)),
817 })
818}
819
820// ---- events push ------------------------------------------------------------
821
822// emailPushReply is what all three push doors answer: where events go, the
823// key an app verifies them with, and how delivery has been going.
824func (s *Server) emailPushReply(ctx context.Context, acct *Account, app string) (map[string]any, error) {
825 hook, err := s.store.MailEventHookFor(ctx, acct.Sqid, app)
826 if err != nil {
827 return nil, err
828 }
829 var since time.Time
830 if hook != nil {
831 since = hook.SetAt
832 }
833 st, err := s.store.MailEventPushStatusFor(ctx, acct.Sqid, app, since)
834 if err != nil {
835 return nil, err
836 }
837 out := map[string]any{"on": hook != nil, "key_env": MailEventsKeyEnv, "status": st}
838 if hook == nil {
839 return out, nil
840 }
841 if hook.Suspended() {
842 out["suspended_at"] = hook.SuspendedAt
843 out["suspended_why"] = hook.SuspendedWhy
844 }
845 key, err := s.store.MailEventsSigningKey()
846 if err != nil {
847 return nil, err
848 }
849 out["path"] = hook.Path
850 out["set_by"] = hook.SetBy
851 out["set_at"] = hook.SetAt
852 out["public_key"] = mailEventsPublicKey(key)
853 if url, err := s.mailPushURL(acct.Sqid, app, hook.Path); err == nil {
854 out["url"] = url
855 }
856 return out, nil
857}
858
859// handleAPIEmailPushStatus answers `carlos email events push --app <a>` with
860// no change asked for.
861func (s *Server) handleAPIEmailPushStatus(w http.ResponseWriter, r *http.Request) {
862 _, acct, app, ok := s.apiAccountApp(w, r)
863 if !ok {
864 return
865 }
866 if !s.mailProvisionerGate(w) {
867 return
868 }
869 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
870 defer cancel()
871 out, err := s.emailPushReply(ctx, acct, app)
872 if err != nil {
873 log.Printf("console: api email push status %s/%s: %v", acct.Sqid, app, err)
874 jsonError(w, http.StatusBadGateway, "could not read the email events push — try again")
875 return
876 }
877 writeJSON(w, http.StatusOK, out)
878}
879
880// handleAPIEmailPushSet turns the push on, or moves its path.
881//
882// The verifying key is delivered BEFORE the hook is recorded, so there is no
883// window in which events flow to an app that has nothing to check them
884// with. A failure between the two leaves the key delivered and the push off,
885// which a retry finishes.
886func (s *Server) handleAPIEmailPushSet(w http.ResponseWriter, r *http.Request) {
887 addr, acct, app, ok := s.apiAccountApp(w, r)
888 if !ok {
889 return
890 }
891 if !s.mailWriteGates(w, addr) {
892 return
893 }
894 var req struct {
895 Path string `json:"path"`
896 }
897 if !readJSON(w, r, &req) {
898 return
899 }
900 path := strings.TrimSpace(req.Path)
901 if err := schedule.ValidPath(path); err != nil {
902 jsonError(w, http.StatusBadRequest, fmt.Sprintf("path %q: %v", req.Path, err))
903 return
904 }
905 if _, err := s.mailPushURL(acct.Sqid, app, path); err != nil {
906 jsonError(w, http.StatusServiceUnavailable, err.Error())
907 return
908 }
909 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
910 defer cancel()
911 before, err := s.store.MailEventHookFor(ctx, acct.Sqid, app)
912 if err != nil {
913 log.Printf("console: api email push read %s/%s: %v", acct.Sqid, app, err)
914 jsonError(w, http.StatusBadGateway, "could not read the email events push — nothing was changed; try again")
915 return
916 }
917 key, err := s.store.MailEventsSigningKey()
918 if err != nil {
919 log.Printf("console: api email push key: %v", err)
920 jsonError(w, http.StatusBadGateway, "could not read the signing key — nothing was changed; try again")
921 return
922 }
923 sets := map[string]appconfig.Var{MailEventsKeyEnv: {Kind: "env", Value: mailEventsPublicKey(key)}}
924 if _, err := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, sets, nil); err != nil {
925 s.configStoreError(w, fmt.Sprintf("api email push key %s/%s", acct.Sqid, app), err)
926 return
927 }
928 s.bumpSerial(ctx)
929 if err := s.store.SetMailEventHook(ctx, acct.Sqid, app, path, addr, time.Now()); err != nil {
930 log.Printf("console: api email push set %s/%s: %v", acct.Sqid, app, err)
931 jsonError(w, http.StatusBadGateway, "the verifying key was delivered, but the push could not be turned on — try again")
932 return
933 }
934 if before == nil || before.Path != path {
935 s.audit(acct, app, addr, "email-events-push", "pushing email events to "+path+" (cli)")
936 }
937 out, err := s.emailPushReply(ctx, acct, app)
938 if err != nil {
939 log.Printf("console: api email push reply %s/%s: %v", acct.Sqid, app, err)
940 jsonError(w, http.StatusBadGateway, "the push is on, but its status could not be read — try `carlos email events push`")
941 return
942 }
943 writeJSON(w, http.StatusOK, out)
944}
945
946// handleAPIEmailPushOff turns the push off: the hook goes, whatever is still
947// queued is given up on, and the key variable is taken back out of the
948// app's config.
949func (s *Server) handleAPIEmailPushOff(w http.ResponseWriter, r *http.Request) {
950 addr, acct, app, ok := s.apiAccountApp(w, r)
951 if !ok {
952 return
953 }
954 if !s.mailWriteGates(w, addr) {
955 return
956 }
957 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
958 defer cancel()
959 was, err := s.store.DeleteMailEventHook(ctx, acct.Sqid, app, time.Now())
960 if err != nil {
961 log.Printf("console: api email push off %s/%s: %v", acct.Sqid, app, err)
962 jsonError(w, http.StatusBadGateway, "could not turn the email events push off — try again")
963 return
964 }
965 // The key is taken out whether or not a push was on: turning it on
966 // delivers the key before recording the hook, so a failure between the
967 // two leaves a key with no push, and --off is how that is cleaned up.
968 if removed, err := appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, nil, []string{MailEventsKeyEnv}); err != nil {
969 // A key left in the environment verifies nothing that will ever
970 // arrive. Said, not failed.
971 log.Printf("console: api email push off %s/%s: unsetting %s: %v", acct.Sqid, app, MailEventsKeyEnv, err)
972 } else if removed > 0 {
973 s.bumpSerial(ctx)
974 }
975 if was {
976 s.audit(acct, app, addr, "email-events-push-off", "stopped pushing email events (cli)")
977 }
978 out, err := s.emailPushReply(ctx, acct, app)
979 if err != nil {
980 log.Printf("console: api email push reply %s/%s: %v", acct.Sqid, app, err)
981 jsonError(w, http.StatusBadGateway, "the push is off, but its status could not be read")
982 return
983 }
984 writeJSON(w, http.StatusOK, out)
985}
986
987// ---- domains ----------------------------------------------------------------
988
989// handleAPIEmailDomainEnsure answers `carlos email domains add`: bring a
990// sending domain into existence for this app, publishing its DNS itself
991// when this deployment owns the zone, or handing back the record set for
992// the member to publish when it does not.
993//
994// A MEMBER VERB (spec §10) — no operator gate. It spends nothing that
995// exceeds the account's tier; only raising the CAP above a tier's default
996// needs an operator, through the pause door.
997func (s *Server) handleAPIEmailDomainEnsure(w http.ResponseWriter, r *http.Request) {
998 addr, acct, app, ok := s.apiAccountApp(w, r)
999 if !ok {
1000 return
1001 }
1002 if s.refuseTemporary(w, acct, app, "give it email") {
1003 return
1004 }
1005 if !s.mailWriteGates(w, addr) {
1006 return
1007 }
1008 var req struct {
1009 Domain string `json:"domain"`
1010 }
1011 if !readJSON(w, r, &req) {
1012 return
1013 }
1014 domain := strings.ToLower(strings.TrimSpace(req.Domain))
1015 if !mail.ValidDomain(domain) {
1016 jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid domain %q", req.Domain))
1017 return
1018 }
1019 ctx, cancel := context.WithTimeout(r.Context(), mailDomainTimeout)
1020 defer cancel()
1021 d, writer := s.mailDeploymentFor()
1022 rec, pending, err := mail.EnsureDomain(ctx, s.claimMailStore(), s.cfg.MailProvisioner, writer, d, acct.Sqid, app, domain, time.Now())
1023 if err != nil {
1024 // I3 (Task 17a review follow-up): ErrDomainClaimed is a refusal a
1025 // retry cannot change, not a transient failure. Rendering it as the
1026 // generic 502 below told a member to retry, with a server-error
1027 // status for a client-side condition. 409 names the real shape (a
1028 // conflict with existing state).
1029 //
1030 // The message is the neutral one (#216), in the shape of the
1031 // hostname door's hostUnavailableMsg: it used to say "already
1032 // claimed by another CARLOS account", which confirmed to anybody
1033 // that some tenant holds the name.
1034 if errors.Is(err, mail.ErrDomainClaimed) {
1035 jsonError(w, http.StatusConflict, mailDomainUnavailableMsg)
1036 return
1037 }
1038 // ErrIdentityNotOwned (#216): SES already has an identity for this
1039 // domain that this account's claim did not create. The member is
1040 // told exactly how to prove control, because the proof is the only
1041 // way in and nobody can guess the record.
1042 if errors.Is(err, mail.ErrIdentityNotOwned) {
1043 jsonError(w, http.StatusConflict, fmt.Sprintf(
1044 "%s already has a sending identity this account did not create. To use it, prove you control the domain: "+
1045 "publish a TXT record at %s with the value %s, then add the domain again.",
1046 domain, mail.ProofRecordName(domain), mail.ProofRecordValue(acct.Sqid)))
1047 return
1048 }
1049 // Same reasoning for ErrNotOwnPlatformName: refused because of what
1050 // the domain NAME is, not because of any backend state, so a retry
1051 // changes nothing either. 400, not 409/502 — this is a malformed
1052 // request for THIS account, not a conflict with someone else's
1053 // state, and naming the caller's own account here leaks nothing
1054 // (mail.EnsureDomain's error already does, deliberately — see
1055 // ErrNotOwnPlatformName's doc comment).
1056 if errors.Is(err, mail.ErrNotOwnPlatformName) {
1057 jsonError(w, http.StatusBadRequest,
1058 fmt.Sprintf("%q is not a platform name this account owns", domain))
1059 return
1060 }
1061 log.Printf("console: api email domain ensure %s/%s (%s): %v", acct.Sqid, app, domain, err)
1062 jsonError(w, http.StatusBadGateway,
1063 "could not ensure the sending domain — nothing unrecorded was left behind; re-run to resume")
1064 return
1065 }
1066 s.audit(acct, app, addr, "email-domain-ensure", domain+" ("+rec.Kind+") (cli)")
1067 // No bumpSerial: nothing in the app's own config changed — only the
1068 // domain record and, for a platform domain, DNS this console published
1069 // itself.
1070 writeJSON(w, http.StatusOK, map[string]any{
1071 "domain": rec,
1072 "pending": dnsRecordsOut(pending),
1073 })
1074}
1075
1076// mailDomainUnavailableMsg is the one refusal for a sending domain another
1077// account holds, in the hostname door's neutral shape (hostUnavailableMsg):
1078// it says no, and not who or whether anyone does.
1079const mailDomainUnavailableMsg = "sending domain unavailable"
1080
1081// handleAPIEmailDomainVerify answers `carlos email domains verify`:
1082// re-check SES's own verdict for this deployment's region and record it.
1083// Account-scoped, like the domain record itself (domain.go's package
1084// comment) — {app} in the path is only the membership gate, not an
1085// ownership filter, so any member of the account may verify any of its
1086// domains.
1087func (s *Server) handleAPIEmailDomainVerify(w http.ResponseWriter, r *http.Request) {
1088 addr, acct, app, ok := s.apiAccountApp(w, r)
1089 if !ok {
1090 return
1091 }
1092 if !s.mailWriteGates(w, addr) {
1093 return
1094 }
1095 domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
1096 if !mail.ValidDomain(domain) {
1097 jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid domain %q", r.PathValue("domain")))
1098 return
1099 }
1100 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
1101 defer cancel()
1102 existing, _, err := mail.GetDomain(ctx, s.cfg.Releases, acct.Sqid, domain)
1103 if err != nil {
1104 log.Printf("console: api email domain read %s/%s: %v", acct.Sqid, domain, err)
1105 jsonError(w, http.StatusBadGateway, "could not read the domain record — try again")
1106 return
1107 }
1108 if existing == nil {
1109 jsonError(w, http.StatusNotFound,
1110 fmt.Sprintf("no sending domain %q for this account — run \"carlos email domains add\" first", domain))
1111 return
1112 }
1113 region := s.cfg.MailDeployment.Region
1114 // The deployment goes in because verify RECONCILES the identity's
1115 // configuration set against it (#272): the binding SES should have is a
1116 // function of whether this deployment has an SNS topic, not of what the
1117 // record last recorded.
1118 rec, err := mail.VerifyDomain(ctx, s.claimMailStore(), s.cfg.MailProvisioner,
1119 s.cfg.MailDeployment, acct.Sqid, domain, region, time.Now())
1120 if errors.Is(err, mail.ErrDomainClaimed) {
1121 // The claim lapsed unverified and another account took the domain
1122 // (#216). The same neutral words as the add door.
1123 jsonError(w, http.StatusConflict, mailDomainUnavailableMsg)
1124 return
1125 }
1126 if err != nil {
1127 log.Printf("console: api email domain verify %s/%s: %v", acct.Sqid, domain, err)
1128 jsonError(w, http.StatusBadGateway, "could not check verification — try again")
1129 return
1130 }
1131 status := ""
1132 if st, ok := rec.Regions[region]; ok {
1133 status = st.Status
1134 }
1135 s.audit(acct, app, addr, "email-domain-verify", domain+": "+status+" (cli)")
1136 // No bumpSerial: nothing in the app's own config changed.
1137 writeJSON(w, http.StatusOK, map[string]any{"domain": rec})
1138}
1139
1140// handleAPIEmailDomainRemove answers `carlos email domains rm`: stop this
1141// app sending from a domain and, when it was the last app doing so, give
1142// the domain's GLOBAL claim back.
1143//
1144// THE DOOR THE CLAIM ALWAYS NEEDED. The claim (internal/mail/domainclaim.go)
1145// is deliberately a create-only, globally-keyed object so two accounts
1146// cannot both hold one domain — right, and unchanged by this door. What was
1147// missing was the way out: until this existed, mail.Teardown, reached only
1148// by PERMANENTLY DELETING the whole app, was the one caller that could ever
1149// release a claim, so a domain claimed by the wrong app stayed unreachable
1150// to every other account — including the account that actually controls its
1151// DNS — for the life of the deployment.
1152//
1153// A MEMBER VERB, Admin like `domains add`, for the same reason: it spends
1154// nothing that exceeds the account's tier, and it is the exact inverse of a
1155// door an admin already has. It can never touch an estate outside the
1156// caller's own account — mail.RemoveDomain reads and releases only through
1157// the owner-gated releaseDomainClaim, and a claim held by anyone else is
1158// refused in the same words as a domain that does not exist.
1159//
1160// Account-scoped like the record itself (domain.go's package comment), so
1161// {app} is the membership gate AND — unlike verify — the referrer actually
1162// being removed. The one exception is a claim with no record at all, where
1163// there is no referrer set to be in; mail.removeOrphanedClaim documents why
1164// the gate reading is the right one there.
1165func (s *Server) handleAPIEmailDomainRemove(w http.ResponseWriter, r *http.Request) {
1166 addr, acct, app, ok := s.apiAccountApp(w, r)
1167 if !ok {
1168 return
1169 }
1170 if !s.mailWriteGates(w, addr) {
1171 return
1172 }
1173 domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
1174 if !mail.ValidDomain(domain) {
1175 jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid domain %q", r.PathValue("domain")))
1176 return
1177 }
1178 ctx, cancel := context.WithTimeout(r.Context(), mailDomainTimeout)
1179 defer cancel()
1180 d, writer := s.mailDeploymentFor()
1181 out, err := mail.RemoveDomain(ctx, s.claimMailStore(), s.cfg.MailProvisioner, writer, d, acct.Sqid, app, domain)
1182 if err != nil {
1183 // The From refusal is a 409, not a 400: it is a conflict with state
1184 // the caller can change (move the From, then remove the domain),
1185 // which is precisely what this message has to say. Contrast the
1186 // ensure door's 409, which is permanent — nothing the caller does
1187 // clears another account's claim.
1188 if errors.Is(err, mail.ErrDomainIsFrom) {
1189 jsonError(w, http.StatusConflict, s.mailFromConflictMessage(ctx, acct, app, domain))
1190 return
1191 }
1192 // 404 covers both "this app does not use that domain" and "that
1193 // domain's claim belongs to another account", deliberately in the
1194 // same words — see mail.ErrDomainNotReferenced.
1195 if errors.Is(err, mail.ErrDomainNotReferenced) {
1196 jsonError(w, http.StatusNotFound, fmt.Sprintf(
1197 "no sending domain %q for this app — `carlos email status --app %s` lists the ones it has", domain, app))
1198 return
1199 }
1200 if out != nil {
1201 // The removal LANDED; only the departing app's IAM policy
1202 // shrink failed (mail.RemoveDomain's own doc comment). Saying
1203 // "re-run" here would be wrong — a re-run answers 404 now —
1204 // so name the verb that actually repays the debt.
1205 log.Printf("console: api email domain remove %s/%s (%s): policy refresh: %v", acct.Sqid, app, domain, err)
1206 s.audit(acct, app, addr, "email-domain-remove", domain+" (cli; policy refresh failed)")
1207 jsonError(w, http.StatusBadGateway,
1208 "the domain was removed, but this app's sending policy could not be rewritten — run `carlos email enable --app "+app+"` to repair it")
1209 return
1210 }
1211 log.Printf("console: api email domain remove %s/%s (%s): %v", acct.Sqid, app, domain, err)
1212 jsonError(w, http.StatusBadGateway,
1213 "could not remove the sending domain — nothing unrecorded was left behind; re-run to resume")
1214 return
1215 }
1216 detail := domain + " (" + out.Kind + ")"
1217 switch {
1218 case out.ClaimOnly:
1219 detail += " released a claim with no domain record"
1220 case out.TornDown:
1221 detail += " torn down, claim released"
1222 default:
1223 detail += " still used by " + strings.Join(out.Remaining, ", ")
1224 }
1225 s.audit(acct, app, addr, "email-domain-remove", detail+" (cli)")
1226 // No bumpSerial: nothing in the app's own config changed — the delivered
1227 // SMTP variables are untouched by a domain leaving the granted set, and
1228 // the app's From is guaranteed by the refusal above to be somewhere
1229 // else.
1230 writeJSON(w, http.StatusOK, map[string]any{
1231 "app": app,
1232 "domain": out.Domain,
1233 "kind": out.Kind,
1234 "torn_down": out.TornDown,
1235 "claim_only": out.ClaimOnly,
1236 "remaining_apps": out.Remaining,
1237 "orphaned": dnsRecordsOut(out.Orphaned),
1238 })
1239}
1240
1241// mailFromConflictMessage names the From that blocks a domain removal, and
1242// the command that moves it. Re-reading the record on this one error path
1243// costs nothing and is the difference between a member knowing what to do
1244// next and being told only that they may not.
1245func (s *Server) mailFromConflictMessage(ctx context.Context, acct *Account, app, domain string) string {
1246 msg := fmt.Sprintf("this app still sends from %s", domain)
1247 if rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app); err == nil && rec != nil && rec.Declared != nil {
1248 msg = fmt.Sprintf("this app still sends as %s", rec.Declared.From)
1249 }
1250 return msg + fmt.Sprintf(" — move its From to another verified domain first"+
1251 " (`carlos email enable --app %s --from <addr>`), then remove %s", app, domain)
1252}
1253
1254// ---- enable -----------------------------------------------------------------
1255
1256// handleAPIEmailEnable answers `carlos email enable`: provision the
1257// declared app's credential against its verified domains and deliver it.
1258//
1259// F2 (controller ruling): the tier's cap is applied via mail.ApplyGrant
1260// BEFORE mail.Enable runs, never after. enforceMailLimits (mailcounts.go)
1261// reads rec.Grant.DailyCap on every SES event this app generates — if
1262// Enable delivered a credential before a grant existed, the FIRST message
1263// this app could ever send would land with no cap on record, and
1264// enforceMailLimits's own fallback (TierCap("")) happens to be the same
1265// number today only by coincidence. Applying the grant first makes the cap
1266// unconditionally present before delivery, not merely usually present.
1267func (s *Server) handleAPIEmailEnable(w http.ResponseWriter, r *http.Request) {
1268 addr, acct, app, ok := s.apiAccountApp(w, r)
1269 if !ok {
1270 return
1271 }
1272 if s.refuseTemporary(w, acct, app, "give it email") {
1273 return
1274 }
1275 if !s.mailWriteGates(w, addr) {
1276 return
1277 }
1278 pub, ok := s.mailSealKey(w)
1279 if !ok {
1280 return
1281 }
1282 ctx, cancel := context.WithTimeout(r.Context(), mailEnableTimeout)
1283 defer cancel()
1284
1285 tier := mailBillingTier(acct)
1286 domains, err := s.mailAppDomains(ctx, acct, app)
1287 if err != nil {
1288 log.Printf("console: api email enable %s/%s: listing domains: %v", acct.Sqid, app, err)
1289 jsonError(w, http.StatusBadGateway, "could not read this app's domains — try again")
1290 return
1291 }
1292 names := make([]string, 0, len(domains))
1293 for _, d := range domains {
1294 names = append(names, d.Domain)
1295 }
1296 sort.Strings(names)
1297 if _, err := mail.ApplyGrant(ctx, s.claimMailStore(), acct.Sqid, app, names, TierCap(tier), "tier:"+tier, addr, time.Now()); err != nil {
1298 if errors.Is(err, mail.ErrNotDeclared) {
1299 jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first")
1300 return
1301 }
1302 log.Printf("console: api email enable %s/%s: apply grant: %v", acct.Sqid, app, err)
1303 jsonError(w, http.StatusBadGateway, "could not authorise sending for this app — try again")
1304 return
1305 }
1306
1307 rec, err := mail.Enable(ctx, s.claimMailStore(), s.cfg.MailProvisioner, pub, s.cfg.MailDeployment, acct.Sqid, app, time.Now())
1308 switch {
1309 case errors.Is(err, mail.ErrNotDeclared):
1310 jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first")
1311 return
1312 case errors.Is(err, mail.ErrNoDomains):
1313 jsonError(w, http.StatusConflict, "no verified sending domain for this app — verify a domain first")
1314 return
1315 case err != nil && strings.Contains(err.Error(), "is not at any verified domain"):
1316 // Not a backend failure: the member's own declared From address
1317 // does not match any domain this app can send from yet. Safe to
1318 // hand back verbatim — it names only data the caller already gave.
1319 jsonError(w, http.StatusConflict, err.Error())
1320 return
1321 case err != nil:
1322 log.Printf("console: api email enable %s/%s: %v", acct.Sqid, app, err)
1323 jsonError(w, http.StatusBadGateway,
1324 "could not enable email — nothing unrecorded was left behind; re-run to resume")
1325 return
1326 }
1327 s.audit(acct, app, addr, "email-enable", "enabled email (cli)")
1328 // The app's config bundle changed: boxes read config only when the
1329 // serial moves.
1330 s.bumpSerial(ctx)
1331 s.mailWalkReply(ctx, w, acct, app, rec)
1332}
1333
1334// ---- rotate -----------------------------------------------------------------
1335
1336// handleAPIEmailRotate answers `carlos email rotate` and, with
1337// `?finish=1`, `carlos email rotate -finish`. A MEMBER VERB throughout
1338// (spec §8's store precedent): rotation cycles a credential inside an
1339// estate already granted, and the person who needs it most is a member who
1340// has just leaked a key.
1341func (s *Server) handleAPIEmailRotate(w http.ResponseWriter, r *http.Request) {
1342 addr, acct, app, ok := s.apiAccountApp(w, r)
1343 if !ok {
1344 return
1345 }
1346 if !s.mailWriteGates(w, addr) {
1347 return
1348 }
1349 if r.URL.Query().Get("finish") == "1" {
1350 ctx, cancel := context.WithTimeout(r.Context(), mailRotateTimeout)
1351 defer cancel()
1352 rec, err := mail.RotateFinish(ctx, s.claimMailStore(), s.cfg.MailProvisioner, acct.Sqid, app)
1353 if errors.Is(err, mail.ErrNotProvisioned) {
1354 jsonError(w, http.StatusConflict, "no provisioned email credential to finish rotating — enable email first")
1355 return
1356 }
1357 if err != nil {
1358 log.Printf("console: api email rotate finish %s/%s: %v", acct.Sqid, app, err)
1359 jsonError(w, http.StatusBadGateway,
1360 "could not finish the rotation — the previous key may still be live; re-run to resume")
1361 return
1362 }
1363 s.audit(acct, app, addr, "email-rotate-finish", "finished the email credential rotation (cli)")
1364 // No bumpSerial: delivery is untouched — only PrevKeyID moved.
1365 s.mailWalkReply(ctx, w, acct, app, rec)
1366 return
1367 }
1368
1369 pub, ok := s.mailSealKey(w)
1370 if !ok {
1371 return
1372 }
1373 ctx, cancel := context.WithTimeout(r.Context(), mailRotateTimeout)
1374 defer cancel()
1375 rec, err := mail.Rotate(ctx, s.claimMailStore(), s.cfg.MailProvisioner, pub, s.cfg.MailDeployment, acct.Sqid, app, time.Now())
1376 switch {
1377 case errors.Is(err, mail.ErrNotDeclared):
1378 jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first")
1379 return
1380 case errors.Is(err, mail.ErrNotProvisioned):
1381 jsonError(w, http.StatusConflict, "no provisioned email credential to rotate — enable email first")
1382 return
1383 case err != nil:
1384 log.Printf("console: api email rotate %s/%s: %v", acct.Sqid, app, err)
1385 jsonError(w, http.StatusBadGateway,
1386 "could not rotate the email credential — nothing unrecorded was left behind; re-run the rotation to resume")
1387 return
1388 }
1389 s.audit(acct, app, addr, "email-rotate", "rotated the email credential (cli)")
1390 s.bumpSerial(ctx)
1391 s.mailWalkReply(ctx, w, acct, app, rec)
1392}
1393
1394// ---- standalone credentials ---------------------------------------------
1395
1396// handleAPIEmailCredentialsCreate answers `carlos email credentials
1397// create`: mint a displayed credential for something not running on CARLOS.
1398//
1399// THE ONLY REPLY IN THIS FILE THAT CARRIES A PASSWORD. mail.CreateStandalone
1400// derives the password and hands it straight back; nothing stores it —
1401// mail.Standalone's own fields are Name, IAMUser, KeyID, At, By, and no
1402// more. writeJSON sets Cache-Control: no-store on every reply in this
1403// console (authcli.go), which is what makes handing the plaintext straight
1404// back here safe rather than requiring a second, cacheable fetch.
1405func (s *Server) handleAPIEmailCredentialsCreate(w http.ResponseWriter, r *http.Request) {
1406 addr, acct, app, ok := s.apiAccountApp(w, r)
1407 if !ok {
1408 return
1409 }
1410 if s.refuseTemporary(w, acct, app, "give it email") {
1411 return
1412 }
1413 if !s.mailWriteGates(w, addr) {
1414 return
1415 }
1416 var req struct {
1417 Name string `json:"name"`
1418 }
1419 if !readJSON(w, r, &req) {
1420 return
1421 }
1422 name := strings.TrimSpace(req.Name)
1423 ctx, cancel := context.WithTimeout(r.Context(), mailStandaloneTimeout)
1424 defer cancel()
1425 creds, err := mail.CreateStandalone(ctx, s.claimMailStore(), s.cfg.MailProvisioner, s.cfg.MailDeployment, acct.Sqid, app, name, addr, time.Now())
1426 switch {
1427 case errors.Is(err, mail.ErrNotDeclared):
1428 jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first")
1429 return
1430 case errors.Is(err, mail.ErrNoDomains):
1431 jsonError(w, http.StatusConflict, "no verified sending domain for this app — verify a domain first")
1432 return
1433 case err != nil && strings.Contains(err.Error(), "invalid credential name"):
1434 jsonError(w, http.StatusBadRequest, err.Error()+" — want ^[a-z0-9][a-z0-9-]{0,30}$")
1435 return
1436 case err != nil:
1437 log.Printf("console: api email credentials create %s/%s: %v", acct.Sqid, app, err)
1438 jsonError(w, http.StatusBadGateway, "could not mint the credential — try again")
1439 return
1440 }
1441 // Audited WITHOUT the password: the name and the minted key id, never
1442 // the secret the reply below is about to carry.
1443 s.audit(acct, app, addr, "email-credentials-create", "minted a standalone credential "+name+" (key "+creds.Username+") (cli)")
1444 // No bumpSerial: a standalone credential is never delivered into the
1445 // app's own config.
1446 writeJSON(w, http.StatusOK, map[string]any{
1447 "app": app, "name": name,
1448 "host": creds.Host, "port": creds.Port,
1449 "username": creds.Username, "password": creds.Password,
1450 "from": creds.From,
1451 })
1452}
1453
1454// handleAPIEmailCredentialsRevoke answers `carlos email credentials
1455// revoke`: delete one displayed credential and its own IAM principal. It
1456// cannot disturb delivery — see mail.Standalone's doc comment on why a
1457// standalone credential gets its own principal.
1458func (s *Server) handleAPIEmailCredentialsRevoke(w http.ResponseWriter, r *http.Request) {
1459 addr, acct, app, ok := s.apiAccountApp(w, r)
1460 if !ok {
1461 return
1462 }
1463 if !s.mailWriteGates(w, addr) {
1464 return
1465 }
1466 name := r.PathValue("name")
1467 ctx, cancel := context.WithTimeout(r.Context(), mailRotateTimeout)
1468 defer cancel()
1469 rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
1470 if err != nil {
1471 log.Printf("console: api email credentials read %s/%s: %v", acct.Sqid, app, err)
1472 jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
1473 return
1474 }
1475 found := false
1476 if rec != nil {
1477 for _, sa := range rec.Standalone {
1478 if sa.Name == name {
1479 found = true
1480 break
1481 }
1482 }
1483 }
1484 if !found {
1485 jsonError(w, http.StatusNotFound, fmt.Sprintf("no credential named %q for this app", name))
1486 return
1487 }
1488 if err := mail.RevokeStandalone(ctx, s.claimMailStore(), s.cfg.MailProvisioner, acct.Sqid, app, name); err != nil {
1489 log.Printf("console: api email credentials revoke %s/%s (%s): %v", acct.Sqid, app, name, err)
1490 jsonError(w, http.StatusBadGateway, "could not revoke the credential — try again")
1491 return
1492 }
1493 s.audit(acct, app, addr, "email-credentials-revoke", "revoked the standalone credential "+name+" (cli)")
1494 // No bumpSerial: revoking a standalone credential never touched the
1495 // app's own delivered config.
1496 writeJSON(w, http.StatusOK, map[string]any{"app": app, "name": name, "revoked": true})
1497}
1498
1499// ---- pause (operator) -------------------------------------------------------
1500
1501// handleAPIEmailPause answers `carlos email pause`/`resume` AND an
1502// operator's cap raise — ONE door for both, per the F2 controller ruling.
1503//
1504// FLEET-OPERATOR GATED via store.IsOperator, NOT apiAccountApp's
1505// member-then-allowlist shape the other eight doors use (review round 1,
1506// I3) — see the file comment for why. The order below matches
1507// economicspass.go's handleAPIEconomicsBill exactly: cliUser -> IsOperator
1508// -> a bare, byte-identical http.NotFound, before ANY account is even
1509// looked up. CARLOS_MAIL_OPERATORS (mailOperator) narrows that further —
1510// see its own doc comment for why a second, smaller list exists at all.
1511// Both checks refuse with the SAME silent 404, never a speaking 403: a 403
1512// here would be an oracle telling a non-operator prober "this route exists
1513// and you lack authority for it," which is exactly the class of leak the
1514// byte-identical 404 exists to prevent everywhere else in this file.
1515//
1516// I1 (review round 1): Paused is *bool, not bool, and SetPaused is called
1517// ONLY when it is present. A body carrying just {"daily_cap": N} decoding
1518// a MISSING paused as false would silently RESUME a tenant an earlier
1519// request paused — directly contradicting "nothing auto-resumes... resume
1520// is a decision" (mailcounts.go), the very rule that makes this whole door
1521// operator-only in the first place.
1522//
1523// F2: a daily_cap in the body raises the cap with mail.ApplyGrant, source
1524// "operator" — mail.ApplyGrant itself already refuses to let a later
1525// tier-sourced grant (the enable door) overwrite an operator's raise; this
1526// door only has to pass the right source.
1527func (s *Server) handleAPIEmailPause(w http.ResponseWriter, r *http.Request) {
1528 addr, ok := s.cliUser(w, r)
1529 if !ok {
1530 return
1531 }
1532 isOp, err := s.store.IsOperator(addr)
1533 if err != nil {
1534 log.Printf("console: api email pause: is-operator: %v", err)
1535 http.NotFound(w, r)
1536 return
1537 }
1538 if !isOp || !s.mailOperator(addr) {
1539 http.NotFound(w, r)
1540 return
1541 }
1542 acct, app, ok := s.mailPauseTarget(w, r)
1543 if !ok {
1544 return
1545 }
1546 if !s.mailWriteGates(w, addr) {
1547 return
1548 }
1549 var req struct {
1550 Paused *bool `json:"paused"`
1551 DailyCap int `json:"daily_cap"`
1552 }
1553 if !readJSON(w, r, &req) {
1554 return
1555 }
1556 if req.Paused == nil && req.DailyCap <= 0 {
1557 jsonError(w, http.StatusBadRequest,
1558 "send paused (true/false) or a daily_cap to raise — this request would change nothing")
1559 return
1560 }
1561 ctx, cancel := context.WithTimeout(r.Context(), mailPauseTimeout)
1562 defer cancel()
1563
1564 if req.DailyCap > 0 {
1565 domains, err := s.mailAppDomains(ctx, acct, app)
1566 if err != nil {
1567 log.Printf("console: api email pause %s/%s: listing domains: %v", acct.Sqid, app, err)
1568 jsonError(w, http.StatusBadGateway, "could not read this app's domains — try again")
1569 return
1570 }
1571 names := make([]string, 0, len(domains))
1572 for _, d := range domains {
1573 names = append(names, d.Domain)
1574 }
1575 sort.Strings(names)
1576 if _, err := mail.ApplyGrant(ctx, s.claimMailStore(), acct.Sqid, app, names, req.DailyCap, "operator", addr, time.Now()); err != nil {
1577 if errors.Is(err, mail.ErrNotDeclared) {
1578 jsonError(w, http.StatusConflict, "no email is declared for this app — declare it first")
1579 return
1580 }
1581 log.Printf("console: api email pause %s/%s: apply grant: %v", acct.Sqid, app, err)
1582 jsonError(w, http.StatusBadGateway, "could not raise the cap — try again")
1583 return
1584 }
1585 }
1586
1587 // I1: SetPaused runs ONLY when the caller actually asked for a pause or
1588 // a resume. A cap-only raise reads the current record instead of
1589 // writing one — it must not touch Active at all.
1590 var rec *mail.Record
1591 if req.Paused != nil {
1592 // "" — this is the MANUAL (operator) pause/resume door, not an
1593 // automatic one; mail.PauseKindCap/PauseKindRate are stamped only
1594 // by mailcounts.go's enforceMailLimits (I3, review follow-up). An
1595 // empty PauseKind is exactly what makes a manual pause never
1596 // auto-resume on the UTC day roll, same as a rate pause.
1597 rec, err = mail.SetPaused(ctx, s.claimMailStore(), s.cfg.MailProvisioner, acct.Sqid, app, *req.Paused, "", time.Now())
1598 if errors.Is(err, mail.ErrNotProvisioned) {
1599 jsonError(w, http.StatusConflict, "no provisioned email credential to pause — enable email first")
1600 return
1601 }
1602 // A permanent refusal, not a transient one. A retry never succeeds
1603 // on its own: the app has moved to per-instance senders, and the
1604 // pause an operator wants belongs to an instance sender, not the
1605 // app sender this door still resumes.
1606 if errors.Is(err, mail.ErrPerInstance) {
1607 jsonError(w, http.StatusConflict, "the app is in per-instance mode. resume its instance senders instead of the app sender.")
1608 return
1609 }
1610 if err != nil {
1611 log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err)
1612 jsonError(w, http.StatusBadGateway, "could not change the pause state — try again")
1613 return
1614 }
1615 } else {
1616 rec, _, err = mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
1617 if err != nil {
1618 log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err)
1619 jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
1620 return
1621 }
1622 if rec == nil {
1623 jsonError(w, http.StatusNotFound, "no email is declared for this app — declare it first")
1624 return
1625 }
1626 }
1627
1628 var detail []string
1629 if req.Paused != nil {
1630 action := "resumed"
1631 if *req.Paused {
1632 action = "paused"
1633 }
1634 detail = append(detail, "email "+action)
1635 }
1636 if req.DailyCap > 0 {
1637 detail = append(detail, fmt.Sprintf("daily cap raised to %d", req.DailyCap))
1638 }
1639 s.audit(acct, app, addr, "email-pause", strings.Join(detail, "; ")+" (cli)")
1640 // No bumpSerial: SetPaused and ApplyGrant both write only the sender
1641 // record (email/senders/app.json, with an email.json read fallback),
1642 // never the app's own delivered config.
1643 s.mailWalkReply(ctx, w, acct, app, rec)
1644}
1645