carlos / platform Public

console: the email status reply carries a senders array beside its unchanged fields (plan 2, task 13)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 7ccfa8a8533ca56dbb264a53cc3c76bdb5692ea8 parent 84e4072
2 files changed, +129 −9
  • internal/console/apimail.go +63 −9
  • internal/console/apimail_test.go +66 −0
diff --git a/internal/console/apimail.go b/internal/console/apimail.go
index 6c895e1a0..899e358ab 100644
--- a/internal/console/apimail.go
+++ b/internal/console/apimail.go
@@ -285,11 +285,20 @@ func (s *Server) mailDefaultHost(acct *Account, app string) string {
// member reading "cap: 1000" while running on the free tier's 50 would have
// no way to know their own console lied to them.
func mailEffectiveCap(rec *mail.Record) int {
- dailyCap := TierCap("")
- if rec != nil && rec.Grant != nil && rec.Grant.DailyCap > 0 {
- dailyCap = rec.Grant.DailyCap
+ if rec == nil {
+ return mailCapFor(nil)
}
- return dailyCap
+ return mailCapFor(rec.Grant)
+}
+
+// mailCapFor is mailEffectiveCap for one sender's grant. Both read the same
+// rule, because the cap a status reply names must be the cap the limiter
+// enforces (mailcounts.go's enforceMailLimits).
+func mailCapFor(g *mail.GrantBlock) int {
+ if g != nil && g.DailyCap > 0 {
+ return g.DailyCap
+ }
+ return TierCap("")
}
// mailAppDomains is every domain record in acct's account that names app as
@@ -364,16 +373,21 @@ func dnsRecordsOut(recs []dnsrec.Record) []dnsRecordOut {
// so enable, rotate and status can never drift into describing the same
// record differently.
//
-// standalone and domains are NEVER null, matching storeReply's env_names —
-// a client reads them rather than testing for absence. Neither carries a
-// password: mail.Standalone stores a name, an IAM user and a key id, never
-// the secret (see that type's doc comment); a domain record carries DKIM
-// tokens and ARNs, not credentials.
+// standalone, domains and senders are NEVER null, matching storeReply's
+// env_names — a client reads them rather than testing for absence. Neither
+// carries a password: mail.Standalone stores a name, an IAM user and a key
+// id, never the secret (see that type's doc comment); a domain record
+// carries DKIM tokens and ARNs, not credentials.
//
// counts/daily_cap/active are computed fresh on every call — never latched
// on the record — because they are exactly the sight a member with no box
// access and no AWS console has of what the automatic limiter (mailcounts.go)
// is about to do to their app.
+//
+// senders (senders design §8) is the per-sender view of the same sight,
+// app sender first. Every top-level field above keeps describing the APP
+// sender and the app's whole day unchanged, so an older CLI reading only
+// those fields still works.
func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec *mail.Record) (map[string]any, error) {
domains, err := s.mailAppDomains(ctx, acct, app)
if err != nil {
@@ -408,6 +422,45 @@ func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec
if standalone == nil {
standalone = []*mail.Standalone{}
}
+ // The senders array (senders design §8). The top-level fields above keep
+ // describing the APP sender and the app's whole day, so an older CLI
+ // still works; this is the per-sender view beside them.
+ all, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ return nil, err
+ }
+ senders := make([]map[string]any, 0, len(all))
+ for _, sn := range all {
+ sc, err := s.store.MailCounts(ctx, acct.Sqid, app, sn.ID, day)
+ if err != nil {
+ return nil, err
+ }
+ entry := map[string]any{
+ "id": sn.ID,
+ "scope": sn.Scope,
+ "source": sn.Source,
+ "active": sn.Provisioned != nil && sn.Provisioned.Active,
+ "daily_cap": mailCapFor(sn.Grant),
+ "counts": map[string]any{
+ "sends": sc.Sends, "bounces": sc.Bounces,
+ "complaints": sc.Complaints, "rejects": sc.Rejects,
+ "deliveries": sc.Deliveries,
+ },
+ }
+ if sn.Host != "" {
+ entry["host"] = sn.Host
+ }
+ if sn.Address != "" {
+ entry["address"] = sn.Address
+ }
+ if sn.Provisioned != nil {
+ entry["key_id"] = sn.Provisioned.KeyID
+ if sn.Provisioned.PauseKind != "" {
+ entry["pause_kind"] = sn.Provisioned.PauseKind
+ }
+ }
+ senders = append(senders, entry)
+ }
return map[string]any{
"app": app,
"declared": rec.Declared,
@@ -428,6 +481,7 @@ func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec
"complaints": counts.Complaints, "rejects": counts.Rejects,
"deliveries": counts.Deliveries,
},
+ "senders": senders,
}, nil
}
diff --git a/internal/console/apimail_test.go b/internal/console/apimail_test.go
index cbe1b9997..706b69a65 100644
--- a/internal/console/apimail_test.go
+++ b/internal/console/apimail_test.go
@@ -1297,3 +1297,69 @@ func TestAPIEmailPushOffTakesTheKeyOutEvenWithNoPush(t *testing.T) {
t.Errorf("%s was left behind by --off with no push recorded", MailEventsKeyEnv)
}
}
+
+// The reply gains a senders array and changes nothing else, so an older CLI
+// reading only the top-level fields keeps working byte for byte.
+func TestEmailReplyCarriesEverySenderAndKeepsItsTopLevelFields(t *testing.T) {
+ fx, now := mailFixture(t)
+ ctx := context.Background()
+ // emailReply reads counts for TODAY (it computes its own day from
+ // time.Now(), not from a caller-supplied clock), so the events seeded
+ // here have to land on the real day, not mailFixture's fixed 2026-08-19
+ // — otherwise MailCounts would legitimately find nothing.
+ day := time.Now().UTC().Format("2006-01-02")
+ if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
+ t.Fatal(err)
+ }
+ // A second sender, written straight to its own object the way step 5's
+ // instance enable will.
+ if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "harbourmail", &mail.Sender{
+ ID: "book.example.com", Scope: mail.ScopeInstance, Source: mail.SourceDomain, Host: "book.example.com",
+ Declared: &mail.Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"},
+ Grant: &mail.GrantBlock{Domains: []string{"x.com"}, DailyCap: 1000, Source: "operator", At: now, By: "op"},
+ Provisioned: &mail.Provisioned{IAMUser: "u2", KeyID: "AKIA9", Active: true, At: now},
+ }, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ acct, err := fx.st.AccountBySqid(release.OpsAccount)
+ if err != nil || acct == nil {
+ t.Fatalf("AccountBySqid = %v %v", acct, err)
+ }
+ rec, _, err := mail.Get(ctx, fx.rel, release.OpsAccount, "harbourmail")
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := fx.srv.emailReply(ctx, acct, "harbourmail", rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Unchanged: the top-level fields still describe the app.
+ if out["active"] != true || out["daily_cap"] != 50 {
+ t.Fatalf("the top-level fields moved: active=%v daily_cap=%v", out["active"], out["daily_cap"])
+ }
+ counts, _ := out["counts"].(map[string]any)
+ if counts["sends"] != 2 {
+ t.Fatalf("top-level sends = %v, want the app's whole day across both senders", counts["sends"])
+ }
+
+ senders, ok := out["senders"].([]map[string]any)
+ if !ok || len(senders) != 2 {
+ t.Fatalf("senders = %#v, want two", out["senders"])
+ }
+ if senders[0]["id"] != mail.SenderAppID || senders[0]["scope"] != mail.ScopeApp {
+ t.Fatalf("the app sender is not first: %#v", senders[0])
+ }
+ if senders[1]["id"] != "book.example.com" || senders[1]["daily_cap"] != 1000 || senders[1]["host"] != "book.example.com" {
+ t.Fatalf("the second sender = %#v", senders[1])
+ }
+ first, _ := senders[0]["counts"].(map[string]any)
+ second, _ := senders[1]["counts"].(map[string]any)
+ if first["sends"] != 1 || second["sends"] != 1 {
+ t.Fatalf("per-sender counts = %v / %v, want one send each", first["sends"], second["sends"])
+ }
+}