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