carlos / platform Public

carlos email: the member surface

One command declares, provisions, publishes, waits for verification and
delivers. A verification timeout exits non-zero naming the axis still
pending — exiting zero on an unverified domain is exactly the failure the
no-infra rule exists to prevent. carlos email test sends a real message,
because the command that claims email works should be the one that proves
it.

Verbs: enable, domains add, status, test, credentials create/list/revoke,
rotate, pause/resume — wired via main.go's case "email" (the brief named
cli.go, but the top-level verb switch actually lives in main.go).
Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9726851d1dfaa3e73bcc5aaa25a219b430b27037 parent ef08b68
3 files changed, +1952 −0
  • mail_cli.go +1191 −0
  • mail_cli_test.go +757 −0
  • main.go +4 −0
diff --git a/mail_cli.go b/mail_cli.go
new file mode 100644
index 000000000..12f72ab8a
--- /dev/null
+++ b/mail_cli.go
@@ -0,0 +1,1191 @@
+// mail_cli.go is `carlos email`: the member surface for tenant email sending
+// (tenant-email spec, task 18). It calls the nine doors internal/console/
+// apimail.go added in task 17 — API mode only, store.go's sibling and its
+// pattern: a package-level flag family, run* verbs that resolve a session
+// and print* helpers that take an io.Writer so the honesty rules below can
+// be pinned without a subprocess.
+//
+// THE STANDING RULE (CLAUDE.md): every carlos command completes AND
+// verifies its own job with no box access and no AWS console visit. Three
+// places in this file exist because of it, not despite it:
+//
+// - `enable` and `domains add` do not return the moment SES accepts a
+// domain — they POLL verification until SES actually says "verified",
+// or a -timeout expires. A member who ran this command and got exit 0
+// must be able to trust that email works; finding out otherwise from a
+// bounced sign-in link is exactly the failure the rule exists to
+// prevent, so a timeout is log.Fatal, never a quiet "started
+// provisioning" message.
+// - `test` sends a REAL message. Every other verb here only reports what
+// the console's own record says; test is the one command that proves
+// the estate can actually deliver, which is why it exists at all
+// despite `enable` already having "worked".
+// - This file never reads a delivered credential's password back out —
+// it cannot: apimail.go's own doc comment says the delivered secret is
+// sealed into the app's config and never returned again after Enable.
+// `test` therefore mints a throwaway standalone credential (the one
+// reply in this whole API family that carries a password — see
+// handleAPIEmailCredentialsCreate's doc comment), sends through it, and
+// revokes it immediately after, success or failure.
+//
+// WIRE TYPES ARE HAND-ROLLED, deliberately not internal/mail's own structs
+// — store.go's decoupling: this file speaks only the JSON the console's
+// door actually promises, the same discipline a real external client would
+// need.
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/tls"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/smtp"
+ "os"
+ "sort"
+ "strings"
+ "text/tabwriter"
+ "time"
+)
+
+const emailUsage = `usage: carlos email <enable|domains|status|test|credentials|rotate|pause|resume> [flags]
+
+ enable -app <a> [-env-prefix <PREFIX>] [-from <addr>] [-domain <d>] [-timeout <dur>]
+ declare, ensure a sending domain, wait for it to
+ verify, then enable delivery — the one-command
+ path. -domain defaults to the domain half of
+ -from; -from defaults to noreply@<domain> when
+ only -domain is given. On a custom domain, the
+ record set to publish is printed once, then this
+ keeps polling (default 10m, -timeout to change)
+ until SES confirms it or the wait gives up — a
+ gave-up wait exits non-zero naming what SES has
+ not confirmed (DKIM, MAIL FROM, or sending).
+ domains add -app <a> [-timeout <dur>] <domain>
+ the domain half of enable on its own: ensure a
+ sending domain for an app already declared, print
+ the record set on a custom domain, and poll to
+ verified the same way.
+ status -app <a> domains and their per-region verification state,
+ today's counts against the cap, and the pause
+ state.
+ test -app <a> -to <addr>
+ send a REAL message and report the result — the
+ verify half of the no-infra rule: this is the
+ command that proves email actually works, not
+ just that the console says it should. Sends
+ through a throwaway standalone credential, minted
+ and revoked around the send; it never touches the
+ delivered credential's password, which this CLI
+ cannot read back out.
+ credentials create -app <a> -name <n>
+ mint a standalone credential for something not
+ running on CARLOS — a laptop, a cron box. Shown
+ ONCE, on this command's stdout, and nowhere else.
+ credentials list -app <a>
+ the standalone credentials that exist — names and
+ key ids, never a password (status carries this
+ too; a memorable second entry point for it).
+ credentials revoke -app <a> -name <n>
+ delete one standalone credential and its own IAM
+ principal.
+ rotate -app <a> [-finish]
+ mint a fresh delivered credential, leaving the old
+ one live until -finish deletes it.
+ pause -app <a> stop this app's sending — operator only.
+ resume -app <a> resume it.
+
+ -account <sqid|name> account the app belongs to (default: your only
+ account, or the configured/chosen one).
+ -account-id <sqid> account sqid, matched as a sqid ONLY.
+ -console <origin> which logged-in console to use (only needed with
+ several).
+
+Signed-in only (carlos auth login): email lives in the console's own
+deployment bucket and every verb here calls out to it; there is no
+direct-bucket equivalent.
+`
+
+// runEmail dispatches the email sub-verbs, mirroring runStore's arg slicing.
+func runEmail(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, emailUsage)
+ os.Exit(1)
+ }
+ switch args[0] {
+ case "enable":
+ runEmailEnable(args[1:])
+ case "domains":
+ runEmailDomains(args[1:])
+ case "status":
+ runEmailStatus(args[1:])
+ case "test":
+ runEmailTest(args[1:])
+ case "credentials":
+ runEmailCredentials(args[1:])
+ case "rotate":
+ runEmailRotate(args[1:])
+ case "pause":
+ runEmailPause(args[1:], true)
+ case "resume":
+ runEmailPause(args[1:], false)
+ default:
+ fmt.Fprint(os.Stderr, emailUsage)
+ os.Exit(1)
+ }
+}
+
+// requireMailAPISession is requireStoreAPISession's exact pattern, for the
+// exact same reason: email has no direct-bucket equivalent (the record is a
+// bucket object only the console can reach, and enable/domains ensure both
+// call out to the deployment's own SES estate).
+func requireMailAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos email works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). email is a member/operator verb with\n" +
+ "no direct-bucket equivalent — the record lives in the console's deployment bucket, and enabling\n" +
+ "or adding a domain calls out to the deployment's own SES estate, an authority only the console\n" +
+ "can exercise. Unset the store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
+
+// ---- wire types --------------------------------------------------------------
+
+type apiEmailDeclared struct {
+ EnvPrefix string `json:"env_prefix"`
+ From string `json:"from"`
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type apiEmailGrant struct {
+ Domains []string `json:"domains"`
+ DailyCap int `json:"daily_cap"`
+ Source string `json:"source"`
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type apiEmailProvisioned struct {
+ IAMUser string `json:"iam_user"`
+ UserARN string `json:"user_arn"`
+ KeyID string `json:"key_id"`
+ PrevKeyID string `json:"prev_key_id,omitempty"`
+ Active bool `json:"active"`
+ At time.Time `json:"at"`
+}
+
+type apiEmailStandalone struct {
+ Name string `json:"name"`
+ IAMUser string `json:"iam_user"`
+ KeyID string `json:"key_id"`
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type apiEmailRegionState struct {
+ IdentityARN string `json:"identity_arn"`
+ DKIMTokens []string `json:"dkim_tokens"`
+ MailFrom string `json:"mail_from"`
+ ConfigSet string `json:"config_set"`
+ Status string `json:"status"` // "pending" | "verified" | "failed"
+ CheckedAt time.Time `json:"checked_at"`
+}
+
+type apiEmailDomain struct {
+ Schema int `json:"schema"`
+ Domain string `json:"domain"`
+ Kind string `json:"kind"` // "platform" | "custom"
+ Apps []string `json:"apps"`
+ Regions map[string]*apiEmailRegionState `json:"regions"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type apiEmailCounts struct {
+ Sends int `json:"sends"`
+ Bounces int `json:"bounces"`
+ Complaints int `json:"complaints"`
+ Rejects int `json:"rejects"`
+}
+
+// apiEmailReply is the shape every declare/status/enable/rotate/pause door
+// answers with — emailReply's wire contract (internal/console/apimail.go).
+type apiEmailReply struct {
+ App string `json:"app"`
+ Declared *apiEmailDeclared `json:"declared"`
+ Grant *apiEmailGrant `json:"grant"`
+ Provisioned *apiEmailProvisioned `json:"provisioned"`
+ Standalone []apiEmailStandalone `json:"standalone"`
+ Domains []apiEmailDomain `json:"domains"`
+ EnvNames []string `json:"env_names"`
+ PendingFinish bool `json:"pending_finish"`
+ MissingVars []string `json:"missing_vars"`
+ DailyCap int `json:"daily_cap"`
+ Active bool `json:"active"`
+ Counts apiEmailCounts `json:"counts"`
+}
+
+type apiDNSRecord struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Values []string `json:"values"`
+ TTL int64 `json:"ttl"`
+}
+
+type apiEmailDomainEnsureReply struct {
+ Domain apiEmailDomain `json:"domain"`
+ Pending []apiDNSRecord `json:"pending"`
+}
+
+type apiEmailDomainVerifyReply struct {
+ Domain apiEmailDomain `json:"domain"`
+}
+
+// apiEmailCredsReply is handleAPIEmailCredentialsCreate's reply — THE ONLY
+// one anywhere in this file that carries a password.
+type apiEmailCredsReply struct {
+ App string `json:"app"`
+ Name string `json:"name"`
+ Host string `json:"host"`
+ Port string `json:"port"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ From string `json:"from"`
+}
+
+type apiEmailRevokeReply struct {
+ App string `json:"app"`
+ Name string `json:"name"`
+ Revoked bool `json:"revoked"`
+}
+
+// apiMailError wraps a non-200 email door reply, preserving the STATUS
+// alongside apiErrorFromBody's message — every other apiSession family in
+// this binary discards the status once it has picked a message, which is
+// fine when every refusal is equally worth retrying. Two of email's
+// refusals are not (a claimed domain, 409; a platform name this account
+// does not own, 400 — both facts about identity, not state a retry could
+// find different), and a caller needs the status to tell them apart from
+// an ordinary transient failure without pattern-matching the message text.
+type apiMailError struct {
+ status int
+ err error
+}
+
+func (e *apiMailError) Error() string { return e.err.Error() }
+func (e *apiMailError) Unwrap() error { return e.err }
+
+// permanent reports whether retrying this call cannot change the outcome.
+func (e *apiMailError) permanent() bool {
+ return e.status == http.StatusConflict || e.status == http.StatusBadRequest
+}
+
+func mailAPIError(status int, body []byte) error {
+ return &apiMailError{status: status, err: apiErrorFromBody(status, body)}
+}
+
+// isEmailNotDeclared reports whether err is the 404 handleAPIEmailStatus
+// gives an app with no email record — a normal, expected state for
+// `carlos email enable`'s own first read, never a failure to surface.
+func isEmailNotDeclared(err error) bool {
+ var me *apiMailError
+ return errors.As(err, &me) && me.status == http.StatusNotFound
+}
+
+// describeDomainEnsureErr renders EnsureDomain's refusal for a human,
+// naming the PERMANENT ones (409 claimed, 400 not-owned) without a shred of
+// retry language — the exact mistake the brief calls out. Every other
+// error passes through apiErrorFromBody's own text unchanged.
+func describeDomainEnsureErr(domain string, err error) error {
+ var me *apiMailError
+ if errors.As(err, &me) && me.permanent() {
+ return fmt.Errorf("%s: %s — permanent: nothing about running this command again would change the "+
+ "outcome; choose a different domain", domain, me.Error())
+ }
+ return fmt.Errorf("ensuring domain %s: %w", domain, err)
+}
+
+// ---- apiSession wire calls ----------------------------------------------
+
+func (s *apiSession) emailDeclare(ctx context.Context, a apiAcct, app, envPrefix, from string) (apiEmailReply, error) {
+ in := struct {
+ EnvPrefix string `json:"env_prefix,omitempty"`
+ From string `json:"from"`
+ }{envPrefix, from}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/email"), in)
+ if err != nil {
+ return apiEmailReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailReply{}, fmt.Errorf("email declare: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailStatus(ctx context.Context, a apiAcct, app string) (apiEmailReply, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/email"))
+ if err != nil {
+ return apiEmailReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailReply{}, fmt.Errorf("email status: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailDomainEnsure(ctx context.Context, a apiAcct, app, domain string) (apiEmailDomainEnsureReply, error) {
+ in := struct {
+ Domain string `json:"domain"`
+ }{domain}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/domains"), in)
+ if err != nil {
+ return apiEmailDomainEnsureReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailDomainEnsureReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailDomainEnsureReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailDomainEnsureReply{}, fmt.Errorf("email domains add: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailDomainVerify(ctx context.Context, a apiAcct, app, domain string) (apiEmailDomainVerifyReply, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/domains/"+pathEscape(domain)+"/verify"), struct{}{})
+ if err != nil {
+ return apiEmailDomainVerifyReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailDomainVerifyReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailDomainVerifyReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailDomainVerifyReply{}, fmt.Errorf("email domains verify: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailEnable(ctx context.Context, a apiAcct, app string) (apiEmailReply, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/enable"), struct{}{})
+ if err != nil {
+ return apiEmailReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailReply{}, fmt.Errorf("email enable: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailRotate(ctx context.Context, a apiAcct, app string, finish bool) (apiEmailReply, error) {
+ tail := "/email/rotate"
+ if finish {
+ tail = "/email/rotate?finish=1"
+ }
+ status, body, err := s.postJSON(ctx, apiPath(a, app, tail), struct{}{})
+ if err != nil {
+ return apiEmailReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailReply{}, fmt.Errorf("email rotate: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailCredentialsCreate(ctx context.Context, a apiAcct, app, name string) (apiEmailCredsReply, error) {
+ in := struct {
+ Name string `json:"name"`
+ }{name}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/credentials"), in)
+ if err != nil {
+ return apiEmailCredsReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailCredsReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailCredsReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailCredsReply{}, fmt.Errorf("email credentials create: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailCredentialsRevoke(ctx context.Context, a apiAcct, app, name string) (apiEmailRevokeReply, error) {
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/email/credentials/"+pathEscape(name)))
+ if err != nil {
+ return apiEmailRevokeReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailRevokeReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailRevokeReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailRevokeReply{}, fmt.Errorf("email credentials revoke: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) emailPause(ctx context.Context, a apiAcct, app string, paused bool) (apiEmailReply, error) {
+ in := struct {
+ Paused *bool `json:"paused"`
+ }{&paused}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/pause"), in)
+ if err != nil {
+ return apiEmailReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiEmailReply{}, mailAPIError(status, body)
+ }
+ var out apiEmailReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiEmailReply{}, fmt.Errorf("email pause: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// pathEscape is url.PathEscape, named locally so this file does not need to
+// import net/url just for one call site each in domain verify and
+// credentials revoke.
+func pathEscape(s string) string {
+ // PathEscape escapes '/' too, but neither a domain nor a credential name
+ // (validStandaloneName's charset) can contain one, so a simple percent
+ // escape of the reserved set below is exactly equivalent and keeps the
+ // import list one package shorter.
+ var b strings.Builder
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.', r == '_', r == '~':
+ b.WriteRune(r)
+ default:
+ fmt.Fprintf(&b, "%%%02X", r)
+ }
+ }
+ return b.String()
+}
+
+// ---- domain verification wait --------------------------------------------
+
+// domainVerifyInterval bounds polling frequency: the domain-verify door is
+// rate-limited at 30 writes/minute per address (cliWriteBurst,
+// authcli.go), shared with declare and domain-ensure's own calls in the
+// same run. 5s keeps a full -timeout worth of polling (up to 120 calls over
+// the default 10m) comfortably under that budget every window, with room
+// left for the other calls this same invocation makes.
+const domainVerifyInterval = 5 * time.Second
+
+// domainStatus collapses a domain's per-region states into one word: SES's
+// own authority is per-region, but v1 is single-region (spec) and a member
+// waiting on `carlos email enable` cares about "can this app send," not
+// which region answered — verified wins if any region has it, failed wins
+// over pending otherwise.
+func domainStatus(d apiEmailDomain) string {
+ failed := false
+ for _, st := range d.Regions {
+ if st == nil {
+ continue
+ }
+ if st.Status == "verified" {
+ return "verified"
+ }
+ if st.Status == "failed" {
+ failed = true
+ }
+ }
+ if failed {
+ return "failed"
+ }
+ return "pending"
+}
+
+// waitForDomainVerified polls verify until domain's identity is verified,
+// SES reports it failed, or timeout expires.
+//
+// A timeout or a failure both return a NON-NIL error — the run* wrapper's
+// log.Fatal on that error is what makes `carlos email enable` exit
+// non-zero on an unverified domain, the exact failure mode the no-infra
+// rule exists to prevent (CLAUDE.md): exiting 0 here would tell a member
+// email works when nobody has confirmed DKIM, MAIL FROM or sending are
+// live, and the first sign they would actually get is a bounce.
+//
+// The pending-timeout message names all three axes SES tracks (DKIM, MAIL
+// FROM, sending — provisioner.go's Status struct) rather than pointing at
+// one: the domain record this CLI reads only carries the COLLAPSED
+// per-region status (apiEmailRegionState.Status), not which individual
+// axis is still short of SUCCESS, so naming one specific axis here would be
+// a guess dressed up as a diagnosis. Naming the true candidate set is the
+// honest form of "saying which axis is still pending" the data supports.
+func waitForDomainVerified(ctx context.Context, verify func(context.Context) (apiEmailDomain, error), domain string, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for {
+ d, err := verify(ctx)
+ if err != nil {
+ return fmt.Errorf("checking verification for %s: %w", domain, err)
+ }
+ switch domainStatus(d) {
+ case "verified":
+ return nil
+ case "failed":
+ return fmt.Errorf("verification FAILED for %s — SES could not confirm DKIM or MAIL FROM; "+
+ "check the published records and re-run `carlos email domains add -app <a> %s`", domain, domain)
+ }
+ if !time.Now().Before(deadline) {
+ return fmt.Errorf("timed out after %s waiting for %s to verify — DKIM, MAIL FROM, or sending "+
+ "is still pending at SES; check again with `carlos email status -app <a>`", timeout, domain)
+ }
+ wait := domainVerifyInterval
+ if remaining := time.Until(deadline); remaining < wait {
+ wait = remaining
+ }
+ if wait > 0 {
+ time.Sleep(wait)
+ }
+ }
+}
+
+// ---- enable ---------------------------------------------------------------
+
+func runEmailEnable(args []string) {
+ fs := flag.NewFlagSet("email enable", flag.ExitOnError)
+ app := fs.String("app", "", "app to enable email for")
+ envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_SMTP)")
+ from := fs.String("from", "", "the From address this app sends as (default: noreply@ the domain)")
+ domain := fs.String("domain", "", "sending domain to ensure and verify (default: the domain half of -from)")
+ timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for domain verification before giving up")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to enable through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ usage := "usage: carlos email enable -app <a> [-env-prefix <PREFIX>] [-from <addr>] [-domain <d>] [-timeout <dur>] " +
+ "[-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email enable: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+
+ ctx := context.Background()
+ if err := runEmailEnableFlow(ctx, stdout, sess, acct, name,
+ strings.TrimSpace(*envPrefix), strings.TrimSpace(*from), strings.ToLower(strings.TrimSpace(*domain)), *timeout); err != nil {
+ log.Fatalf("email enable: %v", err)
+ }
+}
+
+// runEmailEnableFlow is runEmailEnable's testable core: declare, ensure a
+// domain, wait for it, enable, print the delivered names. Returns an error
+// rather than calling log.Fatal itself, so a test can assert on the exact
+// failure (in particular the timeout's, TestEnableTimeoutExitsNonZeroNamingTheAxis)
+// without a subprocess.
+func runEmailEnableFlow(ctx context.Context, w io.Writer, sess *apiSession, acct apiAcct, app, envPrefix, from, domain string, timeout time.Duration) error {
+ // Read first: a re-run after an earlier declare (or a manual `carlos
+ // email domains add`) should not need -from/-domain repeated, and must
+ // never re-declare a DIFFERENT env prefix once delivered (the door's
+ // own 409 — see handleAPIEmailDeclare's "fixed once delivered" gate).
+ cur, err := sess.emailStatus(ctx, acct, app)
+ if err != nil && !isEmailNotDeclared(err) {
+ return fmt.Errorf("reading current status: %w", err)
+ }
+ if envPrefix == "" && cur.Declared != nil {
+ envPrefix = cur.Declared.EnvPrefix
+ }
+ if from == "" && cur.Declared != nil {
+ from = cur.Declared.From
+ }
+ if domain == "" && from != "" {
+ if i := strings.LastIndex(from, "@"); i >= 0 && i+1 < len(from) {
+ domain = strings.ToLower(from[i+1:])
+ }
+ }
+ if from == "" && domain != "" {
+ from = "noreply@" + domain
+ }
+ if from == "" {
+ return errors.New("no From address to declare — pass -from, or -domain (defaults From to noreply@<domain>)")
+ }
+
+ if _, err := sess.emailDeclare(ctx, acct, app, envPrefix, from); err != nil {
+ return fmt.Errorf("declaring: %w", err)
+ }
+
+ if domain != "" {
+ ensured, err := sess.emailDomainEnsure(ctx, acct, app, domain)
+ if err != nil {
+ return describeDomainEnsureErr(domain, err)
+ }
+ if len(ensured.Pending) > 0 {
+ fmt.Fprintf(w, "publish these records for %s, then this keeps polling until SES confirms them:\n", domain)
+ printPendingRecords(w, ensured.Pending)
+ }
+ verify := func(ctx context.Context) (apiEmailDomain, error) {
+ r, err := sess.emailDomainVerify(ctx, acct, app, domain)
+ return r.Domain, err
+ }
+ if err := waitForDomainVerified(ctx, verify, domain, timeout); err != nil {
+ return err
+ }
+ mutedf(w, "%s verified", domain)
+ }
+
+ enabled, err := sess.emailEnable(ctx, acct, app)
+ if err != nil {
+ return fmt.Errorf("enabling: %w", err)
+ }
+ printEmailEnabled(w, enabled)
+ return nil
+}
+
+// ---- domains add ------------------------------------------------------------
+
+func runEmailDomains(args []string) {
+ if len(args) == 0 || args[0] != "add" {
+ fmt.Fprint(os.Stderr, emailUsage)
+ os.Exit(1)
+ }
+ runEmailDomainsAdd(args[1:])
+}
+
+func runEmailDomainsAdd(args []string) {
+ fs := flag.NewFlagSet("email domains add", flag.ExitOnError)
+ app := fs.String("app", "", "app to add the domain for")
+ timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for verification before giving up")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ usage := "usage: carlos email domains add -app <a> [-timeout <dur>] [-account <sqid-or-name>] " +
+ "[-account-id <sqid>] [-console <origin>] <domain>"
+ if fs.NArg() != 1 {
+ log.Fatal(usage)
+ }
+ domain := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ name := strings.TrimSpace(*app)
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email domains add: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+
+ ctx := context.Background()
+ ensured, err := sess.emailDomainEnsure(ctx, acct, name, domain)
+ if err != nil {
+ log.Fatalf("email domains add: %v", describeDomainEnsureErr(domain, err))
+ }
+ if len(ensured.Pending) > 0 {
+ fmt.Fprintf(stdout, "publish these records for %s, then this keeps polling until SES confirms them:\n", domain)
+ printPendingRecords(stdout, ensured.Pending)
+ }
+ verify := func(ctx context.Context) (apiEmailDomain, error) {
+ r, err := sess.emailDomainVerify(ctx, acct, name, domain)
+ return r.Domain, err
+ }
+ if err := waitForDomainVerified(ctx, verify, domain, *timeout); err != nil {
+ log.Fatalf("email domains add: %v", err)
+ }
+ c := colorFor(stdout)
+ fmt.Fprintf(stdout, "%s %s\n", c.ok("verified"), domain)
+}
+
+// printPendingRecords renders the record set a member must publish in a
+// PASTEABLE form — one record per line, name, type, then value(s) — never a
+// JSON blob. A member is about to copy this straight into a DNS zone editor
+// or a `dig`/registrar form; JSON there is friction this command exists to
+// remove, not preserve.
+func printPendingRecords(w io.Writer, recs []apiDNSRecord) {
+ tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
+ for _, r := range recs {
+ for _, v := range r.Values {
+ fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Name, r.Type, v)
+ }
+ }
+ tw.Flush()
+}
+
+// ---- status -----------------------------------------------------------------
+
+func runEmailStatus(args []string) {
+ fs := flag.NewFlagSet("email status", flag.ExitOnError)
+ app := fs.String("app", "", "app whose email to show")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ usage := "usage: carlos email status -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email status: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+ res, err := sess.emailStatus(context.Background(), acct, name)
+ if err != nil {
+ if isEmailNotDeclared(err) {
+ fmt.Fprintln(stdout, err)
+ os.Exit(1)
+ }
+ log.Fatalf("email status: %v", err)
+ }
+ printEmailStatus(stdout, res)
+}
+
+// printEmailStatus is the one view a member with no box access and no AWS
+// console has of every domain's per-region verification state, today's
+// counts against the cap, and the pause state.
+func printEmailStatus(w io.Writer, res apiEmailReply) {
+ c := colorFor(w)
+ fmt.Fprintf(w, "%s %s\n", c.head("email for"), res.App)
+ if res.Declared != nil {
+ fmt.Fprintf(w, " declared: prefix %s, from %s, by %s at %s\n",
+ res.Declared.EnvPrefix, res.Declared.From, res.Declared.By, res.Declared.At.Format(time.RFC3339))
+ } else {
+ mutedf(w, " not declared — carlos email enable -app %s", res.App)
+ }
+
+ if len(res.Domains) == 0 {
+ mutedf(w, " no sending domains yet — carlos email domains add -app %s <domain>", res.App)
+ }
+ for _, d := range res.Domains {
+ regions := make([]string, 0, len(d.Regions))
+ for region := range d.Regions {
+ regions = append(regions, region)
+ }
+ sort.Strings(regions)
+ var parts []string
+ for _, region := range regions {
+ st := d.Regions[region]
+ parts = append(parts, fmt.Sprintf("%s:%s", region, st.Status))
+ }
+ fmt.Fprintf(w, " domain %s (%s): %s\n", d.Domain, d.Kind, strings.Join(parts, ", "))
+ }
+
+ if res.Provisioned != nil {
+ if res.Active {
+ fmt.Fprintf(w, " sending: %s (key %s)\n", c.ok("active"), res.Provisioned.KeyID)
+ } else {
+ warnf(w, " sending: PAUSED — SMTP auth is failing for this app right now")
+ // The record does not carry a causal reason (mail.Provisioned has
+ // no Reason field) — the honest thing to print is the evidence a
+ // member CAN see (today's counts against the cap), not a
+ // diagnosis this reply cannot actually back up.
+ mutedf(w, " today: %d sends, %d bounces, %d complaints, %d rejects against a cap of %d — "+
+ "check the console's audit log for exactly why; carlos email resume -app %s once it's addressed",
+ res.Counts.Sends, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects, res.DailyCap, res.App)
+ }
+ if res.PendingFinish {
+ warnf(w, " a rotation is pending finish — the previous key is still live: carlos email rotate -finish -app %s", res.App)
+ }
+ } else {
+ mutedf(w, " not enabled yet — carlos email enable -app %s", res.App)
+ }
+
+ fmt.Fprintf(w, " today: %d/%d sends, %d bounces, %d complaints, %d rejects\n",
+ res.Counts.Sends, res.DailyCap, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects)
+
+ if len(res.MissingVars) > 0 {
+ warnf(w, " drift: %s not yet delivered to this app's config", strings.Join(res.MissingVars, ", "))
+ }
+ if len(res.Standalone) > 0 {
+ fmt.Fprintf(w, " standalone credentials: %s\n", standaloneNames(res.Standalone))
+ }
+}
+
+func standaloneNames(sa []apiEmailStandalone) string {
+ names := make([]string, 0, len(sa))
+ for _, s := range sa {
+ names = append(names, s.Name)
+ }
+ return strings.Join(names, ", ")
+}
+
+// printEmailEnabled answers `carlos email enable`'s final line: the
+// delivered variable names, never their values (the values are sealed into
+// the app's config; this CLI never sees them either).
+func printEmailEnabled(w io.Writer, res apiEmailReply) {
+ c := colorFor(w)
+ fmt.Fprintf(w, "%s %s — delivers %s\n", c.ok("email enabled for"), res.App, strings.Join(res.EnvNames, ", "))
+ mutedf(w, "running instances pick up the new config within seconds")
+}
+
+// ---- test -------------------------------------------------------------------
+
+func runEmailTest(args []string) {
+ fs := flag.NewFlagSet("email test", flag.ExitOnError)
+ app := fs.String("app", "", "app to send a test message through")
+ to := fs.String("to", "", "address to send the test message to")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ toAddr := strings.TrimSpace(*to)
+ usage := "usage: carlos email test -app <a> -to <addr> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if !strings.Contains(toAddr, "@") {
+ log.Fatal(usage)
+ }
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email test: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+
+ ctx := context.Background()
+ result, err := runEmailTestFlow(ctx, stdout, sess, acct, name, toAddr)
+ if err != nil {
+ log.Fatalf("email test: %v", err)
+ }
+ c := colorFor(stdout)
+ fmt.Fprintf(stdout, "%s sent to %s from %s via %s: %s\n", c.ok("test message"), toAddr, result.from, result.host, result.detail)
+}
+
+type emailTestResult struct {
+ host, from, detail string
+}
+
+// sendTestMessageFn is a seam: tests substitute a fake sender so the
+// mint-send-revoke bookkeeping below is provable without a live SMTP
+// endpoint on the far end.
+var sendTestMessageFn = sendTestMessage
+
+// runEmailTestFlow mints a throwaway standalone credential, sends through
+// it, and revokes it — success or failure, always. This CLI has no other
+// way to prove sending actually works: the delivered credential's password
+// is sealed into the app's config and never handed back (apimail.go's own
+// doc comment on handleAPIEmailCredentialsCreate), so reusing it here is
+// not an option, only minting a fresh one for the purpose.
+func runEmailTestFlow(ctx context.Context, w io.Writer, sess *apiSession, acct apiAcct, app, to string) (emailTestResult, error) {
+ credName := "cli-test-" + randomHex(4)
+ creds, err := sess.emailCredentialsCreate(ctx, acct, app, credName)
+ if err != nil {
+ return emailTestResult{}, fmt.Errorf("minting a test credential: %w", err)
+ }
+ defer func() {
+ if _, rerr := sess.emailCredentialsRevoke(ctx, acct, app, credName); rerr != nil {
+ warnf(w, "could not revoke the temporary test credential %q — remove it with "+
+ "`carlos email credentials revoke -app %s -name %s`: %v", credName, app, credName, rerr)
+ }
+ }()
+
+ detail, err := sendTestMessageFn(creds, to)
+ if err != nil {
+ return emailTestResult{}, fmt.Errorf("sending through %s: %w", creds.Host, err)
+ }
+ return emailTestResult{host: creds.Host, from: creds.From, detail: detail}, nil
+}
+
+func randomHex(n int) string {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ // crypto/rand failing is effectively never on any real platform;
+ // time-derived fallback keeps this from ever blocking a test send.
+ return fmt.Sprintf("%x", time.Now().UnixNano())
+ }
+ return hex.EncodeToString(b)
+}
+
+// sendTestMessage delivers one real message over SMTP submission (STARTTLS,
+// port 587 — the whole of the delivered transport contract, spec §1) using
+// a freshly minted standalone credential, and reports SES's accept/refuse.
+func sendTestMessage(creds apiEmailCredsReply, to string) (string, error) {
+ addr := net.JoinHostPort(creds.Host, creds.Port)
+ c, err := smtp.Dial(addr)
+ if err != nil {
+ return "", fmt.Errorf("dial %s: %w", addr, err)
+ }
+ defer c.Close()
+ if ok, _ := c.Extension("STARTTLS"); ok {
+ if err := c.StartTLS(&tls.Config{ServerName: creds.Host}); err != nil {
+ return "", fmt.Errorf("starttls: %w", err)
+ }
+ }
+ auth := smtp.PlainAuth("", creds.Username, creds.Password, creds.Host)
+ if err := c.Auth(auth); err != nil {
+ return "", fmt.Errorf("smtp auth: %w", err)
+ }
+ if err := c.Mail(creds.From); err != nil {
+ return "", fmt.Errorf("mail from: %w", err)
+ }
+ if err := c.Rcpt(to); err != nil {
+ return "", fmt.Errorf("rcpt to: %w", err)
+ }
+ wc, err := c.Data()
+ if err != nil {
+ return "", fmt.Errorf("data: %w", err)
+ }
+ msg := buildTestMessage(creds.From, to)
+ if _, err := wc.Write(msg); err != nil {
+ wc.Close()
+ return "", fmt.Errorf("writing message: %w", err)
+ }
+ if err := wc.Close(); err != nil {
+ return "", fmt.Errorf("message rejected: %w", err)
+ }
+ _ = c.Quit() // best-effort: the message was already accepted by here
+ return "accepted for delivery", nil
+}
+
+func buildTestMessage(from, to string) []byte {
+ now := time.Now().UTC().Format(time.RFC1123Z)
+ var b strings.Builder
+ fmt.Fprintf(&b, "From: %s\r\n", from)
+ fmt.Fprintf(&b, "To: %s\r\n", to)
+ fmt.Fprintf(&b, "Subject: CARLOS email test\r\n")
+ fmt.Fprintf(&b, "Date: %s\r\n", now)
+ fmt.Fprintf(&b, "\r\n")
+ fmt.Fprintf(&b, "This is a test message sent by `carlos email test` to prove this app's "+
+ "email sending actually works, not just that it was configured.\r\n")
+ return []byte(b.String())
+}
+
+// ---- credentials ------------------------------------------------------------
+
+func runEmailCredentials(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, emailUsage)
+ os.Exit(1)
+ }
+ switch args[0] {
+ case "create":
+ runEmailCredentialsCreate(args[1:])
+ case "list":
+ runEmailCredentialsList(args[1:])
+ case "revoke":
+ runEmailCredentialsRevoke(args[1:])
+ default:
+ fmt.Fprint(os.Stderr, emailUsage)
+ os.Exit(1)
+ }
+}
+
+func runEmailCredentialsCreate(args []string) {
+ fs := flag.NewFlagSet("email credentials create", flag.ExitOnError)
+ app := fs.String("app", "", "app to mint a standalone credential for")
+ credName := fs.String("name", "", "credential name (lowercase, digits, hyphens)")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ usage := "usage: carlos email credentials create -app <a> -name <n> [-account <sqid-or-name>] " +
+ "[-account-id <sqid>] [-console <origin>]"
+ name := strings.TrimSpace(*app)
+ if strings.TrimSpace(*credName) == "" {
+ log.Fatal(usage)
+ }
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email credentials create: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+ res, err := sess.emailCredentialsCreate(context.Background(), acct, name, strings.TrimSpace(*credName))
+ if err != nil {
+ log.Fatalf("email credentials create: %v", err)
+ }
+ printEmailCredentialsCreate(stdout, res)
+}
+
+// printEmailCredentialsCreate prints the password ONCE, to stdout, and
+// nowhere else — no config file, no log line. handleAPIEmailCredentialsCreate's
+// own doc comment: this reply is the only one in the whole family that
+// carries a secret, because nothing else ever will.
+func printEmailCredentialsCreate(w io.Writer, res apiEmailCredsReply) {
+ c := colorFor(w)
+ fmt.Fprintf(w, "%s %s for %s\n", c.ok("standalone credential"), res.Name, res.App)
+ fmt.Fprintf(w, " host: %s\n", res.Host)
+ fmt.Fprintf(w, " port: %s\n", res.Port)
+ fmt.Fprintf(w, " username: %s\n", res.Username)
+ fmt.Fprintf(w, " password: %s\n", res.Password)
+ fmt.Fprintf(w, " from: %s\n", res.From)
+ warnf(w, "shown once — this console will not show it again; store it somewhere safe now")
+}
+
+func runEmailCredentialsList(args []string) {
+ fs := flag.NewFlagSet("email credentials list", flag.ExitOnError)
+ app := fs.String("app", "", "app whose standalone credentials to list")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ usage := "usage: carlos email credentials list -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email credentials list: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+ res, err := sess.emailStatus(context.Background(), acct, name)
+ if err != nil {
+ log.Fatalf("email credentials list: %v", err)
+ }
+ printEmailCredentialsList(stdout, res.Standalone)
+}
+
+// printEmailCredentialsList never prints a password — the status reply this
+// reads from does not carry one (mail.Standalone has no such field at all),
+// so there is nothing here to leak by construction, not just by omission.
+func printEmailCredentialsList(w io.Writer, sa []apiEmailStandalone) {
+ if len(sa) == 0 {
+ mutedf(w, "no standalone credentials")
+ return
+ }
+ t := newTable(w)
+ fmt.Fprintf(t, "NAME\tKEY ID\tCREATED BY\tAT\n")
+ for _, s := range sa {
+ fmt.Fprintf(t, "%s\t%s\t%s\t%s\n", s.Name, s.KeyID, s.By, s.At.Format(time.RFC3339))
+ }
+ t.Flush()
+}
+
+func runEmailCredentialsRevoke(args []string) {
+ fs := flag.NewFlagSet("email credentials revoke", flag.ExitOnError)
+ app := fs.String("app", "", "app whose standalone credential to revoke")
+ credName := fs.String("name", "", "credential name to revoke")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ usage := "usage: carlos email credentials revoke -app <a> -name <n> [-account <sqid-or-name>] " +
+ "[-account-id <sqid>] [-console <origin>]"
+ name := strings.TrimSpace(*app)
+ if strings.TrimSpace(*credName) == "" {
+ log.Fatal(usage)
+ }
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email credentials revoke: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+ res, err := sess.emailCredentialsRevoke(context.Background(), acct, name, strings.TrimSpace(*credName))
+ if err != nil {
+ log.Fatalf("email credentials revoke: %v", err)
+ }
+ c := colorFor(stdout)
+ fmt.Fprintf(stdout, "%s %s for %s\n", c.ok("revoked"), res.Name, res.App)
+}
+
+// ---- rotate -----------------------------------------------------------------
+
+func runEmailRotate(args []string) {
+ fs := flag.NewFlagSet("email rotate", flag.ExitOnError)
+ app := fs.String("app", "", "app whose delivered email credential to rotate")
+ finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ usage := "usage: carlos email rotate -app <a> [-finish] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]"
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email rotate: %v", err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+ res, err := sess.emailRotate(context.Background(), acct, name, *finish)
+ if err != nil {
+ log.Fatalf("email rotate: %v", err)
+ }
+ printEmailRotate(stdout, res, *finish)
+}
+
+func printEmailRotate(w io.Writer, res apiEmailReply, finish bool) {
+ c := colorFor(w)
+ if finish {
+ fmt.Fprintf(w, "%s for %s\n", c.ok("previous email key deleted"), res.App)
+ return
+ }
+ key := ""
+ if res.Provisioned != nil {
+ key = res.Provisioned.KeyID
+ }
+ fmt.Fprintf(w, "%s %s — new key %s\n", c.ok("rotated the email credential for"), res.App, key)
+ mutedf(w, "the old key stays active until `carlos email rotate -finish -app %s`", res.App)
+}
+
+// ---- pause / resume ----------------------------------------------------------
+
+func runEmailPause(args []string, paused bool) {
+ verb := "pause"
+ if !paused {
+ verb = "resume"
+ }
+ fs := flag.NewFlagSet("email "+verb, flag.ExitOnError)
+ app := fs.String("app", "", "app to "+verb)
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ usage := fmt.Sprintf("usage: carlos email %s -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]", verb)
+ if name != "" {
+ if err := validAppName(name); err != nil {
+ log.Fatalf("email %s: %v", verb, err)
+ }
+ }
+
+ sess := requireMailAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ name = resolvedApp(sess, acct, name, usage)
+ res, err := sess.emailPause(context.Background(), acct, name, paused)
+ if err != nil {
+ log.Fatalf("email %s: %v", verb, err)
+ }
+ c := colorFor(stdout)
+ fmt.Fprintf(stdout, "%s %s\n", c.ok(verb+"d email for"), res.App)
+}
diff --git a/mail_cli_test.go b/mail_cli_test.go
new file mode 100644
index 000000000..0cf1d0a78
--- /dev/null
+++ b/mail_cli_test.go
@@ -0,0 +1,757 @@
+// Tests for `carlos email` (tenant-email spec, task 18): the wire shape the
+// apiSession methods send (mail_cli.go, matching apimail_test.go's server
+// side), the honesty rules the print* functions must follow — a pasteable
+// record set, a password shown exactly once, a permanent refusal that never
+// suggests a retry, a timeout that names the pending axis and fails loud —
+// and the full `enable` flow driven against an in-process fake console. Same
+// two-layer shape domains_cli_test.go and fleets_cli_test.go established:
+// apiSession-level tests against a bare httptest server, run*-level tests
+// against a fixture console with a credential written straight to disk, and
+// usage-error/dispatch-refusal paths run as a real subprocess (buildCarlos +
+// carlosTestEnv + a scratch cmd.Dir — CLAUDE.md's rule for anything that
+// shells out to the binary).
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+)
+
+// ---- apiSession-level: path + body + wire-shape round trips ---------------
+
+func TestAPISessionEmailDeclareWireShape(t *testing.T) {
+ var gotPath, gotMethod string
+ var gotBody struct {
+ EnvPrefix string `json:"env_prefix"`
+ From string `json:"from"`
+ }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "seapointish", "declared": map[string]any{"env_prefix": "CARLOS_SMTP", "from": "noreply@x.com"},
+ "standalone": []any{}, "domains": []any{}, "env_names": []string{"CARLOS_SMTP_HOST"},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).emailDeclare(context.Background(), apiAcct{Seg: "acct"}, "seapointish", "", "noreply@x.com")
+ if err != nil {
+ t.Fatalf("emailDeclare: %v", err)
+ }
+ if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/acct/seapointish/email" {
+ t.Fatalf("method/path = %s %s", gotMethod, gotPath)
+ }
+ if gotBody.From != "noreply@x.com" {
+ t.Fatalf("body From = %q", gotBody.From)
+ }
+ if res.Declared == nil || res.Declared.From != "noreply@x.com" {
+ t.Fatalf("result = %+v", res.Declared)
+ }
+}
+
+func TestAPISessionEmailStatus404IsNotDeclared(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]any{"error": "no email is declared for this app — declare it first"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).emailStatus(context.Background(), apiAcct{Seg: "acct"}, "app")
+ if err == nil {
+ t.Fatal("emailStatus: want an error on 404")
+ }
+ if !isEmailNotDeclared(err) {
+ t.Fatalf("isEmailNotDeclared(%v) = false, want true", err)
+ }
+}
+
+func TestAPISessionEmailDomainEnsureWireShape(t *testing.T) {
+ var gotBody struct {
+ Domain string `json:"domain"`
+ }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/acct/app/email/domains" || r.Method != http.MethodPost {
+ t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
+ }
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{
+ "domain": map[string]any{"domain": gotBody.Domain, "kind": "custom", "regions": map[string]any{}},
+ "pending": []map[string]any{{"name": "_dkim1._domainkey.x.com", "type": "CNAME", "values": []string{"a.dkim.amazonses.com"}, "ttl": 300}},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com")
+ if err != nil {
+ t.Fatalf("emailDomainEnsure: %v", err)
+ }
+ if gotBody.Domain != "x.com" {
+ t.Fatalf("sent domain = %q", gotBody.Domain)
+ }
+ if len(res.Pending) != 1 || res.Pending[0].Name != "_dkim1._domainkey.x.com" {
+ t.Fatalf("pending = %+v", res.Pending)
+ }
+}
+
+// TestAPISessionEmailDomainEnsureConflictIsPermanent pins the 409-preserving
+// behaviour: a claimed domain must decode into an apiMailError whose status
+// is Conflict, so the caller can tell it apart from a transient failure.
+func TestAPISessionEmailDomainEnsureConflictIsPermanent(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]any{"error": "this domain is already claimed by another CARLOS account — choose a different domain"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com")
+ var me *apiMailError
+ if !errors.As(err, &me) {
+ t.Fatalf("err = %v, want *apiMailError", err)
+ }
+ if !me.permanent() {
+ t.Fatal("409 claimed-domain error must report permanent() == true")
+ }
+}
+
+// TestAPISessionEmailDomainEnsureBadRequestIsPermanent is the sibling for
+// ErrNotOwnPlatformName's 400.
+func TestAPISessionEmailDomainEnsureBadRequestIsPermanent(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]any{"error": `"x.aaa.oncarlos.com" is not a platform name this account owns`})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.aaa.oncarlos.com")
+ var me *apiMailError
+ if !errors.As(err, &me) || !me.permanent() {
+ t.Fatalf("err = %v, want a permanent apiMailError", err)
+ }
+}
+
+func TestAPISessionEmailDomainVerifyWireShape(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/acct/app/email/domains/x.com/verify" || r.Method != http.MethodPost {
+ t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "domain": map[string]any{"domain": "x.com", "regions": map[string]any{"eu-west-1": map[string]any{"status": "verified"}}},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).emailDomainVerify(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com")
+ if err != nil {
+ t.Fatalf("emailDomainVerify: %v", err)
+ }
+ if domainStatus(res.Domain) != "verified" {
+ t.Fatalf("domainStatus = %q", domainStatus(res.Domain))
+ }
+}
+
+func TestAPISessionEmailEnableWireShape(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/acct/app/email/enable" || r.Method != http.MethodPost {
+ t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "app", "env_names": []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PORT", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS", "CARLOS_SMTP_FROM"},
+ "provisioned": map[string]any{"iam_user": "u", "key_id": "AKIA1", "active": true},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).emailEnable(context.Background(), apiAcct{Seg: "acct"}, "app")
+ if err != nil {
+ t.Fatalf("emailEnable: %v", err)
+ }
+ if len(res.EnvNames) != 5 {
+ t.Fatalf("env_names = %v", res.EnvNames)
+ }
+}
+
+func TestAPISessionEmailRotateFinishQuery(t *testing.T) {
+ var gotQuery string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotQuery = r.URL.RawQuery
+ json.NewEncoder(w).Encode(map[string]any{"app": "app"})
+ }))
+ defer ts.Close()
+
+ if _, err := newTestSession(ts.URL).emailRotate(context.Background(), apiAcct{Seg: "acct"}, "app", true); err != nil {
+ t.Fatalf("emailRotate finish: %v", err)
+ }
+ if gotQuery != "finish=1" {
+ t.Fatalf("query = %q, want finish=1", gotQuery)
+ }
+}
+
+func TestAPISessionEmailCredentialsCreateWireShape(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/acct/app/email/credentials" || r.Method != http.MethodPost {
+ t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "app", "name": "laptop", "host": "email-smtp.eu-west-1.amazonaws.com", "port": "587",
+ "username": "AKIA2", "password": "sekret", "from": "noreply@x.com",
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).emailCredentialsCreate(context.Background(), apiAcct{Seg: "acct"}, "app", "laptop")
+ if err != nil {
+ t.Fatalf("emailCredentialsCreate: %v", err)
+ }
+ if res.Password != "sekret" {
+ t.Fatalf("password = %q", res.Password)
+ }
+}
+
+func TestAPISessionEmailCredentialsRevokeWireShape(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/acct/app/email/credentials/laptop" || r.Method != http.MethodDelete {
+ t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"app": "app", "name": "laptop", "revoked": true})
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).emailCredentialsRevoke(context.Background(), apiAcct{Seg: "acct"}, "app", "laptop")
+ if err != nil {
+ t.Fatalf("emailCredentialsRevoke: %v", err)
+ }
+ if !res.Revoked {
+ t.Fatal("revoked = false")
+ }
+}
+
+func TestAPISessionEmailPauseWireShape(t *testing.T) {
+ var gotBody struct {
+ Paused *bool `json:"paused"`
+ }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/acct/app/email/pause" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{"app": "app", "active": false})
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).emailPause(context.Background(), apiAcct{Seg: "acct"}, "app", true)
+ if err != nil {
+ t.Fatalf("emailPause: %v", err)
+ }
+ if gotBody.Paused == nil || !*gotBody.Paused {
+ t.Fatalf("paused sent = %v, want true", gotBody.Paused)
+ }
+ if res.Active {
+ t.Fatal("active = true after a pause")
+ }
+}
+
+// ---- print* honesty rules ---------------------------------------------------
+
+// TestPrintPendingRecordsIsPasteable pins the required shape: one record per
+// line, name/type/value, never a JSON blob a member would have to parse
+// before they could paste it into a zone editor.
+func TestPrintPendingRecordsIsPasteable(t *testing.T) {
+ var buf bytes.Buffer
+ printPendingRecords(&buf, []apiDNSRecord{
+ {Name: "_dkim1._domainkey.x.com", Type: "CNAME", Values: []string{"a.dkim.amazonses.com"}},
+ {Name: "x.com", Type: "MX", Values: []string{"10 feedback-smtp.eu-west-1.amazonses.com"}},
+ {Name: "_dmarc.x.com", Type: "TXT", Values: []string{"v=DMARC1; p=none"}},
+ })
+ out := buf.String()
+
+ if strings.Contains(out, "{") || strings.Contains(out, "}") || strings.Contains(out, "[") {
+ t.Fatalf("pending records output looks like JSON, not pasteable lines:\n%s", out)
+ }
+ lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("got %d lines, want one per record:\n%s", len(lines), out)
+ }
+ for i, want := range []string{"_dkim1._domainkey.x.com", "x.com", "_dmarc.x.com"} {
+ if !strings.Contains(lines[i], want) {
+ t.Fatalf("line %d = %q, want it to name %q", i, lines[i], want)
+ }
+ }
+ if !strings.Contains(lines[0], "CNAME") || !strings.Contains(lines[0], "a.dkim.amazonses.com") {
+ t.Fatalf("line 0 = %q, want name, type AND value together on one line", lines[0])
+ }
+}
+
+// TestStandalonePrintsThePasswordOnce pins the required rule: the password
+// appears exactly once, on stdout, and the output as a whole never suggests
+// it will be shown again — the mint door's own doc comment says it will not.
+func TestStandalonePrintsThePasswordOnce(t *testing.T) {
+ var buf bytes.Buffer
+ printEmailCredentialsCreate(&buf, apiEmailCredsReply{
+ App: "seapointish", Name: "laptop", Host: "email-smtp.eu-west-1.amazonaws.com",
+ Port: "587", Username: "AKIA2", Password: "swordfish-secret", From: "noreply@x.com",
+ })
+ out := buf.String()
+
+ if n := strings.Count(out, "swordfish-secret"); n != 1 {
+ t.Fatalf("password appears %d times, want exactly 1:\n%s", n, out)
+ }
+ if !strings.Contains(out, "shown once") && !strings.Contains(out, "not show it again") {
+ t.Fatalf("output does not warn this is a one-time display:\n%s", out)
+ }
+}
+
+// TestPrintEmailCredentialsListNeverShowsAPassword: the list view reads off
+// the status reply, whose Standalone entries carry no password field at
+// all — this pins that the print function does not somehow invent one, and
+// that the table is readable (names present).
+func TestPrintEmailCredentialsListNeverShowsAPassword(t *testing.T) {
+ var buf bytes.Buffer
+ printEmailCredentialsList(&buf, []apiEmailStandalone{
+ {Name: "laptop", IAMUser: "u1", KeyID: "AKIA2", By: "a@x.com", At: time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)},
+ })
+ out := buf.String()
+ if !strings.Contains(out, "laptop") || !strings.Contains(out, "AKIA2") {
+ t.Fatalf("list output missing the credential's own fields:\n%s", out)
+ }
+ if strings.Contains(strings.ToLower(out), "password") {
+ t.Fatalf("list output mentions a password at all:\n%s", out)
+ }
+}
+
+func TestPrintEmailEnabledListsDeliveredVars(t *testing.T) {
+ var buf bytes.Buffer
+ printEmailEnabled(&buf, apiEmailReply{App: "seapointish", EnvNames: []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PASS"}})
+ out := buf.String()
+ if !strings.Contains(out, "CARLOS_SMTP_HOST") || !strings.Contains(out, "CARLOS_SMTP_PASS") {
+ t.Fatalf("enabled output missing the delivered variable names:\n%s", out)
+ }
+}
+
+// TestPrintEmailStatusPausedNamesEvidenceNotAGuess: a paused app must be
+// called out loudly, with the counts a member CAN see — never an invented
+// reason the reply does not actually carry.
+func TestPrintEmailStatusPausedNamesEvidenceNotAGuess(t *testing.T) {
+ var buf bytes.Buffer
+ printEmailStatus(&buf, apiEmailReply{
+ App: "seapointish", DailyCap: 50, Active: false,
+ Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
+ Counts: apiEmailCounts{Sends: 51, Bounces: 2},
+ })
+ out := buf.String()
+ if !strings.Contains(strings.ToUpper(out), "PAUSED") {
+ t.Fatalf("status output does not call out the pause:\n%s", out)
+ }
+ if !strings.Contains(out, "51") || !strings.Contains(out, "50") {
+ t.Fatalf("status output missing the counts/cap evidence:\n%s", out)
+ }
+}
+
+func TestPrintEmailStatusActiveShowsKey(t *testing.T) {
+ var buf bytes.Buffer
+ printEmailStatus(&buf, apiEmailReply{
+ App: "seapointish", DailyCap: 1000, Active: true,
+ Provisioned: &apiEmailProvisioned{KeyID: "AKIA9"},
+ })
+ out := buf.String()
+ if !strings.Contains(out, "active") || !strings.Contains(out, "AKIA9") {
+ t.Fatalf("status output missing the active/key line:\n%s", out)
+ }
+}
+
+func TestPrintEmailRotateNamesFinishCommand(t *testing.T) {
+ var buf bytes.Buffer
+ printEmailRotate(&buf, apiEmailReply{App: "app", Provisioned: &apiEmailProvisioned{KeyID: "AKIA2"}}, false)
+ out := buf.String()
+ if !strings.Contains(out, "AKIA2") || !strings.Contains(out, "carlos email rotate -finish -app app") {
+ t.Fatalf("rotate output = %q", out)
+ }
+}
+
+// ---- describeDomainEnsureErr: permanent refusals never suggest a retry ----
+
+// TestDescribeDomainEnsureErrPermanentRefusalsNeverSuggestRetry is the
+// mutation-tested rule directly: a 409 (claimed) or 400 (not this account's
+// platform name) must never contain retry language.
+func TestDescribeDomainEnsureErrPermanentRefusalsNeverSuggestRetry(t *testing.T) {
+ for _, status := range []int{http.StatusConflict, http.StatusBadRequest} {
+ err := &apiMailError{status: status, err: errors.New("refused")}
+ got := describeDomainEnsureErr("x.com", err).Error()
+ for _, bad := range []string{"try again", "retry", "re-run", "resume"} {
+ if strings.Contains(strings.ToLower(got), bad) {
+ t.Fatalf("status %d: refusal text %q suggests a retry via %q", status, got, bad)
+ }
+ }
+ if !strings.Contains(got, "x.com") {
+ t.Fatalf("status %d: refusal text %q does not name the domain", status, got)
+ }
+ }
+}
+
+func TestDescribeDomainEnsureErrTransientPassesThrough(t *testing.T) {
+ err := &apiMailError{status: http.StatusBadGateway, err: errors.New("could not ensure the sending domain — nothing unrecorded was left behind; re-run to resume")}
+ got := describeDomainEnsureErr("x.com", err).Error()
+ if !strings.Contains(got, "re-run") {
+ t.Fatalf("transient refusal lost its own retry text: %q", got)
+ }
+}
+
+// ---- waitForDomainVerified: the timeout that must exit non-zero -----------
+
+// TestWaitForDomainVerifiedSucceeds: a domain that flips to verified before
+// the deadline returns nil.
+func TestWaitForDomainVerifiedSucceeds(t *testing.T) {
+ calls := 0
+ verify := func(context.Context) (apiEmailDomain, error) {
+ calls++
+ status := "pending"
+ if calls >= 2 {
+ status = "verified"
+ }
+ return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: status}}}, nil
+ }
+ if err := waitForDomainVerified(context.Background(), verify, "x.com", 2*time.Second); err != nil {
+ t.Fatalf("waitForDomainVerified: %v", err)
+ }
+ if calls < 2 {
+ t.Fatalf("calls = %d, want at least 2 (the loop must actually poll)", calls)
+ }
+}
+
+func TestWaitForDomainVerifiedFailsOnSESFailedStatus(t *testing.T) {
+ verify := func(context.Context) (apiEmailDomain, error) {
+ return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: "failed"}}}, nil
+ }
+ err := waitForDomainVerified(context.Background(), verify, "x.com", time.Minute)
+ if err == nil {
+ t.Fatal("want a non-nil error on an SES-failed domain")
+ }
+ if !strings.Contains(err.Error(), "FAILED") {
+ t.Fatalf("error = %q, want it to say the verification FAILED", err)
+ }
+}
+
+// TestEnableTimeoutExitsNonZeroNamingTheAxis is the required test: a domain
+// that never leaves "pending" must make waitForDomainVerified return a
+// non-nil error (which the caller's log.Fatal turns into a non-zero exit —
+// every run* verb in this file follows that convention) that names the
+// candidate axes SES tracks, so a member reading it knows what to go check
+// instead of just "it didn't work."
+func TestEnableTimeoutExitsNonZeroNamingTheAxis(t *testing.T) {
+ verify := func(context.Context) (apiEmailDomain, error) {
+ return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: "pending"}}}, nil
+ }
+ start := time.Now()
+ err := waitForDomainVerified(context.Background(), verify, "seapointish.aaa.oncarlos.com", 40*time.Millisecond)
+ elapsed := time.Since(start)
+
+ if err == nil {
+ t.Fatal("waitForDomainVerified on a domain stuck pending must return a non-nil error (non-zero exit) — got nil")
+ }
+ for _, axis := range []string{"DKIM", "MAIL FROM", "sending"} {
+ if !strings.Contains(err.Error(), axis) {
+ t.Fatalf("timeout error %q does not name axis %q", err.Error(), axis)
+ }
+ }
+ if !strings.Contains(err.Error(), "seapointish.aaa.oncarlos.com") {
+ t.Fatalf("timeout error %q does not name the domain", err.Error())
+ }
+ if !strings.Contains(strings.ToLower(err.Error()), "timed out") {
+ t.Fatalf("timeout error %q does not say it timed out", err.Error())
+ }
+ if elapsed > 2*time.Second {
+ t.Fatalf("waitForDomainVerified took %s for a 40ms timeout — polling interval is not capped by the deadline", elapsed)
+ }
+}
+
+// ---- runEmailEnableFlow: the full member path against a fake console ------
+
+// fakeEmailConsole is a minimal in-process console answering every email
+// door with fixed, easily-asserted bodies. verifyCalls counts domain-verify
+// requests so the platform-domain-vs-custom-domain polling behaviour can be
+// pinned without a live SES estate.
+type fakeEmailConsole struct {
+ ts *httptest.Server
+ declared bool
+ declaredVia struct {
+ EnvPrefix, From string
+ }
+ pendingRecords bool
+ verifyCalls int
+ verifiedAfter int // domain reports verified once verifyCalls reaches this
+ enableCalled bool
+ domainConflict bool
+ credRevoked bool
+ lastRevokedName string
+}
+
+func newFakeEmailConsole(t *testing.T, verifiedAfter int) *fakeEmailConsole {
+ t.Helper()
+ fc := &fakeEmailConsole{verifiedAfter: verifiedAfter}
+ fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps":
+ json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
+ {"sqid": "aaa", "name": "ops", "role": "owner", "apps": []string{"seapointish"}},
+ }})
+ case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/email"):
+ if !fc.declared {
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]any{"error": "no email is declared for this app — declare it first"})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "seapointish",
+ "declared": map[string]any{
+ "env_prefix": fc.declaredVia.EnvPrefix, "from": fc.declaredVia.From,
+ },
+ "standalone": []any{}, "domains": []any{}, "env_names": []string{},
+ })
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email"):
+ var body struct{ EnvPrefix, From string }
+ json.NewDecoder(r.Body).Decode(&body)
+ if body.From == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]any{"error": "From is not an address"})
+ return
+ }
+ fc.declared = true
+ fc.declaredVia.EnvPrefix, fc.declaredVia.From = body.EnvPrefix, body.From
+ json.NewEncoder(w).Encode(map[string]any{"app": "seapointish", "declared": map[string]any{"env_prefix": body.EnvPrefix, "from": body.From}})
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email/domains"):
+ if fc.domainConflict {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]any{"error": "this domain is already claimed by another CARLOS account — choose a different domain"})
+ return
+ }
+ pending := []map[string]any{}
+ if fc.pendingRecords {
+ pending = append(pending, map[string]any{"name": "_dkim1._domainkey.x.com", "type": "CNAME", "values": []string{"a.dkim.amazonses.com"}, "ttl": 300})
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "domain": map[string]any{"domain": "x.com", "kind": "custom", "regions": map[string]any{}},
+ "pending": pending,
+ })
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/verify"):
+ fc.verifyCalls++
+ status := "pending"
+ if fc.verifyCalls >= fc.verifiedAfter {
+ status = "verified"
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "domain": map[string]any{"domain": "x.com", "regions": map[string]any{"eu-west-1": map[string]any{"status": status}}},
+ })
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/enable"):
+ fc.enableCalled = true
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "seapointish", "env_names": []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PORT", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS", "CARLOS_SMTP_FROM"},
+ "provisioned": map[string]any{"iam_user": "u", "key_id": "AKIA1", "active": true},
+ })
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email/credentials"):
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "seapointish", "name": "cli-test", "host": "email-smtp.eu-west-1.amazonaws.com",
+ "port": "587", "username": "AKIA9", "password": "temp-secret", "from": "noreply@x.com",
+ })
+ case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/email/credentials/"):
+ fc.credRevoked = true
+ fc.lastRevokedName = strings.TrimPrefix(r.URL.Path, r.URL.Path[:strings.LastIndex(r.URL.Path, "/")+1])
+ json.NewEncoder(w).Encode(map[string]any{"app": "seapointish", "name": fc.lastRevokedName, "revoked": true})
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// TestRunEmailEnableFlowPlatformDomainNoPendingRecords: -domain omitted,
+// -from given, on a platform-kind domain the fake reports with nothing
+// pending — enable runs straight through and prints the delivered names.
+func TestRunEmailEnableFlowPlatformDomainNoPendingRecords(t *testing.T) {
+ fc := newFakeEmailConsole(t, 1) // verified on the first check
+ sess := newTestSession(fc.ts.URL)
+ var buf bytes.Buffer
+
+ err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "seapointish",
+ "", "noreply@seapointish.aaa.oncarlos.com", "", 5*time.Second)
+ if err != nil {
+ t.Fatalf("runEmailEnableFlow: %v", err)
+ }
+ if !fc.enableCalled {
+ t.Fatal("enable door was never called")
+ }
+ out := buf.String()
+ if !strings.Contains(out, "CARLOS_SMTP_HOST") {
+ t.Fatalf("output missing delivered var names:\n%s", out)
+ }
+ if strings.Contains(out, "publish these records") {
+ t.Fatalf("platform domain with nothing pending must not print a record set:\n%s", out)
+ }
+}
+
+// TestRunEmailEnableFlowCustomDomainPrintsRecordsAndPolls: a custom domain
+// with pending records prints them and polls verify more than once before
+// enabling.
+func TestRunEmailEnableFlowCustomDomainPrintsRecordsAndPolls(t *testing.T) {
+ fc := newFakeEmailConsole(t, 2) // verified on the second check
+ fc.pendingRecords = true
+ sess := newTestSession(fc.ts.URL)
+ var buf bytes.Buffer
+
+ err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "seapointish",
+ "", "", "x.com", 5*time.Second)
+ if err != nil {
+ t.Fatalf("runEmailEnableFlow: %v", err)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "publish these records") || !strings.Contains(out, "_dkim1._domainkey.x.com") {
+ t.Fatalf("custom domain output missing the record set:\n%s", out)
+ }
+ if fc.verifyCalls < 2 {
+ t.Fatalf("verifyCalls = %d, want at least 2 (must actually poll, not accept the first pending answer)", fc.verifyCalls)
+ }
+ if fc.declaredVia.From != "noreply@x.com" {
+ t.Fatalf("From defaulted to %q, want noreply@x.com derived from -domain", fc.declaredVia.From)
+ }
+}
+
+// TestRunEmailEnableFlowClaimedDomainFailsPermanently: EnsureDomain's 409
+// must stop the flow with a permanent-refusal message, never reach enable.
+func TestRunEmailEnableFlowClaimedDomainFailsPermanently(t *testing.T) {
+ fc := newFakeEmailConsole(t, 1)
+ fc.domainConflict = true
+ sess := newTestSession(fc.ts.URL)
+ var buf bytes.Buffer
+
+ err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "seapointish",
+ "", "", "x.com", 5*time.Second)
+ if err == nil {
+ t.Fatal("want an error when the domain is already claimed")
+ }
+ if fc.enableCalled {
+ t.Fatal("enable must never be called after a claimed-domain refusal")
+ }
+ if strings.Contains(strings.ToLower(err.Error()), "retry") || strings.Contains(strings.ToLower(err.Error()), "try again") {
+ t.Fatalf("claimed-domain error suggests a retry: %q", err.Error())
+ }
+}
+
+func TestRunEmailEnableFlowNoFromNoDomainRefusesCleanly(t *testing.T) {
+ fc := newFakeEmailConsole(t, 1)
+ sess := newTestSession(fc.ts.URL)
+ var buf bytes.Buffer
+ err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "seapointish", "", "", "", time.Second)
+ if err == nil {
+ t.Fatal("want an error when neither -from nor -domain is given and nothing was declared before")
+ }
+}
+
+// ---- runEmailTestFlow: mint / send / revoke bookkeeping --------------------
+
+// TestRunEmailTestFlowRevokesOnSuccess: the temporary credential is revoked
+// after a successful send.
+func TestRunEmailTestFlowRevokesOnSuccess(t *testing.T) {
+ fc := newFakeEmailConsole(t, 1)
+ sess := newTestSession(fc.ts.URL)
+ orig := sendTestMessageFn
+ sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { return "accepted for delivery", nil }
+ t.Cleanup(func() { sendTestMessageFn = orig })
+
+ var buf bytes.Buffer
+ res, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "seapointish", "friend@gmail.com")
+ if err != nil {
+ t.Fatalf("runEmailTestFlow: %v", err)
+ }
+ if res.detail == "" {
+ t.Fatal("result detail is empty")
+ }
+ if !fc.credRevoked {
+ t.Fatal("the temporary standalone credential was never revoked")
+ }
+}
+
+// TestRunEmailTestFlowRevokesOnFailureToo: a send failure must not leave a
+// live credential behind either.
+func TestRunEmailTestFlowRevokesOnFailureToo(t *testing.T) {
+ fc := newFakeEmailConsole(t, 1)
+ sess := newTestSession(fc.ts.URL)
+ orig := sendTestMessageFn
+ sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { return "", fmt.Errorf("connection refused") }
+ t.Cleanup(func() { sendTestMessageFn = orig })
+
+ var buf bytes.Buffer
+ _, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "seapointish", "friend@gmail.com")
+ if err == nil {
+ t.Fatal("want an error when the send itself fails")
+ }
+ if !fc.credRevoked {
+ t.Fatal("a failed send must still revoke the temporary credential")
+ }
+}
+
+// ---- RunEmail-level: dispatch and refusals (real binary) -------------------
+
+// TestEmailVerbDispatchAndRefusals drives the real binary through
+// carlosTestEnv with a scratch cmd.Dir — the repo rule (CLAUDE.md) — so a
+// logged-in laptop's real credential and console can never be what this
+// test exercises: unknown sub-verb, missing -app, and direct bucket mode
+// refused with email's own sentence. This also pins main.go's `case
+// "email"` wiring end to end.
+func TestEmailVerbDispatchAndRefusals(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ run := func(env []string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, env...)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ for _, args := range [][]string{{"email"}, {"email", "wat"}} {
+ out, err := run(nil, args...)
+ if err == nil {
+ t.Fatalf("carlos %v exited 0:\n%s", args, out)
+ }
+ if !strings.Contains(out, "usage: carlos email <enable|domains|status|test|credentials|rotate|pause|resume>") {
+ t.Fatalf("carlos %v = %q, want the email usage block", args, out)
+ }
+ }
+
+ for _, args := range [][]string{
+ {"email", "enable", "-app", "keymail"},
+ {"email", "status", "-app", "keymail"},
+ {"email", "test", "-app", "keymail", "-to", "a@b.com"},
+ {"email", "rotate", "-app", "keymail"},
+ {"email", "pause", "-app", "keymail"},
+ {"email", "resume", "-app", "keymail"},
+ {"email", "credentials", "create", "-app", "keymail", "-name", "laptop"},
+ {"email", "domains", "add", "-app", "keymail", "x.com"},
+ } {
+ out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, args...)
+ if err == nil {
+ t.Fatalf("carlos %v in direct bucket mode exited 0:\n%s", args, out)
+ }
+ if !strings.Contains(out, "carlos email works through a console") || !strings.Contains(out, "carlos auth login") {
+ t.Fatalf("carlos %v direct-mode refusal = %q, want email's own message and the login fix", args, out)
+ }
+ }
+
+ out, err := run(nil, "email", "status", "-app", "Not An App")
+ if err == nil {
+ t.Fatalf("email status with a bad app name exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "not a usable app name") {
+ t.Fatalf("bad app name = %q, want the name gate", out)
+ }
+}
diff --git a/main.go b/main.go
index 15f585435..95b10f53a 100644
--- a/main.go
+++ b/main.go
@@ -67,6 +67,8 @@ func main() {
runEconomics(args)
case "store":
runStore(args)
+ case "email":
+ runEmail(args)
case "logs":
runLogs(args)
case "channels":
@@ -149,6 +151,8 @@ Your apps — ship and run software on a CARLOS deployment
deploy ship + promote + wait until the URL serves the new version
restart cycle an app's instances — no new version, no config change
store object storage for an app: create | status | grant | rotate
+ email tenant email sending: enable | domains add | status | test |
+ credentials create/list/revoke | rotate | pause | resume
rollback point a channel back at an earlier version
channels show what each of an app's channels is serving
releases every version shipped, newest first, with labels and tags