// mail_cli.go is `carlos email`: the member surface for tenant email sending // (tenant-email spec, task 18). It calls the ten doors internal/console/ // apimail.go carries (nine from task 17, plus `domains rm`) — 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 ( "bytes" "context" "crypto/rand" "crypto/tls" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "log" "mime/multipart" "net" "net/http" "net/smtp" "net/textproto" "net/url" "os" "sort" "strconv" "strings" "text/tabwriter" "time" ) const emailUsage = `usage: carlos email [flags] enable --app [--env-prefix ] [--from ] [--domain ] [--timeout ] 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@ 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 [--timeout ] 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. domains rm --app stop this app sending from a domain and, when it was the LAST app using it, tear the SES identity down and RELEASE the global claim — the only way short of deleting the app to make a domain claimable by another CARLOS account again. Refused while is where this app's own From address sits: move that first. A custom domain's DNS is yours, so the now-stale records are printed rather than deleted. status --app domains and their per-region verification state, today's counts against the cap, and the pause state. events --app [--since ] [--message ] [--to ] [-n ] [--json] what happened to each message the app sent — delivered, bounced, complained about, delayed — newest first, with what the receiving server said. Kept for 30 days. --message takes the id SES gave the message when it was sent. events push --app [--path

| --off] [--json] tell the app itself: POST each delivery, bounce, complaint, rejection and delay SES attributes to it, signed, to

on its own platform host. The verifying key is delivered as CARLOS_MAIL_EVENTS_KEY. Bare, shows where events go and how delivery is going; --off stops it. test --app --to 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 --name 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 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 --name delete one standalone credential and its own IAM principal. rotate --app [--finish] mint a fresh delivered credential, leaving the old one live until --finish deletes it. pause --app stop this app's sending — operator only. resume --app resume it. --account account the app belongs to (default: your only account, or the configured/chosen one). --account-id account sqid, matched as a sqid ONLY. --console 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 "events": runEmailEvents(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 .") } 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"` // PauseKind distinguishes a per-day CAP pause (clears itself on the // UTC day roll) from a RATE pause or a manual operator pause (neither // ever auto-resumes) — mail.Provisioned.PauseKind's wire form (I3, // review follow-up). "" on an active record, on a manual pause, and on // a record written before this field existed. PauseKind string `json:"pause_kind,omitempty"` 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"` Deliveries int `json:"deliveries"` } // apiEmailSender is one sender in the status reply (tenant-email senders // design §8). The reply's top-level fields keep describing the app sender // and the app's whole day, so an older console that sends no senders array // leaves this empty and the output is what it has always been. type apiEmailSender struct { ID string `json:"id"` Scope string `json:"scope"` Source string `json:"source"` Host string `json:"host,omitempty"` Address string `json:"address,omitempty"` Active bool `json:"active"` PauseKind string `json:"pause_kind,omitempty"` DailyCap int `json:"daily_cap"` KeyID string `json:"key_id,omitempty"` Counts apiEmailCounts `json:"counts"` } // 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"` Senders []apiEmailSender `json:"senders"` // DefaultHost is this app's own host — .. — // spec §3's default sending identity, computed console-side (I4, // review follow-up: the CLI itself never learns // CARLOS_MAIL_PLATFORM_SUFFIX). Empty when this deployment has no // platform suffix configured. DefaultHost string `json:"default_host,omitempty"` } 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"` } // apiEmailDomainRemoveReply is handleAPIEmailDomainRemove's reply. It says // what actually happened rather than just "ok", because the three outcomes // have different consequences for the member: a sibling app kept the domain // alive (nothing was released), this app was the last one (the claim is now // free for any account), or only a stranded claim existed to sweep. type apiEmailDomainRemoveReply struct { App string `json:"app"` Domain string `json:"domain"` Kind string `json:"kind"` TornDown bool `json:"torn_down"` ClaimOnly bool `json:"claim_only"` RemainingApps []string `json:"remaining_apps"` Orphaned []apiDNSRecord `json:"orphaned"` } // 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. // // The console's own words carry the advice. This used to add "choose a // different domain" to every 409, which is wrong for the refusal that says // how to prove control of the domain (#216). func describeDomainEnsureErr(domain string, err error) error { var me *apiMailError if errors.As(err, &me) && me.permanent() { return fmt.Errorf("%s: %s", 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 { // Even the "not declared" 404 carries the app's default sending // host (I4, review follow-up) — `carlos email enable` with no // --from needs it on the very FIRST call, before anything has ever // been declared, which is exactly when this branch fires. Best // effort: an unparseable body just leaves DefaultHost empty, same // as a deployment with no platform suffix configured. var partial struct { DefaultHost string `json:"default_host"` } json.Unmarshal(body, &partial) return apiEmailReply{DefaultHost: partial.DefaultHost}, 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) emailDomainRemove(ctx context.Context, a apiAcct, app, domain string) (apiEmailDomainRemoveReply, error) { status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/email/domains/"+pathEscape(domain))) if err != nil { return apiEmailDomainRemoveReply{}, err } if status != http.StatusOK { return apiEmailDomainRemoveReply{}, mailAPIError(status, body) } var out apiEmailDomainRemoveReply if err := json.Unmarshal(body, &out); err != nil { return apiEmailDomainRemoveReply{}, fmt.Errorf("email domains rm: 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 %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 `", 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 := appFlag(fs, "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 := consoleOption(fs, "console origin to enable through (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos email enable --app [--env-prefix ] [--from ] [--domain ] [--timeout ] " + "[--account ] [--account-id ] [--console ]" 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 } // Spec §3's default identity — the app's own host — computed // console-side and carried on cur (I4, review follow-up). Only when // NEITHER --from NOR --domain named anything, and no prior declaration // supplied one either: this is what makes `carlos email enable --app // X`, with no other flags at all, a one-command story. Empty when // this deployment has no platform suffix configured, in which case // the explicit error below still fires, exactly as before this fix. if from == "" && domain == "" && cur.DefaultHost != "" { domain = cur.DefaultHost from = "noreply@" + domain } if from == "" { return errors.New("no From address to declare — pass --from, or --domain (defaults From to noreply@)") } 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 { fmt.Fprint(os.Stderr, emailUsage) os.Exit(1) } switch args[0] { case "add": runEmailDomainsAdd(args[1:]) // "remove" is accepted alongside "rm" because this is the one verb in // the family whose effect is irreversible for other people — a domain // released is a domain a stranger may claim — and a member who spells // it out in full should not be answered with a usage block. case "rm", "remove": runEmailDomainsRemove(args[1:]) default: fmt.Fprint(os.Stderr, emailUsage) os.Exit(1) } } func runEmailDomainsAdd(args []string) { fs := flag.NewFlagSet("email domains add", flag.ExitOnError) app := appFlag(fs, "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 := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos email domains add --app [--timeout ] [--account ] " + "[--account-id ] [--console ] " 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() } // ---- domains rm ------------------------------------------------------------- // runEmailDomainsRemove is `carlos email domains rm`: the release door for a // sending domain's GLOBAL claim. // // Why it exists, since the shape is otherwise unremarkable: the claim // (internal/mail/domainclaim.go) is a deliberately global, create-only // object, so exactly one CARLOS account can hold a domain. That is right. // What was missing was the way back out — the only thing that ever released // a claim was permanently DELETING the app that held it, so a domain // claimed by the wrong app of the wrong account was unreachable to // everybody, its rightful owner included, forever. This is that door. func runEmailDomainsRemove(args []string) { fs := flag.NewFlagSet("email domains rm", flag.ExitOnError) app := appFlag(fs, "app to remove the domain from") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos email domains rm --app [--account ] " + "[--account-id ] [--console ] " 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 rm: %v", err) } } sess := requireMailAPISession(*consoleFlag) printUsing(stdout, sess) acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) res, err := sess.emailDomainRemove(context.Background(), acct, name, domain) if err != nil { // No retry framing and no "permanent" framing either, unlike // `domains add`: this command's two refusals are both states the // caller can change (move the From; name a domain the app actually // uses), and the console's own message already says which and how. log.Fatalf("email domains rm: %v", err) } printDomainRemoval(stdout, res) } // printDomainRemoval says which of the three things happened, because they // are not interchangeable: a sibling app kept the domain alive, this app was // the last one and the claim is now free, or there was never a record at all // and a stranded claim was swept. func printDomainRemoval(w io.Writer, res apiEmailDomainRemoveReply) { c := colorFor(w) fmt.Fprintf(w, "%s %s from %s\n", c.ok("removed"), res.Domain, res.App) switch { case !res.TornDown: mutedf(w, " %s still uses it, so its identity, its DNS and its claim are untouched", strings.Join(res.RemainingApps, ", ")) return case res.ClaimOnly: mutedf(w, " there was no domain record — only a stranded claim, now released") default: mutedf(w, " last app to use it: the SES identity is gone and the domain is claimable again") } if len(res.Orphaned) == 0 { return } // A CUSTOM domain's records live in the customer's own zone and are the // customer's to remove — CARLOS never published them and will not delete // them. Printing them is the whole point: nothing else in this CLI can // tell an operator which names in their zone just went stale. dead, durable := splitDKIMRecords(res.Orphaned) if len(dead) > 0 { fmt.Fprintln(w, "these records in your DNS are now dead — the identity that signed with them is gone:") printPendingRecords(w, dead) } if len(durable) > 0 { fmt.Fprintf(w, "these are unused but still correct — re-creating %s here would want them back unchanged:\n", res.Domain) printPendingRecords(w, durable) } } // splitDKIMRecords divides an orphaned record set into the half that is now // DEAD and the half that is merely unused. // // Only the DKIM CNAMEs are derived from the identity's own tokens // (mail.RecordSet), so only they stop meaning anything the moment that // identity is deleted. The MAIL FROM MX, its SPF TXT and the DMARC TXT are // deterministic functions of the domain and the region: they come back // byte-identical if the domain is ever re-created here, so telling an // operator to rip them out would be advice that costs them a second // publish for nothing. func splitDKIMRecords(recs []apiDNSRecord) (dead, durable []apiDNSRecord) { for _, r := range recs { if r.Type == "CNAME" { dead = append(dead, r) continue } durable = append(durable, r) } return dead, durable } // ---- status ----------------------------------------------------------------- func runEmailStatus(args []string) { fs := flag.NewFlagSet("email status", flag.ExitOnError) app := appFlag(fs, "app whose email to show") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos email status --app [--account ] [--account-id ] [--console ]" 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) } // apiEmailEvent is one row of `carlos email events`, as the console sends it. type apiEmailEvent struct { MessageID string `json:"message_id"` Type string `json:"type"` Recipient string `json:"recipient"` Status string `json:"status,omitempty"` Diagnostic string `json:"diagnostic,omitempty"` Detail string `json:"detail,omitempty"` BySender bool `json:"by_sender"` At time.Time `json:"at"` } type apiEmailEventsReply struct { Events []apiEmailEvent `json:"events"` RetentionDays int `json:"retention_days"` } func (s *apiSession) emailEvents(ctx context.Context, a apiAcct, app string, q url.Values) (apiEmailEventsReply, error) { tail := "/email/events" if len(q) > 0 { tail += "?" + q.Encode() } status, body, err := s.getJSON(ctx, apiPath(a, app, tail)) if err != nil { return apiEmailEventsReply{}, err } if status != http.StatusOK { return apiEmailEventsReply{}, mailAPIError(status, body) } var out apiEmailEventsReply if err := json.Unmarshal(body, &out); err != nil { return apiEmailEventsReply{}, fmt.Errorf("email events: unreadable response: %w", err) } return out, nil } // sinceParam turns --since into the RFC 3339 time the console reads: a // duration counts back from now, anything else must already be a time. // Empty stays empty — the whole retention window. func sinceParam(raw string, now time.Time) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", nil } if d, err := time.ParseDuration(raw); err == nil { if d < 0 { d = -d } return now.Add(-d).UTC().Format(time.RFC3339), nil } if t, err := time.Parse(time.RFC3339, raw); err == nil { return t.UTC().Format(time.RFC3339), nil } return "", fmt.Errorf("--since %q is neither a duration (24h) nor a time (2026-09-15T08:00:00Z)", raw) } func runEmailEvents(args []string) { if len(args) > 0 && args[0] == "push" { runEmailEventsPush(args[1:]) return } fs := flag.NewFlagSet("email events", flag.ExitOnError) app := appFlag(fs, "app whose email events to show") acctRaw, acctIDRaw, _ := accountFlags(fs) since := fs.String("since", "", "only events since: a duration back from now (24h), or an RFC 3339 time (default: all 30 days kept)") message := fs.String("message", "", "only this message: the id SES gave it when it was sent") to := fs.String("to", "", "only events about this recipient address") n := fs.Int("n", 0, "at most this many events (default 100, max 1000)") asJSON := fs.Bool("json", false, "print the events as JSON, one array, for a script") consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos email events --app [--since ] [--message ] [--to ] [-n ] [--json] [--account ] [--console ]" if name != "" { if err := validAppName(name); err != nil { log.Fatalf("email events: %v", err) } } q := url.Values{} sinceAt, err := sinceParam(*since, time.Now()) if err != nil { log.Fatalf("email events: %v", err) } if sinceAt != "" { q.Set("since", sinceAt) } if v := strings.TrimSpace(*message); v != "" { q.Set("message", v) } if v := strings.TrimSpace(*to); v != "" { q.Set("to", v) } if *n > 0 { q.Set("limit", strconv.Itoa(*n)) } sess := requireMailAPISession(*consoleFlag) if !*asJSON { printUsing(stdout, sess) } acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) res, err := sess.emailEvents(context.Background(), acct, name, q) if err != nil { log.Fatalf("email events: %v", err) } if *asJSON { if res.Events == nil { res.Events = []apiEmailEvent{} } enc := json.NewEncoder(stdout) enc.SetIndent("", " ") enc.Encode(res.Events) return } printEmailEvents(stdout, name, res) } // apiEmailPushReply is what the three push doors answer. type apiEmailPushReply struct { On bool `json:"on"` Path string `json:"path,omitempty"` URL string `json:"url,omitempty"` KeyEnv string `json:"key_env"` PublicKey string `json:"public_key,omitempty"` SetBy string `json:"set_by,omitempty"` SetAt time.Time `json:"set_at,omitempty"` // SuspendedAt is set when the push was suspended after a day in which // the app accepted nothing; SuspendedWhy says what the last attempt got. SuspendedAt time.Time `json:"suspended_at,omitempty"` SuspendedWhy string `json:"suspended_why,omitempty"` Status apiEmailPushDelivery `json:"status"` } type apiEmailPushDelivery struct { Pending int `json:"pending"` Delivered int `json:"delivered"` Abandoned int `json:"abandoned"` LastDelivered time.Time `json:"last_delivered,omitempty"` LastFailedAt time.Time `json:"last_failed_at,omitempty"` LastStatus int `json:"last_status,omitempty"` LastError string `json:"last_error,omitempty"` } func (s *apiSession) emailPush(ctx context.Context, a apiAcct, app, path string, off bool) (apiEmailPushReply, error) { var status int var body []byte var err error endpoint := apiPath(a, app, "/email/events/push") switch { case off: status, body, err = s.deleteJSON(ctx, endpoint, "") case path != "": status, body, err = s.putJSON(ctx, endpoint, map[string]string{"path": path}) default: status, body, err = s.getJSON(ctx, endpoint) } if err != nil { return apiEmailPushReply{}, err } if status != http.StatusOK { return apiEmailPushReply{}, mailAPIError(status, body) } var out apiEmailPushReply if err := json.Unmarshal(body, &out); err != nil { return apiEmailPushReply{}, fmt.Errorf("email events push: unreadable response: %w", err) } return out, nil } func runEmailEventsPush(args []string) { fs := flag.NewFlagSet("email events push", flag.ExitOnError) app := appFlag(fs, "app to tell about its email") acctRaw, acctIDRaw, _ := accountFlags(fs) path := fs.String("path", "", "the path on the app's own host to POST each event to, e.g. /mail/events") off := fs.Bool("off", false, "stop telling the app") asJSON := fs.Bool("json", false, "print the reply as JSON") consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos email events push --app [--path

| --off] [--json] [--account ] [--console ]" if *off && strings.TrimSpace(*path) != "" { log.Fatalf("email events push: --path and --off ask for opposite things — give one\n%s", usage) } if name != "" { if err := validAppName(name); err != nil { log.Fatalf("email events push: %v", err) } } sess := requireMailAPISession(*consoleFlag) if !*asJSON { printUsing(stdout, sess) } acct := resolvedAccount(sess, acctRaw, acctIDRaw) name = resolvedApp(sess, acct, name, usage) res, err := sess.emailPush(context.Background(), acct, name, strings.TrimSpace(*path), *off) if err != nil { log.Fatalf("email events push: %v", err) } if *asJSON { enc := json.NewEncoder(stdout) enc.SetIndent("", " ") enc.Encode(res) return } printEmailPush(stdout, name, res, strings.TrimSpace(*path) != "") } // printEmailPush says where events go, what the app needs to check them, // and how delivery has been going — the last failure in the far end's own // terms, because "answered 404" means the app has no handler at that path // yet and "answered 401" means a gate is in the way, and those are two // different things to go and fix. func printEmailPush(w io.Writer, app string, res apiEmailPushReply, justSet bool) { if !res.On { fmt.Fprintf(w, "%s is not told about its email\n", app) mutedf(w, "turn it on: carlos email events push --app %s --path /mail/events", app) return } if !res.SuspendedAt.IsZero() { fmt.Fprintf(w, "%s's email events push is SUSPENDED since %s\n", app, res.SuspendedAt.Local().Format("2006-01-02 15:04")) fmt.Fprintf(w, " %s\n", res.SuspendedWhy) fmt.Fprintf(w, " nothing is queued or sent while it is suspended; what was waiting was given up on\n") mutedf(w, " when %s answers 2xx again, turn it back on: carlos email events push --app %s --path %s", res.URL, app, res.Path) return } fmt.Fprintf(w, "%s is told about its email at %s\n", app, res.URL) fmt.Fprintf(w, " verify with %s: an Ed25519 public key, standard base64\n", res.KeyEnv) if justSet { mutedf(w, " the key has been added to the app's config — restart the app to pick it up: carlos restart --app %s", app) } st := res.Status fmt.Fprintf(w, " %d delivered, %d waiting, %d given up on (last 30 days)\n", st.Delivered, st.Pending, st.Abandoned) if !st.LastDelivered.IsZero() { fmt.Fprintf(w, " last delivered %s\n", st.LastDelivered.Local().Format("2006-01-02 15:04:05")) } if st.LastError != "" && st.LastFailedAt.After(st.LastDelivered) { fmt.Fprintf(w, " last attempt failed: %s\n", st.LastError) switch { case st.LastStatus == http.StatusNotFound: mutedf(w, " the app has nothing at %s yet — deploy the handler; waiting deliveries are retried, and a day with none accepted suspends the push", res.Path) case st.LastStatus == http.StatusUnauthorized: mutedf(w, " a 401 is usually carlos gate — a gated app cannot receive email events") } } } // printEmailEvents is one line per recipient per event, newest first: when, // what happened, to whom, and the message it happened to — then, indented // beneath, what the receiving server actually said, which is the part that // tells a member what to do about a bounce. func printEmailEvents(w io.Writer, app string, res apiEmailEventsReply) { days := res.RetentionDays if days == 0 { days = 30 } if len(res.Events) == 0 { mutedf(w, "no email events for %s in the last %d days that match — events arrive a few seconds after SES hears back from the receiving server", app, days) return } width := 0 for _, e := range res.Events { if n := len(eventWord(e)); n > width { width = n } } for _, e := range res.Events { to := e.Recipient if to == "" { to = "(no recipient named)" } fmt.Fprintf(w, "%s %-*s %s %s\n", e.At.Local().Format("2006-01-02 15:04:05"), width, eventWord(e), to, e.MessageID) said := e.Diagnostic if e.Status != "" && !strings.Contains(said, e.Status) { said = strings.TrimSpace(e.Status + " " + said) } if said != "" { mutedf(w, " %s", said) } } mutedf(w, "%d event(s), newest first — kept for %d days", len(res.Events), days) } // eventWord is the event type as a member reads it, with the kind of bounce // or complaint where SES said: "bounce (Permanent/NoEmail)". func eventWord(e apiEmailEvent) string { word := map[string]string{ "Send": "sent", "Delivery": "delivered", "Bounce": "bounced", "Complaint": "complaint", "Reject": "rejected", "DeliveryDelay": "delayed", }[e.Type] if word == "" { word = strings.ToLower(e.Type) } if e.Detail != "" { word += " (" + e.Detail + ")" } return word } // 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 ", 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 // Regions whose identity is verified but carries NO configuration // set. See the eventless warning below for why only those count. var eventless []string for _, region := range regions { st := d.Regions[region] if st == nil { continue } parts = append(parts, fmt.Sprintf("%s:%s", region, st.Status)) if st.Status == "verified" && st.ConfigSet == "" { eventless = append(eventless, region) } } fmt.Fprintf(w, " domain %s (%s): %s\n", d.Domain, d.Kind, strings.Join(parts, ", ")) // A VERIFIED region with an empty config_set sends mail perfectly // well and emits no SES events at all — so every count below stays // zero forever, and "today: 0/50 sends" is indistinguishable from a // quiet day. That silence is the whole of spec §9 not working: no // daily cap, no bounce/complaint threshold, no automatic pause. // // It is not a hypothetical state. A deployment binds a configuration // set only inside mail.EnsureDomain's `SNSTopicARN != ""` branch, so // every identity verified BEFORE the deployment's // CARLOS_MAIL_SNS_TOPIC_ARN was set stays unbound permanently — // setting the variable later binds nothing retroactively, and // nothing re-walks existing domains on its own. // // Only VERIFIED regions are named. A pending or failed region has no // configuration set either, but the reason is that it has not // finished verifying, which the status line above already says; // warning there would put a second, misleading diagnosis on a state // the member is already acting on. if len(eventless) > 0 { warnf(w, " no SES event destination in %s — sends, bounces and complaints are never counted for this domain, so the daily cap and the automatic pause cannot fire", strings.Join(eventless, ", ")) mutedf(w, " re-run `carlos email domains add --app %s %s` to bind one — it binds the event "+ "destination and refreshes this app's sending policy together, and leaves a verified identity verified", res.App, d.Domain) } } 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 beyond PauseKind // (mail.Provisioned has no free-text 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 delivered, %d bounces, %d complaints, %d rejects against a cap of %d", res.Counts.Sends, res.Counts.Deliveries, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects, res.DailyCap) // I3 (review follow-up): `carlos email resume` is OPERATOR-only // and 404s for an ordinary member — pointing every paused // member at it, regardless of why, was actively wrong advice. // A CAP pause (PauseKindCap) clears itself on the next UTC day // roll — evaluated both on the SES event path // (mailcounts.go's enforceMailLimits) and on THIS status read // itself (R2, review follow-up: a paused app sends nothing, so // no SES event ever arrives to trigger the event-path check — // running `carlos email status` after the roll is what // actually clears it for an app alone on its own domain). A // RATE pause, or a manual pause this reply carries no kind // for, does not clear on its own and genuinely needs an // operator. switch res.Provisioned.PauseKind { case "cap": mutedf(w, " daily cap reached — this clears at the next UTC day roll; running `carlos email status` after that will clear it") case "rate": mutedf(w, " bounce/complaint rate over threshold — this does not clear on its own; ask a deployment operator to resume it") default: mutedf(w, " paused by a deployment operator — ask them to resume it") } } 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 delivered, %d bounces, %d complaints, %d rejects\n", res.Counts.Sends, res.DailyCap, res.Counts.Deliveries, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects) // One line per sender, and only when there is more than the app sender // to say something about: an app with one sender has already had its // whole story told by the lines above, and repeating it would be noise. if len(res.Senders) > 1 { fmt.Fprintln(w, " senders:") for _, sn := range res.Senders { what := sn.ID if sn.Host != "" { what = sn.Host } else if sn.Address != "" { what = sn.Address } state := c.ok("active") if !sn.Active { state = "paused" if sn.PauseKind != "" { state += " (" + sn.PauseKind + ")" } } fmt.Fprintf(w, " %s %s: %s, %d/%d sends, %d bounces, %d complaints\n", sn.Scope, what, state, sn.Counts.Sends, sn.DailyCap, sn.Counts.Bounces, sn.Counts.Complaints) } } 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 := appFlag(fs, "app to send a test message through") to := fs.String("to", "", "address to send the test message to") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) toAddr := strings.TrimSpace(*to) usage := "usage: carlos email test --app --to [--account ] [--account-id ] [--console ]" 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 // sleepFn is the second seam, and it exists so the retry window below can be // asserted without a test that actually waits two minutes. var sleepFn = func(ctx context.Context, d time.Duration) error { t := time.NewTimer(d) defer t.Stop() select { case <-t.C: return nil case <-ctx.Done(): return ctx.Err() } } // smtpAuthError marks the ONE failure this command must be patient about, so // the retry below can tell it from every other way a send can fail. type smtpAuthError struct{ err error } func (e *smtpAuthError) Error() string { return "smtp auth: " + e.err.Error() } func (e *smtpAuthError) Unwrap() error { return e.err } // The auth-retry window (issue #265). SES's SMTP endpoint does not recognise // a just-minted IAM access key for up to a minute or two; until it does, // every attempt is refused with 535 "Authentication Credentials Invalid", // which reads exactly like a wrong password and is not one. // // Retrying is not a nicety here, it is the difference between a command that // works and one that never does. This flow mints a FRESH credential on every // invocation, so a human who reruns it after a failure is not giving the key // more time — they are testing a brand-new zero-second-old key. The patience // has to live inside the run that minted it. const ( emailTestAuthAttempts = 13 emailTestAuthPause = 10 * time.Second ) // 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) } }() var detail string for attempt := 1; ; attempt++ { detail, err = sendTestMessageFn(creds, to) if err == nil { break } // Only the auth refusal is retried. A refused connection or a // message SES rejects is a real answer, and spending two minutes // re-asking would turn a clear error into a hang. var authErr *smtpAuthError if !errors.As(err, &authErr) || attempt >= emailTestAuthAttempts { return emailTestResult{}, fmt.Errorf("sending through %s: %w", creds.Host, err) } if attempt == 1 { fmt.Fprintf(w, "waiting for the new credential to reach %s — SES does not see a "+ "fresh key for a minute or two (up to %s)\n", creds.Host, (emailTestAuthAttempts-1)*emailTestAuthPause) } if serr := sleepFn(ctx, emailTestAuthPause); serr != nil { return emailTestResult{}, serr } } 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 "", &smtpAuthError{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 } // buildTestMessage is multipart/alternative, a plain part and an HTML part, // because that is the shape most real app mail takes. The transport takes any // MIME message the app builds (SendRawEmail, nothing inspects the body), and // sending the common case is what lets this command say that HTML mail gets // through as well as plain text. func buildTestMessage(from, to string) []byte { now := time.Now().UTC().Format(time.RFC1123Z) var body bytes.Buffer mw := multipart.NewWriter(&body) parts := []struct{ ctype, text string }{ {"text/plain; charset=utf-8", "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"}, {"text/html; charset=utf-8", "

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"}, } for _, p := range parts { pw, err := mw.CreatePart(textproto.MIMEHeader{ "Content-Type": {p.ctype}, "Content-Transfer-Encoding": {"7bit"}, }) if err != nil { panic(err) // writes to a bytes.Buffer cannot fail } io.WriteString(pw, p.text) } mw.Close() 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, "MIME-Version: 1.0\r\n") fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", mw.Boundary()) fmt.Fprintf(&b, "\r\n") b.Write(body.Bytes()) 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 := appFlag(fs, "app to mint a standalone credential for") credName := fs.String("name", "", "credential name (lowercase, digits, hyphens)") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos email credentials create --app --name [--account ] " + "[--account-id ] [--console ]" 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 := appFlag(fs, "app whose standalone credentials to list") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos email credentials list --app [--account ] [--account-id ] [--console ]" 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 := appFlag(fs, "app whose standalone credential to revoke") credName := fs.String("name", "", "credential name to revoke") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) usage := "usage: carlos email credentials revoke --app --name [--account ] " + "[--account-id ] [--console ]" 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 := appFlag(fs, "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 := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := "usage: carlos email rotate --app [--finish] [--account ] [--account-id ] [--console ]" 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 := appFlag(fs, "app to "+verb) acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)") fs.Usage = flagUsage(fs, "") fs.Parse(args) name := strings.TrimSpace(*app) usage := fmt.Sprintf("usage: carlos email %s --app [--account ] [--account-id ] [--console ]", 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) }