console: the email doors
Nine member-facing doors (declare, status, domains ensure/verify, enable, rotate/finish, standalone credentials create/revoke, and the operator pause/cap door) — apistore.go's sibling: byte-identical 404 for a stranger, audit-after-write, and serial bumping gated on an actual write. One departure, deliberate: a nil MailProvisioner answers 404 rather than apistore's 503 — a deployment with no SES estate reads as "this API does not exist" (spec §10's self-serve design has no queued-operator step for a missing backend the way object storage does). F2 (controller ruling): the enable door applies mail.ApplyGrant with the account's tier cap BEFORE mail.Enable runs, so the limiter's cap is present before the first message can be sent. The pause door applies it with source "operator" when an operator raises a cap; ApplyGrant's own precedence rule (operator beats a later tier grant) is exercised, not reimplemented. The standalone credential reply is the only one that ever carries a password, and every reply in this file is Cache-Control: no-store via the console's existing writeJSON. A re-declare that changes nothing (env prefix unchanged, From unchanged) writes no audit line — mail.Declare itself has no such concept, so the door decides "changed" from a pre-read. Also: a small adaptation (mailDeploymentFor) fulfils Config.DNSWriter's documented "nil means platform domains behave like custom ones" contract, which mail.EnsureDomain itself has no nil handling for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+1674
−0
internal/console/apimail.go+913 −0internal/console/apimail_test.go+744 −0internal/console/server.go+17 −0
diff --git a/internal/console/apimail.go b/internal/console/apimail.go| new file mode 100644 |
| index 000000000..4f3e61d70 |
| --- /dev/null |
| +++ b/internal/console/apimail.go |
| @@ -0,0 +1,913 @@ |
| +// 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 |
| +// (CARLOS_MAIL_OPERATORS) — the one door in this file with an allowlist gate, |
| +// run AFTER the member 404 exactly as store's grant door runs its own. |
| +// |
| +// 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" |
| + "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" |
| +) |
| + |
| +// 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. Matches |
| +// storeOperator's exact shape, including the reason it must NOT be |
| +// strings.EqualFold (see that function's doc comment): the address on a CLI |
| +// token is already ASCII-lowercased at every entry point, so the allowlist |
| +// entry is folded the same way and never Unicode-folded, which would let a |
| +// long-s lookalike claim someone else's entry. |
| +func (s *Server) mailOperator(addr string) bool { |
| + for _, op := range s.cfg.MailOperators { |
| + if strings.ToLower(strings.TrimSpace(op)) == addr { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// 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 |
| +} |
| + |
| +// 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 { |
| + dailyCap := TierCap("") |
| + if rec != nil && rec.Grant != nil && rec.Grant.DailyCap > 0 { |
| + dailyCap = rec.Grant.DailyCap |
| + } |
| + return dailyCap |
| +} |
| + |
| +// 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 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." mail.EnsureDomain itself has no such nil handling — it calls |
| +// w.EnsureRecord unconditionally for a platform-kind domain, which would |
| +// panic on a nil Writer — because DomainKindFor decides platform-vs-custom |
| +// purely from d.PlatformSuffix. Clearing PlatformSuffix here is what makes |
| +// the documented behaviour true: with no suffix, DomainKindFor can only ever |
| +// answer KindCustom, so the placeholder Writer below is provably never |
| +// called. This is this task's first caller of DNSWriter; the adaptation |
| +// belongs here, not in mail.EnsureDomain, which has no opinion on what "no |
| +// writer" should degrade to. |
| +func (s *Server) mailDeploymentFor() (mail.Deployment, dnsrec.Writer) { |
| + d := s.cfg.MailDeployment |
| + w := s.cfg.DNSWriter |
| + if w == nil { |
| + d.PlatformSuffix = "" |
| + w = dnsrec.NewMemWriter() |
| + } |
| + return d, w |
| +} |
| + |
| +// 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 and domains 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. |
| +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 |
| + got, err := mail.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantKeyID) |
| + 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{} |
| + } |
| + return map[string]any{ |
| + "app": app, |
| + "declared": rec.Declared, |
| + "grant": rec.Grant, |
| + "provisioned": rec.Provisioned, |
| + "standalone": standalone, |
| + "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, |
| + }, |
| + }, 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 nine doors onto s.mux. Every other |
| +// CLI route in the console registers centrally in registerCLIRoutes |
| +// (authcli.go); this one is separate because this task's file scope is |
| +// server.go (which calls it, beside registerCLIRoutes) and apimail.go — |
| +// not authcli.go. |
| +func (s *Server) registerEmailRoutes() { |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email", s.handleAPIEmailDeclare) |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/email", s.handleAPIEmailStatus) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/domains", s.handleAPIEmailDomainEnsure) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify", s.handleAPIEmailDomainVerify) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/enable", s.handleAPIEmailEnable) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/rotate", s.handleAPIEmailRotate) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/credentials", s.handleAPIEmailCredentialsCreate) |
| + s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/email/credentials/{name}", s.handleAPIEmailCredentialsRevoke) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/email/pause", 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.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.cfg.Releases, 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 { |
| + jsonError(w, http.StatusNotFound, "no email is declared for this app — declare it first") |
| + 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) |
| +} |
| + |
| +// ---- 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.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.cfg.Releases, s.cfg.MailProvisioner, writer, d, acct.Sqid, app, domain, time.Now()) |
| + if err != nil { |
| + 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), |
| + }) |
| +} |
| + |
| +// 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 |
| + rec, err := mail.VerifyDomain(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, domain, region, time.Now()) |
| + 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}) |
| +} |
| + |
| +// ---- 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.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.cfg.Releases, 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.cfg.Releases, 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.cfg.Releases, 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.cfg.Releases, 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.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.cfg.Releases, 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.cfg.Releases, 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. |
| +// |
| +// OPERATOR-ONLY, and that is deliberate for pause/resume too, not just the |
| +// cap half: mailcounts.go's automatic abuse pause exists precisely so one |
| +// tenant's bounce rate cannot put the whole shared SES account under |
| +// review, and "NOTHING auto-resumes... resume is a decision" (that file's |
| +// own doc comment) would be hollow if the paused tenant could simply |
| +// resume itself through this door. The gate order is store's grant door's |
| +// exactly: member 404 FIRST (apiAccountApp), THEN the allowlist — an |
| +// operator is not automatically a member, and a stranger holding the |
| +// allowlist address must still get the byte-identical 404 for an account |
| +// they are not in. |
| +// |
| +// F2: a daily_cap in the body raises the cap with mail.ApplyGrant, source |
| +// "operator" — mail.ApplyGrant itself already refuses to let a later |
| +// tier-sourced grant (the enable door) overwrite an operator's raise; this |
| +// door only has to pass the right source. |
| +func (s *Server) handleAPIEmailPause(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.mailOperator(addr) { |
| + jsonError(w, http.StatusForbidden, |
| + "pausing, resuming or raising an email cap needs a deployment operator (CARLOS_MAIL_OPERATORS)") |
| + 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 |
| + } |
| + 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.cfg.Releases, 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 |
| + } |
| + } |
| + |
| + rec, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, app, req.Paused, time.Now()) |
| + if errors.Is(err, mail.ErrNotProvisioned) { |
| + jsonError(w, http.StatusConflict, "no provisioned email credential to pause — enable email first") |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not change the pause state — try again") |
| + return |
| + } |
| + action := "resumed" |
| + if req.Paused { |
| + action = "paused" |
| + } |
| + detail := "email " + action + " (cli)" |
| + if req.DailyCap > 0 { |
| + detail += fmt.Sprintf("; daily cap raised to %d", req.DailyCap) |
| + } |
| + s.audit(acct, app, addr, "email-pause", detail) |
| + // No bumpSerial: SetPaused and ApplyGrant both write only email.json, |
| + // never the app's own delivered config. |
| + s.mailWalkReply(ctx, w, acct, app, rec) |
| +} |
diff --git a/internal/console/apimail_test.go b/internal/console/apimail_test.go| new file mode 100644 |
| index 000000000..b2dfc7969 |
| --- /dev/null |
| +++ b/internal/console/apimail_test.go |
| @@ -0,0 +1,744 @@ |
| +package console |
| + |
| +// The member half of the email API (apimail.go, tenant-email task 17). What |
| +// is pinned here, in the order it would hurt if lost: a stranger probing any |
| +// of the nine doors learns nothing (the byte-identical 404 the whole |
| +// /api/cli surface keeps, and — new to this file — the same 404 when this |
| +// deployment has no SES estate at all); the standalone-credential reply is |
| +// the only one that ever carries a password, and it is uncacheable; a |
| +// re-declare that changes nothing writes no audit line and bumps no serial; |
| +// and the enable door always leaves a grant behind BEFORE it provisions |
| +// anything, so the limiter never runs an app on the wrong cap. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/mail" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func emailFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +func emailURL(fx *consoleFixture, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + "/email" |
| +} |
| + |
| +// emailArmed is emailFixture with a whole deployment behind it: an |
| +// in-memory SES/IAM backend, an in-memory DNS writer, a secrets key the |
| +// console can seal with, a platform suffix, and an operator allowlist. It |
| +// hands back the fake provisioner and DNS writer so a test can assert on |
| +// the estate they actually built. |
| +func emailArmed(t *testing.T, code string) (*consoleFixture, *mail.MemProvisioner, *dnsrec.MemWriter, string) { |
| + t.Helper() |
| + fx, token := emailFixture(t, code) |
| + pub, _, _ := sealedFor(t, "irrelevant") |
| + fake := mail.NewMemProvisioner("1") |
| + writer := dnsrec.NewMemWriter() |
| + fx.srv.cfg.SecretsPub = pub |
| + fx.srv.cfg.MailProvisioner = fake |
| + fx.srv.cfg.DNSWriter = writer |
| + fx.srv.cfg.MailDeployment = mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com"} |
| + // Spelled with different capitals on purpose, matching storeArmed: an |
| + // operator list is typed by a human into a unit file, and addresses are |
| + // case-insensitive. |
| + fx.srv.cfg.MailOperators = []string{"Ops@Keymail.dev"} |
| + return fx, fake, writer, token |
| +} |
| + |
| +// emailOperatorToken mints a token for an address on MailOperators, adding |
| +// it as a member first — an operator is not automatically a member (the |
| +// pause door's own doc comment), so every operator test needs both. |
| +func emailOperatorToken(t *testing.T, fx *consoleFixture, code string) string { |
| + t.Helper() |
| + if err := fx.st.AddMember(OpsAccountID, "ops@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + return cliToken(t, fx, "ops@keymail.dev", code) |
| +} |
| + |
| +func declareEmail(t *testing.T, fx *consoleFixture, token, app, from string) { |
| + t.Helper() |
| + status, out := postJSON(t, emailURL(fx, app), map[string]any{"from": from}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("declare %s = %d %v", app, status, out) |
| + } |
| +} |
| + |
| +func ensureEmailDomain(t *testing.T, fx *consoleFixture, token, app, domain string) map[string]any { |
| + t.Helper() |
| + status, out := postJSON(t, emailURL(fx, app)+"/domains", map[string]any{"domain": domain}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("ensure domain %s = %d %v", domain, status, out) |
| + } |
| + return out |
| +} |
| + |
| +func verifyEmailDomain(t *testing.T, fx *consoleFixture, token, app, domain string) { |
| + t.Helper() |
| + status, out := postJSON(t, emailURL(fx, app)+"/domains/"+domain+"/verify", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("verify domain %s = %d %v", domain, status, out) |
| + } |
| +} |
| + |
| +func enableEmail(t *testing.T, fx *consoleFixture, token, app string) map[string]any { |
| + t.Helper() |
| + status, out := postJSON(t, emailURL(fx, app)+"/enable", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("enable %s = %d %v", app, status, out) |
| + } |
| + return out |
| +} |
| + |
| +// provisionEmail runs the whole self-serve happy path — declare, ensure a |
| +// platform-suffixed domain, drive the fake to verified, verify, enable — |
| +// leaving app with a live, delivered credential. |
| +func provisionEmail(t *testing.T, fx *consoleFixture, fake *mail.MemProvisioner, token, app, domain, from string) { |
| + t.Helper() |
| + declareEmail(t, fx, token, app, from) |
| + fake.StatusOnEnsure = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} |
| + ensureEmailDomain(t, fx, token, app, domain) |
| + verifyEmailDomain(t, fx, token, app, domain) |
| + enableEmail(t, fx, token, app) |
| +} |
| + |
| +// pendingList reads the "pending" field of a domain-ensure reply — a list |
| +// of record OBJECTS (name/type/values/ttl), not plain strings, so it needs |
| +// its own reader rather than strList. |
| +func pendingList(t *testing.T, out map[string]any) []any { |
| + t.Helper() |
| + raw, ok := out["pending"].([]any) |
| + if !ok { |
| + t.Fatalf("pending = %#v, want a list", out["pending"]) |
| + } |
| + return raw |
| +} |
| + |
| +// emailRecord reads the record straight out of the bucket — the check that |
| +// a 200 actually landed, and that a refusal left nothing behind. |
| +func emailRecord(t *testing.T, fx *consoleFixture, app string) *mail.Record { |
| + t.Helper() |
| + rec, _, err := mail.Get(context.Background(), fx.rel, release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatalf("read email record: %v", err) |
| + } |
| + return rec |
| +} |
| + |
| +// ---- the gate --------------------------------------------------------------- |
| + |
| +// TestEmailDoorsAre404ForNonMembers: on all nine doors, a token that belongs |
| +// to no account, an account segment that doesn't exist, and an app the |
| +// account never claimed are one and the same 404 — byte-identical to |
| +// http.NotFound's own output. This runs on an UNARMED fixture (no |
| +// MailProvisioner, no secrets key) on purpose: the member gate must fire |
| +// before any of that is even consulted. |
| +func TestEmailDoorsAre404ForNonMembers(t *testing.T) { |
| + fx, token := emailFixture(t, "code-email-gate") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-email-gate-eve") |
| + |
| + doors := []struct { |
| + name string |
| + call func(base, bearer string) (int, string) |
| + }{ |
| + {"GET status", func(base, bearer string) (int, string) { return getRaw(t, base, bearer) }}, |
| + {"POST declare", func(base, bearer string) (int, string) { |
| + return postRaw(t, base, map[string]any{"from": "hello@example.com"}, bearer) |
| + }}, |
| + {"POST domains", func(base, bearer string) (int, string) { |
| + return postRaw(t, base+"/domains", map[string]any{"domain": "example.com"}, bearer) |
| + }}, |
| + {"POST domains verify", func(base, bearer string) (int, string) { |
| + return postRaw(t, base+"/domains/example.com/verify", map[string]any{}, bearer) |
| + }}, |
| + {"POST enable", func(base, bearer string) (int, string) { |
| + return postRaw(t, base+"/enable", map[string]any{}, bearer) |
| + }}, |
| + {"POST rotate", func(base, bearer string) (int, string) { |
| + return postRaw(t, base+"/rotate", map[string]any{}, bearer) |
| + }}, |
| + {"POST credentials", func(base, bearer string) (int, string) { |
| + return postRaw(t, base+"/credentials", map[string]any{"name": "laptop"}, bearer) |
| + }}, |
| + {"DELETE credentials", func(base, bearer string) (int, string) { |
| + status, _, body := rawReq(t, http.MethodDelete, base+"/credentials/laptop", bearer, "") |
| + return status, body |
| + }}, |
| + {"POST pause", func(base, bearer string) (int, string) { |
| + return postRaw(t, base+"/pause", map[string]any{"paused": true}, bearer) |
| + }}, |
| + } |
| + for _, d := range doors { |
| + t.Run(d.name, func(t *testing.T) { |
| + // (a) a member of nothing, against a real account and a real app. |
| + statusA, bodyA := d.call(emailURL(fx, "titogo"), eve) |
| + // (b) a member, against an account that does not exist. |
| + statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/email", token) |
| + // (c) a member of the account, against an app it never claimed. |
| + statusC, bodyC := d.call(emailURL(fx, "ghost-app"), token) |
| + if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| + t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC) |
| + } |
| + if bodyA != bodyB || bodyB != bodyC { |
| + t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC) |
| + } |
| + if bodyA != "404 page not found\n" { |
| + t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestEmailDoorsAre404WithNoProvisioner: a deployment with no SES estate |
| +// answers 404 on every door, never a panic on the nil interface and never |
| +// the 503 store's sibling doors give for a missing backend — see |
| +// apimail.go's file comment for why the two differ. |
| +func TestEmailDoorsAre404WithNoProvisioner(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-noprovisioner") |
| + opsToken := emailOperatorToken(t, fx, "code-email-noprovisioner-ops") |
| + fx.srv.cfg.MailProvisioner = nil |
| + |
| + calls := []struct { |
| + name string |
| + do func() (int, string) |
| + }{ |
| + {"GET status", func() (int, string) { return getRaw(t, emailURL(fx, "titogo"), token) }}, |
| + {"POST declare", func() (int, string) { |
| + return postRaw(t, emailURL(fx, "titogo"), map[string]any{"from": "hello@example.com"}, token) |
| + }}, |
| + {"POST domains", func() (int, string) { |
| + return postRaw(t, emailURL(fx, "titogo")+"/domains", map[string]any{"domain": "example.com"}, token) |
| + }}, |
| + {"POST domains verify", func() (int, string) { |
| + return postRaw(t, emailURL(fx, "titogo")+"/domains/example.com/verify", map[string]any{}, token) |
| + }}, |
| + {"POST enable", func() (int, string) { |
| + return postRaw(t, emailURL(fx, "titogo")+"/enable", map[string]any{}, token) |
| + }}, |
| + {"POST rotate", func() (int, string) { |
| + return postRaw(t, emailURL(fx, "titogo")+"/rotate", map[string]any{}, token) |
| + }}, |
| + {"POST credentials", func() (int, string) { |
| + return postRaw(t, emailURL(fx, "titogo")+"/credentials", map[string]any{"name": "laptop"}, token) |
| + }}, |
| + {"DELETE credentials", func() (int, string) { |
| + status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, "titogo")+"/credentials/laptop", token, "") |
| + return status, body |
| + }}, |
| + // The pause door needs an operator too — proving the 404 fires |
| + // AFTER operator authority is established, not in place of it. |
| + {"POST pause", func() (int, string) { |
| + return postRaw(t, emailURL(fx, "titogo")+"/pause", map[string]any{"paused": true}, opsToken) |
| + }}, |
| + } |
| + for _, c := range calls { |
| + t.Run(c.name, func(t *testing.T) { |
| + status, body := c.do() |
| + if status != http.StatusNotFound { |
| + t.Fatalf("%s with a nil provisioner = %d %q, want 404 (never a panic)", c.name, status, body) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// ---- declare and its changed-gating ------------------------------------ |
| + |
| +// TestAPIEmailDeclareWritesTheRecord: the happy path. From is required (a |
| +// bare env_prefix with no From is refused), the record lands in the |
| +// bucket, and declaring never bumps the serial — nothing a box reads has |
| +// changed. |
| +func TestAPIEmailDeclareWritesTheRecord(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-declare") |
| + app := "titogo" |
| + if rec := emailRecord(t, fx, app); rec != nil { |
| + t.Fatalf("a record exists before any declaration: %+v", rec) |
| + } |
| + |
| + status, out := postJSON(t, emailURL(fx, app), map[string]any{"from": "hello@titogo.example"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("declare = %d %v", status, out) |
| + } |
| + declared := block(t, out, "declared") |
| + if declared["from"] != "hello@titogo.example" || declared["by"] != "alice@keymail.dev" { |
| + t.Fatalf("declared = %v", declared) |
| + } |
| + if declared["env_prefix"] != mail.DefaultEnvPrefix { |
| + t.Fatalf("env_prefix = %v, want the default %q", declared["env_prefix"], mail.DefaultEnvPrefix) |
| + } |
| + if out["grant"] != nil || out["provisioned"] != nil { |
| + t.Fatalf("grant/provisioned = %v/%v, want null before enable", out["grant"], out["provisioned"]) |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after a declaration = %d, want 0 — declaring delivers nothing to a box", got) |
| + } |
| + auditedBy(t, fx, app, "email-declare", "alice@keymail.dev", "hello@titogo.example") |
| +} |
| + |
| +// TestAPIEmailDeclareRefusesAnUnaddressedFrom: a From with no "@" is refused |
| +// before anything is written. |
| +func TestAPIEmailDeclareRefusesAnUnaddressedFrom(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-declare-badfrom") |
| + status, out := postJSON(t, emailURL(fx, "titogo"), map[string]any{"from": "not-an-address"}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("declare with a bad From = %d %v, want 400", status, out) |
| + } |
| + if rec := emailRecord(t, fx, "titogo"); rec != nil { |
| + t.Fatalf("a refused declaration wrote a record: %+v", rec) |
| + } |
| +} |
| + |
| +// TestNoOpDeclareDoesNotBumpTheSerial: a re-declare of the SAME env prefix |
| +// and From writes no audit line — declaring never bumps the serial either |
| +// way, changed or not, but the audit trail must not be buried under a no-op |
| +// re-run. A declaration that DOES change something still audits. |
| +func TestNoOpDeclareDoesNotBumpTheSerial(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-noop") |
| + app := "titogo" |
| + body := map[string]any{"env_prefix": "TITOGO_SMTP", "from": "hello@titogo.example"} |
| + |
| + status, out := postJSON(t, emailURL(fx, app), body, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("declare = %d %v", status, out) |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after declare = %d, want 0", got) |
| + } |
| + before, err := fx.st.Events(OpsAccountID, app, 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // A no-op re-declare: identical prefix, identical From. |
| + status, out = postJSON(t, emailURL(fx, app), body, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("no-op re-declare = %d %v", status, out) |
| + } |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after a no-op re-declare = %d, want 0", got) |
| + } |
| + after, err := fx.st.Events(OpsAccountID, app, 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(after) != len(before) { |
| + t.Fatalf("event count = %d after a no-op re-declare, want unchanged from %d\nbefore=%+v\nafter=%+v", |
| + len(after), len(before), before, after) |
| + } |
| + |
| + // A genuinely changed re-declare DOES audit — the gating is on the |
| + // content, not a blanket "never audit a re-declare". |
| + body["from"] = "other@titogo.example" |
| + status, out = postJSON(t, emailURL(fx, app), body, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("changed re-declare = %d %v", status, out) |
| + } |
| + changed, err := fx.st.Events(OpsAccountID, app, 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(changed) != len(after)+1 { |
| + t.Fatalf("event count = %d after a changed re-declare, want %d", len(changed), len(after)+1) |
| + } |
| +} |
| + |
| +// ---- domains -------------------------------------------------------------- |
| + |
| +// TestAPIEmailDomainEnsurePublishesForAPlatformDomain: a domain under this |
| +// deployment's own suffix is auto-published — pending is empty, and the |
| +// records actually landed in the fake DNS writer. |
| +func TestAPIEmailDomainEnsurePublishesForAPlatformDomain(t *testing.T) { |
| + fx, _, writer, token := emailArmed(t, "code-email-domain-platform") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + declareEmail(t, fx, token, app, "hello@"+domain) |
| + |
| + out := ensureEmailDomain(t, fx, token, app, domain) |
| + dom := block(t, out, "domain") |
| + if dom["kind"] != mail.KindPlatform { |
| + t.Fatalf("kind = %v, want platform", dom["kind"]) |
| + } |
| + if pending := pendingList(t, out); len(pending) != 0 { |
| + t.Fatalf("pending = %v, want empty — a platform domain publishes itself", pending) |
| + } |
| + if len(writer.Records) == 0 { |
| + t.Fatal("no records landed in the DNS writer for a platform domain") |
| + } |
| + auditedBy(t, fx, app, "email-domain-ensure", "alice@keymail.dev", domain) |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after ensuring a domain = %d, want 0 — nothing in the app's own config changed", got) |
| + } |
| +} |
| + |
| +// TestAPIEmailDomainEnsureReturnsPendingForACustomDomain: a domain outside |
| +// this deployment's suffix is never auto-published — the member gets the |
| +// record set back to publish by hand, and nothing lands in the DNS writer. |
| +func TestAPIEmailDomainEnsureReturnsPendingForACustomDomain(t *testing.T) { |
| + fx, _, writer, token := emailArmed(t, "code-email-domain-custom") |
| + app, domain := "titogo", "mail.example.com" |
| + declareEmail(t, fx, token, app, "hello@"+domain) |
| + |
| + out := ensureEmailDomain(t, fx, token, app, domain) |
| + dom := block(t, out, "domain") |
| + if dom["kind"] != mail.KindCustom { |
| + t.Fatalf("kind = %v, want custom", dom["kind"]) |
| + } |
| + if pending := pendingList(t, out); len(pending) == 0 { |
| + t.Fatal("pending is empty for a custom domain — the member has nothing to publish") |
| + } |
| + if len(writer.Records) != 0 { |
| + t.Fatalf("records landed in the DNS writer for a CUSTOM domain: %v", writer.Records) |
| + } |
| +} |
| + |
| +// TestAPIEmailDomainEnsureWithNilDNSWriterDegradesToCustom proves |
| +// mailDeploymentFor's adaptation: a deployment with no DNSWriter must not |
| +// panic on a platform-suffixed domain — it has to hand back pending records |
| +// instead, exactly Config.DNSWriter's own documented contract. |
| +func TestAPIEmailDomainEnsureWithNilDNSWriterDegradesToCustom(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-domain-nowriter") |
| + fx.srv.cfg.DNSWriter = nil |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" // under the platform suffix |
| + declareEmail(t, fx, token, app, "hello@"+domain) |
| + |
| + out := ensureEmailDomain(t, fx, token, app, domain) |
| + dom := block(t, out, "domain") |
| + if dom["kind"] != mail.KindCustom { |
| + t.Fatalf("kind = %v, want custom (no DNSWriter degrades a platform domain to custom)", dom["kind"]) |
| + } |
| + if pending := pendingList(t, out); len(pending) == 0 { |
| + t.Fatal("pending is empty — a platform domain with no DNSWriter must still hand back records") |
| + } |
| +} |
| + |
| +// TestAPIEmailDomainVerifyRequiresAnEnsuredDomain: verifying a domain that |
| +// was never ensured is a speaking 404, not a crash on a missing region. |
| +func TestAPIEmailDomainVerifyRequiresAnEnsuredDomain(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-verify-missing") |
| + status, out := postJSON(t, emailURL(fx, "titogo")+"/domains/never-ensured.example.com/verify", map[string]any{}, token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("verify an unensured domain = %d %v, want 404", status, out) |
| + } |
| +} |
| + |
| +// TestAPIEmailDomainVerifyRecordsSESVerdict: driving the fake to verified |
| +// and calling verify moves the domain's region to StatusVerified. |
| +func TestAPIEmailDomainVerifyRecordsSESVerdict(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-verify") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + declareEmail(t, fx, token, app, "hello@"+domain) |
| + fake.StatusOnEnsure = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} |
| + ensureEmailDomain(t, fx, token, app, domain) |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/domains/"+domain+"/verify", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("verify = %d %v", status, out) |
| + } |
| + dom := block(t, out, "domain") |
| + regions, _ := dom["regions"].(map[string]any) |
| + region, _ := regions["eu-west-1"].(map[string]any) |
| + if region["status"] != mail.StatusVerified { |
| + t.Fatalf("region status = %v, want verified", region["status"]) |
| + } |
| + auditedBy(t, fx, app, "email-domain-verify", "alice@keymail.dev", "verified") |
| +} |
| + |
| +// ---- enable and the F2 grant wiring ---------------------------------------- |
| + |
| +// TestEnableAppliesTheTierGrantBeforeProvisioning: F2's ruling, pinned. The |
| +// enable door must leave a `grant` block on the record naming the tier's |
| +// cap — without it the limiter silently runs the app on the default free |
| +// cap with no record of why. |
| +func TestEnableAppliesTheTierGrantBeforeProvisioning(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-grant") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + declareEmail(t, fx, token, app, "hello@"+domain) |
| + fake.StatusOnEnsure = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"} |
| + ensureEmailDomain(t, fx, token, app, domain) |
| + verifyEmailDomain(t, fx, token, app, domain) |
| + |
| + out := enableEmail(t, fx, token, app) |
| + grant := block(t, out, "grant") |
| + if grant["daily_cap"] != float64(TierCap("free")) { |
| + t.Fatalf("grant.daily_cap = %v, want %d", grant["daily_cap"], TierCap("free")) |
| + } |
| + if grant["source"] != "tier:free" { |
| + t.Fatalf("grant.source = %v, want tier:free", grant["source"]) |
| + } |
| + if out["daily_cap"] != float64(TierCap("free")) { |
| + t.Fatalf("daily_cap = %v, want %d", out["daily_cap"], TierCap("free")) |
| + } |
| + |
| + rec := emailRecord(t, fx, app) |
| + if rec.Grant == nil { |
| + t.Fatal("record has no grant block after enable") |
| + } |
| + if rec.Grant.DailyCap != TierCap("free") || rec.Grant.Source != "tier:free" { |
| + t.Fatalf("record grant = %+v, want cap %d source tier:free", rec.Grant, TierCap("free")) |
| + } |
| + if rec.Provisioned == nil || rec.Provisioned.KeyID == "" { |
| + t.Fatalf("record provisioned = %+v, want a delivered credential", rec.Provisioned) |
| + } |
| + auditedBy(t, fx, app, "email-enable", "alice@keymail.dev", "enabled") |
| + if got := serial(t, fx); got == 0 { |
| + t.Fatal("serial did not bump after enable delivered config") |
| + } |
| +} |
| + |
| +// TestAPIEmailEnableRequiresAVerifiedDomain: enabling before any domain has |
| +// verified is refused, not silently short-circuited. |
| +func TestAPIEmailEnableRequiresAVerifiedDomain(t *testing.T) { |
| + fx, _, _, token := emailArmed(t, "code-email-enable-nodomain") |
| + app := "titogo" |
| + declareEmail(t, fx, token, app, "hello@titogo.bab.oncarlos.com") |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/enable", map[string]any{}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("enable with no verified domain = %d %v, want 409", status, out) |
| + } |
| +} |
| + |
| +// ---- rotate ----------------------------------------------------------------- |
| + |
| +// TestAPIEmailRotateThenFinish: rotate leaves both keys live |
| +// (pending_finish true) and delivers a new key; finish closes the window |
| +// and bumps no serial. |
| +func TestAPIEmailRotateThenFinish(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-rotate") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + firstKey := emailRecord(t, fx, app).Provisioned.KeyID |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/rotate", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("rotate = %d %v", status, out) |
| + } |
| + if out["pending_finish"] != true { |
| + t.Fatalf("pending_finish = %v, want true right after a rotate", out["pending_finish"]) |
| + } |
| + rec := emailRecord(t, fx, app) |
| + if rec.Provisioned.KeyID == firstKey { |
| + t.Fatal("rotate did not mint a new key") |
| + } |
| + if rec.Provisioned.PrevKeyID != firstKey { |
| + t.Fatalf("PrevKeyID = %q, want %q", rec.Provisioned.PrevKeyID, firstKey) |
| + } |
| + serialAfterRotate := serial(t, fx) |
| + if serialAfterRotate == 0 { |
| + t.Fatal("serial did not bump after rotate delivered a new key") |
| + } |
| + |
| + status, out = postJSON(t, emailURL(fx, app)+"/rotate?finish=1", map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("rotate finish = %d %v", status, out) |
| + } |
| + if out["pending_finish"] != false { |
| + t.Fatalf("pending_finish = %v, want false after finish", out["pending_finish"]) |
| + } |
| + if got := serial(t, fx); got != serialAfterRotate { |
| + t.Fatalf("serial after finish = %d, want unchanged from %d — finish delivers nothing", got, serialAfterRotate) |
| + } |
| +} |
| + |
| +// ---- standalone credentials ------------------------------------------------- |
| + |
| +// TestStandaloneResponseIsNoStoreAndTheOnlyOneWithAPassword: the credential |
| +// create reply is uncacheable and the only reply in this file carrying a |
| +// password. Every sibling reply in the same flow is checked and must not. |
| +func TestStandaloneResponseIsNoStoreAndTheOnlyOneWithAPassword(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-standalone") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + |
| + status, headers, body := rawReq(t, http.MethodPost, emailURL(fx, app)+"/credentials", token, `{"name":"laptop"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("credentials create = %d %s", status, body) |
| + } |
| + if got := headers.Get("Cache-Control"); got != "no-store" { |
| + t.Fatalf("Cache-Control = %q, want no-store", got) |
| + } |
| + var out map[string]any |
| + if err := json.Unmarshal([]byte(body), &out); err != nil { |
| + t.Fatalf("body is not JSON: %v\n%s", err, body) |
| + } |
| + pass, _ := out["password"].(string) |
| + if pass == "" { |
| + t.Fatalf("password = %q, want a real credential", pass) |
| + } |
| + user, _ := out["username"].(string) |
| + if user == "" { |
| + t.Fatal("username (the key id) is empty") |
| + } |
| + |
| + // No sibling reply in this same flow ever carries the substring |
| + // "password" — declare, status, enable, rotate. |
| + _, declareBody := postRaw(t, emailURL(fx, app), map[string]any{"from": "hello@" + domain}, token) |
| + if strings.Contains(declareBody, "password") { |
| + t.Fatalf("declare reply carries a password: %s", declareBody) |
| + } |
| + _, statusBody := getRaw(t, emailURL(fx, app), token) |
| + if strings.Contains(statusBody, "password") { |
| + t.Fatalf("status reply carries a password: %s", statusBody) |
| + } |
| + _, enableBody := postRaw(t, emailURL(fx, app)+"/enable", map[string]any{}, token) |
| + if strings.Contains(enableBody, "password") { |
| + t.Fatalf("enable reply carries a password: %s", enableBody) |
| + } |
| + _, rotateBody := postRaw(t, emailURL(fx, app)+"/rotate", map[string]any{}, token) |
| + if strings.Contains(rotateBody, "password") { |
| + t.Fatalf("rotate reply carries a password: %s", rotateBody) |
| + } |
| +} |
| + |
| +// TestStatusReplyNeverCarriesAPassword: a dedicated check on the status |
| +// door specifically, with a standalone credential already minted (the one |
| +// place a password could leak from if rec.Standalone were ever echoed |
| +// wrong) — key_id is expected and fine; password is not. |
| +func TestStatusReplyNeverCarriesAPassword(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-status-password") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + if status, out := postJSON(t, emailURL(fx, app)+"/credentials", map[string]any{"name": "laptop"}, token); status != http.StatusOK { |
| + t.Fatalf("credentials create = %d %v", status, out) |
| + } |
| + |
| + status, body := getRaw(t, emailURL(fx, app), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %s", status, body) |
| + } |
| + if strings.Contains(body, "password") { |
| + t.Fatalf("status reply carries a password: %s", body) |
| + } |
| + var out map[string]any |
| + if err := json.Unmarshal([]byte(body), &out); err != nil { |
| + t.Fatalf("body is not JSON: %v", err) |
| + } |
| + prov := block(t, out, "provisioned") |
| + if s, _ := prov["key_id"].(string); s == "" { |
| + t.Fatalf("provisioned.key_id = %v, want the delivered key id (not a secret)", prov["key_id"]) |
| + } |
| + standalone, _ := out["standalone"].([]any) |
| + if len(standalone) != 1 { |
| + t.Fatalf("standalone = %v, want the one minted credential", out["standalone"]) |
| + } |
| +} |
| + |
| +// TestAPIEmailCredentialsRevokeUnknownNameIs404: revoking a name that was |
| +// never minted is a speaking 404, not a silent no-op 200. |
| +func TestAPIEmailCredentialsRevokeUnknownNameIs404(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-revoke-missing") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + |
| + status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, app)+"/credentials/never-minted", token, "") |
| + if status != http.StatusNotFound { |
| + t.Fatalf("revoke an unminted credential = %d %s, want 404", status, body) |
| + } |
| +} |
| + |
| +// TestAPIEmailCredentialsRevokeRemovesIt: revoke actually deletes the |
| +// credential and does not disturb the delivered one. |
| +func TestAPIEmailCredentialsRevokeRemovesIt(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-revoke") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + deliveredKey := emailRecord(t, fx, app).Provisioned.KeyID |
| + if status, out := postJSON(t, emailURL(fx, app)+"/credentials", map[string]any{"name": "laptop"}, token); status != http.StatusOK { |
| + t.Fatalf("credentials create = %d %v", status, out) |
| + } |
| + |
| + status, _, body := rawReq(t, http.MethodDelete, emailURL(fx, app)+"/credentials/laptop", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("revoke = %d %s", status, body) |
| + } |
| + rec := emailRecord(t, fx, app) |
| + if len(rec.Standalone) != 0 { |
| + t.Fatalf("standalone = %+v, want empty after revoke", rec.Standalone) |
| + } |
| + if rec.Provisioned.KeyID != deliveredKey { |
| + t.Fatal("revoking a standalone credential disturbed the delivered one") |
| + } |
| +} |
| + |
| +// ---- pause (operator) ------------------------------------------------------- |
| + |
| +// TestAPIEmailPauseNeedsAnOperator: a member of the account — even the one |
| +// who declared and enabled — cannot pause or raise a cap. The refusal |
| +// names CARLOS_MAIL_OPERATORS, and nothing is changed. |
| +func TestAPIEmailPauseNeedsAnOperator(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-pause-operator") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("pause by a non-operator member = %d %v, want 403", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_MAIL_OPERATORS") { |
| + t.Fatalf("error = %q, want the operator list named", msg) |
| + } |
| + rec := emailRecord(t, fx, app) |
| + if !rec.Provisioned.Active { |
| + t.Fatal("a refused pause changed the record's active state") |
| + } |
| +} |
| + |
| +// TestAPIEmailPauseTogglesTheKeyAndRaisesTheCap: an operator can pause, |
| +// resume and raise the cap through the same door. Raising the cap applies |
| +// mail.ApplyGrant with source "operator" (F2), and that grant SURVIVES a |
| +// later tier-sourced enable — proving the wiring passes the right source |
| +// rather than re-implementing ApplyGrant's own precedence rule. |
| +func TestAPIEmailPauseTogglesTheKeyAndRaisesTheCap(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-pause") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + ops := emailOperatorToken(t, fx, "code-email-pause-ops") |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops) |
| + if status != http.StatusOK { |
| + t.Fatalf("pause = %d %v", status, out) |
| + } |
| + if out["active"] != false { |
| + t.Fatalf("active = %v, want false after a pause", out["active"]) |
| + } |
| + rec := emailRecord(t, fx, app) |
| + if rec.Provisioned.Active { |
| + t.Fatal("record still active after a pause") |
| + } |
| + auditedBy(t, fx, app, "email-pause", "ops@keymail.dev", "paused") |
| + |
| + status, out = postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": false, "daily_cap": 5000}, ops) |
| + if status != http.StatusOK { |
| + t.Fatalf("resume+raise = %d %v", status, out) |
| + } |
| + if out["active"] != true { |
| + t.Fatalf("active = %v, want true after a resume", out["active"]) |
| + } |
| + grant := block(t, out, "grant") |
| + if grant["daily_cap"] != float64(5000) || grant["source"] != "operator" { |
| + t.Fatalf("grant = %v, want cap 5000 source operator", grant) |
| + } |
| + |
| + // A later enable's TIER-sourced grant must not silently flatten the |
| + // operator's raise back to the free cap. |
| + enableEmail(t, fx, token, app) |
| + rec = emailRecord(t, fx, app) |
| + if rec.Grant.Source != "operator" || rec.Grant.DailyCap != 5000 { |
| + t.Fatalf("grant after a later enable = %+v, want the operator's raise to survive", rec.Grant) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 1299d112f..b6ef86fe8 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -528,6 +528,19 @@ type Config struct { |
| // MailDeployment carries the region, the platform suffix, the DMARC rua |
| // and the SNS topic. |
| MailDeployment mail.Deployment |
| + // MailOperators are the addresses allowed to drive the email pause/cap |
| + // door (CARLOS_MAIL_OPERATORS, apimail.go): pausing or resuming a |
| + // tenant's sending, and raising its daily cap above what its tier |
| + // grants automatically. Its own list rather than StoreOperators' or |
| + // console ownership, for the same reason StoreOperators is its own |
| + // list (that field's doc comment): the authority here is specific to |
| + // this deployment's shared SENDING REPUTATION, not to spending its |
| + // cloud bill, and the two should be grantable to different people. |
| + // Empty is fail-closed — nobody may pause, resume or raise a cap |
| + // through this door. Automatic abuse enforcement (mailcounts.go) is |
| + // unaffected either way: it calls mail.SetPaused directly, never |
| + // through this door. |
| + MailOperators []string |
| } |
| // Server is the console's http.Handler. |
| @@ -729,6 +742,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // CLI identity: the device-code login `carlos auth login` drives, the |
| // approval page, and the tokens an owner can see and kill (authcli.go). |
| s.registerCLIRoutes() |
| + // The email API's nine doors (apimail.go, tenant-email task 17) — |
| + // registered separately from registerCLIRoutes because this task's file |
| + // scope is server.go and apimail.go only. |
| + s.registerEmailRoutes() |
| // Account-scoped and explicit: /a/<sqid>/apps/<app> rather than |
| // account-by-session, so a link a teammate opens lands where you meant. |
| s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome) |