carlos / platform Public

carlos email status prints a line per sender (plan 2, task 14)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 18f7dbc184b3ed5d5399097bf93da7927a5b8f7a parent 8be533b
2 files changed, +83 −0
  • mail_cli.go +42 −0
  • mail_cli_test.go +41 −0
diff --git a/mail_cli.go b/mail_cli.go
index 7cc800ae1..caf2d6517 100644
--- a/mail_cli.go
+++ b/mail_cli.go
@@ -244,6 +244,23 @@ type apiEmailCounts struct {
Deliveries int `json:"deliveries"`
}
+// apiEmailSender is one sender in the status reply (tenant-email senders
+// design §8). The reply's top-level fields keep describing the app sender
+// and the app's whole day, so an older console that sends no senders array
+// leaves this empty and the output is what it has always been.
+type apiEmailSender struct {
+ ID string `json:"id"`
+ Scope string `json:"scope"`
+ Source string `json:"source"`
+ Host string `json:"host,omitempty"`
+ Address string `json:"address,omitempty"`
+ Active bool `json:"active"`
+ PauseKind string `json:"pause_kind,omitempty"`
+ DailyCap int `json:"daily_cap"`
+ KeyID string `json:"key_id,omitempty"`
+ Counts apiEmailCounts `json:"counts"`
+}
+
// apiEmailReply is the shape every declare/status/enable/rotate/pause door
// answers with — emailReply's wire contract (internal/console/apimail.go).
type apiEmailReply struct {
@@ -259,6 +276,7 @@ type apiEmailReply struct {
DailyCap int `json:"daily_cap"`
Active bool `json:"active"`
Counts apiEmailCounts `json:"counts"`
+ Senders []apiEmailSender `json:"senders"`
// DefaultHost is this app's own host — <app>.<sqid>.<platform-suffix> —
// spec §3's default sending identity, computed console-side (I4,
// review follow-up: the CLI itself never learns
@@ -1231,6 +1249,30 @@ func printEmailStatus(w io.Writer, res apiEmailReply) {
fmt.Fprintf(w, " today: %d/%d sends, %d delivered, %d bounces, %d complaints, %d rejects\n",
res.Counts.Sends, res.DailyCap, res.Counts.Deliveries, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects)
+ // One line per sender, and only when there is more than the app sender
+ // to say something about: an app with one sender has already had its
+ // whole story told by the lines above, and repeating it would be noise.
+ if len(res.Senders) > 1 {
+ fmt.Fprintln(w, " senders:")
+ for _, sn := range res.Senders {
+ what := sn.ID
+ if sn.Host != "" {
+ what = sn.Host
+ } else if sn.Address != "" {
+ what = sn.Address
+ }
+ state := c.ok("active")
+ if !sn.Active {
+ state = "paused"
+ if sn.PauseKind != "" {
+ state += " (" + sn.PauseKind + ")"
+ }
+ }
+ fmt.Fprintf(w, " %s %s: %s, %d/%d sends, %d bounces, %d complaints\n",
+ sn.Scope, what, state, sn.Counts.Sends, sn.DailyCap, sn.Counts.Bounces, sn.Counts.Complaints)
+ }
+ }
+
if len(res.MissingVars) > 0 {
warnf(w, " drift: %s not yet delivered to this app's config", strings.Join(res.MissingVars, ", "))
}
diff --git a/mail_cli_test.go b/mail_cli_test.go
index 7c0dcafa5..4e9cb11db 100644
--- a/mail_cli_test.go
+++ b/mail_cli_test.go
@@ -1225,3 +1225,44 @@ func TestPrintEmailPushSaysItIsSuspended(t *testing.T) {
}
}
}
+
+// 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())
+ }
+}