| 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) |
| +} |