carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// mail_cli.go is `carlos email`: the member surface for tenant email sending
2// (tenant-email spec, task 18). It calls the ten doors internal/console/
3// apimail.go carries (nine from task 17, plus `domains rm`) — API mode only, store.go's sibling and its
4// pattern: a package-level flag family, run* verbs that resolve a session
5// and print* helpers that take an io.Writer so the honesty rules below can
6// be pinned without a subprocess.
7//
8// THE STANDING RULE (CLAUDE.md): every carlos command completes AND
9// verifies its own job with no box access and no AWS console visit. Three
10// places in this file exist because of it, not despite it:
11//
12// - `enable` and `domains add` do not return the moment SES accepts a
13// domain — they POLL verification until SES actually says "verified",
14// or a --timeout expires. A member who ran this command and got exit 0
15// must be able to trust that email works; finding out otherwise from a
16// bounced sign-in link is exactly the failure the rule exists to
17// prevent, so a timeout is log.Fatal, never a quiet "started
18// provisioning" message.
19// - `test` sends a REAL message. Every other verb here only reports what
20// the console's own record says; test is the one command that proves
21// the estate can actually deliver, which is why it exists at all
22// despite `enable` already having "worked".
23// - This file never reads a delivered credential's password back out —
24// it cannot: apimail.go's own doc comment says the delivered secret is
25// sealed into the app's config and never returned again after Enable.
26// `test` therefore mints a throwaway standalone credential (the one
27// reply in this whole API family that carries a password — see
28// handleAPIEmailCredentialsCreate's doc comment), sends through it, and
29// revokes it immediately after, success or failure.
30//
31// WIRE TYPES ARE HAND-ROLLED, deliberately not internal/mail's own structs
32// — store.go's decoupling: this file speaks only the JSON the console's
33// door actually promises, the same discipline a real external client would
34// need.
35package main
36
37import (
38 "bytes"
39 "context"
40 "crypto/rand"
41 "crypto/tls"
42 "encoding/hex"
43 "encoding/json"
44 "errors"
45 "flag"
46 "fmt"
47 "io"
48 "log"
49 "mime/multipart"
50 "net"
51 "net/http"
52 "net/smtp"
53 "net/textproto"
54 "net/url"
55 "os"
56 "sort"
57 "strconv"
58 "strings"
59 "text/tabwriter"
60 "time"
61)
62
63const emailUsage = `usage: carlos email <enable|domains|status|test|credentials|rotate|pause|resume> [flags]
64
65 enable --app <a> [--env-prefix <PREFIX>] [--from <addr>] [--domain <d>] [--timeout <dur>]
66 declare, ensure a sending domain, wait for it to
67 verify, then enable delivery — the one-command
68 path. --domain defaults to the domain half of
69 --from; --from defaults to noreply@<domain> when
70 only --domain is given. On a custom domain, the
71 record set to publish is printed once, then this
72 keeps polling (default 10m, --timeout to change)
73 until SES confirms it or the wait gives up — a
74 gave-up wait exits non-zero naming what SES has
75 not confirmed (DKIM, MAIL FROM, or sending).
76 domains add --app <a> [--timeout <dur>] <domain>
77 the domain half of enable on its own: ensure a
78 sending domain for an app already declared, print
79 the record set on a custom domain, and poll to
80 verified the same way.
81 domains rm --app <a> <domain>
82 stop this app sending from a domain and, when it
83 was the LAST app using it, tear the SES identity
84 down and RELEASE the global claim — the only way
85 short of deleting the app to make a domain
86 claimable by another CARLOS account again.
87 Refused while <domain> is where this app's own
88 From address sits: move that first. A custom
89 domain's DNS is yours, so the now-stale records
90 are printed rather than deleted.
91 status --app <a> domains and their per-region verification state,
92 today's counts against the cap, and the pause
93 state.
94 events --app <a> [--since <dur|time>] [--message <id>] [--to <addr>] [-n <count>] [--json]
95 what happened to each message the app sent —
96 delivered, bounced, complained about, delayed —
97 newest first, with what the receiving server
98 said. Kept for 30 days. --message takes the id SES
99 gave the message when it was sent.
100 events push --app <a> [--path <p> | --off] [--json]
101 tell the app itself: POST each delivery, bounce,
102 complaint, rejection and delay SES attributes to
103 it, signed, to <p> on its own platform host. The
104 verifying key is delivered as CARLOS_MAIL_EVENTS_KEY.
105 Bare, shows where events go and how delivery is
106 going; --off stops it.
107 test --app <a> --to <addr>
108 send a REAL message and report the result — the
109 verify half of the no-infra rule: this is the
110 command that proves email actually works, not
111 just that the console says it should. Sends
112 through a throwaway standalone credential, minted
113 and revoked around the send; it never touches the
114 delivered credential's password, which this CLI
115 cannot read back out.
116 credentials create --app <a> --name <n>
117 mint a standalone credential for something not
118 running on CARLOS — a laptop, a cron box. Shown
119 ONCE, on this command's stdout, and nowhere else.
120 credentials list --app <a>
121 the standalone credentials that exist — names and
122 key ids, never a password (status carries this
123 too; a memorable second entry point for it).
124 credentials revoke --app <a> --name <n>
125 delete one standalone credential and its own IAM
126 principal.
127 rotate --app <a> [--finish]
128 mint a fresh delivered credential, leaving the old
129 one live until --finish deletes it.
130 pause --app <a> stop this app's sending — operator only.
131 resume --app <a> resume it.
132
133 --account <sqid|name> account the app belongs to (default: your only
134 account, or the configured/chosen one).
135 --account-id <sqid> account sqid, matched as a sqid ONLY.
136 --console <origin> which logged-in console to use (only needed with
137 several).
138
139Signed-in only (carlos auth login): email lives in the console's own
140deployment bucket and every verb here calls out to it; there is no
141direct-bucket equivalent.
142`
143
144// runEmail dispatches the email sub-verbs, mirroring runStore's arg slicing.
145func runEmail(args []string) {
146 if len(args) == 0 {
147 fmt.Fprint(os.Stderr, emailUsage)
148 os.Exit(1)
149 }
150 switch args[0] {
151 case "enable":
152 runEmailEnable(args[1:])
153 case "domains":
154 runEmailDomains(args[1:])
155 case "status":
156 runEmailStatus(args[1:])
157 case "events":
158 runEmailEvents(args[1:])
159 case "test":
160 runEmailTest(args[1:])
161 case "credentials":
162 runEmailCredentials(args[1:])
163 case "rotate":
164 runEmailRotate(args[1:])
165 case "pause":
166 runEmailPause(args[1:], true)
167 case "resume":
168 runEmailPause(args[1:], false)
169 default:
170 fmt.Fprint(os.Stderr, emailUsage)
171 os.Exit(1)
172 }
173}
174
175// requireMailAPISession is requireStoreAPISession's exact pattern, for the
176// exact same reason: email has no direct-bucket equivalent (the record is a
177// bucket object only the console can reach, and enable/domains ensure both
178// call out to the deployment's own SES estate).
179func requireMailAPISession(consoleFlag string) *apiSession {
180 sess, err := resolveMode(consoleFlag)
181 if err != nil {
182 log.Fatal(err)
183 }
184 if sess == nil {
185 log.Fatal("carlos email works through a console, and this shell is in direct bucket mode\n" +
186 "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). email is a member/operator verb with\n" +
187 "no direct-bucket equivalent — the record lives in the console's deployment bucket, and enabling\n" +
188 "or adding a domain calls out to the deployment's own SES estate, an authority only the console\n" +
189 "can exercise. Unset the store env and run: carlos auth login --console <url>.")
190 }
191 return sess
192}
193
194// ---- wire types --------------------------------------------------------------
195
196type apiEmailDeclared struct {
197 EnvPrefix string `json:"env_prefix"`
198 From string `json:"from"`
199 At time.Time `json:"at"`
200 By string `json:"by"`
201}
202
203type apiEmailGrant struct {
204 Domains []string `json:"domains"`
205 DailyCap int `json:"daily_cap"`
206 Source string `json:"source"`
207 At time.Time `json:"at"`
208 By string `json:"by"`
209}
210
211type apiEmailProvisioned struct {
212 IAMUser string `json:"iam_user"`
213 UserARN string `json:"user_arn"`
214 KeyID string `json:"key_id"`
215 PrevKeyID string `json:"prev_key_id,omitempty"`
216 Active bool `json:"active"`
217 // PauseKind distinguishes a per-day CAP pause (clears itself on the
218 // UTC day roll) from a RATE pause or a manual operator pause (neither
219 // ever auto-resumes) — mail.Provisioned.PauseKind's wire form (I3,
220 // review follow-up). "" on an active record, on a manual pause, and on
221 // a record written before this field existed.
222 PauseKind string `json:"pause_kind,omitempty"`
223 At time.Time `json:"at"`
224}
225
226type apiEmailStandalone struct {
227 Name string `json:"name"`
228 IAMUser string `json:"iam_user"`
229 KeyID string `json:"key_id"`
230 At time.Time `json:"at"`
231 By string `json:"by"`
232}
233
234type apiEmailRegionState struct {
235 IdentityARN string `json:"identity_arn"`
236 DKIMTokens []string `json:"dkim_tokens"`
237 MailFrom string `json:"mail_from"`
238 ConfigSet string `json:"config_set"`
239 Status string `json:"status"` // "pending" | "verified" | "failed"
240 CheckedAt time.Time `json:"checked_at"`
241}
242
243type apiEmailDomain struct {
244 Schema int `json:"schema"`
245 Domain string `json:"domain"`
246 Kind string `json:"kind"` // "platform" | "custom"
247 Apps []string `json:"apps"`
248 Regions map[string]*apiEmailRegionState `json:"regions"`
249 CreatedAt time.Time `json:"created_at"`
250}
251
252type apiEmailCounts struct {
253 Sends int `json:"sends"`
254 Bounces int `json:"bounces"`
255 Complaints int `json:"complaints"`
256 Rejects int `json:"rejects"`
257 Deliveries int `json:"deliveries"`
258}
259
260// apiEmailSender is one sender in the status reply (tenant-email senders
261// design §8). The reply's top-level fields keep describing the app sender
262// and the app's whole day, so an older console that sends no senders array
263// leaves this empty and the output is what it has always been.
264type apiEmailSender struct {
265 ID string `json:"id"`
266 Scope string `json:"scope"`
267 Source string `json:"source"`
268 Host string `json:"host,omitempty"`
269 Address string `json:"address,omitempty"`
270 Active bool `json:"active"`
271 PauseKind string `json:"pause_kind,omitempty"`
272 DailyCap int `json:"daily_cap"`
273 KeyID string `json:"key_id,omitempty"`
274 Counts apiEmailCounts `json:"counts"`
275}
276
277// apiEmailReply is the shape every declare/status/enable/rotate/pause door
278// answers with — emailReply's wire contract (internal/console/apimail.go).
279type apiEmailReply struct {
280 App string `json:"app"`
281 Declared *apiEmailDeclared `json:"declared"`
282 Grant *apiEmailGrant `json:"grant"`
283 Provisioned *apiEmailProvisioned `json:"provisioned"`
284 Standalone []apiEmailStandalone `json:"standalone"`
285 Domains []apiEmailDomain `json:"domains"`
286 EnvNames []string `json:"env_names"`
287 PendingFinish bool `json:"pending_finish"`
288 MissingVars []string `json:"missing_vars"`
289 DailyCap int `json:"daily_cap"`
290 Active bool `json:"active"`
291 Counts apiEmailCounts `json:"counts"`
292 Senders []apiEmailSender `json:"senders"`
293 // DefaultHost is this app's own host — <app>.<sqid>.<platform-suffix> —
294 // spec §3's default sending identity, computed console-side (I4,
295 // review follow-up: the CLI itself never learns
296 // CARLOS_MAIL_PLATFORM_SUFFIX). Empty when this deployment has no
297 // platform suffix configured.
298 DefaultHost string `json:"default_host,omitempty"`
299}
300
301type apiDNSRecord struct {
302 Name string `json:"name"`
303 Type string `json:"type"`
304 Values []string `json:"values"`
305 TTL int64 `json:"ttl"`
306}
307
308type apiEmailDomainEnsureReply struct {
309 Domain apiEmailDomain `json:"domain"`
310 Pending []apiDNSRecord `json:"pending"`
311}
312
313type apiEmailDomainVerifyReply struct {
314 Domain apiEmailDomain `json:"domain"`
315}
316
317// apiEmailDomainRemoveReply is handleAPIEmailDomainRemove's reply. It says
318// what actually happened rather than just "ok", because the three outcomes
319// have different consequences for the member: a sibling app kept the domain
320// alive (nothing was released), this app was the last one (the claim is now
321// free for any account), or only a stranded claim existed to sweep.
322type apiEmailDomainRemoveReply struct {
323 App string `json:"app"`
324 Domain string `json:"domain"`
325 Kind string `json:"kind"`
326 TornDown bool `json:"torn_down"`
327 ClaimOnly bool `json:"claim_only"`
328 RemainingApps []string `json:"remaining_apps"`
329 Orphaned []apiDNSRecord `json:"orphaned"`
330}
331
332// apiEmailCredsReply is handleAPIEmailCredentialsCreate's reply — THE ONLY
333// one anywhere in this file that carries a password.
334type apiEmailCredsReply struct {
335 App string `json:"app"`
336 Name string `json:"name"`
337 Host string `json:"host"`
338 Port string `json:"port"`
339 Username string `json:"username"`
340 Password string `json:"password"`
341 From string `json:"from"`
342}
343
344type apiEmailRevokeReply struct {
345 App string `json:"app"`
346 Name string `json:"name"`
347 Revoked bool `json:"revoked"`
348}
349
350// apiMailError wraps a non-200 email door reply, preserving the STATUS
351// alongside apiErrorFromBody's message — every other apiSession family in
352// this binary discards the status once it has picked a message, which is
353// fine when every refusal is equally worth retrying. Two of email's
354// refusals are not (a claimed domain, 409; a platform name this account
355// does not own, 400 — both facts about identity, not state a retry could
356// find different), and a caller needs the status to tell them apart from
357// an ordinary transient failure without pattern-matching the message text.
358type apiMailError struct {
359 status int
360 err error
361}
362
363func (e *apiMailError) Error() string { return e.err.Error() }
364func (e *apiMailError) Unwrap() error { return e.err }
365
366// permanent reports whether retrying this call cannot change the outcome.
367func (e *apiMailError) permanent() bool {
368 return e.status == http.StatusConflict || e.status == http.StatusBadRequest
369}
370
371func mailAPIError(status int, body []byte) error {
372 return &apiMailError{status: status, err: apiErrorFromBody(status, body)}
373}
374
375// isEmailNotDeclared reports whether err is the 404 handleAPIEmailStatus
376// gives an app with no email record — a normal, expected state for
377// `carlos email enable`'s own first read, never a failure to surface.
378func isEmailNotDeclared(err error) bool {
379 var me *apiMailError
380 return errors.As(err, &me) && me.status == http.StatusNotFound
381}
382
383// describeDomainEnsureErr renders EnsureDomain's refusal for a human,
384// naming the PERMANENT ones (409 claimed, 400 not-owned) without a shred of
385// retry language — the exact mistake the brief calls out. Every other
386// error passes through apiErrorFromBody's own text unchanged.
387//
388// The console's own words carry the advice. This used to add "choose a
389// different domain" to every 409, which is wrong for the refusal that says
390// how to prove control of the domain (#216).
391func describeDomainEnsureErr(domain string, err error) error {
392 var me *apiMailError
393 if errors.As(err, &me) && me.permanent() {
394 return fmt.Errorf("%s: %s", domain, me.Error())
395 }
396 return fmt.Errorf("ensuring domain %s: %w", domain, err)
397}
398
399// ---- apiSession wire calls ----------------------------------------------
400
401func (s *apiSession) emailDeclare(ctx context.Context, a apiAcct, app, envPrefix, from string) (apiEmailReply, error) {
402 in := struct {
403 EnvPrefix string `json:"env_prefix,omitempty"`
404 From string `json:"from"`
405 }{envPrefix, from}
406 status, body, err := s.postJSON(ctx, apiPath(a, app, "/email"), in)
407 if err != nil {
408 return apiEmailReply{}, err
409 }
410 if status != http.StatusOK {
411 return apiEmailReply{}, mailAPIError(status, body)
412 }
413 var out apiEmailReply
414 if err := json.Unmarshal(body, &out); err != nil {
415 return apiEmailReply{}, fmt.Errorf("email declare: unreadable response: %w", err)
416 }
417 return out, nil
418}
419
420func (s *apiSession) emailStatus(ctx context.Context, a apiAcct, app string) (apiEmailReply, error) {
421 status, body, err := s.getJSON(ctx, apiPath(a, app, "/email"))
422 if err != nil {
423 return apiEmailReply{}, err
424 }
425 if status != http.StatusOK {
426 // Even the "not declared" 404 carries the app's default sending
427 // host (I4, review follow-up) — `carlos email enable` with no
428 // --from needs it on the very FIRST call, before anything has ever
429 // been declared, which is exactly when this branch fires. Best
430 // effort: an unparseable body just leaves DefaultHost empty, same
431 // as a deployment with no platform suffix configured.
432 var partial struct {
433 DefaultHost string `json:"default_host"`
434 }
435 json.Unmarshal(body, &partial)
436 return apiEmailReply{DefaultHost: partial.DefaultHost}, mailAPIError(status, body)
437 }
438 var out apiEmailReply
439 if err := json.Unmarshal(body, &out); err != nil {
440 return apiEmailReply{}, fmt.Errorf("email status: unreadable response: %w", err)
441 }
442 return out, nil
443}
444
445func (s *apiSession) emailDomainEnsure(ctx context.Context, a apiAcct, app, domain string) (apiEmailDomainEnsureReply, error) {
446 in := struct {
447 Domain string `json:"domain"`
448 }{domain}
449 status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/domains"), in)
450 if err != nil {
451 return apiEmailDomainEnsureReply{}, err
452 }
453 if status != http.StatusOK {
454 return apiEmailDomainEnsureReply{}, mailAPIError(status, body)
455 }
456 var out apiEmailDomainEnsureReply
457 if err := json.Unmarshal(body, &out); err != nil {
458 return apiEmailDomainEnsureReply{}, fmt.Errorf("email domains add: unreadable response: %w", err)
459 }
460 return out, nil
461}
462
463func (s *apiSession) emailDomainVerify(ctx context.Context, a apiAcct, app, domain string) (apiEmailDomainVerifyReply, error) {
464 status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/domains/"+pathEscape(domain)+"/verify"), struct{}{})
465 if err != nil {
466 return apiEmailDomainVerifyReply{}, err
467 }
468 if status != http.StatusOK {
469 return apiEmailDomainVerifyReply{}, mailAPIError(status, body)
470 }
471 var out apiEmailDomainVerifyReply
472 if err := json.Unmarshal(body, &out); err != nil {
473 return apiEmailDomainVerifyReply{}, fmt.Errorf("email domains verify: unreadable response: %w", err)
474 }
475 return out, nil
476}
477
478func (s *apiSession) emailDomainRemove(ctx context.Context, a apiAcct, app, domain string) (apiEmailDomainRemoveReply, error) {
479 status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/email/domains/"+pathEscape(domain)))
480 if err != nil {
481 return apiEmailDomainRemoveReply{}, err
482 }
483 if status != http.StatusOK {
484 return apiEmailDomainRemoveReply{}, mailAPIError(status, body)
485 }
486 var out apiEmailDomainRemoveReply
487 if err := json.Unmarshal(body, &out); err != nil {
488 return apiEmailDomainRemoveReply{}, fmt.Errorf("email domains rm: unreadable response: %w", err)
489 }
490 return out, nil
491}
492
493func (s *apiSession) emailEnable(ctx context.Context, a apiAcct, app string) (apiEmailReply, error) {
494 status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/enable"), struct{}{})
495 if err != nil {
496 return apiEmailReply{}, err
497 }
498 if status != http.StatusOK {
499 return apiEmailReply{}, mailAPIError(status, body)
500 }
501 var out apiEmailReply
502 if err := json.Unmarshal(body, &out); err != nil {
503 return apiEmailReply{}, fmt.Errorf("email enable: unreadable response: %w", err)
504 }
505 return out, nil
506}
507
508func (s *apiSession) emailRotate(ctx context.Context, a apiAcct, app string, finish bool) (apiEmailReply, error) {
509 tail := "/email/rotate"
510 if finish {
511 tail = "/email/rotate?finish=1"
512 }
513 status, body, err := s.postJSON(ctx, apiPath(a, app, tail), struct{}{})
514 if err != nil {
515 return apiEmailReply{}, err
516 }
517 if status != http.StatusOK {
518 return apiEmailReply{}, mailAPIError(status, body)
519 }
520 var out apiEmailReply
521 if err := json.Unmarshal(body, &out); err != nil {
522 return apiEmailReply{}, fmt.Errorf("email rotate: unreadable response: %w", err)
523 }
524 return out, nil
525}
526
527func (s *apiSession) emailCredentialsCreate(ctx context.Context, a apiAcct, app, name string) (apiEmailCredsReply, error) {
528 in := struct {
529 Name string `json:"name"`
530 }{name}
531 status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/credentials"), in)
532 if err != nil {
533 return apiEmailCredsReply{}, err
534 }
535 if status != http.StatusOK {
536 return apiEmailCredsReply{}, mailAPIError(status, body)
537 }
538 var out apiEmailCredsReply
539 if err := json.Unmarshal(body, &out); err != nil {
540 return apiEmailCredsReply{}, fmt.Errorf("email credentials create: unreadable response: %w", err)
541 }
542 return out, nil
543}
544
545func (s *apiSession) emailCredentialsRevoke(ctx context.Context, a apiAcct, app, name string) (apiEmailRevokeReply, error) {
546 status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/email/credentials/"+pathEscape(name)), "")
547 if err != nil {
548 return apiEmailRevokeReply{}, err
549 }
550 if status != http.StatusOK {
551 return apiEmailRevokeReply{}, mailAPIError(status, body)
552 }
553 var out apiEmailRevokeReply
554 if err := json.Unmarshal(body, &out); err != nil {
555 return apiEmailRevokeReply{}, fmt.Errorf("email credentials revoke: unreadable response: %w", err)
556 }
557 return out, nil
558}
559
560func (s *apiSession) emailPause(ctx context.Context, a apiAcct, app string, paused bool) (apiEmailReply, error) {
561 in := struct {
562 Paused *bool `json:"paused"`
563 }{&paused}
564 status, body, err := s.postJSON(ctx, apiPath(a, app, "/email/pause"), in)
565 if err != nil {
566 return apiEmailReply{}, err
567 }
568 if status != http.StatusOK {
569 return apiEmailReply{}, mailAPIError(status, body)
570 }
571 var out apiEmailReply
572 if err := json.Unmarshal(body, &out); err != nil {
573 return apiEmailReply{}, fmt.Errorf("email pause: unreadable response: %w", err)
574 }
575 return out, nil
576}
577
578// pathEscape is url.PathEscape, named locally so this file does not need to
579// import net/url just for one call site each in domain verify and
580// credentials revoke.
581func pathEscape(s string) string {
582 // PathEscape escapes '/' too, but neither a domain nor a credential name
583 // (validStandaloneName's charset) can contain one, so a simple percent
584 // escape of the reserved set below is exactly equivalent and keeps the
585 // import list one package shorter.
586 var b strings.Builder
587 for _, r := range s {
588 switch {
589 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.', r == '_', r == '~':
590 b.WriteRune(r)
591 default:
592 fmt.Fprintf(&b, "%%%02X", r)
593 }
594 }
595 return b.String()
596}
597
598// ---- domain verification wait --------------------------------------------
599
600// domainVerifyInterval bounds polling frequency: the domain-verify door is
601// rate-limited at 30 writes/minute per address (cliWriteBurst,
602// authcli.go), shared with declare and domain-ensure's own calls in the
603// same run. 5s keeps a full --timeout worth of polling (up to 120 calls over
604// the default 10m) comfortably under that budget every window, with room
605// left for the other calls this same invocation makes.
606const domainVerifyInterval = 5 * time.Second
607
608// domainStatus collapses a domain's per-region states into one word: SES's
609// own authority is per-region, but v1 is single-region (spec) and a member
610// waiting on `carlos email enable` cares about "can this app send," not
611// which region answered — verified wins if any region has it, failed wins
612// over pending otherwise.
613func domainStatus(d apiEmailDomain) string {
614 failed := false
615 for _, st := range d.Regions {
616 if st == nil {
617 continue
618 }
619 if st.Status == "verified" {
620 return "verified"
621 }
622 if st.Status == "failed" {
623 failed = true
624 }
625 }
626 if failed {
627 return "failed"
628 }
629 return "pending"
630}
631
632// waitForDomainVerified polls verify until domain's identity is verified,
633// SES reports it failed, or timeout expires.
634//
635// A timeout or a failure both return a NON-NIL error — the run* wrapper's
636// log.Fatal on that error is what makes `carlos email enable` exit
637// non-zero on an unverified domain, the exact failure mode the no-infra
638// rule exists to prevent (CLAUDE.md): exiting 0 here would tell a member
639// email works when nobody has confirmed DKIM, MAIL FROM or sending are
640// live, and the first sign they would actually get is a bounce.
641//
642// The pending-timeout message names all three axes SES tracks (DKIM, MAIL
643// FROM, sending — provisioner.go's Status struct) rather than pointing at
644// one: the domain record this CLI reads only carries the COLLAPSED
645// per-region status (apiEmailRegionState.Status), not which individual
646// axis is still short of SUCCESS, so naming one specific axis here would be
647// a guess dressed up as a diagnosis. Naming the true candidate set is the
648// honest form of "saying which axis is still pending" the data supports.
649func waitForDomainVerified(ctx context.Context, verify func(context.Context) (apiEmailDomain, error), domain string, timeout time.Duration) error {
650 deadline := time.Now().Add(timeout)
651 for {
652 d, err := verify(ctx)
653 if err != nil {
654 return fmt.Errorf("checking verification for %s: %w", domain, err)
655 }
656 switch domainStatus(d) {
657 case "verified":
658 return nil
659 case "failed":
660 return fmt.Errorf("verification FAILED for %s — SES could not confirm DKIM or MAIL FROM; "+
661 "check the published records and re-run `carlos email domains add --app <a> %s`", domain, domain)
662 }
663 if !time.Now().Before(deadline) {
664 return fmt.Errorf("timed out after %s waiting for %s to verify — DKIM, MAIL FROM, or sending "+
665 "is still pending at SES; check again with `carlos email status --app <a>`", timeout, domain)
666 }
667 wait := domainVerifyInterval
668 if remaining := time.Until(deadline); remaining < wait {
669 wait = remaining
670 }
671 if wait > 0 {
672 time.Sleep(wait)
673 }
674 }
675}
676
677// ---- enable ---------------------------------------------------------------
678
679func runEmailEnable(args []string) {
680 fs := flag.NewFlagSet("email enable", flag.ExitOnError)
681 app := appFlag(fs, "app to enable email for")
682 envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_SMTP)")
683 from := fs.String("from", "", "the From address this app sends as (default: noreply@ the domain)")
684 domain := fs.String("domain", "", "sending domain to ensure and verify (default: the domain half of --from)")
685 timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for domain verification before giving up")
686 acctRaw, acctIDRaw, _ := accountFlags(fs)
687 consoleFlag := consoleOption(fs, "console origin to enable through (API mode only; default: the only logged-in one)")
688 fs.Usage = flagUsage(fs, "")
689 fs.Parse(args)
690 name := strings.TrimSpace(*app)
691 usage := "usage: carlos email enable --app <a> [--env-prefix <PREFIX>] [--from <addr>] [--domain <d>] [--timeout <dur>] " +
692 "[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
693 if name != "" {
694 if err := validAppName(name); err != nil {
695 log.Fatalf("email enable: %v", err)
696 }
697 }
698
699 sess := requireMailAPISession(*consoleFlag)
700 printUsing(stdout, sess)
701 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
702 name = resolvedApp(sess, acct, name, usage)
703
704 ctx := context.Background()
705 if err := runEmailEnableFlow(ctx, stdout, sess, acct, name,
706 strings.TrimSpace(*envPrefix), strings.TrimSpace(*from), strings.ToLower(strings.TrimSpace(*domain)), *timeout); err != nil {
707 log.Fatalf("email enable: %v", err)
708 }
709}
710
711// runEmailEnableFlow is runEmailEnable's testable core: declare, ensure a
712// domain, wait for it, enable, print the delivered names. Returns an error
713// rather than calling log.Fatal itself, so a test can assert on the exact
714// failure (in particular the timeout's, TestEnableTimeoutExitsNonZeroNamingTheAxis)
715// without a subprocess.
716func runEmailEnableFlow(ctx context.Context, w io.Writer, sess *apiSession, acct apiAcct, app, envPrefix, from, domain string, timeout time.Duration) error {
717 // Read first: a re-run after an earlier declare (or a manual `carlos
718 // email domains add`) should not need --from/--domain repeated, and must
719 // never re-declare a DIFFERENT env prefix once delivered (the door's
720 // own 409 — see handleAPIEmailDeclare's "fixed once delivered" gate).
721 cur, err := sess.emailStatus(ctx, acct, app)
722 if err != nil && !isEmailNotDeclared(err) {
723 return fmt.Errorf("reading current status: %w", err)
724 }
725 if envPrefix == "" && cur.Declared != nil {
726 envPrefix = cur.Declared.EnvPrefix
727 }
728 if from == "" && cur.Declared != nil {
729 from = cur.Declared.From
730 }
731 if domain == "" && from != "" {
732 if i := strings.LastIndex(from, "@"); i >= 0 && i+1 < len(from) {
733 domain = strings.ToLower(from[i+1:])
734 }
735 }
736 if from == "" && domain != "" {
737 from = "noreply@" + domain
738 }
739 // Spec §3's default identity — the app's own host — computed
740 // console-side and carried on cur (I4, review follow-up). Only when
741 // NEITHER --from NOR --domain named anything, and no prior declaration
742 // supplied one either: this is what makes `carlos email enable --app
743 // X`, with no other flags at all, a one-command story. Empty when
744 // this deployment has no platform suffix configured, in which case
745 // the explicit error below still fires, exactly as before this fix.
746 if from == "" && domain == "" && cur.DefaultHost != "" {
747 domain = cur.DefaultHost
748 from = "noreply@" + domain
749 }
750 if from == "" {
751 return errors.New("no From address to declare — pass --from, or --domain (defaults From to noreply@<domain>)")
752 }
753
754 if _, err := sess.emailDeclare(ctx, acct, app, envPrefix, from); err != nil {
755 return fmt.Errorf("declaring: %w", err)
756 }
757
758 if domain != "" {
759 ensured, err := sess.emailDomainEnsure(ctx, acct, app, domain)
760 if err != nil {
761 return describeDomainEnsureErr(domain, err)
762 }
763 if len(ensured.Pending) > 0 {
764 fmt.Fprintf(w, "publish these records for %s, then this keeps polling until SES confirms them:\n", domain)
765 printPendingRecords(w, ensured.Pending)
766 }
767 verify := func(ctx context.Context) (apiEmailDomain, error) {
768 r, err := sess.emailDomainVerify(ctx, acct, app, domain)
769 return r.Domain, err
770 }
771 if err := waitForDomainVerified(ctx, verify, domain, timeout); err != nil {
772 return err
773 }
774 mutedf(w, "%s verified", domain)
775 }
776
777 enabled, err := sess.emailEnable(ctx, acct, app)
778 if err != nil {
779 return fmt.Errorf("enabling: %w", err)
780 }
781 printEmailEnabled(w, enabled)
782 return nil
783}
784
785// ---- domains add ------------------------------------------------------------
786
787func runEmailDomains(args []string) {
788 if len(args) == 0 {
789 fmt.Fprint(os.Stderr, emailUsage)
790 os.Exit(1)
791 }
792 switch args[0] {
793 case "add":
794 runEmailDomainsAdd(args[1:])
795 // "remove" is accepted alongside "rm" because this is the one verb in
796 // the family whose effect is irreversible for other people — a domain
797 // released is a domain a stranger may claim — and a member who spells
798 // it out in full should not be answered with a usage block.
799 case "rm", "remove":
800 runEmailDomainsRemove(args[1:])
801 default:
802 fmt.Fprint(os.Stderr, emailUsage)
803 os.Exit(1)
804 }
805}
806
807func runEmailDomainsAdd(args []string) {
808 fs := flag.NewFlagSet("email domains add", flag.ExitOnError)
809 app := appFlag(fs, "app to add the domain for")
810 timeout := fs.Duration("timeout", 10*time.Minute, "how long to wait for verification before giving up")
811 acctRaw, acctIDRaw, _ := accountFlags(fs)
812 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
813 fs.Usage = flagUsage(fs, "")
814 fs.Parse(args)
815 usage := "usage: carlos email domains add --app <a> [--timeout <dur>] [--account <sqid-or-name>] " +
816 "[--account-id <sqid>] [--console <origin>] <domain>"
817 if fs.NArg() != 1 {
818 log.Fatal(usage)
819 }
820 domain := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
821 name := strings.TrimSpace(*app)
822 if name != "" {
823 if err := validAppName(name); err != nil {
824 log.Fatalf("email domains add: %v", err)
825 }
826 }
827
828 sess := requireMailAPISession(*consoleFlag)
829 printUsing(stdout, sess)
830 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
831 name = resolvedApp(sess, acct, name, usage)
832
833 ctx := context.Background()
834 ensured, err := sess.emailDomainEnsure(ctx, acct, name, domain)
835 if err != nil {
836 log.Fatalf("email domains add: %v", describeDomainEnsureErr(domain, err))
837 }
838 if len(ensured.Pending) > 0 {
839 fmt.Fprintf(stdout, "publish these records for %s, then this keeps polling until SES confirms them:\n", domain)
840 printPendingRecords(stdout, ensured.Pending)
841 }
842 verify := func(ctx context.Context) (apiEmailDomain, error) {
843 r, err := sess.emailDomainVerify(ctx, acct, name, domain)
844 return r.Domain, err
845 }
846 if err := waitForDomainVerified(ctx, verify, domain, *timeout); err != nil {
847 log.Fatalf("email domains add: %v", err)
848 }
849 c := colorFor(stdout)
850 fmt.Fprintf(stdout, "%s %s\n", c.ok("verified"), domain)
851}
852
853// printPendingRecords renders the record set a member must publish in a
854// PASTEABLE form — one record per line, name, type, then value(s) — never a
855// JSON blob. A member is about to copy this straight into a DNS zone editor
856// or a `dig`/registrar form; JSON there is friction this command exists to
857// remove, not preserve.
858func printPendingRecords(w io.Writer, recs []apiDNSRecord) {
859 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
860 for _, r := range recs {
861 for _, v := range r.Values {
862 fmt.Fprintf(tw, "%s\t%s\t%s\n", r.Name, r.Type, v)
863 }
864 }
865 tw.Flush()
866}
867
868// ---- domains rm -------------------------------------------------------------
869
870// runEmailDomainsRemove is `carlos email domains rm`: the release door for a
871// sending domain's GLOBAL claim.
872//
873// Why it exists, since the shape is otherwise unremarkable: the claim
874// (internal/mail/domainclaim.go) is a deliberately global, create-only
875// object, so exactly one CARLOS account can hold a domain. That is right.
876// What was missing was the way back out — the only thing that ever released
877// a claim was permanently DELETING the app that held it, so a domain
878// claimed by the wrong app of the wrong account was unreachable to
879// everybody, its rightful owner included, forever. This is that door.
880func runEmailDomainsRemove(args []string) {
881 fs := flag.NewFlagSet("email domains rm", flag.ExitOnError)
882 app := appFlag(fs, "app to remove the domain from")
883 acctRaw, acctIDRaw, _ := accountFlags(fs)
884 consoleFlag := fs.String("console", "", "console origin (API mode only; default: the only logged-in one)")
885 fs.Usage = flagUsage(fs, "")
886 fs.Parse(args)
887 usage := "usage: carlos email domains rm --app <a> [--account <sqid-or-name>] " +
888 "[--account-id <sqid>] [--console <origin>] <domain>"
889 if fs.NArg() != 1 {
890 log.Fatal(usage)
891 }
892 domain := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
893 name := strings.TrimSpace(*app)
894 if name != "" {
895 if err := validAppName(name); err != nil {
896 log.Fatalf("email domains rm: %v", err)
897 }
898 }
899
900 sess := requireMailAPISession(*consoleFlag)
901 printUsing(stdout, sess)
902 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
903 name = resolvedApp(sess, acct, name, usage)
904
905 res, err := sess.emailDomainRemove(context.Background(), acct, name, domain)
906 if err != nil {
907 // No retry framing and no "permanent" framing either, unlike
908 // `domains add`: this command's two refusals are both states the
909 // caller can change (move the From; name a domain the app actually
910 // uses), and the console's own message already says which and how.
911 log.Fatalf("email domains rm: %v", err)
912 }
913 printDomainRemoval(stdout, res)
914}
915
916// printDomainRemoval says which of the three things happened, because they
917// are not interchangeable: a sibling app kept the domain alive, this app was
918// the last one and the claim is now free, or there was never a record at all
919// and a stranded claim was swept.
920func printDomainRemoval(w io.Writer, res apiEmailDomainRemoveReply) {
921 c := colorFor(w)
922 fmt.Fprintf(w, "%s %s from %s\n", c.ok("removed"), res.Domain, res.App)
923 switch {
924 case !res.TornDown:
925 mutedf(w, " %s still uses it, so its identity, its DNS and its claim are untouched",
926 strings.Join(res.RemainingApps, ", "))
927 return
928 case res.ClaimOnly:
929 mutedf(w, " there was no domain record — only a stranded claim, now released")
930 default:
931 mutedf(w, " last app to use it: the SES identity is gone and the domain is claimable again")
932 }
933 if len(res.Orphaned) == 0 {
934 return
935 }
936 // A CUSTOM domain's records live in the customer's own zone and are the
937 // customer's to remove — CARLOS never published them and will not delete
938 // them. Printing them is the whole point: nothing else in this CLI can
939 // tell an operator which names in their zone just went stale.
940 dead, durable := splitDKIMRecords(res.Orphaned)
941 if len(dead) > 0 {
942 fmt.Fprintln(w, "these records in your DNS are now dead — the identity that signed with them is gone:")
943 printPendingRecords(w, dead)
944 }
945 if len(durable) > 0 {
946 fmt.Fprintf(w, "these are unused but still correct — re-creating %s here would want them back unchanged:\n", res.Domain)
947 printPendingRecords(w, durable)
948 }
949}
950
951// splitDKIMRecords divides an orphaned record set into the half that is now
952// DEAD and the half that is merely unused.
953//
954// Only the DKIM CNAMEs are derived from the identity's own tokens
955// (mail.RecordSet), so only they stop meaning anything the moment that
956// identity is deleted. The MAIL FROM MX, its SPF TXT and the DMARC TXT are
957// deterministic functions of the domain and the region: they come back
958// byte-identical if the domain is ever re-created here, so telling an
959// operator to rip them out would be advice that costs them a second
960// publish for nothing.
961func splitDKIMRecords(recs []apiDNSRecord) (dead, durable []apiDNSRecord) {
962 for _, r := range recs {
963 if r.Type == "CNAME" {
964 dead = append(dead, r)
965 continue
966 }
967 durable = append(durable, r)
968 }
969 return dead, durable
970}
971
972// ---- status -----------------------------------------------------------------
973
974func runEmailStatus(args []string) {
975 fs := flag.NewFlagSet("email status", flag.ExitOnError)
976 app := appFlag(fs, "app whose email to show")
977 acctRaw, acctIDRaw, _ := accountFlags(fs)
978 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
979 fs.Usage = flagUsage(fs, "")
980 fs.Parse(args)
981 name := strings.TrimSpace(*app)
982 usage := "usage: carlos email status --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
983 if name != "" {
984 if err := validAppName(name); err != nil {
985 log.Fatalf("email status: %v", err)
986 }
987 }
988
989 sess := requireMailAPISession(*consoleFlag)
990 printUsing(stdout, sess)
991 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
992 name = resolvedApp(sess, acct, name, usage)
993 res, err := sess.emailStatus(context.Background(), acct, name)
994 if err != nil {
995 if isEmailNotDeclared(err) {
996 fmt.Fprintln(stdout, err)
997 os.Exit(1)
998 }
999 log.Fatalf("email status: %v", err)
1000 }
1001 printEmailStatus(stdout, res)
1002}
1003
1004// apiEmailEvent is one row of `carlos email events`, as the console sends it.
1005type apiEmailEvent struct {
1006 MessageID string `json:"message_id"`
1007 Type string `json:"type"`
1008 Recipient string `json:"recipient"`
1009 Status string `json:"status,omitempty"`
1010 Diagnostic string `json:"diagnostic,omitempty"`
1011 Detail string `json:"detail,omitempty"`
1012 BySender bool `json:"by_sender"`
1013 At time.Time `json:"at"`
1014}
1015
1016type apiEmailEventsReply struct {
1017 Events []apiEmailEvent `json:"events"`
1018 RetentionDays int `json:"retention_days"`
1019}
1020
1021func (s *apiSession) emailEvents(ctx context.Context, a apiAcct, app string, q url.Values) (apiEmailEventsReply, error) {
1022 tail := "/email/events"
1023 if len(q) > 0 {
1024 tail += "?" + q.Encode()
1025 }
1026 status, body, err := s.getJSON(ctx, apiPath(a, app, tail))
1027 if err != nil {
1028 return apiEmailEventsReply{}, err
1029 }
1030 if status != http.StatusOK {
1031 return apiEmailEventsReply{}, mailAPIError(status, body)
1032 }
1033 var out apiEmailEventsReply
1034 if err := json.Unmarshal(body, &out); err != nil {
1035 return apiEmailEventsReply{}, fmt.Errorf("email events: unreadable response: %w", err)
1036 }
1037 return out, nil
1038}
1039
1040// sinceParam turns --since into the RFC 3339 time the console reads: a
1041// duration counts back from now, anything else must already be a time.
1042// Empty stays empty — the whole retention window.
1043func sinceParam(raw string, now time.Time) (string, error) {
1044 raw = strings.TrimSpace(raw)
1045 if raw == "" {
1046 return "", nil
1047 }
1048 if d, err := time.ParseDuration(raw); err == nil {
1049 if d < 0 {
1050 d = -d
1051 }
1052 return now.Add(-d).UTC().Format(time.RFC3339), nil
1053 }
1054 if t, err := time.Parse(time.RFC3339, raw); err == nil {
1055 return t.UTC().Format(time.RFC3339), nil
1056 }
1057 return "", fmt.Errorf("--since %q is neither a duration (24h) nor a time (2026-09-15T08:00:00Z)", raw)
1058}
1059
1060func runEmailEvents(args []string) {
1061 if len(args) > 0 && args[0] == "push" {
1062 runEmailEventsPush(args[1:])
1063 return
1064 }
1065 fs := flag.NewFlagSet("email events", flag.ExitOnError)
1066 app := appFlag(fs, "app whose email events to show")
1067 acctRaw, acctIDRaw, _ := accountFlags(fs)
1068 since := fs.String("since", "", "only events since: a duration back from now (24h), or an RFC 3339 time (default: all 30 days kept)")
1069 message := fs.String("message", "", "only this message: the id SES gave it when it was sent")
1070 to := fs.String("to", "", "only events about this recipient address")
1071 n := fs.Int("n", 0, "at most this many events (default 100, max 1000)")
1072 asJSON := fs.Bool("json", false, "print the events as JSON, one array, for a script")
1073 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1074 fs.Usage = flagUsage(fs, "")
1075 fs.Parse(args)
1076 name := strings.TrimSpace(*app)
1077 usage := "usage: carlos email events --app <a> [--since <dur|time>] [--message <id>] [--to <addr>] [-n <count>] [--json] [--account <sqid-or-name>] [--console <origin>]"
1078 if name != "" {
1079 if err := validAppName(name); err != nil {
1080 log.Fatalf("email events: %v", err)
1081 }
1082 }
1083 q := url.Values{}
1084 sinceAt, err := sinceParam(*since, time.Now())
1085 if err != nil {
1086 log.Fatalf("email events: %v", err)
1087 }
1088 if sinceAt != "" {
1089 q.Set("since", sinceAt)
1090 }
1091 if v := strings.TrimSpace(*message); v != "" {
1092 q.Set("message", v)
1093 }
1094 if v := strings.TrimSpace(*to); v != "" {
1095 q.Set("to", v)
1096 }
1097 if *n > 0 {
1098 q.Set("limit", strconv.Itoa(*n))
1099 }
1100
1101 sess := requireMailAPISession(*consoleFlag)
1102 if !*asJSON {
1103 printUsing(stdout, sess)
1104 }
1105 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1106 name = resolvedApp(sess, acct, name, usage)
1107 res, err := sess.emailEvents(context.Background(), acct, name, q)
1108 if err != nil {
1109 log.Fatalf("email events: %v", err)
1110 }
1111 if *asJSON {
1112 if res.Events == nil {
1113 res.Events = []apiEmailEvent{}
1114 }
1115 enc := json.NewEncoder(stdout)
1116 enc.SetIndent("", " ")
1117 enc.Encode(res.Events)
1118 return
1119 }
1120 printEmailEvents(stdout, name, res)
1121}
1122
1123// apiEmailPushReply is what the three push doors answer.
1124type apiEmailPushReply struct {
1125 On bool `json:"on"`
1126 Path string `json:"path,omitempty"`
1127 URL string `json:"url,omitempty"`
1128 KeyEnv string `json:"key_env"`
1129 PublicKey string `json:"public_key,omitempty"`
1130 SetBy string `json:"set_by,omitempty"`
1131 SetAt time.Time `json:"set_at,omitempty"`
1132 // SuspendedAt is set when the push was suspended after a day in which
1133 // the app accepted nothing; SuspendedWhy says what the last attempt got.
1134 SuspendedAt time.Time `json:"suspended_at,omitempty"`
1135 SuspendedWhy string `json:"suspended_why,omitempty"`
1136 Status apiEmailPushDelivery `json:"status"`
1137}
1138
1139type apiEmailPushDelivery struct {
1140 Pending int `json:"pending"`
1141 Delivered int `json:"delivered"`
1142 Abandoned int `json:"abandoned"`
1143 LastDelivered time.Time `json:"last_delivered,omitempty"`
1144 LastFailedAt time.Time `json:"last_failed_at,omitempty"`
1145 LastStatus int `json:"last_status,omitempty"`
1146 LastError string `json:"last_error,omitempty"`
1147}
1148
1149func (s *apiSession) emailPush(ctx context.Context, a apiAcct, app, path string, off bool) (apiEmailPushReply, error) {
1150 var status int
1151 var body []byte
1152 var err error
1153 endpoint := apiPath(a, app, "/email/events/push")
1154 switch {
1155 case off:
1156 status, body, err = s.deleteJSON(ctx, endpoint, "")
1157 case path != "":
1158 status, body, err = s.putJSON(ctx, endpoint, map[string]string{"path": path})
1159 default:
1160 status, body, err = s.getJSON(ctx, endpoint)
1161 }
1162 if err != nil {
1163 return apiEmailPushReply{}, err
1164 }
1165 if status != http.StatusOK {
1166 return apiEmailPushReply{}, mailAPIError(status, body)
1167 }
1168 var out apiEmailPushReply
1169 if err := json.Unmarshal(body, &out); err != nil {
1170 return apiEmailPushReply{}, fmt.Errorf("email events push: unreadable response: %w", err)
1171 }
1172 return out, nil
1173}
1174
1175func runEmailEventsPush(args []string) {
1176 fs := flag.NewFlagSet("email events push", flag.ExitOnError)
1177 app := appFlag(fs, "app to tell about its email")
1178 acctRaw, acctIDRaw, _ := accountFlags(fs)
1179 path := fs.String("path", "", "the path on the app's own host to POST each event to, e.g. /mail/events")
1180 off := fs.Bool("off", false, "stop telling the app")
1181 asJSON := fs.Bool("json", false, "print the reply as JSON")
1182 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1183 fs.Usage = flagUsage(fs, "")
1184 fs.Parse(args)
1185 name := strings.TrimSpace(*app)
1186 usage := "usage: carlos email events push --app <a> [--path <p> | --off] [--json] [--account <sqid-or-name>] [--console <origin>]"
1187 if *off && strings.TrimSpace(*path) != "" {
1188 log.Fatalf("email events push: --path and --off ask for opposite things — give one\n%s", usage)
1189 }
1190 if name != "" {
1191 if err := validAppName(name); err != nil {
1192 log.Fatalf("email events push: %v", err)
1193 }
1194 }
1195
1196 sess := requireMailAPISession(*consoleFlag)
1197 if !*asJSON {
1198 printUsing(stdout, sess)
1199 }
1200 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1201 name = resolvedApp(sess, acct, name, usage)
1202 res, err := sess.emailPush(context.Background(), acct, name, strings.TrimSpace(*path), *off)
1203 if err != nil {
1204 log.Fatalf("email events push: %v", err)
1205 }
1206 if *asJSON {
1207 enc := json.NewEncoder(stdout)
1208 enc.SetIndent("", " ")
1209 enc.Encode(res)
1210 return
1211 }
1212 printEmailPush(stdout, name, res, strings.TrimSpace(*path) != "")
1213}
1214
1215// printEmailPush says where events go, what the app needs to check them,
1216// and how delivery has been going — the last failure in the far end's own
1217// terms, because "answered 404" means the app has no handler at that path
1218// yet and "answered 401" means a gate is in the way, and those are two
1219// different things to go and fix.
1220func printEmailPush(w io.Writer, app string, res apiEmailPushReply, justSet bool) {
1221 if !res.On {
1222 fmt.Fprintf(w, "%s is not told about its email\n", app)
1223 mutedf(w, "turn it on: carlos email events push --app %s --path /mail/events", app)
1224 return
1225 }
1226 if !res.SuspendedAt.IsZero() {
1227 fmt.Fprintf(w, "%s's email events push is SUSPENDED since %s\n", app, res.SuspendedAt.Local().Format("2006-01-02 15:04"))
1228 fmt.Fprintf(w, " %s\n", res.SuspendedWhy)
1229 fmt.Fprintf(w, " nothing is queued or sent while it is suspended; what was waiting was given up on\n")
1230 mutedf(w, " when %s answers 2xx again, turn it back on: carlos email events push --app %s --path %s", res.URL, app, res.Path)
1231 return
1232 }
1233 fmt.Fprintf(w, "%s is told about its email at %s\n", app, res.URL)
1234 fmt.Fprintf(w, " verify with %s: an Ed25519 public key, standard base64\n", res.KeyEnv)
1235 if justSet {
1236 mutedf(w, " the key has been added to the app's config — restart the app to pick it up: carlos restart --app %s", app)
1237 }
1238 st := res.Status
1239 fmt.Fprintf(w, " %d delivered, %d waiting, %d given up on (last 30 days)\n", st.Delivered, st.Pending, st.Abandoned)
1240 if !st.LastDelivered.IsZero() {
1241 fmt.Fprintf(w, " last delivered %s\n", st.LastDelivered.Local().Format("2006-01-02 15:04:05"))
1242 }
1243 if st.LastError != "" && st.LastFailedAt.After(st.LastDelivered) {
1244 fmt.Fprintf(w, " last attempt failed: %s\n", st.LastError)
1245 switch {
1246 case st.LastStatus == http.StatusNotFound:
1247 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)
1248 case st.LastStatus == http.StatusUnauthorized:
1249 mutedf(w, " a 401 is usually carlos gate — a gated app cannot receive email events")
1250 }
1251 }
1252}
1253
1254// printEmailEvents is one line per recipient per event, newest first: when,
1255// what happened, to whom, and the message it happened to — then, indented
1256// beneath, what the receiving server actually said, which is the part that
1257// tells a member what to do about a bounce.
1258func printEmailEvents(w io.Writer, app string, res apiEmailEventsReply) {
1259 days := res.RetentionDays
1260 if days == 0 {
1261 days = 30
1262 }
1263 if len(res.Events) == 0 {
1264 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)
1265 return
1266 }
1267 width := 0
1268 for _, e := range res.Events {
1269 if n := len(eventWord(e)); n > width {
1270 width = n
1271 }
1272 }
1273 for _, e := range res.Events {
1274 to := e.Recipient
1275 if to == "" {
1276 to = "(no recipient named)"
1277 }
1278 fmt.Fprintf(w, "%s %-*s %s %s\n", e.At.Local().Format("2006-01-02 15:04:05"), width, eventWord(e), to, e.MessageID)
1279 said := e.Diagnostic
1280 if e.Status != "" && !strings.Contains(said, e.Status) {
1281 said = strings.TrimSpace(e.Status + " " + said)
1282 }
1283 if said != "" {
1284 mutedf(w, " %s", said)
1285 }
1286 }
1287 mutedf(w, "%d event(s), newest first — kept for %d days", len(res.Events), days)
1288}
1289
1290// eventWord is the event type as a member reads it, with the kind of bounce
1291// or complaint where SES said: "bounce (Permanent/NoEmail)".
1292func eventWord(e apiEmailEvent) string {
1293 word := map[string]string{
1294 "Send": "sent", "Delivery": "delivered", "Bounce": "bounced", "Complaint": "complaint",
1295 "Reject": "rejected", "DeliveryDelay": "delayed",
1296 }[e.Type]
1297 if word == "" {
1298 word = strings.ToLower(e.Type)
1299 }
1300 if e.Detail != "" {
1301 word += " (" + e.Detail + ")"
1302 }
1303 return word
1304}
1305
1306// printEmailStatus is the one view a member with no box access and no AWS
1307// console has of every domain's per-region verification state, today's
1308// counts against the cap, and the pause state.
1309func printEmailStatus(w io.Writer, res apiEmailReply) {
1310 c := colorFor(w)
1311 fmt.Fprintf(w, "%s %s\n", c.head("email for"), res.App)
1312 if res.Declared != nil {
1313 fmt.Fprintf(w, " declared: prefix %s, from %s, by %s at %s\n",
1314 res.Declared.EnvPrefix, res.Declared.From, res.Declared.By, res.Declared.At.Format(time.RFC3339))
1315 } else {
1316 mutedf(w, " not declared — carlos email enable --app %s", res.App)
1317 }
1318
1319 if len(res.Domains) == 0 {
1320 mutedf(w, " no sending domains yet — carlos email domains add --app %s <domain>", res.App)
1321 }
1322 for _, d := range res.Domains {
1323 regions := make([]string, 0, len(d.Regions))
1324 for region := range d.Regions {
1325 regions = append(regions, region)
1326 }
1327 sort.Strings(regions)
1328 var parts []string
1329 // Regions whose identity is verified but carries NO configuration
1330 // set. See the eventless warning below for why only those count.
1331 var eventless []string
1332 for _, region := range regions {
1333 st := d.Regions[region]
1334 if st == nil {
1335 continue
1336 }
1337 parts = append(parts, fmt.Sprintf("%s:%s", region, st.Status))
1338 if st.Status == "verified" && st.ConfigSet == "" {
1339 eventless = append(eventless, region)
1340 }
1341 }
1342 fmt.Fprintf(w, " domain %s (%s): %s\n", d.Domain, d.Kind, strings.Join(parts, ", "))
1343 // A VERIFIED region with an empty config_set sends mail perfectly
1344 // well and emits no SES events at all — so every count below stays
1345 // zero forever, and "today: 0/50 sends" is indistinguishable from a
1346 // quiet day. That silence is the whole of spec §9 not working: no
1347 // daily cap, no bounce/complaint threshold, no automatic pause.
1348 //
1349 // It is not a hypothetical state. A deployment binds a configuration
1350 // set only inside mail.EnsureDomain's `SNSTopicARN != ""` branch, so
1351 // every identity verified BEFORE the deployment's
1352 // CARLOS_MAIL_SNS_TOPIC_ARN was set stays unbound permanently —
1353 // setting the variable later binds nothing retroactively, and
1354 // nothing re-walks existing domains on its own.
1355 //
1356 // Only VERIFIED regions are named. A pending or failed region has no
1357 // configuration set either, but the reason is that it has not
1358 // finished verifying, which the status line above already says;
1359 // warning there would put a second, misleading diagnosis on a state
1360 // the member is already acting on.
1361 if len(eventless) > 0 {
1362 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",
1363 strings.Join(eventless, ", "))
1364 mutedf(w, " re-run `carlos email domains add --app %s %s` to bind one — it binds the event "+
1365 "destination and refreshes this app's sending policy together, and leaves a verified identity verified",
1366 res.App, d.Domain)
1367 }
1368 }
1369
1370 if res.Provisioned != nil {
1371 if res.Active {
1372 fmt.Fprintf(w, " sending: %s (key %s)\n", c.ok("active"), res.Provisioned.KeyID)
1373 } else {
1374 warnf(w, " sending: PAUSED — SMTP auth is failing for this app right now")
1375 // The record does not carry a causal reason beyond PauseKind
1376 // (mail.Provisioned has no free-text Reason field) — the
1377 // honest thing to print is the evidence a member CAN see
1378 // (today's counts against the cap), not a diagnosis this
1379 // reply cannot actually back up.
1380 mutedf(w, " today: %d sends, %d delivered, %d bounces, %d complaints, %d rejects against a cap of %d",
1381 res.Counts.Sends, res.Counts.Deliveries, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects, res.DailyCap)
1382 // I3 (review follow-up): `carlos email resume` is OPERATOR-only
1383 // and 404s for an ordinary member — pointing every paused
1384 // member at it, regardless of why, was actively wrong advice.
1385 // A CAP pause (PauseKindCap) clears itself on the next UTC day
1386 // roll — evaluated both on the SES event path
1387 // (mailcounts.go's enforceMailLimits) and on THIS status read
1388 // itself (R2, review follow-up: a paused app sends nothing, so
1389 // no SES event ever arrives to trigger the event-path check —
1390 // running `carlos email status` after the roll is what
1391 // actually clears it for an app alone on its own domain). A
1392 // RATE pause, or a manual pause this reply carries no kind
1393 // for, does not clear on its own and genuinely needs an
1394 // operator.
1395 switch res.Provisioned.PauseKind {
1396 case "cap":
1397 mutedf(w, " daily cap reached — this clears at the next UTC day roll; running `carlos email status` after that will clear it")
1398 case "rate":
1399 mutedf(w, " bounce/complaint rate over threshold — this does not clear on its own; ask a deployment operator to resume it")
1400 default:
1401 mutedf(w, " paused by a deployment operator — ask them to resume it")
1402 }
1403 }
1404 if res.PendingFinish {
1405 warnf(w, " a rotation is pending finish — the previous key is still live: carlos email rotate --finish --app %s", res.App)
1406 }
1407 } else {
1408 mutedf(w, " not enabled yet — carlos email enable --app %s", res.App)
1409 }
1410
1411 fmt.Fprintf(w, " today: %d/%d sends, %d delivered, %d bounces, %d complaints, %d rejects\n",
1412 res.Counts.Sends, res.DailyCap, res.Counts.Deliveries, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects)
1413
1414 // One line per sender, and only when there is more than the app sender
1415 // to say something about: an app with one sender has already had its
1416 // whole story told by the lines above, and repeating it would be noise.
1417 if len(res.Senders) > 1 {
1418 fmt.Fprintln(w, " senders:")
1419 for _, sn := range res.Senders {
1420 what := sn.ID
1421 if sn.Host != "" {
1422 what = sn.Host
1423 } else if sn.Address != "" {
1424 what = sn.Address
1425 }
1426 state := c.ok("active")
1427 if !sn.Active {
1428 state = "paused"
1429 if sn.PauseKind != "" {
1430 state += " (" + sn.PauseKind + ")"
1431 }
1432 }
1433 fmt.Fprintf(w, " %s %s: %s, %d/%d sends, %d bounces, %d complaints\n",
1434 sn.Scope, what, state, sn.Counts.Sends, sn.DailyCap, sn.Counts.Bounces, sn.Counts.Complaints)
1435 }
1436 }
1437
1438 if len(res.MissingVars) > 0 {
1439 warnf(w, " drift: %s not yet delivered to this app's config", strings.Join(res.MissingVars, ", "))
1440 }
1441 if len(res.Standalone) > 0 {
1442 fmt.Fprintf(w, " standalone credentials: %s\n", standaloneNames(res.Standalone))
1443 }
1444}
1445
1446func standaloneNames(sa []apiEmailStandalone) string {
1447 names := make([]string, 0, len(sa))
1448 for _, s := range sa {
1449 names = append(names, s.Name)
1450 }
1451 return strings.Join(names, ", ")
1452}
1453
1454// printEmailEnabled answers `carlos email enable`'s final line: the
1455// delivered variable names, never their values (the values are sealed into
1456// the app's config; this CLI never sees them either).
1457func printEmailEnabled(w io.Writer, res apiEmailReply) {
1458 c := colorFor(w)
1459 fmt.Fprintf(w, "%s %s — delivers %s\n", c.ok("email enabled for"), res.App, strings.Join(res.EnvNames, ", "))
1460 mutedf(w, "running instances pick up the new config within seconds")
1461}
1462
1463// ---- test -------------------------------------------------------------------
1464
1465func runEmailTest(args []string) {
1466 fs := flag.NewFlagSet("email test", flag.ExitOnError)
1467 app := appFlag(fs, "app to send a test message through")
1468 to := fs.String("to", "", "address to send the test message to")
1469 acctRaw, acctIDRaw, _ := accountFlags(fs)
1470 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1471 fs.Usage = flagUsage(fs, "")
1472 fs.Parse(args)
1473 name := strings.TrimSpace(*app)
1474 toAddr := strings.TrimSpace(*to)
1475 usage := "usage: carlos email test --app <a> --to <addr> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
1476 if !strings.Contains(toAddr, "@") {
1477 log.Fatal(usage)
1478 }
1479 if name != "" {
1480 if err := validAppName(name); err != nil {
1481 log.Fatalf("email test: %v", err)
1482 }
1483 }
1484
1485 sess := requireMailAPISession(*consoleFlag)
1486 printUsing(stdout, sess)
1487 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1488 name = resolvedApp(sess, acct, name, usage)
1489
1490 ctx := context.Background()
1491 result, err := runEmailTestFlow(ctx, stdout, sess, acct, name, toAddr)
1492 if err != nil {
1493 log.Fatalf("email test: %v", err)
1494 }
1495 c := colorFor(stdout)
1496 fmt.Fprintf(stdout, "%s sent to %s from %s via %s: %s\n", c.ok("test message"), toAddr, result.from, result.host, result.detail)
1497}
1498
1499type emailTestResult struct {
1500 host, from, detail string
1501}
1502
1503// sendTestMessageFn is a seam: tests substitute a fake sender so the
1504// mint-send-revoke bookkeeping below is provable without a live SMTP
1505// endpoint on the far end.
1506var sendTestMessageFn = sendTestMessage
1507
1508// sleepFn is the second seam, and it exists so the retry window below can be
1509// asserted without a test that actually waits two minutes.
1510var sleepFn = func(ctx context.Context, d time.Duration) error {
1511 t := time.NewTimer(d)
1512 defer t.Stop()
1513 select {
1514 case <-t.C:
1515 return nil
1516 case <-ctx.Done():
1517 return ctx.Err()
1518 }
1519}
1520
1521// smtpAuthError marks the ONE failure this command must be patient about, so
1522// the retry below can tell it from every other way a send can fail.
1523type smtpAuthError struct{ err error }
1524
1525func (e *smtpAuthError) Error() string { return "smtp auth: " + e.err.Error() }
1526func (e *smtpAuthError) Unwrap() error { return e.err }
1527
1528// The auth-retry window (issue #265). SES's SMTP endpoint does not recognise
1529// a just-minted IAM access key for up to a minute or two; until it does,
1530// every attempt is refused with 535 "Authentication Credentials Invalid",
1531// which reads exactly like a wrong password and is not one.
1532//
1533// Retrying is not a nicety here, it is the difference between a command that
1534// works and one that never does. This flow mints a FRESH credential on every
1535// invocation, so a human who reruns it after a failure is not giving the key
1536// more time — they are testing a brand-new zero-second-old key. The patience
1537// has to live inside the run that minted it.
1538const (
1539 emailTestAuthAttempts = 13
1540 emailTestAuthPause = 10 * time.Second
1541)
1542
1543// runEmailTestFlow mints a throwaway standalone credential, sends through
1544// it, and revokes it — success or failure, always. This CLI has no other
1545// way to prove sending actually works: the delivered credential's password
1546// is sealed into the app's config and never handed back (apimail.go's own
1547// doc comment on handleAPIEmailCredentialsCreate), so reusing it here is
1548// not an option, only minting a fresh one for the purpose.
1549func runEmailTestFlow(ctx context.Context, w io.Writer, sess *apiSession, acct apiAcct, app, to string) (emailTestResult, error) {
1550 credName := "cli-test-" + randomHex(4)
1551 creds, err := sess.emailCredentialsCreate(ctx, acct, app, credName)
1552 if err != nil {
1553 return emailTestResult{}, fmt.Errorf("minting a test credential: %w", err)
1554 }
1555 defer func() {
1556 if _, rerr := sess.emailCredentialsRevoke(ctx, acct, app, credName); rerr != nil {
1557 warnf(w, "could not revoke the temporary test credential %q — remove it with "+
1558 "`carlos email credentials revoke --app %s --name %s`: %v", credName, app, credName, rerr)
1559 }
1560 }()
1561
1562 var detail string
1563 for attempt := 1; ; attempt++ {
1564 detail, err = sendTestMessageFn(creds, to)
1565 if err == nil {
1566 break
1567 }
1568 // Only the auth refusal is retried. A refused connection or a
1569 // message SES rejects is a real answer, and spending two minutes
1570 // re-asking would turn a clear error into a hang.
1571 var authErr *smtpAuthError
1572 if !errors.As(err, &authErr) || attempt >= emailTestAuthAttempts {
1573 return emailTestResult{}, fmt.Errorf("sending through %s: %w", creds.Host, err)
1574 }
1575 if attempt == 1 {
1576 fmt.Fprintf(w, "waiting for the new credential to reach %s — SES does not see a "+
1577 "fresh key for a minute or two (up to %s)\n",
1578 creds.Host, (emailTestAuthAttempts-1)*emailTestAuthPause)
1579 }
1580 if serr := sleepFn(ctx, emailTestAuthPause); serr != nil {
1581 return emailTestResult{}, serr
1582 }
1583 }
1584 return emailTestResult{host: creds.Host, from: creds.From, detail: detail}, nil
1585}
1586
1587func randomHex(n int) string {
1588 b := make([]byte, n)
1589 if _, err := rand.Read(b); err != nil {
1590 // crypto/rand failing is effectively never on any real platform;
1591 // time-derived fallback keeps this from ever blocking a test send.
1592 return fmt.Sprintf("%x", time.Now().UnixNano())
1593 }
1594 return hex.EncodeToString(b)
1595}
1596
1597// sendTestMessage delivers one real message over SMTP submission (STARTTLS,
1598// port 587 — the whole of the delivered transport contract, spec §1) using
1599// a freshly minted standalone credential, and reports SES's accept/refuse.
1600func sendTestMessage(creds apiEmailCredsReply, to string) (string, error) {
1601 addr := net.JoinHostPort(creds.Host, creds.Port)
1602 c, err := smtp.Dial(addr)
1603 if err != nil {
1604 return "", fmt.Errorf("dial %s: %w", addr, err)
1605 }
1606 defer c.Close()
1607 if ok, _ := c.Extension("STARTTLS"); ok {
1608 if err := c.StartTLS(&tls.Config{ServerName: creds.Host}); err != nil {
1609 return "", fmt.Errorf("starttls: %w", err)
1610 }
1611 }
1612 auth := smtp.PlainAuth("", creds.Username, creds.Password, creds.Host)
1613 if err := c.Auth(auth); err != nil {
1614 return "", &smtpAuthError{err}
1615 }
1616 if err := c.Mail(creds.From); err != nil {
1617 return "", fmt.Errorf("mail from: %w", err)
1618 }
1619 if err := c.Rcpt(to); err != nil {
1620 return "", fmt.Errorf("rcpt to: %w", err)
1621 }
1622 wc, err := c.Data()
1623 if err != nil {
1624 return "", fmt.Errorf("data: %w", err)
1625 }
1626 msg := buildTestMessage(creds.From, to)
1627 if _, err := wc.Write(msg); err != nil {
1628 wc.Close()
1629 return "", fmt.Errorf("writing message: %w", err)
1630 }
1631 if err := wc.Close(); err != nil {
1632 return "", fmt.Errorf("message rejected: %w", err)
1633 }
1634 _ = c.Quit() // best-effort: the message was already accepted by here
1635 return "accepted for delivery", nil
1636}
1637
1638// buildTestMessage is multipart/alternative, a plain part and an HTML part,
1639// because that is the shape most real app mail takes. The transport takes any
1640// MIME message the app builds (SendRawEmail, nothing inspects the body), and
1641// sending the common case is what lets this command say that HTML mail gets
1642// through as well as plain text.
1643func buildTestMessage(from, to string) []byte {
1644 now := time.Now().UTC().Format(time.RFC1123Z)
1645 var body bytes.Buffer
1646 mw := multipart.NewWriter(&body)
1647 parts := []struct{ ctype, text string }{
1648 {"text/plain; charset=utf-8", "This is a test message sent by `carlos email test` to prove this app's " +
1649 "email sending actually works, not just that it was configured.\r\n"},
1650 {"text/html; charset=utf-8", "<p>This is a test message sent by <code>carlos email test</code> to prove " +
1651 "this app's email sending <strong>actually works</strong>, not just that it was configured.</p>\r\n"},
1652 }
1653 for _, p := range parts {
1654 pw, err := mw.CreatePart(textproto.MIMEHeader{
1655 "Content-Type": {p.ctype},
1656 "Content-Transfer-Encoding": {"7bit"},
1657 })
1658 if err != nil {
1659 panic(err) // writes to a bytes.Buffer cannot fail
1660 }
1661 io.WriteString(pw, p.text)
1662 }
1663 mw.Close()
1664
1665 var b strings.Builder
1666 fmt.Fprintf(&b, "From: %s\r\n", from)
1667 fmt.Fprintf(&b, "To: %s\r\n", to)
1668 fmt.Fprintf(&b, "Subject: CARLOS email test\r\n")
1669 fmt.Fprintf(&b, "Date: %s\r\n", now)
1670 fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
1671 fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", mw.Boundary())
1672 fmt.Fprintf(&b, "\r\n")
1673 b.Write(body.Bytes())
1674 return []byte(b.String())
1675}
1676
1677// ---- credentials ------------------------------------------------------------
1678
1679func runEmailCredentials(args []string) {
1680 if len(args) == 0 {
1681 fmt.Fprint(os.Stderr, emailUsage)
1682 os.Exit(1)
1683 }
1684 switch args[0] {
1685 case "create":
1686 runEmailCredentialsCreate(args[1:])
1687 case "list":
1688 runEmailCredentialsList(args[1:])
1689 case "revoke":
1690 runEmailCredentialsRevoke(args[1:])
1691 default:
1692 fmt.Fprint(os.Stderr, emailUsage)
1693 os.Exit(1)
1694 }
1695}
1696
1697func runEmailCredentialsCreate(args []string) {
1698 fs := flag.NewFlagSet("email credentials create", flag.ExitOnError)
1699 app := appFlag(fs, "app to mint a standalone credential for")
1700 credName := fs.String("name", "", "credential name (lowercase, digits, hyphens)")
1701 acctRaw, acctIDRaw, _ := accountFlags(fs)
1702 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1703 fs.Usage = flagUsage(fs, "")
1704 fs.Parse(args)
1705 usage := "usage: carlos email credentials create --app <a> --name <n> [--account <sqid-or-name>] " +
1706 "[--account-id <sqid>] [--console <origin>]"
1707 name := strings.TrimSpace(*app)
1708 if strings.TrimSpace(*credName) == "" {
1709 log.Fatal(usage)
1710 }
1711 if name != "" {
1712 if err := validAppName(name); err != nil {
1713 log.Fatalf("email credentials create: %v", err)
1714 }
1715 }
1716
1717 sess := requireMailAPISession(*consoleFlag)
1718 printUsing(stdout, sess)
1719 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1720 name = resolvedApp(sess, acct, name, usage)
1721 res, err := sess.emailCredentialsCreate(context.Background(), acct, name, strings.TrimSpace(*credName))
1722 if err != nil {
1723 log.Fatalf("email credentials create: %v", err)
1724 }
1725 printEmailCredentialsCreate(stdout, res)
1726}
1727
1728// printEmailCredentialsCreate prints the password ONCE, to stdout, and
1729// nowhere else — no config file, no log line. handleAPIEmailCredentialsCreate's
1730// own doc comment: this reply is the only one in the whole family that
1731// carries a secret, because nothing else ever will.
1732func printEmailCredentialsCreate(w io.Writer, res apiEmailCredsReply) {
1733 c := colorFor(w)
1734 fmt.Fprintf(w, "%s %s for %s\n", c.ok("standalone credential"), res.Name, res.App)
1735 fmt.Fprintf(w, " host: %s\n", res.Host)
1736 fmt.Fprintf(w, " port: %s\n", res.Port)
1737 fmt.Fprintf(w, " username: %s\n", res.Username)
1738 fmt.Fprintf(w, " password: %s\n", res.Password)
1739 fmt.Fprintf(w, " from: %s\n", res.From)
1740 warnf(w, "shown once — this console will not show it again; store it somewhere safe now")
1741}
1742
1743func runEmailCredentialsList(args []string) {
1744 fs := flag.NewFlagSet("email credentials list", flag.ExitOnError)
1745 app := appFlag(fs, "app whose standalone credentials to list")
1746 acctRaw, acctIDRaw, _ := accountFlags(fs)
1747 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1748 fs.Usage = flagUsage(fs, "")
1749 fs.Parse(args)
1750 name := strings.TrimSpace(*app)
1751 usage := "usage: carlos email credentials list --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
1752 if name != "" {
1753 if err := validAppName(name); err != nil {
1754 log.Fatalf("email credentials list: %v", err)
1755 }
1756 }
1757
1758 sess := requireMailAPISession(*consoleFlag)
1759 printUsing(stdout, sess)
1760 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1761 name = resolvedApp(sess, acct, name, usage)
1762 res, err := sess.emailStatus(context.Background(), acct, name)
1763 if err != nil {
1764 log.Fatalf("email credentials list: %v", err)
1765 }
1766 printEmailCredentialsList(stdout, res.Standalone)
1767}
1768
1769// printEmailCredentialsList never prints a password — the status reply this
1770// reads from does not carry one (mail.Standalone has no such field at all),
1771// so there is nothing here to leak by construction, not just by omission.
1772func printEmailCredentialsList(w io.Writer, sa []apiEmailStandalone) {
1773 if len(sa) == 0 {
1774 mutedf(w, "no standalone credentials")
1775 return
1776 }
1777 t := newTable(w)
1778 fmt.Fprintf(t, "NAME\tKEY ID\tCREATED BY\tAT\n")
1779 for _, s := range sa {
1780 fmt.Fprintf(t, "%s\t%s\t%s\t%s\n", s.Name, s.KeyID, s.By, s.At.Format(time.RFC3339))
1781 }
1782 t.Flush()
1783}
1784
1785func runEmailCredentialsRevoke(args []string) {
1786 fs := flag.NewFlagSet("email credentials revoke", flag.ExitOnError)
1787 app := appFlag(fs, "app whose standalone credential to revoke")
1788 credName := fs.String("name", "", "credential name to revoke")
1789 acctRaw, acctIDRaw, _ := accountFlags(fs)
1790 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1791 fs.Usage = flagUsage(fs, "")
1792 fs.Parse(args)
1793 usage := "usage: carlos email credentials revoke --app <a> --name <n> [--account <sqid-or-name>] " +
1794 "[--account-id <sqid>] [--console <origin>]"
1795 name := strings.TrimSpace(*app)
1796 if strings.TrimSpace(*credName) == "" {
1797 log.Fatal(usage)
1798 }
1799 if name != "" {
1800 if err := validAppName(name); err != nil {
1801 log.Fatalf("email credentials revoke: %v", err)
1802 }
1803 }
1804
1805 sess := requireMailAPISession(*consoleFlag)
1806 printUsing(stdout, sess)
1807 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1808 name = resolvedApp(sess, acct, name, usage)
1809 res, err := sess.emailCredentialsRevoke(context.Background(), acct, name, strings.TrimSpace(*credName))
1810 if err != nil {
1811 log.Fatalf("email credentials revoke: %v", err)
1812 }
1813 c := colorFor(stdout)
1814 fmt.Fprintf(stdout, "%s %s for %s\n", c.ok("revoked"), res.Name, res.App)
1815}
1816
1817// ---- rotate -----------------------------------------------------------------
1818
1819func runEmailRotate(args []string) {
1820 fs := flag.NewFlagSet("email rotate", flag.ExitOnError)
1821 app := appFlag(fs, "app whose delivered email credential to rotate")
1822 finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one")
1823 acctRaw, acctIDRaw, _ := accountFlags(fs)
1824 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1825 fs.Usage = flagUsage(fs, "")
1826 fs.Parse(args)
1827 name := strings.TrimSpace(*app)
1828 usage := "usage: carlos email rotate --app <a> [--finish] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]"
1829 if name != "" {
1830 if err := validAppName(name); err != nil {
1831 log.Fatalf("email rotate: %v", err)
1832 }
1833 }
1834
1835 sess := requireMailAPISession(*consoleFlag)
1836 printUsing(stdout, sess)
1837 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1838 name = resolvedApp(sess, acct, name, usage)
1839 res, err := sess.emailRotate(context.Background(), acct, name, *finish)
1840 if err != nil {
1841 log.Fatalf("email rotate: %v", err)
1842 }
1843 printEmailRotate(stdout, res, *finish)
1844}
1845
1846func printEmailRotate(w io.Writer, res apiEmailReply, finish bool) {
1847 c := colorFor(w)
1848 if finish {
1849 fmt.Fprintf(w, "%s for %s\n", c.ok("previous email key deleted"), res.App)
1850 return
1851 }
1852 key := ""
1853 if res.Provisioned != nil {
1854 key = res.Provisioned.KeyID
1855 }
1856 fmt.Fprintf(w, "%s %s — new key %s\n", c.ok("rotated the email credential for"), res.App, key)
1857 mutedf(w, "the old key stays active until `carlos email rotate --finish --app %s`", res.App)
1858}
1859
1860// ---- pause / resume ----------------------------------------------------------
1861
1862func runEmailPause(args []string, paused bool) {
1863 verb := "pause"
1864 if !paused {
1865 verb = "resume"
1866 }
1867 fs := flag.NewFlagSet("email "+verb, flag.ExitOnError)
1868 app := appFlag(fs, "app to "+verb)
1869 acctRaw, acctIDRaw, _ := accountFlags(fs)
1870 consoleFlag := consoleOption(fs, "console origin (API mode only; default: the only logged-in one)")
1871 fs.Usage = flagUsage(fs, "")
1872 fs.Parse(args)
1873 name := strings.TrimSpace(*app)
1874 usage := fmt.Sprintf("usage: carlos email %s --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]", verb)
1875 if name != "" {
1876 if err := validAppName(name); err != nil {
1877 log.Fatalf("email %s: %v", verb, err)
1878 }
1879 }
1880
1881 sess := requireMailAPISession(*consoleFlag)
1882 printUsing(stdout, sess)
1883 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
1884 name = resolvedApp(sess, acct, name, usage)
1885 res, err := sess.emailPause(context.Background(), acct, name, paused)
1886 if err != nil {
1887 log.Fatalf("email %s: %v", verb, err)
1888 }
1889 c := colorFor(stdout)
1890 fmt.Fprintf(stdout, "%s %s\n", c.ok(verb+"d email for"), res.App)
1891}
1892