// Tests for `carlos email` (tenant-email spec, task 18): the wire shape the // apiSession methods send (mail_cli.go, matching apimail_test.go's server // side), the honesty rules the print* functions must follow — a pasteable // record set, a password shown exactly once, a permanent refusal that never // suggests a retry, a timeout that names the pending axis and fails loud — // and the full `enable` flow driven against an in-process fake console. Same // two-layer shape domains_cli_test.go and fleets_cli_test.go established: // apiSession-level tests against a bare httptest server, run*-level tests // against a fixture console with a credential written straight to disk, and // usage-error/dispatch-refusal paths run as a real subprocess (buildCarlos + // carlosTestEnv + a scratch cmd.Dir — CLAUDE.md's rule for anything that // shells out to the binary). package main import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "log" "mime" "mime/multipart" "net" "net/http" "net/http/httptest" "net/mail" "os/exec" "strings" "testing" "time" ) // ---- apiSession-level: path + body + wire-shape round trips --------------- func TestAPISessionEmailDeclareWireShape(t *testing.T) { var gotPath, gotMethod string var gotBody struct { EnvPrefix string `json:"env_prefix"` From string `json:"from"` } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath, gotMethod = r.URL.Path, r.Method json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{ "app": "harbourmail", "declared": map[string]any{"env_prefix": "CARLOS_SMTP", "from": "noreply@x.com"}, "standalone": []any{}, "domains": []any{}, "env_names": []string{"CARLOS_SMTP_HOST"}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).emailDeclare(context.Background(), apiAcct{Seg: "acct"}, "harbourmail", "", "noreply@x.com") if err != nil { t.Fatalf("emailDeclare: %v", err) } if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/acct/harbourmail/email" { t.Fatalf("method/path = %s %s", gotMethod, gotPath) } if gotBody.From != "noreply@x.com" { t.Fatalf("body From = %q", gotBody.From) } if res.Declared == nil || res.Declared.From != "noreply@x.com" { t.Fatalf("result = %+v", res.Declared) } } func TestAPISessionEmailStatus404IsNotDeclared(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]any{"error": "no email is declared for this app — declare it first"}) })) defer ts.Close() _, err := newTestSession(ts.URL).emailStatus(context.Background(), apiAcct{Seg: "acct"}, "app") if err == nil { t.Fatal("emailStatus: want an error on 404") } if !isEmailNotDeclared(err) { t.Fatalf("isEmailNotDeclared(%v) = false, want true", err) } } func TestAPISessionEmailDomainEnsureWireShape(t *testing.T) { var gotBody struct { Domain string `json:"domain"` } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/acct/app/email/domains" || r.Method != http.MethodPost { t.Errorf("path/method = %s %s", r.Method, r.URL.Path) } json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{ "domain": map[string]any{"domain": gotBody.Domain, "kind": "custom", "regions": map[string]any{}}, "pending": []map[string]any{{"name": "_dkim1._domainkey.x.com", "type": "CNAME", "values": []string{"a.dkim.amazonses.com"}, "ttl": 300}}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com") if err != nil { t.Fatalf("emailDomainEnsure: %v", err) } if gotBody.Domain != "x.com" { t.Fatalf("sent domain = %q", gotBody.Domain) } if len(res.Pending) != 1 || res.Pending[0].Name != "_dkim1._domainkey.x.com" { t.Fatalf("pending = %+v", res.Pending) } } // TestAPISessionEmailDomainEnsureConflictIsPermanent pins the 409-preserving // behaviour: a claimed domain must decode into an apiMailError whose status // is Conflict, so the caller can tell it apart from a transient failure. func TestAPISessionEmailDomainEnsureConflictIsPermanent(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]any{"error": "sending domain unavailable"}) })) defer ts.Close() _, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com") var me *apiMailError if !errors.As(err, &me) { t.Fatalf("err = %v, want *apiMailError", err) } if !me.permanent() { t.Fatal("409 claimed-domain error must report permanent() == true") } } // TestAPISessionEmailDomainEnsureBadRequestIsPermanent is the sibling for // ErrNotOwnPlatformName's 400. func TestAPISessionEmailDomainEnsureBadRequestIsPermanent(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{"error": `"x.aaa.oncarlos.com" is not a platform name this account owns`}) })) defer ts.Close() _, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.aaa.oncarlos.com") var me *apiMailError if !errors.As(err, &me) || !me.permanent() { t.Fatalf("err = %v, want a permanent apiMailError", err) } } func TestAPISessionEmailDomainVerifyWireShape(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/acct/app/email/domains/x.com/verify" || r.Method != http.MethodPost { t.Errorf("path/method = %s %s", r.Method, r.URL.Path) } json.NewEncoder(w).Encode(map[string]any{ "domain": map[string]any{"domain": "x.com", "regions": map[string]any{"eu-west-1": map[string]any{"status": "verified"}}}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).emailDomainVerify(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com") if err != nil { t.Fatalf("emailDomainVerify: %v", err) } if domainStatus(res.Domain) != "verified" { t.Fatalf("domainStatus = %q", domainStatus(res.Domain)) } } func TestAPISessionEmailDomainRemoveWireShape(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/acct/app/email/domains/x.com" || r.Method != http.MethodDelete { t.Errorf("path/method = %s %s", r.Method, r.URL.Path) } json.NewEncoder(w).Encode(map[string]any{ "app": "app", "domain": "x.com", "kind": "custom", "torn_down": true, "orphaned": []map[string]any{ {"name": "tok._domainkey.x.com", "type": "CNAME", "values": []string{"tok.dkim.amazonses.com"}, "ttl": 300}, }, }) })) defer ts.Close() res, err := newTestSession(ts.URL).emailDomainRemove(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com") if err != nil { t.Fatalf("emailDomainRemove: %v", err) } if !res.TornDown || len(res.Orphaned) != 1 { t.Fatalf("reply = %+v, want a torn-down domain with one orphaned record", res) } } // TestPrintDomainRemovalSaysWhichOfTheThreeThingsHappened: the outcomes are // not interchangeable — a sibling app kept the domain alive (nothing was // released), this app was the last one (the claim is free for any account), // or only a stranded claim existed to sweep. Printing the same line for all // three would tell a member nothing about whether the domain is now // claimable. func TestPrintDomainRemovalSaysWhichOfTheThreeThingsHappened(t *testing.T) { render := func(res apiEmailDomainRemoveReply) string { var buf bytes.Buffer printDomainRemoval(&buf, res) return buf.String() } kept := render(apiEmailDomainRemoveReply{ App: "jam", Domain: "jelly.tito.io", Kind: "custom", RemainingApps: []string{"stripe"}, }) if !strings.Contains(kept, "stripe") { t.Fatalf("a kept domain does not name the app still using it:\n%s", kept) } if strings.Contains(kept, "claimable") { t.Fatalf("a kept domain claims to have been released:\n%s", kept) } torn := render(apiEmailDomainRemoveReply{ App: "jam", Domain: "jelly.tito.io", Kind: "custom", TornDown: true, }) if !strings.Contains(torn, "claimable again") { t.Fatalf("a released domain does not say it is claimable again:\n%s", torn) } swept := render(apiEmailDomainRemoveReply{ App: "jam", Domain: "jelly.tito.io", Kind: "custom", TornDown: true, ClaimOnly: true, }) if !strings.Contains(swept, "no domain record") { t.Fatalf("an orphaned-claim sweep does not say what it swept:\n%s", swept) } } // TestPrintDomainRemovalSeparatesDeadDKIMFromDurableRecords: of the six // records in a sending domain's set, only the three DKIM CNAMEs are derived // from the identity's own tokens, so only they are dead once it is gone. // The MAIL FROM MX, its SPF TXT and the DMARC TXT are deterministic // functions of the domain and region — telling an operator to rip those out // would cost them a second publish for nothing. func TestPrintDomainRemovalSeparatesDeadDKIMFromDurableRecords(t *testing.T) { var buf bytes.Buffer printDomainRemoval(&buf, apiEmailDomainRemoveReply{ App: "jam", Domain: "jelly.tito.io", Kind: "custom", TornDown: true, Orphaned: []apiDNSRecord{ {Name: "tok1._domainkey.jelly.tito.io", Type: "CNAME", Values: []string{"tok1.dkim.amazonses.com"}}, {Name: "mail-eu-west-1.jelly.tito.io", Type: "MX", Values: []string{"10 feedback-smtp.eu-west-1.amazonses.com"}}, {Name: "_dmarc.jelly.tito.io", Type: "TXT", Values: []string{"v=DMARC1; p=none"}}, }, }) out := buf.String() deadAt := strings.Index(out, "tok1._domainkey.jelly.tito.io") durableAt := strings.Index(out, "mail-eu-west-1.jelly.tito.io") if deadAt < 0 || durableAt < 0 { t.Fatalf("the orphaned records were not printed at all:\n%s", out) } if !strings.Contains(out, "now dead") || !strings.Contains(out, "still correct") { t.Fatalf("output does not separate the dead records from the durable ones:\n%s", out) } if deadAt > durableAt { t.Fatalf("the dead records must come first — they are the ones to remove:\n%s", out) } } func TestAPISessionEmailEnableWireShape(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/acct/app/email/enable" || r.Method != http.MethodPost { t.Errorf("path/method = %s %s", r.Method, r.URL.Path) } json.NewEncoder(w).Encode(map[string]any{ "app": "app", "env_names": []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PORT", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS", "CARLOS_SMTP_FROM"}, "provisioned": map[string]any{"iam_user": "u", "key_id": "AKIA1", "active": true}, }) })) defer ts.Close() res, err := newTestSession(ts.URL).emailEnable(context.Background(), apiAcct{Seg: "acct"}, "app") if err != nil { t.Fatalf("emailEnable: %v", err) } if len(res.EnvNames) != 5 { t.Fatalf("env_names = %v", res.EnvNames) } } func TestAPISessionEmailRotateFinishQuery(t *testing.T) { var gotQuery string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.RawQuery json.NewEncoder(w).Encode(map[string]any{"app": "app"}) })) defer ts.Close() if _, err := newTestSession(ts.URL).emailRotate(context.Background(), apiAcct{Seg: "acct"}, "app", true); err != nil { t.Fatalf("emailRotate finish: %v", err) } if gotQuery != "finish=1" { t.Fatalf("query = %q, want finish=1", gotQuery) } } func TestAPISessionEmailCredentialsCreateWireShape(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/acct/app/email/credentials" || r.Method != http.MethodPost { t.Errorf("path/method = %s %s", r.Method, r.URL.Path) } json.NewEncoder(w).Encode(map[string]any{ "app": "app", "name": "laptop", "host": "email-smtp.eu-west-1.amazonaws.com", "port": "587", "username": "AKIA2", "password": "sekret", "from": "noreply@x.com", }) })) defer ts.Close() res, err := newTestSession(ts.URL).emailCredentialsCreate(context.Background(), apiAcct{Seg: "acct"}, "app", "laptop") if err != nil { t.Fatalf("emailCredentialsCreate: %v", err) } if res.Password != "sekret" { t.Fatalf("password = %q", res.Password) } } func TestAPISessionEmailCredentialsRevokeWireShape(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/acct/app/email/credentials/laptop" || r.Method != http.MethodDelete { t.Errorf("path/method = %s %s", r.Method, r.URL.Path) } json.NewEncoder(w).Encode(map[string]any{"app": "app", "name": "laptop", "revoked": true}) })) defer ts.Close() res, err := newTestSession(ts.URL).emailCredentialsRevoke(context.Background(), apiAcct{Seg: "acct"}, "app", "laptop") if err != nil { t.Fatalf("emailCredentialsRevoke: %v", err) } if !res.Revoked { t.Fatal("revoked = false") } } func TestAPISessionEmailPauseWireShape(t *testing.T) { var gotBody struct { Paused *bool `json:"paused"` } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/cli/apps/acct/app/email/pause" { t.Errorf("path = %s", r.URL.Path) } json.NewDecoder(r.Body).Decode(&gotBody) json.NewEncoder(w).Encode(map[string]any{"app": "app", "active": false}) })) defer ts.Close() res, err := newTestSession(ts.URL).emailPause(context.Background(), apiAcct{Seg: "acct"}, "app", true) if err != nil { t.Fatalf("emailPause: %v", err) } if gotBody.Paused == nil || !*gotBody.Paused { t.Fatalf("paused sent = %v, want true", gotBody.Paused) } if res.Active { t.Fatal("active = true after a pause") } } // ---- print* honesty rules --------------------------------------------------- // TestPrintPendingRecordsIsPasteable pins the required shape: one record per // line, name/type/value, never a JSON blob a member would have to parse // before they could paste it into a zone editor. func TestPrintPendingRecordsIsPasteable(t *testing.T) { var buf bytes.Buffer printPendingRecords(&buf, []apiDNSRecord{ {Name: "_dkim1._domainkey.x.com", Type: "CNAME", Values: []string{"a.dkim.amazonses.com"}}, {Name: "x.com", Type: "MX", Values: []string{"10 feedback-smtp.eu-west-1.amazonses.com"}}, {Name: "_dmarc.x.com", Type: "TXT", Values: []string{"v=DMARC1; p=none"}}, }) out := buf.String() if strings.Contains(out, "{") || strings.Contains(out, "}") || strings.Contains(out, "[") { t.Fatalf("pending records output looks like JSON, not pasteable lines:\n%s", out) } lines := strings.Split(strings.TrimRight(out, "\n"), "\n") if len(lines) != 3 { t.Fatalf("got %d lines, want one per record:\n%s", len(lines), out) } for i, want := range []string{"_dkim1._domainkey.x.com", "x.com", "_dmarc.x.com"} { if !strings.Contains(lines[i], want) { t.Fatalf("line %d = %q, want it to name %q", i, lines[i], want) } } if !strings.Contains(lines[0], "CNAME") || !strings.Contains(lines[0], "a.dkim.amazonses.com") { t.Fatalf("line 0 = %q, want name, type AND value together on one line", lines[0]) } } // TestStandalonePrintsThePasswordOnce pins the required rule: the password // appears exactly once, on stdout, and the output as a whole never suggests // it will be shown again — the mint door's own doc comment says it will not. func TestStandalonePrintsThePasswordOnce(t *testing.T) { var buf bytes.Buffer printEmailCredentialsCreate(&buf, apiEmailCredsReply{ App: "harbourmail", Name: "laptop", Host: "email-smtp.eu-west-1.amazonaws.com", Port: "587", Username: "AKIA2", Password: "swordfish-secret", From: "noreply@x.com", }) out := buf.String() if n := strings.Count(out, "swordfish-secret"); n != 1 { t.Fatalf("password appears %d times, want exactly 1:\n%s", n, out) } if !strings.Contains(out, "shown once") && !strings.Contains(out, "not show it again") { t.Fatalf("output does not warn this is a one-time display:\n%s", out) } } // TestStandalonePasswordNeverReachesTheLogger closes the gap review found: a // future accidental `log.Printf` of the password inside // printEmailCredentialsCreate would pass every existing assertion, because // TestStandalonePrintsThePasswordOnce only inspects the io.Writer it was // given, never the standard logger. A credential leaked into a log has no // revocation story (it is the one door in this whole family that carries a // secret at all — printEmailCredentialsCreate's own doc comment), so this // asserts on the ACTUAL password value in BOTH places: present, once, in the // intended writer, and absent from the logger's own output entirely. func TestStandalonePasswordNeverReachesTheLogger(t *testing.T) { var logBuf bytes.Buffer origOut := log.Writer() origFlags := log.Flags() log.SetOutput(&logBuf) defer func() { log.SetOutput(origOut) log.SetFlags(origFlags) }() var out bytes.Buffer printEmailCredentialsCreate(&out, apiEmailCredsReply{ App: "harbourmail", Name: "laptop", Host: "email-smtp.eu-west-1.amazonaws.com", Port: "587", Username: "AKIA2", Password: "swordfish-secret", From: "noreply@x.com", }) if n := strings.Count(out.String(), "swordfish-secret"); n != 1 { t.Fatalf("intended writer: password appears %d times, want exactly 1:\n%s", n, out.String()) } if strings.Contains(logBuf.String(), "swordfish-secret") { t.Fatalf("the password reached the standard logger — a leak with no revocation story:\n%s", logBuf.String()) } } // TestPrintEmailCredentialsListNeverShowsAPassword: the list view reads off // the status reply, whose Standalone entries carry no password field at // all — this pins that the print function does not somehow invent one, and // that the table is readable (names present). func TestPrintEmailCredentialsListNeverShowsAPassword(t *testing.T) { var buf bytes.Buffer printEmailCredentialsList(&buf, []apiEmailStandalone{ {Name: "laptop", IAMUser: "u1", KeyID: "AKIA2", By: "a@x.com", At: time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)}, }) out := buf.String() if !strings.Contains(out, "laptop") || !strings.Contains(out, "AKIA2") { t.Fatalf("list output missing the credential's own fields:\n%s", out) } if strings.Contains(strings.ToLower(out), "password") { t.Fatalf("list output mentions a password at all:\n%s", out) } } func TestPrintEmailEnabledListsDeliveredVars(t *testing.T) { var buf bytes.Buffer printEmailEnabled(&buf, apiEmailReply{App: "harbourmail", EnvNames: []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PASS"}}) out := buf.String() if !strings.Contains(out, "CARLOS_SMTP_HOST") || !strings.Contains(out, "CARLOS_SMTP_PASS") { t.Fatalf("enabled output missing the delivered variable names:\n%s", out) } } // TestPrintEmailStatusPausedNamesEvidenceNotAGuess: a paused app must be // called out loudly, with the counts a member CAN see — never an invented // reason the reply does not actually carry. func TestPrintEmailStatusPausedNamesEvidenceNotAGuess(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 50, Active: false, Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"}, Counts: apiEmailCounts{Sends: 51, Bounces: 2}, }) out := buf.String() if !strings.Contains(strings.ToUpper(out), "PAUSED") { t.Fatalf("status output does not call out the pause:\n%s", out) } if !strings.Contains(out, "51") || !strings.Contains(out, "50") { t.Fatalf("status output missing the counts/cap evidence:\n%s", out) } } // TestPrintEmailStatusCapPausePointsAtDayRollNotResumeVerb (I3, review // follow-up): `carlos email resume` is operator-only and 404s for an // ordinary member — a cap-paused member must be told the pause clears // itself, never sent at a verb that will refuse them. func TestPrintEmailStatusCapPausePointsAtDayRollNotResumeVerb(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 50, Active: false, Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "cap"}, Counts: apiEmailCounts{Sends: 51}, }) out := buf.String() if !strings.Contains(out, "UTC day roll") { t.Fatalf("cap-pause status does not mention the day roll:\n%s", out) } if strings.Contains(out, "carlos email resume") { t.Fatalf("cap-pause status still points an ordinary member at the operator-only resume verb:\n%s", out) } } // TestPrintEmailStatusRatePausePointsAtOperator: the symmetric case — a // rate pause genuinely needs an operator, and the status output must say // so rather than suggesting the pause clears on its own. func TestPrintEmailStatusRatePausePointsAtOperator(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 100000, Active: false, Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "rate"}, Counts: apiEmailCounts{Sends: 1000, Bounces: 60}, }) out := buf.String() if !strings.Contains(out, "operator") { t.Fatalf("rate-pause status does not point at an operator:\n%s", out) } if strings.Contains(out, "UTC day roll") { t.Fatalf("rate-pause status wrongly claims it clears on its own:\n%s", out) } } func TestPrintEmailStatusActiveShowsKey(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 1000, Active: true, Provisioned: &apiEmailProvisioned{KeyID: "AKIA9"}, }) out := buf.String() if !strings.Contains(out, "active") || !strings.Contains(out, "AKIA9") { t.Fatalf("status output missing the active/key line:\n%s", out) } } func TestPrintEmailRotateNamesFinishCommand(t *testing.T) { var buf bytes.Buffer printEmailRotate(&buf, apiEmailReply{App: "app", Provisioned: &apiEmailProvisioned{KeyID: "AKIA2"}}, false) out := buf.String() if !strings.Contains(out, "AKIA2") || !strings.Contains(out, "carlos email rotate --finish --app app") { t.Fatalf("rotate output = %q", out) } } // ---- describeDomainEnsureErr: permanent refusals never suggest a retry ---- // TestDescribeDomainEnsureErrPermanentRefusalsNeverSuggestRetry is the // mutation-tested rule directly: a 409 (claimed) or 400 (not this account's // platform name) must never contain retry language. func TestDescribeDomainEnsureErrPermanentRefusalsNeverSuggestRetry(t *testing.T) { for _, status := range []int{http.StatusConflict, http.StatusBadRequest} { err := &apiMailError{status: status, err: errors.New("refused")} got := describeDomainEnsureErr("x.com", err).Error() for _, bad := range []string{"try again", "retry", "re-run", "resume"} { if strings.Contains(strings.ToLower(got), bad) { t.Fatalf("status %d: refusal text %q suggests a retry via %q", status, got, bad) } } if !strings.Contains(got, "x.com") { t.Fatalf("status %d: refusal text %q does not name the domain", status, got) } } } func TestDescribeDomainEnsureErrTransientPassesThrough(t *testing.T) { err := &apiMailError{status: http.StatusBadGateway, err: errors.New("could not ensure the sending domain — nothing unrecorded was left behind; re-run to resume")} got := describeDomainEnsureErr("x.com", err).Error() if !strings.Contains(got, "re-run") { t.Fatalf("transient refusal lost its own retry text: %q", got) } } // ---- waitForDomainVerified: the timeout that must exit non-zero ----------- // TestWaitForDomainVerifiedSucceeds: a domain that flips to verified before // the deadline returns nil. func TestWaitForDomainVerifiedSucceeds(t *testing.T) { calls := 0 verify := func(context.Context) (apiEmailDomain, error) { calls++ status := "pending" if calls >= 2 { status = "verified" } return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: status}}}, nil } if err := waitForDomainVerified(context.Background(), verify, "x.com", 2*time.Second); err != nil { t.Fatalf("waitForDomainVerified: %v", err) } if calls < 2 { t.Fatalf("calls = %d, want at least 2 (the loop must actually poll)", calls) } } func TestWaitForDomainVerifiedFailsOnSESFailedStatus(t *testing.T) { verify := func(context.Context) (apiEmailDomain, error) { return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: "failed"}}}, nil } err := waitForDomainVerified(context.Background(), verify, "x.com", time.Minute) if err == nil { t.Fatal("want a non-nil error on an SES-failed domain") } if !strings.Contains(err.Error(), "FAILED") { t.Fatalf("error = %q, want it to say the verification FAILED", err) } } // TestEnableTimeoutExitsNonZeroNamingTheAxis is the required test: a domain // that never leaves "pending" must make waitForDomainVerified return a // non-nil error (which the caller's log.Fatal turns into a non-zero exit — // every run* verb in this file follows that convention) that names the // candidate axes SES tracks, so a member reading it knows what to go check // instead of just "it didn't work." func TestEnableTimeoutExitsNonZeroNamingTheAxis(t *testing.T) { verify := func(context.Context) (apiEmailDomain, error) { return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: "pending"}}}, nil } start := time.Now() err := waitForDomainVerified(context.Background(), verify, "harbourmail.aaa.oncarlos.com", 40*time.Millisecond) elapsed := time.Since(start) if err == nil { t.Fatal("waitForDomainVerified on a domain stuck pending must return a non-nil error (non-zero exit) — got nil") } for _, axis := range []string{"DKIM", "MAIL FROM", "sending"} { if !strings.Contains(err.Error(), axis) { t.Fatalf("timeout error %q does not name axis %q", err.Error(), axis) } } if !strings.Contains(err.Error(), "harbourmail.aaa.oncarlos.com") { t.Fatalf("timeout error %q does not name the domain", err.Error()) } if !strings.Contains(strings.ToLower(err.Error()), "timed out") { t.Fatalf("timeout error %q does not say it timed out", err.Error()) } if elapsed > 2*time.Second { t.Fatalf("waitForDomainVerified took %s for a 40ms timeout — polling interval is not capped by the deadline", elapsed) } } // ---- runEmailEnableFlow: the full member path against a fake console ------ // fakeEmailConsole is a minimal in-process console answering every email // door with fixed, easily-asserted bodies. verifyCalls counts domain-verify // requests so the platform-domain-vs-custom-domain polling behaviour can be // pinned without a live SES estate. type fakeEmailConsole struct { ts *httptest.Server declared bool declaredVia struct { EnvPrefix, From string } pendingRecords bool verifyCalls int verifiedAfter int // domain reports verified once verifyCalls reaches this enableCalled bool domainConflict bool credRevoked bool lastRevokedName string // defaultHost is the not-declared GET /email 404's default_host field // (I4, review follow-up) — "" reproduces a deployment with no platform // suffix configured. defaultHost string } func newFakeEmailConsole(t *testing.T, verifiedAfter int) *fakeEmailConsole { t.Helper() fc := &fakeEmailConsole{verifiedAfter: verifiedAfter} fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps": json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{ {"sqid": "aaa", "name": "ops", "role": "owner", "apps": []string{"harbourmail"}}, }}) case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/email"): if !fc.declared { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]any{ "error": "no email is declared for this app — declare it first", "default_host": fc.defaultHost, }) return } json.NewEncoder(w).Encode(map[string]any{ "app": "harbourmail", "declared": map[string]any{ "env_prefix": fc.declaredVia.EnvPrefix, "from": fc.declaredVia.From, }, "standalone": []any{}, "domains": []any{}, "env_names": []string{}, }) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email"): var body struct{ EnvPrefix, From string } json.NewDecoder(r.Body).Decode(&body) if body.From == "" { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{"error": "From is not an address"}) return } fc.declared = true fc.declaredVia.EnvPrefix, fc.declaredVia.From = body.EnvPrefix, body.From json.NewEncoder(w).Encode(map[string]any{"app": "harbourmail", "declared": map[string]any{"env_prefix": body.EnvPrefix, "from": body.From}}) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email/domains"): if fc.domainConflict { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(map[string]any{"error": "sending domain unavailable"}) return } pending := []map[string]any{} if fc.pendingRecords { pending = append(pending, map[string]any{"name": "_dkim1._domainkey.x.com", "type": "CNAME", "values": []string{"a.dkim.amazonses.com"}, "ttl": 300}) } json.NewEncoder(w).Encode(map[string]any{ "domain": map[string]any{"domain": "x.com", "kind": "custom", "regions": map[string]any{}}, "pending": pending, }) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/verify"): fc.verifyCalls++ status := "pending" if fc.verifyCalls >= fc.verifiedAfter { status = "verified" } json.NewEncoder(w).Encode(map[string]any{ "domain": map[string]any{"domain": "x.com", "regions": map[string]any{"eu-west-1": map[string]any{"status": status}}}, }) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/enable"): fc.enableCalled = true json.NewEncoder(w).Encode(map[string]any{ "app": "harbourmail", "env_names": []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PORT", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS", "CARLOS_SMTP_FROM"}, "provisioned": map[string]any{"iam_user": "u", "key_id": "AKIA1", "active": true}, }) case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email/credentials"): json.NewEncoder(w).Encode(map[string]any{ "app": "harbourmail", "name": "cli-test", "host": "email-smtp.eu-west-1.amazonaws.com", "port": "587", "username": "AKIA9", "password": "temp-secret", "from": "noreply@x.com", }) case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/email/credentials/"): fc.credRevoked = true fc.lastRevokedName = strings.TrimPrefix(r.URL.Path, r.URL.Path[:strings.LastIndex(r.URL.Path, "/")+1]) json.NewEncoder(w).Encode(map[string]any{"app": "harbourmail", "name": fc.lastRevokedName, "revoked": true}) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(fc.ts.Close) return fc } // TestRunEmailEnableFlowPlatformDomainNoPendingRecords: -domain omitted, // -from given, on a platform-kind domain the fake reports with nothing // pending — enable runs straight through and prints the delivered names. func TestRunEmailEnableFlowPlatformDomainNoPendingRecords(t *testing.T) { fc := newFakeEmailConsole(t, 1) // verified on the first check sess := newTestSession(fc.ts.URL) var buf bytes.Buffer err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "", "noreply@harbourmail.aaa.oncarlos.com", "", 5*time.Second) if err != nil { t.Fatalf("runEmailEnableFlow: %v", err) } if !fc.enableCalled { t.Fatal("enable door was never called") } out := buf.String() if !strings.Contains(out, "CARLOS_SMTP_HOST") { t.Fatalf("output missing delivered var names:\n%s", out) } if strings.Contains(out, "publish these records") { t.Fatalf("platform domain with nothing pending must not print a record set:\n%s", out) } } // TestRunEmailEnableFlowCustomDomainPrintsRecordsAndPolls: a custom domain // with pending records prints them and polls verify more than once before // enabling. func TestRunEmailEnableFlowCustomDomainPrintsRecordsAndPolls(t *testing.T) { fc := newFakeEmailConsole(t, 2) // verified on the second check fc.pendingRecords = true sess := newTestSession(fc.ts.URL) var buf bytes.Buffer err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "", "", "x.com", 5*time.Second) if err != nil { t.Fatalf("runEmailEnableFlow: %v", err) } out := buf.String() if !strings.Contains(out, "publish these records") || !strings.Contains(out, "_dkim1._domainkey.x.com") { t.Fatalf("custom domain output missing the record set:\n%s", out) } if fc.verifyCalls < 2 { t.Fatalf("verifyCalls = %d, want at least 2 (must actually poll, not accept the first pending answer)", fc.verifyCalls) } if fc.declaredVia.From != "noreply@x.com" { t.Fatalf("From defaulted to %q, want noreply@x.com derived from --domain", fc.declaredVia.From) } } // TestRunEmailEnableFlowClaimedDomainFailsPermanently: EnsureDomain's 409 // must stop the flow with a permanent-refusal message, never reach enable. func TestRunEmailEnableFlowClaimedDomainFailsPermanently(t *testing.T) { fc := newFakeEmailConsole(t, 1) fc.domainConflict = true sess := newTestSession(fc.ts.URL) var buf bytes.Buffer err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "", "", "x.com", 5*time.Second) if err == nil { t.Fatal("want an error when the domain is already claimed") } if fc.enableCalled { t.Fatal("enable must never be called after a claimed-domain refusal") } if strings.Contains(strings.ToLower(err.Error()), "retry") || strings.Contains(strings.ToLower(err.Error()), "try again") { t.Fatalf("claimed-domain error suggests a retry: %q", err.Error()) } } func TestRunEmailEnableFlowNoFromNoDomainRefusesCleanly(t *testing.T) { fc := newFakeEmailConsole(t, 1) sess := newTestSession(fc.ts.URL) var buf bytes.Buffer err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "", "", "", time.Second) if err == nil { t.Fatal("want an error when neither --from nor --domain is given and nothing was declared before") } } // TestRunEmailEnableFlowNoFromNoDomainDefaultsToTheAppsPlatformHost (I4, // review follow-up): with neither -from nor -domain given, and nothing // declared before, the console's own default_host (spec §3's default // identity, the app's own host) must drive both — the one-command story // `carlos email enable -app X` promises when the deployment has a // platform suffix configured. func TestRunEmailEnableFlowNoFromNoDomainDefaultsToTheAppsPlatformHost(t *testing.T) { fc := newFakeEmailConsole(t, 1) // verified on the first check fc.defaultHost = "harbourmail.aaa.oncarlos.com" sess := newTestSession(fc.ts.URL) var buf bytes.Buffer err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "", "", "", 5*time.Second) if err != nil { t.Fatalf("runEmailEnableFlow: %v", err) } if !fc.enableCalled { t.Fatal("enable door was never called") } if fc.declaredVia.From != "noreply@harbourmail.aaa.oncarlos.com" { t.Fatalf("From = %q, want noreply@ the console's default_host", fc.declaredVia.From) } } // ---- runEmailTestFlow: mint / send / revoke bookkeeping -------------------- // TestRunEmailTestFlowRevokesOnSuccess: the temporary credential is revoked // after a successful send. func TestRunEmailTestFlowRevokesOnSuccess(t *testing.T) { fc := newFakeEmailConsole(t, 1) sess := newTestSession(fc.ts.URL) orig := sendTestMessageFn sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { return "accepted for delivery", nil } t.Cleanup(func() { sendTestMessageFn = orig }) var buf bytes.Buffer res, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com") if err != nil { t.Fatalf("runEmailTestFlow: %v", err) } if res.detail == "" { t.Fatal("result detail is empty") } if !fc.credRevoked { t.Fatal("the temporary standalone credential was never revoked") } } // TestRunEmailTestFlowRevokesOnFailureToo: a send failure must not leave a // live credential behind either. func TestRunEmailTestFlowRevokesOnFailureToo(t *testing.T) { fc := newFakeEmailConsole(t, 1) sess := newTestSession(fc.ts.URL) orig := sendTestMessageFn sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { return "", fmt.Errorf("connection refused") } t.Cleanup(func() { sendTestMessageFn = orig }) var buf bytes.Buffer _, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com") if err == nil { t.Fatal("want an error when the send itself fails") } if !fc.credRevoked { t.Fatal("a failed send must still revoke the temporary credential") } } // The test message is multipart/alternative with a plain and an HTML part, // and a real MIME parser has to read both back out of it. A malformed // boundary would still be "accepted for delivery" by SES and then render as // raw MIME in the recipient's client, which nothing else here would catch. func TestBuildTestMessageIsMultipartAlternative(t *testing.T) { raw := buildTestMessage("noreply@example.com", "friend@example.com") msg, err := mail.ReadMessage(bytes.NewReader(raw)) if err != nil { t.Fatalf("not a parseable message: %v", err) } if got := msg.Header.Get("MIME-Version"); got != "1.0" { t.Errorf("MIME-Version = %q, want 1.0", got) } mt, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) if err != nil || mt != "multipart/alternative" { t.Fatalf("Content-Type = %q (%v), want multipart/alternative", msg.Header.Get("Content-Type"), err) } mr := multipart.NewReader(msg.Body, params["boundary"]) var types []string for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Fatalf("reading part %d: %v", len(types)+1, err) } pt, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type")) body, _ := io.ReadAll(part) if !strings.Contains(string(body), "carlos email test") { t.Errorf("%s part has no body text: %q", pt, body) } types = append(types, pt) } // Plain first: a client shows the LAST alternative it can render. if strings.Join(types, ",") != "text/plain,text/html" { t.Errorf("parts = %v, want [text/plain text/html]", types) } for _, line := range strings.Split(string(raw), "\r\n") { if len(line) > 998 { t.Errorf("line of %d bytes exceeds RFC 5322's 998", len(line)) } } } // TestSendTestMessageMarksA535AsAnAuthFailure pins the link the whole #265 // fix hangs from: the real sendTestMessage must return an error the retry // loop can RECOGNISE. The retry tests above substitute sendTestMessageFn, so // they cannot see this — mutation-testing them proved it, by reverting the // sentinel to a plain fmt.Errorf and watching every one of them stay green. // // So this one drives the real function against a fake SMTP server that // refuses AUTH with 535, exactly as SES does for a key it cannot see yet. // The server deliberately does NOT advertise STARTTLS: sendTestMessage only // upgrades when the extension is offered, and Go's PlainAuth permits // unencrypted credentials to a loopback host, which is what makes an // in-process server possible at all. func TestSendTestMessageMarksA535AsAnAuthFailure(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } defer ln.Close() go func() { c, err := ln.Accept() if err != nil { return } defer c.Close() br := bufio.NewReader(c) fmt.Fprint(c, "220 fake ESMTP\r\n") for { line, err := br.ReadString('\n') if err != nil { return } switch { case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): fmt.Fprint(c, "250-fake\r\n250 AUTH PLAIN\r\n") case strings.HasPrefix(line, "AUTH"): fmt.Fprint(c, "535 Authentication Credentials Invalid\r\n") case strings.HasPrefix(line, "QUIT"): fmt.Fprint(c, "221 bye\r\n") return default: fmt.Fprint(c, "250 ok\r\n") } } }() host, port, err := net.SplitHostPort(ln.Addr().String()) if err != nil { t.Fatalf("split: %v", err) } creds := apiEmailCredsReply{ Host: host, Port: port, Username: "AKIAEXAMPLE", Password: "not-the-right-one", From: "noreply@example.com", } _, err = sendTestMessage(creds, "friend@example.com") if err == nil { t.Fatal("want an error from a 535") } var authErr *smtpAuthError if !errors.As(err, &authErr) { t.Fatalf("a 535 must be recognisable as an auth failure, got %T: %v — "+ "the retry loop keys on this, so an unmarked 535 makes carlos email test "+ "fail on the first attempt again (#265)", err, err) } } // TestRunEmailTestFlowRetriesTheAuthWindow is issue #265. SES's SMTP // endpoint does not see a just-minted IAM access key for a minute or two, so // the FIRST auth attempt against a fresh credential fails with 535. Because // this flow mints a new credential every run, a human retrying the command // never gets past it either — every retry is another zero-second-old key. // The flow must therefore be patient itself. func TestRunEmailTestFlowRetriesTheAuthWindow(t *testing.T) { fc := newFakeEmailConsole(t, 1) sess := newTestSession(fc.ts.URL) origSend, origSleep := sendTestMessageFn, sleepFn calls := 0 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { calls++ if calls < 3 { return "", &smtpAuthError{fmt.Errorf("535 %q", "Authentication Credentials Invalid")} } return "accepted for delivery", nil } slept := 0 sleepFn = func(context.Context, time.Duration) error { slept++; return nil } t.Cleanup(func() { sendTestMessageFn, sleepFn = origSend, origSleep }) var buf bytes.Buffer res, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com") if err != nil { t.Fatalf("runEmailTestFlow: %v", err) } if res.detail == "" { t.Fatal("result detail is empty") } if calls != 3 { t.Fatalf("send attempts = %d, want 3 (two refusals then success)", calls) } if slept != 2 { t.Fatalf("slept %d times, want 2 — one wait between each attempt", slept) } // The wait is long enough to look like a hang; saying so is part of the // fix, not decoration. if !strings.Contains(buf.String(), "waiting") { t.Fatalf("nothing told the user why it is waiting; output = %q", buf.String()) } if !fc.credRevoked { t.Fatal("the temporary standalone credential was never revoked") } } // TestRunEmailTestFlowGivesUpAndStillRevokes: patience is bounded. An auth // failure that outlasts the window is a real failure and must surface as // one — and the credential must still not be left behind. func TestRunEmailTestFlowGivesUpAndStillRevokes(t *testing.T) { fc := newFakeEmailConsole(t, 1) sess := newTestSession(fc.ts.URL) origSend, origSleep := sendTestMessageFn, sleepFn calls := 0 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { calls++ return "", &smtpAuthError{fmt.Errorf("535 %q", "Authentication Credentials Invalid")} } sleepFn = func(context.Context, time.Duration) error { return nil } t.Cleanup(func() { sendTestMessageFn, sleepFn = origSend, origSleep }) var buf bytes.Buffer _, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com") if err == nil { t.Fatal("want an error when auth never succeeds") } if calls != emailTestAuthAttempts { t.Fatalf("send attempts = %d, want emailTestAuthAttempts = %d", calls, emailTestAuthAttempts) } if !fc.credRevoked { t.Fatal("giving up must still revoke the temporary credential") } } // TestRunEmailTestFlowDoesNotRetryANonAuthFailure: the retry exists for one // specific transient. A refused connection, or a message SES rejects, must // fail on the FIRST attempt — retrying those for two minutes would turn a // clear error into a hang. func TestRunEmailTestFlowDoesNotRetryANonAuthFailure(t *testing.T) { fc := newFakeEmailConsole(t, 1) sess := newTestSession(fc.ts.URL) origSend, origSleep := sendTestMessageFn, sleepFn calls := 0 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { calls++ return "", fmt.Errorf("connection refused") } sleepFn = func(context.Context, time.Duration) error { t.Fatal("slept on a non-auth failure"); return nil } t.Cleanup(func() { sendTestMessageFn, sleepFn = origSend, origSleep }) var buf bytes.Buffer if _, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com"); err == nil { t.Fatal("want an error") } if calls != 1 { t.Fatalf("send attempts = %d, want 1 — a non-auth failure is not retried", calls) } } // ---- RunEmail-level: dispatch and refusals (real binary) ------------------- // TestEmailVerbDispatchAndRefusals drives the real binary through // carlosTestEnv with a scratch cmd.Dir — the repo rule (CLAUDE.md) — so a // logged-in laptop's real credential and console can never be what this // test exercises: unknown sub-verb, missing -app, and direct bucket mode // refused with email's own sentence. This also pins main.go's `case // "email"` wiring end to end. func TestEmailVerbDispatchAndRefusals(t *testing.T) { bin := buildCarlos(t) scratch := t.TempDir() run := func(env []string, args ...string) (string, error) { t.Helper() cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, env...) cmd.Dir = scratch out, err := cmd.CombinedOutput() return string(out), err } for _, args := range [][]string{{"email"}, {"email", "wat"}, {"email", "domains"}, {"email", "domains", "wat"}} { out, err := run(nil, args...) if err == nil { t.Fatalf("carlos %v exited 0:\n%s", args, out) } if !strings.Contains(out, "usage: carlos email ") { t.Fatalf("carlos %v = %q, want the email usage block", args, out) } } for _, args := range [][]string{ {"email", "enable", "-app", "keymail"}, {"email", "status", "-app", "keymail"}, {"email", "test", "-app", "keymail", "-to", "a@b.com"}, {"email", "rotate", "-app", "keymail"}, {"email", "pause", "-app", "keymail"}, {"email", "resume", "-app", "keymail"}, {"email", "credentials", "create", "-app", "keymail", "-name", "laptop"}, {"email", "domains", "add", "-app", "keymail", "x.com"}, {"email", "domains", "rm", "-app", "keymail", "x.com"}, } { out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, args...) if err == nil { t.Fatalf("carlos %v in direct bucket mode exited 0:\n%s", args, out) } if !strings.Contains(out, "carlos email works through a console") || !strings.Contains(out, "carlos auth login") { t.Fatalf("carlos %v direct-mode refusal = %q, want email's own message and the login fix", args, out) } } out, err := run(nil, "email", "status", "-app", "Not An App") if err == nil { t.Fatalf("email status with a bad app name exited 0:\n%s", out) } if !strings.Contains(out, "not a usable app name") { t.Fatalf("bad app name = %q, want the name gate", out) } } // TestPrintEmailStatusNamesADomainWithNoEventDestination: a verified domain // whose region carries no configuration set emits NO SES events at all, so // the daily cap, the bounce/complaint thresholds and the automatic pause are // all inert for it — and the "today: 0/50 sends" line reads exactly the same // as a quiet day. Every identity verified before a deployment's // CARLOS_MAIL_SNS_TOPIC_ARN was set is in that state permanently, because // setting the variable binds nothing retroactively. The record already // carries config_set and the status door already returns it; this is the one // view a member with no AWS console has, so it has to say so, and name the // verb that repairs it. func TestPrintEmailStatusNamesADomainWithNoEventDestination(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 50, Active: true, Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"}, Domains: []apiEmailDomain{{ Domain: "harbourmail.bag.oncarlos.com", Kind: "platform", Regions: map[string]*apiEmailRegionState{ "eu-west-1": {Status: "verified", ConfigSet: ""}, }, }}, }) out := buf.String() if !strings.Contains(out, "no SES event destination") { t.Fatalf("status output does not say the domain emits no events:\n%s", out) } // The consequence, not just the condition: a member reading "no event // destination" learns nothing unless it also says what stops working. if !strings.Contains(out, "cap") || !strings.Contains(out, "pause") { t.Fatalf("status output does not name what is inert without events:\n%s", out) } // The no-infra rule (CLAUDE.md): the fix must be a carlos verb, never // "go look in the AWS console". if !strings.Contains(out, "carlos email domains add --app harbourmail harbourmail.bag.oncarlos.com") { t.Fatalf("status output does not name the verb that repairs it:\n%s", out) } } // TestPrintEmailStatusStaysQuietWhenEventsAreBound: the symmetric case. A // domain bound to a configuration set is the healthy shape, and the status // view must not nag about it — a warning printed on every healthy domain is // a warning nobody reads on the unhealthy one. func TestPrintEmailStatusStaysQuietWhenEventsAreBound(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 50, Active: true, Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"}, Domains: []apiEmailDomain{{ Domain: "harbourmail.bag.oncarlos.com", Kind: "platform", Regions: map[string]*apiEmailRegionState{ "eu-west-1": {Status: "verified", ConfigSet: "carlos-bag"}, }, }}, }) out := buf.String() if strings.Contains(out, "no SES event destination") { t.Fatalf("status output warns about a domain that IS bound to a config set:\n%s", out) } } // TestPrintEmailStatusDoesNotWarnOnAnUnverifiedDomain: a pending or failed // region has no configuration set yet either, but the reason is that it is // not finished verifying — telling a member their events are broken while // the identity is still converging is noise on top of a state they are // already being told about. func TestPrintEmailStatusDoesNotWarnOnAnUnverifiedDomain(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 50, Domains: []apiEmailDomain{{ Domain: "harbourmail.bag.oncarlos.com", Kind: "platform", Regions: map[string]*apiEmailRegionState{ "eu-west-1": {Status: "pending", ConfigSet: ""}, }, }}, }) out := buf.String() if strings.Contains(out, "no SES event destination") { t.Fatalf("status output warns about events on a still-verifying domain:\n%s", out) } } // `carlos email events` puts what happened first and what the receiving // server said beneath it — the second line is what tells a member whether // a bounce was a typo in the address or a full mailbox. func TestPrintEmailEventsSaysWhatHappenedAndWhy(t *testing.T) { var buf bytes.Buffer at := time.Date(2026, 9, 15, 8, 0, 2, 0, time.UTC) printEmailEvents(&buf, "jb-book", apiEmailEventsReply{RetentionDays: 30, Events: []apiEmailEvent{ {MessageID: "0102-ann", Type: "Bounce", Recipient: "ann@example.com", Status: "5.1.1", Diagnostic: "smtp; 550 5.1.1 Recipient address rejected", Detail: "Permanent/NoEmail", At: at}, {MessageID: "0102-bob", Type: "Delivery", Recipient: "bob@example.com", Diagnostic: "250 2.0.0 OK", At: at.Add(-time.Minute)}, }}) out := buf.String() for _, want := range []string{ "bounced (Permanent/NoEmail)", "ann@example.com", "0102-ann", "Recipient address rejected", "delivered", "bob@example.com", "250 2.0.0 OK", "2 event(s)", "30 days", } { if !strings.Contains(out, want) { t.Errorf("output is missing %q:\n%s", want, out) } } if strings.Index(out, "0102-ann") > strings.Index(out, "0102-bob") { t.Errorf("events should print in the order the console sent them, newest first:\n%s", out) } // The status code is already in the diagnostic here; it is not said twice. if strings.Count(out, "5.1.1") != 1 { t.Errorf("the status code was repeated:\n%s", out) } buf.Reset() printEmailEvents(&buf, "jb-book", apiEmailEventsReply{RetentionDays: 30}) if !strings.Contains(buf.String(), "no email events for jb-book") { t.Errorf("an empty log should say so, and why it might be empty:\n%s", buf.String()) } } func TestSinceParamTakesADurationOrATime(t *testing.T) { now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) for _, c := range []struct{ in, want string }{ {"", ""}, {"24h", "2026-09-14T12:00:00Z"}, {"90m", "2026-09-15T10:30:00Z"}, {"2026-09-10T08:00:00+01:00", "2026-09-10T07:00:00Z"}, } { got, err := sinceParam(c.in, now) if err != nil || got != c.want { t.Errorf("sinceParam(%q) = %q, %v; want %q", c.in, got, err, c.want) } } if _, err := sinceParam("yesterday", now); err == nil { t.Error("a word that is neither should be refused, not sent to the console") } } // `carlos email events push` says where events go and what the app checks // them with, and when delivery is failing says so in terms that point at // the fix: nothing at the path yet, or a gate in the way. func TestPrintEmailPushPointsAtTheFix(t *testing.T) { var buf bytes.Buffer printEmailPush(&buf, "jb-book", apiEmailPushReply{}, false) if !strings.Contains(buf.String(), "not told") || !strings.Contains(buf.String(), "--path /mail/events") { t.Errorf("off should say how to turn it on:\n%s", buf.String()) } on := apiEmailPushReply{On: true, Path: "/mail/events", URL: "https://jb-book.beb.oncarlos.com/mail/events", KeyEnv: "CARLOS_MAIL_EVENTS_KEY", Status: apiEmailPushDelivery{Delivered: 3, Pending: 1}} buf.Reset() printEmailPush(&buf, "jb-book", on, true) out := buf.String() for _, want := range []string{"https://jb-book.beb.oncarlos.com/mail/events", "CARLOS_MAIL_EVENTS_KEY", "carlos restart --app jb-book", "3 delivered, 1 waiting"} { if !strings.Contains(out, want) { t.Errorf("missing %q:\n%s", want, out) } } delivered := time.Date(2026, 9, 15, 8, 0, 0, 0, time.UTC) for status, hint := range map[int]string{404: "nothing at /mail/events", 401: "carlos gate"} { failing := on failing.Status = apiEmailPushDelivery{Pending: 1, LastDelivered: delivered, LastFailedAt: delivered.Add(time.Hour), LastStatus: status, LastError: fmt.Sprintf("answered %d", status)} buf.Reset() printEmailPush(&buf, "jb-book", failing, false) if !strings.Contains(buf.String(), fmt.Sprintf("answered %d", status)) || !strings.Contains(buf.String(), hint) { t.Errorf("a %d should point at %q:\n%s", status, hint, buf.String()) } if strings.Contains(buf.String(), "restart") { t.Errorf("the restart hint belongs to turning it on, not to every look:\n%s", buf.String()) } } // A failure since recovered from is not news. recovered := on recovered.Status = apiEmailPushDelivery{Delivered: 4, LastDelivered: delivered.Add(2 * time.Hour), LastFailedAt: delivered, LastStatus: 404, LastError: "answered 404"} buf.Reset() printEmailPush(&buf, "jb-book", recovered, false) if strings.Contains(buf.String(), "failed") { t.Errorf("an old failure was reported after a later delivery landed:\n%s", buf.String()) } } // A suspended push says so first, says why, and says how to turn it back on. func TestPrintEmailPushSaysItIsSuspended(t *testing.T) { var buf bytes.Buffer printEmailPush(&buf, "jb-book", apiEmailPushReply{ On: true, Path: "/mail/events", URL: "https://jb-book.beb.oncarlos.com/mail/events", KeyEnv: "CARLOS_MAIL_EVENTS_KEY", SuspendedAt: time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC), SuspendedWhy: "nothing was accepted for a day — the last attempt answered 404", }, false) out := buf.String() for _, want := range []string{"SUSPENDED", "answered 404", "given up on", "carlos email events push --app jb-book --path /mail/events"} { if !strings.Contains(out, want) { t.Errorf("missing %q:\n%s", want, out) } } } // A member with more than one sender sees one line each, and a member with // only the app sender sees exactly what they saw before. func TestPrintEmailStatusRendersALinePerSender(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 50, Active: true, Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"}, Counts: apiEmailCounts{Sends: 12}, Senders: []apiEmailSender{ {ID: "app", Scope: "app", Source: "domain", Active: true, DailyCap: 50, KeyID: "AKIA1", Counts: apiEmailCounts{Sends: 4}}, {ID: "book.example.com", Scope: "instance", Source: "domain", Host: "book.example.com", Active: false, PauseKind: "cap", DailyCap: 1000, KeyID: "AKIA9", Counts: apiEmailCounts{Sends: 8, Bounces: 1}}, }, }) out := buf.String() for _, want := range []string{"app", "book.example.com", "4/50", "8/1000"} { if !strings.Contains(out, want) { t.Errorf("status output is missing %q:\n%s", want, out) } } if !strings.Contains(strings.ToUpper(out), "PAUSED") { t.Errorf("a paused sender is not called out:\n%s", out) } } // An older console answers with no senders array at all, and the output is // the output it has always been. func TestPrintEmailStatusWithNoSendersArrayIsUnchanged(t *testing.T) { var buf bytes.Buffer printEmailStatus(&buf, apiEmailReply{ App: "harbourmail", DailyCap: 50, Active: true, Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"}, Counts: apiEmailCounts{Sends: 12}, }) if strings.Contains(buf.String(), "senders") { t.Errorf("a reply with no senders array printed a senders heading:\n%s", buf.String()) } }