carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Tenant Email, Plan 2 of 5: Senders
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Turn the one implicit per-app sender into an explicit, named, individually counted and individually pausable **sender** record, with no new capability for anyone: `apps/<acct>/<app>/email/senders/app.json` replaces `email.json` under a read-fallback migration, one `SenderNames` function owns every AWS name and its bound, a reverse index at `email/callers/<iamuser>.json` makes an IAM caller resolvable back to its sender, SES events are counted per sender in a new `mail_sender_counts` table, and the cap, the pause and the status all move onto the sender while the app-level wire fields keep saying exactly what they say today.
6
7**Architecture:** `internal/mail` gains `sender.go` (the record, the names, the caller index, `SenderOfCaller`) and keeps `Record` as the app sender's in-memory view, so every existing walk caller compiles unchanged; `Get`/`Put` become the app sender's accessors with the legacy key as a read-only fallback that hands back an empty etag, which makes every writer's `Put` a create by construction. The console's counts move to a second table keyed by `(account, app, sender, day)`, backfilled once from `mail_counts`; `recordAndEnforceMail` resolves the caller to a sender first and only falls back to today's domain-wide loop when it cannot. The status reply grows a `senders` array beside its unchanged top-level fields. `PerInstance`, `PauseKindPerInstance`, `Host`, `Address`, `Identity` and `TenantARN` land on the record with nothing in this plan setting them: they are step 3's and step 5's to write.
8
9**Tech Stack:** Go 1.26, `internal/mail`, `internal/console` (SQLite store, SES event ingest, the email API doors), `internal/release` (Store, `NewDirStore`, `ErrCASConflict`, `AppPrefix`), `aws-sdk-go-v2/service/sesv2 v1.66.6`, root-package `mail_cli.go`.
10
11**Spec:** `docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md` — §11 step 2 is the whole of this plan's mandate. §5 (the record, the names, the caller index, the migration), §8 (attribution by caller, `mail_sender_counts`, caps, pauses, status) and §7's one sentence "Associations are wired in step 2, not here" are in scope. §6 and the rest of §7 are **not**: no instance senders, no external identities, no address claims, no per-instance config layer, no `svcrole.Mail`, no new doors.
12
13**Two parts of §8 are deferred to step 3, and that is a ruling rather than an oversight.** §8's "SES's own pauses" paragraph asks for three things this plan does not build: `EnsureTenant` storing `TenantArn` on the record, `SetTenantSending`/`TenantStatus` on the provisioner with `UpdateReputationEntityCustomerManagedStatus` becoming the pause verb for a sender that has a tenant, and status reading `GetReputationEntity` so that an SES-imposed pause is reported apart from a CARLOS one (`SendingStatusAggregate`, `CustomerManagedStatus` and `AwsSesManagedStatus` kept distinct, per the spike at §4.1 item 4). They are deferred because all three are only reachable once a send is attributed to a tenant, which no send is until step 3 emits `X-SES-TENANT`: adopting the tenant pause verb now would move every existing app's pause onto a control SES does not yet apply to them, and reporting an SES pause distinctly would report a field that is `null` for every sender in the fleet. `SetKeyActive` stays the pause verb for this release, which is what it already is. The `TenantARN` field lands on the record in task 1 with nothing setting it, so step 3 has somewhere to write.
14
15## Global Constraints
16
17- **Run `gofmt -w` on every file you touched before running the gate.** Several code blocks in this plan are pasted with trailing comments that gofmt will realign (task 1's `Sender` struct, task 2's `longAcct`/`longApp` pair, task 5's `CallerIndex`). `gofmt -l .` only NAMES an unformatted file, so a plan followed literally goes red on the gate's last step rather than being fixed by it.
18- **The gate before every commit:** `go build ./... && go vet ./... && go test ./... -count=1` all clean, and `gofmt -l .` empty over the whole tree. Env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. Socket-binding tests need `TMPDIR=/tmp/g`. Go commands may need the sandbox disabled; "read-only file system" from the toolchain is the tell.
19- **This repo lands through `amadan branch merge`, never a GitHub PR.** Branch `worktree-tenant-email-senders`. CI is `.amadan/ci.d/`, triggered by a push to the `amadan` remote; a branch pushed only to `origin` has been gated by nothing.
20- **No capability change.** Every task must leave the tree shippable on its own: an app that can send today sends after each commit, with the same credential, the same cap and the same delivered variables. The one deliberate behaviour change is task 11's attribution narrowing, which is a roll note, not a capability.
21- **Wire-shape changes are additive.** Live `email.json` objects exist in production buckets and must still parse after every task. The `senders` array is added beside the top-level reply fields, never instead of them, so an older CLI keeps working.
22- **Existing AWS names never change.** `UserName`, `StandaloneUserName`, `TenantName` and `ConfigSetName` render exactly what they render today for the app sender; `SenderNames` is a superset whose app-sender branch is pinned byte-identical by a test. `RefreshPolicies` recomputes `StandaloneUserName` rather than reading `sa.IAMUser` (`walk.go:364`), so a changed rule would write a policy for a new principal and leave the live one stale.
23- **`PerInstance` and `PauseKindPerInstance` exist on the record and nothing in this plan sets them.** The refusals that read them (task 12) are written and tested against a hand-set flag, because they are cheap now and load-bearing in step 5.
24- Copy: no em dashes. Full stop and a new sentence, lower case. Comments are full sentences saying why, never process history; log lines use `->` between values.
25- Task 14 adds member-facing CLI copy. Run the `copy-review` gate on the new strings before they land, as every user-facing string in this repo does.
26- Commit after every task with the trailer `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
27
28---
29
30## File Structure
31
32| File | Responsibility |
33|---|---|
34| `internal/mail/sender.go` (create) | `Sender`, `Scope*`/`Source*`/`SenderAppID`/`PauseKindPerInstance`, `SendersPrefix`, `SenderKey`, `SenderNames`, `SenderNameSet`, the caller index, `SenderOfCaller`, `ResolveSenderNames` |
35| `internal/mail/sender_test.go` (create) | record round-trip, the bounds enumeration, the caller index rules, `SenderOfCaller` |
36| `internal/mail/mail.go` (modify) | `Key` -> the sender key, `LegacyKey`, `Get`'s fallback with the empty etag, `Put` always writing `senders/app.json`, `GetSender`/`PutSender`/`ListSenders`, `Record.sender` passthrough |
37| `internal/mail/sweep.go` (create) | `SweepLegacyRecords`: the one-shot `email.json` delete, a release later, off by default |
38| `internal/mail/walk.go` (modify) | caller index written after `EnsureUser` and deleted by `RevokeStandalone`/`Teardown`; tenant associations in `EnsureDomain`; disassociate before `DeleteIdentity`; `SetSenderPaused` |
39| `internal/mail/provisioner.go`, `memprovisioner.go`, `aws.go` (modify) | `AssociateTenantResource` / `DisassociateTenantResource` |
40| `internal/mail/domain.go` (modify) | `ConfigSetARN(identityARN, set)` extracted from `VerifiedConfigSetARNs` |
41| `internal/console/store.go` (modify) | `mail_sender_counts` DDL, the one-shot backfill behind `PRAGMA user_version`, `DropLegacyMailCounts` |
42| `internal/console/mailcounts.go` (modify) | `RecordMailEvent`/`MailCounts` take a sender; `enforceMailLimits`/`resumeStaleCapPause` work per sender |
43| `internal/console/mailevents.go` (modify) | `mail_sender_counts` joins `mailAppTables`; the explicit `day` case in `mailAppTimeColumn` and `carryMailApp` |
44| `internal/console/sesevents.go` (modify) | `recordAndEnforceMail` resolves the sender first |
45| `internal/console/apimail.go` (modify) | `emailReply` gains `senders`; the status door resumes the app sender |
46| `mail_cli.go` (modify) | `apiEmailSender`, `apiEmailReply.Senders`, a line per sender in `printEmailStatus` |
47| `cmd/console/main.go` (modify) | the legacy sweeps behind `CARLOS_MAIL_LEGACY_SWEEP` |
48
49---
50
51### Task 1: The sender record
52
53**Files:**
54- Create: `internal/mail/sender.go`, `internal/mail/sender_test.go`
55
56**Interfaces:**
57- Consumes: `Declared`, `GrantBlock`, `Provisioned`, `Standalone` (`internal/mail/mail.go:35`, `:46`, `:57`, `:95`) unchanged; `release.AppPrefix` (`internal/release/release.go:212`).
58- Produces:
59 ```go
60 const (
61 ScopeApp = "app"
62 ScopeInstance = "instance"
63 SourceDomain = "domain"
64 SourceAddress = "address"
65 SenderAppID = "app"
66 PauseKindPerInstance = "per-instance"
67 )
68 type Sender struct { ... }
69 func SendersPrefix(acct, app string) string // apps/<acct>/<app>/email/senders/
70 func SenderKey(acct, app, id string) string // .../senders/<id>.json
71 func ValidSenderID(id string) bool
72 ```
73
74- [ ] **Step 1: Write the failing test**
75
76`internal/mail/sender_test.go` (create):
77
78```go
79package mail
80
81import (
82 "encoding/json"
83 "strings"
84 "testing"
85 "time"
86)
87
88// The app sender is the shape every app has had since before senders
89// existed, written under its own key. The four blocks move inside it
90// unchanged, so a Sender decodes the same Declared/Grant/Provisioned/
91// Standalone an email.json object carries.
92func TestSenderCarriesTheFourBlocksUnchanged(t *testing.T) {
93 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
94 sn := &Sender{
95 Schema: Schema, ID: SenderAppID, Slug: "", Scope: ScopeApp, Source: SourceDomain,
96 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"},
97 Grant: &GrantBlock{Domains: []string{"x.com"}, DailyCap: 50, Source: "tier:free", At: now, By: "tier"},
98 Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-hm", KeyID: "AKIA1", Active: true, At: now},
99 Standalone: []*Standalone{{Name: "laptop", IAMUser: "carlos-mail-bag-hm+s+laptop", KeyID: "AKIA2", At: now, By: "owner@example.test"}},
100 }
101 body, err := json.Marshal(sn)
102 if err != nil {
103 t.Fatal(err)
104 }
105 // Nothing this sender does not carry appears on the wire: per_instance,
106 // host, address, identity, tenant_arn and salt are all omitempty, so an
107 // app sender's object is the four blocks plus its own identity.
108 for _, absent := range []string{"per_instance", "host", "address", "identity", "tenant_arn", "salt"} {
109 if strings.Contains(string(body), absent) {
110 t.Errorf("an app sender's wire form carries %q: %s", absent, body)
111 }
112 }
113 var back Sender
114 if err := json.Unmarshal(body, &back); err != nil {
115 t.Fatal(err)
116 }
117 if back.Declared.EnvPrefix != "HARBOURMAIL_SMTP" || back.Grant.DailyCap != 50 ||
118 back.Provisioned.KeyID != "AKIA1" || len(back.Standalone) != 1 || back.Standalone[0].Name != "laptop" {
119 t.Fatalf("round trip lost a block: %+v", back)
120 }
121 if back.ID != SenderAppID || back.Scope != ScopeApp || back.Source != SourceDomain {
122 t.Fatalf("round trip lost the sender's identity: %+v", back)
123 }
124}
125
126func TestSenderKeysSitUnderTheAppsOwnPrefix(t *testing.T) {
127 if got := SendersPrefix("bag", "harbourmail"); got != "apps/bag/harbourmail/email/senders/" {
128 t.Fatalf("SendersPrefix = %q", got)
129 }
130 if got := SenderKey("bag", "harbourmail", SenderAppID); got != "apps/bag/harbourmail/email/senders/app.json" {
131 t.Fatalf("SenderKey = %q", got)
132 }
133 // An id is a bucket key segment, so a traversal or a separator is
134 // refused at the one gate every caller passes through.
135 for _, bad := range []string{"", "..", "a/b", "A", "x.json", strings.Repeat("a", 65)} {
136 if ValidSenderID(bad) {
137 t.Errorf("ValidSenderID(%q) = true", bad)
138 }
139 }
140 for _, good := range []string{"app", "book.example.com", "_carlos-1-abc123"} {
141 if !ValidSenderID(good) {
142 t.Errorf("ValidSenderID(%q) = false", good)
143 }
144 }
145}
146```
147
148- [ ] **Step 2: Run it to verify it fails**
149
150```bash
151go test -run 'TestSenderCarriesTheFourBlocksUnchanged|TestSenderKeysSitUnderTheAppsOwnPrefix' ./internal/mail/
152```
153
154Expected: build failure, `undefined: Sender`.
155
156- [ ] **Step 3: Write the code**
157
158`internal/mail/sender.go` (create):
159
160```go
161// The sender (senders design §5): a From address, a credential, a cap, a
162// pause and a set of counts, owned by one record.
163//
164// Before this file an app had exactly one implicit sender and everything was
165// keyed by (account, app): email.json, the IAM user, the DailyCap, the pause,
166// the mail_counts rows. Two requests do not fit that shape (#147 sends as an
167// address under an identity CARLOS does not own, #148 gives one instance per
168// customer its own domain, credential, cap and pause), so the sender becomes
169// a thing with a name. This step gives every app exactly ONE sender, "app",
170// which is what it already had. Nothing here creates a second one.
171package mail
172
173import (
174 "regexp"
175
176 "github.com/carlosframework/platform/internal/release"
177)
178
179// The two scopes and the two sources a sender can have. Scope says what the
180// sender belongs to; Source says where its authority to send comes from. An
181// app sender on a domain the account verified is the only combination this
182// release produces; the others are named here because SenderNames, the
183// status reply and the caller index all branch on them, and a closed set
184// that is written down once is a set nothing invents a third member of.
185const (
186 ScopeApp = "app"
187 ScopeInstance = "instance"
188
189 SourceDomain = "domain"
190 SourceAddress = "address"
191)
192
193// SenderAppID is the id of the sender every app has always had. It is a
194// constant rather than a literal because it is written into every migrated
195// record, read back by SenderOfCaller and compared in a dozen places.
196const SenderAppID = "app"
197
198// PauseKindPerInstance is the pause an app sender carries once its app has
199// moved to per-instance senders (design §6.3). It is never a cap pause and
200// never auto-resumes: resumeStaleCapPause reactivates every key it touches,
201// and re-arming the app credential that every instance still holds is
202// exactly what per-instance mode exists to prevent. Nothing in this release
203// sets it; the refusals that read it are in place so step 5 cannot land
204// without them.
205const PauseKindPerInstance = "per-instance"
206
207// Sender is apps/<acct>/<app>/email/senders/<id>.json, one object per
208// sender, each with its own CAS etag so two writers never contend over a
209// sibling's state.
210type Sender struct {
211 Schema int `json:"schema"`
212 ID string `json:"id"` // "app", or provision.InstanceID(host)
213 Slug string `json:"slug"` // the AWS-safe name component, see SenderNames
214 Scope string `json:"scope"` // ScopeApp | ScopeInstance
215 Host string `json:"host,omitempty"`
216 Source string `json:"source"` // SourceDomain | SourceAddress
217
218 // The four blocks keep their current shapes and move inside the sender
219 // unchanged, which is what lets a legacy email.json decode straight into
220 // one and what keeps every walk in walk.go compiling against Record.
221 Declared *Declared `json:"declared,omitempty"`
222 Grant *GrantBlock `json:"grant,omitempty"`
223 Provisioned *Provisioned `json:"provisioned,omitempty"`
224 Standalone []*Standalone `json:"standalone,omitempty"`
225
226 Identity string `json:"identity,omitempty"` // external identity ARN (#147, step 3)
227 Address string `json:"address,omitempty"` // the exact address, lowercased (#147, step 3)
228 TenantARN string `json:"tenant_arn,omitempty"` // from CreateTenant
229
230 // PerInstance marks an app sender whose app has moved to per-instance
231 // senders (design §6.3). Nothing in this release sets it. It lives on
232 // the app sender rather than being derived from a prefix listing
233 // because a listing cannot decide the question without racing an
234 // app-sender enable, and the transition has to be one CAS write.
235 PerInstance bool `json:"per_instance,omitempty"`
236
237 // Salt disambiguates a slug collision. The slug is 40 bits of a hash, so
238 // two senders can in principle want the same IAM user name; the caller
239 // index detects that and ResolveSenderNames recomputes the slug with a
240 // fresh salt, which has to be stored or the name would not be stable
241 // across the next enable.
242 Salt string `json:"salt,omitempty"`
243}
244
245// SendersPrefix is where an app's senders live. Inside apps/<acct>/<app>/ on
246// purpose: release.PurgeApp's prefix sweep reaches it, so a purged app
247// leaves no sender behind.
248func SendersPrefix(acct, app string) string {
249 return release.AppPrefix(acct, app) + "email/senders/"
250}
251
252func SenderKey(acct, app, id string) string { return SendersPrefix(acct, app) + id + ".json" }
253
254// senderIDRe is deliberately stricter than a host name: this string becomes
255// a bucket key segment and half an IAM user name, and the only safe place to
256// refuse a separator or a traversal is the one gate every caller passes
257// through. It accepts a host (provision.InstanceID returns the host itself
258// when it is short enough) and the underscore-prefixed digest form
259// InstanceID falls back to for a long one.
260var senderIDRe = regexp.MustCompile(`^_?[a-z0-9]([a-z0-9.-]{0,62}[a-z0-9])?$`)
261
262// ValidSenderID reports whether id is usable as a sender id. ".json" is
263// refused along with every other dotted suffix that would collide with the
264// key's own extension only incidentally: what actually refuses it is the
265// check below, which is why it is spelled out.
266func ValidSenderID(id string) bool {
267 if id == "" || len(id) > 64 || id == "." || id == ".." {
268 return false
269 }
270 if !senderIDRe.MatchString(id) {
271 return false
272 }
273 return !hasJSONSuffix(id)
274}
275
276func hasJSONSuffix(id string) bool { return len(id) > 5 && id[len(id)-5:] == ".json" }
277```
278
279- [ ] **Step 4: Run it to verify it passes**
280
281```bash
282go test -run 'TestSenderCarriesTheFourBlocksUnchanged|TestSenderKeysSitUnderTheAppsOwnPrefix' ./internal/mail/
283```
284
285Expected: PASS.
286
287- [ ] **Step 5: Gate and commit**
288
289```bash
290go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
291git add internal/mail/sender.go internal/mail/sender_test.go
292git commit -m "mail: the sender record, its scopes, sources and key shape (plan 2, task 1)"
293```
294
295---
296
297### Task 2: `SenderNames` and the IAM and SES bounds
298
299**Files:**
300- Modify: `internal/mail/sender.go`, `internal/mail/sender_test.go`
301
302**Interfaces:**
303- Consumes: `UserName` (`policy.go:27`), `StandaloneUserName` (`policy.go:92`), `TenantName` (`policy.go:84`), `ConfigSetName` (`walk.go:375`).
304- Produces:
305 ```go
306 type SenderNameSet struct { Slug, IAMUser, Tenant, ConfigSet string }
307 func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error)
308 func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error)
309 var ErrNameTooLong = errors.New("mail: name exceeds the AWS bound")
310 ```
311
312- [ ] **Step 1: Write the failing test**
313
314`internal/mail/sender_test.go` (append):
315
316```go
317// The bounds, enumerated. SES caps a tenant name and a configuration-set
318// name at 64 characters of [A-Za-z0-9_-]; IAM caps a user name at 64. An
319// account sqid is at most 16 lowercase letters and an app name at most 32
320// of [a-z0-9-], so the app sender always fits and the instance sender only
321// fits because the app component is truncated to 24 with the hash carrying
322// uniqueness.
323func TestSenderNamesEnumeratesTheBounds(t *testing.T) {
324 const longAcct = "abcdefghijklmnop" // 16, the longest legal account
325 const longApp = "a123456789012345678901234567890b" // 32, the longest legal app
326 if len(longAcct) != 16 || len(longApp) != 32 {
327 t.Fatalf("the fixture names are not at the bound: %d %d", len(longAcct), len(longApp))
328 }
329
330 // The app sender's names are today's names, byte for byte.
331 app, err := SenderNames(longAcct, longApp, SenderAppID, "")
332 if err != nil {
333 t.Fatalf("app sender at the bound: %v", err)
334 }
335 if app.Slug != "" {
336 t.Errorf("app sender slug = %q, want empty", app.Slug)
337 }
338 if app.IAMUser != UserName(longAcct, longApp) {
339 t.Errorf("IAMUser = %q, want %q — an existing name must never change", app.IAMUser, UserName(longAcct, longApp))
340 }
341 if app.Tenant != TenantName(longAcct) || app.ConfigSet != ConfigSetName(longAcct) {
342 t.Errorf("tenant/set = %q/%q, want %q/%q", app.Tenant, app.ConfigSet, TenantName(longAcct), ConfigSetName(longAcct))
343 }
344 if len(app.IAMUser) > 64 || len(app.Tenant) > 64 || len(app.ConfigSet) > 64 {
345 t.Errorf("app sender over a bound: %d/%d/%d", len(app.IAMUser), len(app.Tenant), len(app.ConfigSet))
346 }
347
348 // The instance sender at the same bound. 12 + 16 + 1 + 24 + 3 + 8 = 64.
349 inst, err := SenderNames(longAcct, longApp, "book.customer.example.com", "")
350 if err != nil {
351 t.Fatalf("instance sender at the bound: %v", err)
352 }
353 if len(inst.Slug) != 8 {
354 t.Fatalf("slug = %q, want 8 characters", inst.Slug)
355 }
356 if len(inst.IAMUser) != 64 {
357 t.Fatalf("IAMUser = %q (%d), want exactly 64 at the bound", inst.IAMUser, len(inst.IAMUser))
358 }
359 if !strings.Contains(inst.IAMUser, "+i+"+inst.Slug) {
360 t.Errorf("IAMUser = %q, want it to end in +i+<slug>", inst.IAMUser)
361 }
362 if len(inst.Tenant) > 64 || !strings.HasSuffix(inst.Tenant, "-"+inst.Slug) {
363 t.Errorf("tenant = %q (%d)", inst.Tenant, len(inst.Tenant))
364 }
365 for _, name := range []string{inst.IAMUser, inst.Tenant, inst.ConfigSet} {
366 for _, r := range name {
367 if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz0123456789-_+.", r) {
368 t.Errorf("name %q holds %q, which SES or IAM does not accept", name, r)
369 }
370 }
371 }
372
373 // The slug is a function of (account, app, sender, salt) and nothing
374 // else, so a re-enable computes the same name.
375 again, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "")
376 if again.Slug != inst.Slug {
377 t.Fatalf("slug is not stable: %q then %q", inst.Slug, again.Slug)
378 }
379 salted, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "0f0f")
380 if salted.Slug == inst.Slug {
381 t.Fatal("a salt did not move the slug, so a collision could never be resolved")
382 }
383
384 // A standalone credential's name is unchanged where it fits and refused
385 // where it does not. carlos-mail- (12) + 16 + 1 + 32 + +s+ (3) + 31
386 // reaches 95 against IAM's 64, which nothing checked before this.
387 if _, err := app.StandaloneUser(longAcct, longApp, "a123456789012345678901234567890"); !errors.Is(err, ErrNameTooLong) {
388 t.Fatalf("the longest standalone name on the longest app = %v, want ErrNameTooLong", err)
389 }
390 short, err := SenderNames("bag", "hm", SenderAppID, "")
391 if err != nil {
392 t.Fatal(err)
393 }
394 got, err := short.StandaloneUser("bag", "hm", "laptop")
395 if err != nil {
396 t.Fatal(err)
397 }
398 if got != StandaloneUserName("bag", "hm", "laptop") {
399 t.Fatalf("StandaloneUser = %q, want %q — RefreshPolicies recomputes this name and must get the live one",
400 got, StandaloneUserName("bag", "hm", "laptop"))
401 }
402}
403```
404
405Add `"errors"` to the test file's imports.
406
407- [ ] **Step 2: Run it to verify it fails**
408
409```bash
410go test -run TestSenderNamesEnumeratesTheBounds ./internal/mail/
411```
412
413Expected: build failure, `undefined: SenderNames`.
414
415- [ ] **Step 3: Write the code**
416
417`internal/mail/sender.go` (append; add `crypto/sha256`, `encoding/base32`, `errors`, `fmt`, `strings` to the imports):
418
419```go
420// The AWS bounds this file exists to respect. IAM caps a user name at 64
421// characters and SES caps a tenant name and a configuration-set name at 64
422// of [A-Za-z0-9_-]. The first draft of the senders design got this wrong:
423// carlos-mail-<acct>-<app>+i+<instance-id> reaches 104 characters when the
424// instance id is the host itself, which it is for any host of 40 characters
425// or less.
426const (
427 iamUserNameMax = 64
428 sesNameMax = 64
429 // senderAppComponentMax truncates the app name inside a slugged sender's
430 // names. 12 + 16 + 1 + 24 + 3 + 8 = 64 exactly, at the longest legal
431 // account and app. Uniqueness comes from the hash, not from the app
432 // component, so truncating it costs nothing but readability.
433 senderAppComponentMax = 24
434 // senderSlugLen is 40 bits of base32. Short enough to fit, long enough
435 // that a collision is rare, and detected either way by the caller index.
436 senderSlugLen = 8
437)
438
439// ErrNameTooLong is the refusal at create for a name no AWS bound can hold.
440// It is refused rather than truncated because a truncated name would
441// collide silently with its neighbour, and because the only name this can
442// happen to today is a standalone credential's, whose name the member chose
443// and can choose differently.
444var ErrNameTooLong = errors.New("mail: name exceeds the AWS bound")
445
446// SenderNameSet is every AWS name one sender owns.
447type SenderNameSet struct {
448 Slug string // "" for the app sender, 8 base32 characters otherwise
449 IAMUser string
450 Tenant string
451 ConfigSet string
452}
453
454// SenderNames is THE rule for what a sender's AWS resources are called, and
455// there is exactly one of it so that a bounds test can enumerate every form.
456//
457// The app sender's names are unchanged, deliberately and permanently:
458// carlos-mail-<acct>-<app>, tenant and configuration set carlos-<acct>.
459// Renaming an SES tenant means destroying it and recreating it along with
460// every association it carries, and rewriting an IAM user name means a new
461// principal with the old one left live and stale. So the rule is that
462// existing shapes never change and a name that would exceed a bound is
463// refused at create.
464//
465// Configuration sets stay per account even for a slugged sender: an identity
466// has only one default set, and SES has no tenant-level default, so
467// per-sender sets on a shared identity are impossible (design §3).
468func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error) {
469 if !release.ValidAccount(acct) {
470 return SenderNameSet{}, fmt.Errorf("mail: invalid account %q", acct)
471 }
472 if !release.ValidApp(app) {
473 return SenderNameSet{}, fmt.Errorf("mail: invalid app %q", app)
474 }
475 if !ValidSenderID(senderID) {
476 return SenderNameSet{}, fmt.Errorf("mail: invalid sender id %q", senderID)
477 }
478 n := SenderNameSet{Tenant: TenantName(acct), ConfigSet: ConfigSetName(acct)}
479 if senderID == SenderAppID {
480 n.IAMUser = UserName(acct, app)
481 return n, checkNames(n)
482 }
483 n.Slug = senderSlug(acct, app, senderID, salt)
484 short := app
485 if len(short) > senderAppComponentMax {
486 short = short[:senderAppComponentMax]
487 }
488 n.IAMUser = "carlos-mail-" + acct + "-" + short + "+i+" + n.Slug
489 n.Tenant = "carlos-" + acct + "-" + short + "-" + n.Slug
490 return n, checkNames(n)
491}
492
493// StandaloneUser is the name of one displayed credential belonging to this
494// sender, bound-checked. The rendered string is byte-identical to
495// StandaloneUserName for an app sender, because RefreshPolicies recomputes
496// the name rather than reading sa.IAMUser (walk.go:364): a rule that
497// rendered anything else would write a policy for a principal that does not
498// exist and leave the live one stale.
499func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error) {
500 if !validStandaloneName(credential) {
501 return "", fmt.Errorf("mail: invalid credential name %q", credential)
502 }
503 name := StandaloneUserName(acct, app, credential)
504 if n.Slug != "" {
505 name = n.IAMUser + "+s+" + credential
506 }
507 if len(name) > iamUserNameMax {
508 return "", fmt.Errorf("mail: credential name %q makes the IAM user %d characters, over IAM's %d: choose a shorter name: %w",
509 credential, len(name), iamUserNameMax, ErrNameTooLong)
510 }
511 return name, nil
512}
513
514func checkNames(n SenderNameSet) error {
515 if len(n.IAMUser) > iamUserNameMax {
516 return fmt.Errorf("mail: IAM user %q is %d characters, over IAM's %d: %w", n.IAMUser, len(n.IAMUser), iamUserNameMax, ErrNameTooLong)
517 }
518 for _, name := range []string{n.Tenant, n.ConfigSet} {
519 if len(name) > sesNameMax {
520 return fmt.Errorf("mail: SES name %q is %d characters, over SES's %d: %w", name, len(name), sesNameMax, ErrNameTooLong)
521 }
522 }
523 return nil
524}
525
526// senderSlug is 40 bits of base32 over (account, app, sender, salt),
527// lowercased so it reads like every other name this package renders. Nothing
528// parses it back, which is why the caller index exists.
529func senderSlug(acct, app, id, salt string) string {
530 sum := sha256.Sum256([]byte(acct + "/" + app + "/" + id + "/" + salt))
531 enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])
532 return strings.ToLower(enc[:senderSlugLen])
533}
534```
535
536- [ ] **Step 4: Run it to verify it passes**
537
538```bash
539go test -run TestSenderNames ./internal/mail/
540```
541
542Expected: PASS.
543
544- [ ] **Step 5: Gate and commit**
545
546```bash
547go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
548git add internal/mail
549git commit -m "mail: SenderNames owns every AWS name and its bound (plan 2, task 2)"
550```
551
552---
553
554### Task 3: The migration to `senders/app.json`
555
556**Files:**
557- Modify: `internal/mail/mail.go` (`Key` at `:114`, `Get` at `:130`, `Put` at `:154`), `internal/mail/mail_test.go` (`TestKeyIsUnderTheAppPrefix` at `:13-17`, rewritten), `internal/mail/walk.go` (`Teardown`'s delete at `:1297`)
558
559**Interfaces:**
560- Produces:
561 ```go
562 func Key(acct, app string) string // apps/<acct>/<app>/email/senders/app.json
563 func LegacyKey(acct, app string) string // apps/<acct>/<app>/email.json
564 func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) // etag "" when served from LegacyKey
565 func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) // always writes Key
566 func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error)
567 func PutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error)
568 func ListSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error)
569 ```
570
571- [ ] **Step 1: Write the failing test**
572
573`internal/mail/mail_test.go`, REPLACING `TestKeyIsUnderTheAppPrefix` (`:13-17`), which pins `Key` at the old object and goes red the moment `Key` moves. It is the reason this task is not additive:
574
575```go
576func TestKeyIsUnderTheAppPrefix(t *testing.T) {
577 if got, want := Key("bag", "harbourmail"), "apps/bag/harbourmail/email/senders/app.json"; got != want {
578 t.Fatalf("Key = %q, want %q", got, want)
579 }
580 // The pre-senders object keeps its name, because Get still reads it and
581 // the sweep still has to find it.
582 if got, want := LegacyKey("bag", "harbourmail"), "apps/bag/harbourmail/email.json"; got != want {
583 t.Fatalf("LegacyKey = %q, want %q", got, want)
584 }
585}
586```
587
588`internal/mail/mail_test.go` (append):
589
590```go
591// The migration (design §5). Get prefers senders/app.json and falls back to
592// email.json; when it served the fallback it returns an EMPTY etag, so every
593// writer's Put — and they all pass back the etag they were given — is a
594// create by construction, and a lost race is ErrCASConflict exactly as it is
595// today.
596func TestGetFallsBackToEmailJSONWithAnEmptyETag(t *testing.T) {
597 ctx := context.Background()
598 s := release.NewDirStore(t.TempDir())
599 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
600
601 legacy := &Record{Schema: Schema, App: "harbourmail",
602 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"},
603 Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIA1", Active: true, At: now},
604 }
605 body, err := json.MarshalIndent(legacy, "", " ")
606 if err != nil {
607 t.Fatal(err)
608 }
609 if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), append(body, '\n'), ""); err != nil {
610 t.Fatal(err)
611 }
612
613 rec, etag, err := Get(ctx, s, "bag", "harbourmail")
614 if err != nil || rec == nil {
615 t.Fatalf("Get = %+v %v", rec, err)
616 }
617 if etag != "" {
618 t.Fatalf("etag = %q, want empty so the next Put is a create", etag)
619 }
620 if rec.Provisioned.KeyID != "AKIA1" {
621 t.Fatalf("the fallback lost the record: %+v", rec)
622 }
623
624 // Put always writes the sender key and never the legacy one.
625 if _, err := Put(ctx, s, "bag", "harbourmail", rec, etag); err != nil {
626 t.Fatalf("Put after the fallback: %v", err)
627 }
628 if _, _, err := s.Get(ctx, Key("bag", "harbourmail")); err != nil {
629 t.Fatalf("senders/app.json was not written: %v", err)
630 }
631 // email.json is left in place, unwritten, for one release.
632 raw, _, err := s.Get(ctx, LegacyKey("bag", "harbourmail"))
633 if err != nil || !bytes.Contains(raw, []byte("AKIA1")) {
634 t.Fatalf("email.json was disturbed: %q %v", raw, err)
635 }
636
637 // Once the sender object exists it is preferred, with a real etag.
638 rec2, etag2, err := Get(ctx, s, "bag", "harbourmail")
639 if err != nil || rec2 == nil || etag2 == "" {
640 t.Fatalf("Get after the migration = %+v %q %v", rec2, etag2, err)
641 }
642 if rec2.Provisioned.KeyID != "AKIA1" || rec2.Declared.From != "noreply@x.com" {
643 t.Fatalf("the migrated record lost a block: %+v", rec2)
644 }
645 // A second writer still holding the fallback's empty etag loses the race
646 // rather than clobbering the migrated object.
647 if _, err := Put(ctx, s, "bag", "harbourmail", rec, ""); !errors.Is(err, release.ErrCASConflict) {
648 t.Fatalf("a create over an existing sender = %v, want ErrCASConflict", err)
649 }
650}
651
652// An app with neither object is "no email", a state and not an error.
653func TestGetWithNothingWrittenIsNotAnError(t *testing.T) {
654 rec, etag, err := Get(context.Background(), release.NewDirStore(t.TempDir()), "bag", "harbourmail")
655 if rec != nil || etag != "" || err != nil {
656 t.Fatalf("Get = %+v %q %v", rec, etag, err)
657 }
658}
659
660// ListSenders answers for an app that has never been migrated, because the
661// status reply reads it on every call and an unmigrated app must not look
662// like an app with no senders at all.
663func TestListSendersAnswersFromTheLegacyRecord(t *testing.T) {
664 ctx := context.Background()
665 s := release.NewDirStore(t.TempDir())
666 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
667 body, _ := json.Marshal(&Record{Schema: Schema, App: "harbourmail",
668 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"}})
669 if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), body, ""); err != nil {
670 t.Fatal(err)
671 }
672 got, err := ListSenders(ctx, s, "bag", "harbourmail")
673 if err != nil || len(got) != 1 {
674 t.Fatalf("ListSenders = %+v %v, want one sender", got, err)
675 }
676 if got[0].ID != SenderAppID || got[0].Scope != ScopeApp || got[0].Source != SourceDomain {
677 t.Fatalf("the legacy record did not read back as the app sender: %+v", got[0])
678 }
679}
680```
681
682Add `"bytes"`, `"context"`, `"encoding/json"`, `"errors"`, `"testing"`, `"time"` and `"github.com/carlosframework/platform/internal/release"` to `mail_test.go`'s imports as needed.
683
684- [ ] **Step 2: Run it to verify it fails**
685
686```bash
687go test -run 'TestGetFallsBack|TestListSendersAnswers' ./internal/mail/
688```
689
690Expected: build failure, `undefined: LegacyKey`.
691
692- [ ] **Step 3: Write the code**
693
694`internal/mail/mail.go`, replacing `Key`, `Get` and `Put` (`:114`, `:130-162`):
695
696```go
697// Key is the app sender's object. It moved from email.json to
698// email/senders/app.json when senders got names (design §5); LegacyKey is
699// still read, and still on disk, for one release.
700func Key(acct, app string) string { return SenderKey(acct, app, SenderAppID) }
701
702// LegacyKey is the pre-senders record. Read-only from here: Get falls back
703// to it and hands back an EMPTY etag when it did, so the writer's Put is a
704// create by construction and a lost race is ErrCASConflict exactly as it is
705// today. It is left in place, unwritten, until SweepLegacyRecords deletes it
706// a release later. A console rolled back after the first sender write loses
707// the writes made in between, which is in the roll notes.
708func LegacyKey(acct, app string) string { return release.AppPrefix(acct, app) + "email.json" }
709
710// Get reads the app sender as a Record — the view every walk in walk.go
711// works with. Absent is (nil, "", nil): "no email" is a state, not an error.
712//
713// The returned Record keeps a private handle on the Sender it was decoded
714// from, so a read-modify-write through Put preserves the fields Record has
715// no name for (PerInstance, TenantARN, Identity, Address, Salt). Without
716// that, an ordinary rotate would silently clear a flag step 3 or step 5
717// wrote.
718func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) {
719 sn, etag, err := GetSender(ctx, s, acct, app, SenderAppID)
720 if err != nil || sn == nil {
721 return nil, "", err
722 }
723 return sn.record(app), etag, nil
724}
725
726// Put writes the app sender at the caller's CAS position; etag "" is
727// create-only. It ALWAYS writes the sender key, never the legacy one.
728func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) {
729 return PutSender(ctx, s, acct, app, rec.sender(), etag)
730}
731
732// GetSender reads one sender. The app sender falls back to LegacyKey with an
733// empty etag; any other sender has no legacy form and is simply absent.
734func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error) {
735 if !release.ValidAccount(acct) {
736 return nil, "", fmt.Errorf("mail: invalid account %q", acct)
737 }
738 if !release.ValidApp(app) {
739 return nil, "", fmt.Errorf("mail: invalid app %q", app)
740 }
741 if !ValidSenderID(id) {
742 return nil, "", fmt.Errorf("mail: invalid sender id %q", id)
743 }
744 raw, etag, err := s.Get(ctx, SenderKey(acct, app, id))
745 if err == nil {
746 var sn Sender
747 if err := json.Unmarshal(raw, &sn); err != nil {
748 return nil, "", fmt.Errorf("mail: %s: %w", SenderKey(acct, app, id), err)
749 }
750 return &sn, etag, nil
751 }
752 if !errors.Is(err, os.ErrNotExist) {
753 return nil, "", err
754 }
755 if id != SenderAppID {
756 return nil, "", nil
757 }
758 raw, _, err = s.Get(ctx, LegacyKey(acct, app))
759 if err != nil {
760 if errors.Is(err, os.ErrNotExist) {
761 return nil, "", nil
762 }
763 return nil, "", err
764 }
765 var rec Record
766 if err := json.Unmarshal(raw, &rec); err != nil {
767 return nil, "", fmt.Errorf("mail: %s: %w", LegacyKey(acct, app), err)
768 }
769 // The empty etag is the whole of the migration's safety: it makes the
770 // caller's Put a create, so two consoles racing the first sender write
771 // cannot both win.
772 return rec.sender(), "", nil
773}
774
775// PutSender writes one sender at the caller's CAS position.
776func PutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error) {
777 if !ValidSenderID(sn.ID) {
778 return "", fmt.Errorf("mail: invalid sender id %q", sn.ID)
779 }
780 sn.Schema = Schema
781 if sn.Scope == "" {
782 sn.Scope = ScopeApp
783 }
784 if sn.Source == "" {
785 sn.Source = SourceDomain
786 }
787 body, err := json.MarshalIndent(sn, "", " ")
788 if err != nil {
789 return "", err
790 }
791 return s.Put(ctx, SenderKey(acct, app, sn.ID), append(body, '\n'), etag)
792}
793
794// ListSenders reads every sender an app has, app sender first and the rest
795// by id. An app that has never been migrated answers with the one sender its
796// legacy record describes, because the status reply reads this on every call
797// and an unmigrated app must not read as an app with no senders at all.
798func ListSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error) {
799 keys, err := s.List(ctx, SendersPrefix(acct, app))
800 if err != nil {
801 return nil, err
802 }
803 var rest []*Sender
804 var appSender *Sender
805 for _, k := range keys {
806 if !strings.HasSuffix(k, ".json") {
807 continue
808 }
809 raw, _, err := s.Get(ctx, k)
810 if err != nil {
811 continue
812 }
813 var sn Sender
814 if json.Unmarshal(raw, &sn) != nil {
815 continue
816 }
817 if sn.ID == SenderAppID {
818 appSender = &sn
819 continue
820 }
821 rest = append(rest, &sn)
822 }
823 if appSender == nil {
824 appSender, _, err = GetSender(ctx, s, acct, app, SenderAppID)
825 if err != nil {
826 return nil, err
827 }
828 }
829 sort.Slice(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID })
830 out := make([]*Sender, 0, len(rest)+1)
831 if appSender != nil {
832 out = append(out, appSender)
833 }
834 return append(out, rest...), nil
835}
836```
837
838`internal/mail/mail.go`, `Record` (`:105`) gains the passthrough and the two converters:
839
840```go
841type Record struct {
842 Schema int `json:"schema"`
843 App string `json:"app"`
844 Declared *Declared `json:"declared,omitempty"`
845 Grant *GrantBlock `json:"grant,omitempty"`
846 Provisioned *Provisioned `json:"provisioned,omitempty"`
847 Standalone []*Standalone `json:"standalone,omitempty"`
848
849 // from is the sender this Record was decoded from, unexported so it
850 // never reaches the wire and never changes what a Record marshals to.
851 // It exists so a read-modify-write through Put carries forward the
852 // fields Record has no name for: an ordinary rotate must not clear a
853 // PerInstance flag or a TenantARN that something else wrote.
854 from *Sender
855}
856
857// sender renders this record as the app sender, preserving whatever the
858// record was decoded from. A Record built from nothing (Declare's first
859// call) gets a fresh app sender.
860func (r *Record) sender() *Sender {
861 sn := r.from
862 if sn == nil {
863 sn = &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain}
864 }
865 sn.Schema = Schema
866 sn.Declared, sn.Grant, sn.Provisioned, sn.Standalone = r.Declared, r.Grant, r.Provisioned, r.Standalone
867 return sn
868}
869
870// record is the inverse: the four blocks as the walks read them, with the
871// sender kept for the write back.
872func (s *Sender) record(app string) *Record {
873 return &Record{Schema: Schema, App: app, Declared: s.Declared, Grant: s.Grant,
874 Provisioned: s.Provisioned, Standalone: s.Standalone, from: s}
875}
876
877// sender on a legacy Record: the pre-senders object IS the app sender, with
878// the identity fields it never carried filled in.
879func (r *Record) legacySender() *Sender {
880 return &Sender{Schema: Schema, ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain,
881 Declared: r.Declared, Grant: r.Grant, Provisioned: r.Provisioned, Standalone: r.Standalone}
882}
883```
884
885In `GetSender`'s legacy branch use `rec.legacySender()` rather than `rec.sender()`.
886
887`internal/mail/walk.go`, `Teardown` (`:1296-1300`): delete both keys.
888
889```go
890 if rec != nil {
891 // Both objects: the sender the app is on, and the legacy one the
892 // migration deliberately leaves standing until the sweep. Leaving
893 // email.json behind here would resurrect the app's whole email
894 // record on the next Get.
895 if err := s.Delete(ctx, Key(acct, app)); err != nil {
896 return err
897 }
898 if err := s.Delete(ctx, LegacyKey(acct, app)); err != nil {
899 return err
900 }
901 }
902```
903
904Add `"sort"` and `"strings"` to `mail.go`'s imports.
905
906- [ ] **Step 4: Run it to verify it passes**
907
908```bash
909go test ./internal/mail/ -count=1
910```
911
912Expected: PASS, including every existing walk test unchanged.
913
914- [ ] **Step 5: Gate and commit**
915
916```bash
917go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
918git add internal/mail
919git commit -m "mail: the app sender is senders/app.json, with email.json as a read fallback (plan 2, task 3)"
920```
921
922---
923
924### Task 4: The one-shot legacy sweep, off by default
925
926**Files:**
927- Create: `internal/mail/sweep.go`, `internal/mail/sweep_test.go`
928- Modify: `cmd/console/main.go` (beside `go srv.StartAlertWatcher(...)`, `:334`)
929
930**Interfaces:**
931- Produces:
932 ```go
933 func SweepLegacyRecords(ctx context.Context, s release.Store, enabled bool) (deleted int, err error)
934 ```
935 Deletes `apps/<acct>/<app>/email.json` for every app that already has a `senders/app.json`, and only then. `enabled == false` walks nothing and returns `(0, nil)`.
936
937- [ ] **Step 1: Write the failing test**
938
939`internal/mail/sweep_test.go` (create):
940
941```go
942package mail
943
944import (
945 "context"
946 "encoding/json"
947 "errors"
948 "os"
949 "testing"
950 "time"
951
952 "github.com/carlosframework/platform/internal/release"
953)
954
955// The sweep is what finally removes email.json, a release after the
956// migration stopped writing it. It is off by default and it never deletes a
957// legacy record for an app that has not been migrated, because that record
958// is still the only copy of that app's email.
959func TestSweepLegacyRecordsOnlyRemovesMigratedApps(t *testing.T) {
960 ctx := context.Background()
961 s := release.NewDirStore(t.TempDir())
962 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
963 seed := func(app string, migrated bool) {
964 body, _ := json.Marshal(&Record{Schema: Schema, App: app,
965 Declared: &Declared{EnvPrefix: "P", From: "noreply@x.com", At: now, By: "o@example.test"}})
966 if _, err := s.Put(ctx, LegacyKey("bag", app), body, ""); err != nil {
967 t.Fatal(err)
968 }
969 if migrated {
970 if _, err := PutSender(ctx, s, "bag", app, &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain}, ""); err != nil {
971 t.Fatal(err)
972 }
973 }
974 }
975 seed("migrated", true)
976 seed("untouched", false)
977
978 // Off by default: the flag is the whole guard, and a console that has
979 // not been told to sweep sweeps nothing.
980 if n, err := SweepLegacyRecords(ctx, s, false); n != 0 || err != nil {
981 t.Fatalf("the sweep ran while disabled: %d %v", n, err)
982 }
983 if _, _, err := s.Get(ctx, LegacyKey("bag", "migrated")); err != nil {
984 t.Fatalf("the disabled sweep deleted something: %v", err)
985 }
986
987 n, err := SweepLegacyRecords(ctx, s, true)
988 if err != nil {
989 t.Fatal(err)
990 }
991 if n != 1 {
992 t.Fatalf("deleted %d, want 1", n)
993 }
994 if _, _, err := s.Get(ctx, LegacyKey("bag", "migrated")); !errors.Is(err, os.ErrNotExist) {
995 t.Fatalf("the migrated app's email.json survived: %v", err)
996 }
997 if _, _, err := s.Get(ctx, LegacyKey("bag", "untouched")); err != nil {
998 t.Fatalf("an unmigrated app's only record was deleted: %v", err)
999 }
1000 // Running it twice is a no-op, which is what makes it safe at every start.
1001 if n, err := SweepLegacyRecords(ctx, s, true); n != 0 || err != nil {
1002 t.Fatalf("a second sweep = %d %v, want 0 and no error", n, err)
1003 }
1004}
1005```
1006
1007- [ ] **Step 2: Run it to verify it fails**
1008
1009```bash
1010go test -run TestSweepLegacyRecords ./internal/mail/
1011```
1012
1013Expected: build failure, `undefined: SweepLegacyRecords`.
1014
1015- [ ] **Step 3: Write the code**
1016
1017`internal/mail/sweep.go` (create):
1018
1019```go
1020// The one-shot sweep that finishes the senders migration (design §5).
1021package mail
1022
1023import (
1024 "context"
1025 "errors"
1026 "fmt"
1027 "log"
1028 "os"
1029 "path"
1030 "strings"
1031
1032 "github.com/carlosframework/platform/internal/release"
1033)
1034
1035// SweepLegacyRecords deletes apps/<acct>/<app>/email.json for every app that
1036// already has a senders/app.json, and nothing else.
1037//
1038// It is guarded by a flag that defaults OFF, and it is written now rather
1039// than a release from now because the thing that makes it safe is knowing,
1040// while the migration is fresh in view, exactly which object it may remove.
1041// The rule is one sentence: a legacy record is deletable when, and only
1042// when, the sender object that replaced it exists. An unmigrated app's
1043// email.json is still the only copy of its email and is left alone, so a
1044// deployment that turns the flag on early loses nothing.
1045//
1046// A failure on one app is logged and the sweep continues: one unreadable
1047// object must not stop the other five hundred, and every deletion is
1048// independent.
1049//
1050// It walks with ListDirs, twice, and never with List. List(ctx, "apps/") on
1051// a production bucket returns EVERY object under every app — every release
1052// artifact of every version of every app — to find the handful of keys
1053// ending in /email.json, and it would do it at console start. ListDirs is
1054// the delimiter-aware listing (release/store.go:50-58): one page of
1055// accounts, one page of apps per account, then two Gets per app.
1056func SweepLegacyRecords(ctx context.Context, s release.Store, enabled bool) (int, error) {
1057 if !enabled {
1058 return 0, nil
1059 }
1060 accts, err := s.ListDirs(ctx, "apps/")
1061 if err != nil {
1062 return 0, fmt.Errorf("mail: sweep legacy records: %w", err)
1063 }
1064 deleted := 0
1065 for _, acctPrefix := range accts {
1066 // ListDirs hands back a FULL prefix ending in "/", never a bare
1067 // name, so the account is the last segment of it.
1068 acct := path.Base(strings.TrimSuffix(acctPrefix, "/"))
1069 if !release.ValidAccount(acct) {
1070 continue
1071 }
1072 apps, err := s.ListDirs(ctx, acctPrefix)
1073 if err != nil {
1074 log.Printf("mail: sweep legacy records: listing %s -> %v", acctPrefix, err)
1075 continue
1076 }
1077 for _, appPrefix := range apps {
1078 app := path.Base(strings.TrimSuffix(appPrefix, "/"))
1079 if !release.ValidApp(app) {
1080 continue
1081 }
1082 if _, _, err := s.Get(ctx, SenderKey(acct, app, SenderAppID)); err != nil {
1083 if !errors.Is(err, os.ErrNotExist) {
1084 log.Printf("mail: sweep legacy records: reading %s -> %v", SenderKey(acct, app, SenderAppID), err)
1085 }
1086 continue // not migrated: this object is still the only copy
1087 }
1088 legacy := LegacyKey(acct, app)
1089 if _, _, err := s.Get(ctx, legacy); err != nil {
1090 if !errors.Is(err, os.ErrNotExist) {
1091 log.Printf("mail: sweep legacy records: reading %s -> %v", legacy, err)
1092 }
1093 continue // nothing to remove, which is the steady state
1094 }
1095 if err := s.Delete(ctx, legacy); err != nil {
1096 log.Printf("mail: sweep legacy records: deleting %s -> %v", legacy, err)
1097 continue
1098 }
1099 deleted++
1100 }
1101 }
1102 return deleted, nil
1103}
1104```
1105
1106Imports: `path` rather than `path/filepath`, since these are bucket keys and not OS paths. `fmt` is still used by the first wrap.
1107
1108`cmd/console/main.go`: there is no variable holding the release store to reuse. The `Config` literal starts at `:265` and passes `Releases: openStore()` inline (`:269`), and `srv.cfg` is unexported (`internal/console/server.go:680`), so the sweep cannot read it back off the server. Hoist it:
1109
1110```go
1111 // One release.Store, named, because the senders sweep below needs the
1112 // same one the server got.
1113 rel := openStore()
1114 srv, err := console.NewServer(console.Config{
1115 Origin: origin,
1116 Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"),
1117 Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"),
1118 Releases: rel,
1119```
1120
1121Leave the rest of the literal untouched, including the `Bucket` comment below `Releases` that mentions `openStore()` by name.
1122
1123Then, immediately after `go srv.StartAlertWatcher(context.Background())` (`:334`):
1124
1125```go
1126 // The senders migration's tail (tenant-email senders design §5): once a
1127 // release has passed with every console writing senders/app.json, an
1128 // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the
1129 // email.json objects that have already been replaced. Off by default, and
1130 // it never touches an app that has not been migrated.
1131 if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" {
1132 go func() {
1133 n, err := mail.SweepLegacyRecords(context.Background(), rel, true)
1134 if err != nil {
1135 log.Printf("console: mail legacy sweep: %v", err)
1136 return
1137 }
1138 log.Printf("console: mail legacy sweep -> %d email.json objects removed", n)
1139 }()
1140 }
1141```
1142
1143Add the `internal/mail` import if `cmd/console/main.go` does not already have it.
1144
1145- [ ] **Step 4: Run it to verify it passes**
1146
1147```bash
1148go test -run TestSweepLegacyRecords ./internal/mail/ && go build ./...
1149```
1150
1151Expected: PASS and a clean build.
1152
1153- [ ] **Step 5: Gate and commit**
1154
1155```bash
1156go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1157git add internal/mail cmd/console
1158git commit -m "mail: the one-shot email.json sweep, behind a flag that defaults off (plan 2, task 4)"
1159```
1160
1161---
1162
1163### Task 5: The caller reverse index
1164
1165**Files:**
1166- Modify: `internal/mail/sender.go`, `internal/mail/sender_test.go`, `internal/mail/walk.go` (`Enable` at `:669-673`, `CreateStandalone` at `:1032-1035`, `RevokeStandalone` at `:1094-1105`, `Teardown` at `:1139-1150`)
1167
1168**Interfaces:**
1169- Consumes: `release.ErrCASConflict`, `getDomainClaimETag`'s idempotence rule (`domainclaim.go:119-142`) as the pattern.
1170- Produces:
1171 ```go
1172 type CallerIndex struct { Schema int; IAMUser, Account, App, Sender string; CreatedAt time.Time }
1173 func CallerKey(iamUser string) string // email/callers/<iamuser>.json
1174 func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error)
1175 func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error
1176 func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error
1177 func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error)
1178 var ErrCallerClaimed = errors.New("mail: caller name belongs to another sender")
1179 ```
1180
1181- [ ] **Step 1: Write the failing test**
1182
1183`internal/mail/sender_test.go` (append):
1184
1185```go
1186// The caller index is create-only with the domain claim's idempotence rule:
1187// the same (account, app, sender) is success, a different one is a
1188// collision. It is what makes a slugged IAM user name resolvable back to
1189// the sender that owns it, since nothing parses a hash.
1190func TestCallerIndexIsCreateOnlyAndIdempotentForItsOwnSender(t *testing.T) {
1191 ctx := context.Background()
1192 s := release.NewDirStore(t.TempDir())
1193 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
1194 const user = "carlos-mail-bag-hm+i+abcd1234"
1195
1196 if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil {
1197 t.Fatal(err)
1198 }
1199 if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil {
1200 t.Fatalf("a re-enable of the same sender was refused by the index it holds: %v", err)
1201 }
1202 if err := PutCallerIndex(ctx, s, user, "bag", "hm", "other.example.com", now); !errors.Is(err, ErrCallerClaimed) {
1203 t.Fatalf("a different sender at the same name = %v, want ErrCallerClaimed", err)
1204 }
1205 got, err := GetCallerIndex(ctx, s, user)
1206 if err != nil || got == nil || got.Account != "bag" || got.App != "hm" || got.Sender != "book.example.com" {
1207 t.Fatalf("GetCallerIndex = %+v %v", got, err)
1208 }
1209
1210 // Deletion is owner-gated, the way releaseDomainClaim is: a stranger
1211 // cannot remove an entry it does not hold.
1212 if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "someone.else"); err != nil {
1213 t.Fatalf("a mismatched delete should be a quiet no-op: %v", err)
1214 }
1215 if got, _ := GetCallerIndex(ctx, s, user); got == nil {
1216 t.Fatal("a mismatched delete removed the entry")
1217 }
1218 if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil {
1219 t.Fatal(err)
1220 }
1221 if got, _ := GetCallerIndex(ctx, s, user); got != nil {
1222 t.Fatalf("the entry survived its own delete: %+v", got)
1223 }
1224 // Deleting again is a quiet no-op, so a retried teardown is not an error.
1225 if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil {
1226 t.Fatal(err)
1227 }
1228}
1229
1230// A 40-bit slug needs collision detection, and the index is where it
1231// happens: the losing sender recomputes its slug with a salt that is stored
1232// on its record so the name stays stable across the next enable.
1233func TestResolveSenderNamesRetriesACollisionWithASalt(t *testing.T) {
1234 ctx := context.Background()
1235 s := release.NewDirStore(t.TempDir())
1236 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
1237
1238 sn := &Sender{ID: "book.example.com", Scope: ScopeInstance, Source: SourceDomain, Host: "book.example.com"}
1239 first, err := SenderNames("bag", "hm", sn.ID, "")
1240 if err != nil {
1241 t.Fatal(err)
1242 }
1243 // Somebody else is already at the unsalted name.
1244 if err := PutCallerIndex(ctx, s, first.IAMUser, "bag", "other", "book.example.com", now); err != nil {
1245 t.Fatal(err)
1246 }
1247
1248 names, salted, err := ResolveSenderNames(ctx, s, "bag", "hm", sn, now)
1249 if err != nil {
1250 t.Fatalf("ResolveSenderNames: %v", err)
1251 }
1252 if !salted {
1253 t.Fatal("the collision did not report that the record must be written back with its new salt")
1254 }
1255 if sn.Salt == "" {
1256 t.Fatal("no salt was stored on the record, so the next enable would collide again")
1257 }
1258 if names.IAMUser == first.IAMUser {
1259 t.Fatalf("the slug did not move: %q", names.IAMUser)
1260 }
1261 got, err := GetCallerIndex(ctx, s, names.IAMUser)
1262 if err != nil || got == nil || got.App != "hm" {
1263 t.Fatalf("the index at the new name = %+v %v", got, err)
1264 }
1265 // The app sender takes the same path and never needs a salt.
1266 appSender := &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain}
1267 appNames, saltedApp, err := ResolveSenderNames(ctx, s, "bag", "hm", appSender, now)
1268 if err != nil || saltedApp || appSender.Salt != "" {
1269 t.Fatalf("the app sender was salted: %+v %v %v", appNames, saltedApp, err)
1270 }
1271 if appNames.IAMUser != UserName("bag", "hm") {
1272 t.Fatalf("the app sender's name changed: %q", appNames.IAMUser)
1273 }
1274}
1275```
1276
1277Add `"context"` and `"github.com/carlosframework/platform/internal/release"` to `sender_test.go`'s imports.
1278
1279- [ ] **Step 2: Run it to verify it fails**
1280
1281```bash
1282go test -run 'TestCallerIndex|TestResolveSenderNames' ./internal/mail/
1283```
1284
1285Expected: build failure, `undefined: PutCallerIndex`.
1286
1287- [ ] **Step 3: Write the code**
1288
1289`internal/mail/sender.go` (append; add `crypto/rand`, `encoding/hex`, `encoding/json`, `os`, `time` and `context` to the imports):
1290
1291```go
1292// callersPrefix is the reverse index from an IAM caller name to the sender
1293// that owns it. GLOBAL, not under apps/<acct>/<app>/, because SES hands the
1294// ingest a bare user name with no account attached and the ingest has
1295// nowhere to start looking. The price of that placement is that
1296// release.PurgeApp's prefix sweep never reaches it, so every entry has an
1297// explicit delete: without one, a stale entry would attribute SES events to
1298// a purged app and insert counts for it.
1299const callersPrefix = "email/callers/"
1300
1301func CallerKey(iamUser string) string { return callersPrefix + iamUser + ".json" }
1302
1303// CallerIndex is the wire shape at CallerKey(IAMUser).
1304type CallerIndex struct {
1305 Schema int `json:"schema"` // 1
1306 IAMUser string `json:"iam_user"`
1307 Account string `json:"account"`
1308 App string `json:"app"`
1309 Sender string `json:"sender"`
1310 CreatedAt time.Time `json:"created_at"`
1311}
1312
1313// ErrCallerClaimed means this IAM name already belongs to a different
1314// sender. For a slugged name that is a hash collision and
1315// ResolveSenderNames recomputes with a salt; for an unslugged one it is a
1316// genuine conflict and the caller surfaces it.
1317var ErrCallerClaimed = errors.New("mail: caller name belongs to another sender")
1318
1319// GetCallerIndex reads one entry. Absent is (nil, nil), matching every other
1320// read in this package.
1321func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error) {
1322 raw, _, err := s.Get(ctx, CallerKey(iamUser))
1323 if err != nil {
1324 if errors.Is(err, os.ErrNotExist) {
1325 return nil, nil
1326 }
1327 return nil, err
1328 }
1329 var c CallerIndex
1330 if err := json.Unmarshal(raw, &c); err != nil {
1331 return nil, fmt.Errorf("mail: caller index %s: %w", iamUser, err)
1332 }
1333 if c.Schema > 1 {
1334 return nil, fmt.Errorf("mail: caller index %s: schema %d is newer than this binary understands, upgrade carlos", iamUser, c.Schema)
1335 }
1336 return &c, nil
1337}
1338
1339// PutCallerIndex claims iamUser for one sender. Create-only, with
1340// claimDomain's rule (domainclaim.go:119): the same triple is idempotent
1341// success, any other triple is ErrCallerClaimed.
1342//
1343// Written right AFTER EnsureUser, which runs on every enable, so a re-enable
1344// converges it. Writing it before MintKey would be no better and writing it
1345// before EnsureUser would be worse: a crash between the index write and the
1346// user's creation would strand the name against a principal that does not
1347// exist.
1348func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error {
1349 body, err := json.Marshal(CallerIndex{Schema: 1, IAMUser: iamUser, Account: acct, App: app, Sender: sender, CreatedAt: now})
1350 if err != nil {
1351 return err
1352 }
1353 for attempt := 0; ; attempt++ {
1354 cur, err := GetCallerIndex(ctx, s, iamUser)
1355 if err != nil {
1356 return err
1357 }
1358 if cur == nil {
1359 if _, err := s.Put(ctx, CallerKey(iamUser), body, ""); err == nil {
1360 return nil
1361 } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
1362 continue // lost the create race, re-read and judge the winner
1363 } else {
1364 return err
1365 }
1366 }
1367 if cur.Account != acct || cur.App != app || cur.Sender != sender {
1368 return ErrCallerClaimed
1369 }
1370 return nil // already ours
1371 }
1372}
1373
1374// DeleteCallerIndex removes an entry, but only when it is held by this
1375// sender, the same owner gate releaseDomainClaim applies. An entry already
1376// absent is a quiet no-op, so a retried teardown is not an error.
1377func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error {
1378 cur, err := GetCallerIndex(ctx, s, iamUser)
1379 if err != nil || cur == nil {
1380 return err
1381 }
1382 if cur.Account != acct || cur.App != app || cur.Sender != sender {
1383 return nil
1384 }
1385 return s.Delete(ctx, CallerKey(iamUser))
1386}
1387
1388// ResolveSenderNames renders a sender's names AND claims them in the caller
1389// index, recomputing a slugged name with a fresh salt on a collision.
1390//
1391// It reports whether the salt moved, because a moved salt has to be written
1392// back to the sender record under its own CAS or the next enable computes a
1393// different name again and the estate grows an orphan.
1394func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error) {
1395 salted := false
1396 for attempt := 0; attempt < 4; attempt++ {
1397 names, err := SenderNames(acct, app, sn.ID, sn.Salt)
1398 if err != nil {
1399 return SenderNameSet{}, salted, err
1400 }
1401 err = PutCallerIndex(ctx, s, names.IAMUser, acct, app, sn.ID, now)
1402 if err == nil {
1403 sn.Slug = names.Slug
1404 return names, salted, nil
1405 }
1406 if !errors.Is(err, ErrCallerClaimed) {
1407 return SenderNameSet{}, salted, err
1408 }
1409 if names.Slug == "" {
1410 // An unslugged name is deterministic, so a conflict here is not
1411 // a hash collision: another account or app is genuinely using
1412 // this principal, and a salt would not help.
1413 return SenderNameSet{}, salted, fmt.Errorf("mail: IAM user %s is already claimed by another sender: %w", names.IAMUser, err)
1414 }
1415 buf := make([]byte, 4)
1416 if _, err := rand.Read(buf); err != nil {
1417 return SenderNameSet{}, salted, err
1418 }
1419 sn.Salt = hex.EncodeToString(buf)
1420 salted = true
1421 }
1422 return SenderNameSet{}, salted, fmt.Errorf("mail: could not find a free IAM name for sender %s/%s/%s after four attempts", acct, app, sn.ID)
1423}
1424```
1425
1426`internal/mail/walk.go`, `Enable`, right after `EnsureUser` (`:670-673`):
1427
1428```go
1429 user := UserName(acct, app)
1430 arn, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names)
1431 if err != nil {
1432 return nil, fmt.Errorf("mail: ensure user %s: %w", user, err)
1433 }
1434 // The reverse index, right after EnsureUser and before MintKey (design
1435 // §5). EnsureUser runs on every enable, so a re-enable converges the
1436 // index; putting it after MintKey would strand a sender forever when a
1437 // crash landed between the two.
1438 if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil {
1439 return nil, fmt.Errorf("mail: index caller %s: %w", user, err)
1440 }
1441```
1442
1443`CreateStandalone`, after its `EnsureUser` (`:1033-1035`):
1444
1445```go
1446 if _, err := p.EnsureUser(ctx, user, UserPath, arns, configSets, names); err != nil {
1447 return StandaloneCreds{}, err
1448 }
1449 // A standalone credential sends for the app sender, so its caller name
1450 // resolves to the same sender the delivered credential does.
1451 if err := PutCallerIndex(ctx, s, user, acct, app, SenderAppID, now); err != nil {
1452 return StandaloneCreds{}, fmt.Errorf("mail: index caller %s: %w", user, err)
1453 }
1454```
1455
1456Also in `CreateStandalone`, before `StandaloneUserName` is called (`:1032`), bound-check the name so an over-long one is refused at create rather than at IAM:
1457
1458```go
1459 senderNames, err := SenderNames(acct, app, SenderAppID, "")
1460 if err != nil {
1461 return StandaloneCreds{}, err
1462 }
1463 user, err := senderNames.StandaloneUser(acct, app, name)
1464 if err != nil {
1465 return StandaloneCreds{}, err
1466 }
1467```
1468
1469`RevokeStandalone` (`:1096-1104`), before `DeleteUser`:
1470
1471```go
1472 if sa.Name == name {
1473 found = true
1474 if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil {
1475 return err
1476 }
1477 if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
1478 return err
1479 }
1480 continue
1481 }
1482```
1483
1484`Teardown` (`:1139-1150`), beside each `DeleteUser`:
1485
1486```go
1487 if rec != nil {
1488 if rec.Provisioned != nil {
1489 // The index sits outside apps/<acct>/<app>/, so PurgeApp's
1490 // prefix sweep never reaches it. Without this delete a stale
1491 // entry would attribute SES events to a purged app and insert
1492 // counts for it.
1493 if err := DeleteCallerIndex(ctx, s, rec.Provisioned.IAMUser, acct, app, SenderAppID); err != nil {
1494 return err
1495 }
1496 if err := p.DeleteUser(ctx, rec.Provisioned.IAMUser); err != nil {
1497 return err
1498 }
1499 }
1500 for _, sa := range rec.Standalone {
1501 if err := DeleteCallerIndex(ctx, s, sa.IAMUser, acct, app, SenderAppID); err != nil {
1502 return err
1503 }
1504 if err := p.DeleteUser(ctx, sa.IAMUser); err != nil {
1505 return err
1506 }
1507 }
1508 }
1509```
1510
1511An account is only ever deleted once it holds no apps (`handleDeleteAccount`, `internal/console/ownership.go:82`, and `DeleteAccount` re-checks it in its own transaction), so the account reap reaches the index through each app's `Teardown` and needs no sweep of its own. That is a ruling, recorded here so the next reader does not go looking for one.
1512
1513- [ ] **Step 4: Run it to verify it passes**
1514
1515```bash
1516go test ./internal/mail/ -count=1
1517```
1518
1519Expected: PASS.
1520
1521- [ ] **Step 5: Gate and commit**
1522
1523```bash
1524go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1525git add internal/mail
1526git commit -m "mail: the caller reverse index, written after EnsureUser and deleted with the principal (plan 2, task 5)"
1527```
1528
1529---
1530
1531### Task 6: `SenderOfCaller`
1532
1533**Files:**
1534- Modify: `internal/mail/sender.go`, `internal/mail/sender_test.go`
1535
1536**Interfaces:**
1537- Consumes: `AppOfCaller` (`policy.go:42`), `GetCallerIndex`.
1538- Produces:
1539 ```go
1540 func SenderOfCaller(ctx context.Context, s release.Store, caller string) (acct, app, sender string, ok bool)
1541 ```
1542
1543- [ ] **Step 1: Write the failing test**
1544
1545`internal/mail/sender_test.go` (append):
1546
1547```go
1548// Parse first, index second. Every app enabled today short-circuits Enable
1549// before MintKey, so it has no index entry until it next rotates: a
1550// Get-only resolver would silently stop attributing their events the moment
1551// it shipped.
1552func TestSenderOfCallerParsesBeforeItReads(t *testing.T) {
1553 ctx := context.Background()
1554 s := release.NewDirStore(t.TempDir())
1555 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
1556
1557 // No index entry exists at all, and the delivered and standalone names
1558 // still resolve, to the app sender.
1559 for _, caller := range []string{
1560 UserName("bag", "harbourmail"),
1561 StandaloneUserName("bag", "harbourmail", "laptop"),
1562 "arn:aws:iam::123456789012:user/carlos-mail/carlos-mail-bag-harbourmail",
1563 } {
1564 acct, app, sender, ok := SenderOfCaller(ctx, s, caller)
1565 if !ok || acct != "bag" || app != "harbourmail" || sender != SenderAppID {
1566 t.Errorf("SenderOfCaller(%q) = %q %q %q %v", caller, acct, app, sender, ok)
1567 }
1568 }
1569
1570 // A name nobody CARLOS minted stays unknown, and costs no read.
1571 for _, caller := range []string{"", "someone-else", "carlos-mail-bag-harbourmail+s+"} {
1572 if _, _, _, ok := SenderOfCaller(ctx, s, caller); ok {
1573 t.Errorf("SenderOfCaller(%q) claimed to know it", caller)
1574 }
1575 }
1576
1577 // A slugged name is unparseable by construction and comes from the index.
1578 names, err := SenderNames("bag", "harbourmail", "book.example.com", "")
1579 if err != nil {
1580 t.Fatal(err)
1581 }
1582 if _, _, _, ok := SenderOfCaller(ctx, s, names.IAMUser); ok {
1583 t.Fatal("a slugged name resolved before it was ever indexed")
1584 }
1585 if err := PutCallerIndex(ctx, s, names.IAMUser, "bag", "harbourmail", "book.example.com", now); err != nil {
1586 t.Fatal(err)
1587 }
1588 acct, app, sender, ok := SenderOfCaller(ctx, s, names.IAMUser)
1589 if !ok || acct != "bag" || app != "harbourmail" || sender != "book.example.com" {
1590 t.Fatalf("SenderOfCaller(slug) = %q %q %q %v", acct, app, sender, ok)
1591 }
1592}
1593```
1594
1595- [ ] **Step 2: Run it to verify it fails**
1596
1597```bash
1598go test -run TestSenderOfCallerParsesBeforeItReads ./internal/mail/
1599```
1600
1601Expected: build failure, `undefined: SenderOfCaller`.
1602
1603- [ ] **Step 3: Write the code**
1604
1605`internal/mail/sender.go` (append; add `log` and `strings` if not already imported):
1606
1607```go
1608// SenderOfCaller resolves the IAM identity SES stamps on an event
1609// (mail.tags["ses:caller-identity"]) to the sender that owns it.
1610//
1611// PARSE FIRST, in two steps, and the order is load-bearing. AppOfCaller
1612// answers for carlos-mail-<acct>-<app> and its +s+ standalones, which is
1613// every sender that exists today, and it costs nothing. Only a slugged +i+
1614// name, which is a hash and unparseable by construction, reaches the index
1615// Get. Every app enabled today short-circuits Enable before MintKey
1616// (walk.go:677), so it has no index entry until it next rotates: a Get-only
1617// resolver would silently stop writing the by_sender log rows that
1618// sesevents.go writes now, for every app in the fleet, on the roll.
1619//
1620// An unreadable index entry is logged and answers "unknown", which puts the
1621// event back on the domain-wide fallback rather than dropping it.
1622func SenderOfCaller(ctx context.Context, s release.Store, caller string) (string, string, string, bool) {
1623 if acct, app, ok := AppOfCaller(caller); ok {
1624 return acct, app, SenderAppID, true
1625 }
1626 if !strings.Contains(caller, "+i+") {
1627 return "", "", "", false
1628 }
1629 name := caller
1630 if i := strings.LastIndex(name, "/"); i >= 0 {
1631 name = name[i+1:]
1632 }
1633 idx, err := GetCallerIndex(ctx, s, name)
1634 if err != nil {
1635 log.Printf("mail: caller index %s -> %v", name, err)
1636 return "", "", "", false
1637 }
1638 if idx == nil || !release.ValidAccount(idx.Account) || !release.ValidApp(idx.App) || !ValidSenderID(idx.Sender) {
1639 return "", "", "", false
1640 }
1641 return idx.Account, idx.App, idx.Sender, true
1642}
1643```
1644
1645- [ ] **Step 4: Run it to verify it passes**
1646
1647```bash
1648go test -run TestSenderOfCaller ./internal/mail/
1649```
1650
1651Expected: PASS.
1652
1653- [ ] **Step 5: Gate and commit**
1654
1655```bash
1656go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1657git add internal/mail
1658git commit -m "mail: SenderOfCaller parses first and reads the index only for a slug (plan 2, task 6)"
1659```
1660
1661---
1662
1663### Task 7: Tenant resource associations
1664
1665**Files:**
1666- Modify: `internal/mail/provisioner.go` (`:49`), `internal/mail/memprovisioner.go` (`:40`, `:86`), `internal/mail/aws.go` (`sesAPI` at `:54`, after `EnsureTenant` at `:182`), `internal/mail/domain.go` (`VerifiedConfigSetARNs` at `:164`), `internal/mail/walk.go` (`EnsureDomain`'s association block goes in AFTER `PutDomain` at `:280`, beside `RefreshPolicies` at `:297`; `Teardown` at `:1190-1208`), `internal/mail/memprovisioner_test.go`, `internal/mail/walk_domain_test.go`
1667
1668**THE ASSOCIATION GOES AFTER `PutDomain`, AND THAT PLACEMENT IS LOAD-BEARING.** Do not associate beside `EnsureIdentity` (`:202-205`) or beside `EnsureConfigSet` (`:213-216`). `Teardown`'s orphan-claim sweep calls `p.DeleteIdentity` (`walk.go:1288`) on a claim that has NO domain record, so it never reaches the disassociate this task adds to the region loop — and once `MemProvisioner.DeleteIdentity` refuses an associated identity, an association written before the record exists makes that sweep unreachable. Associating only after the record is durable means an orphan claim never holds an associated identity, so the sweep still deletes it.
1669
1670**Interfaces:**
1671- Produces:
1672 ```go
1673 // Provisioner
1674 AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error
1675 DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error
1676 // mail
1677 func ConfigSetARN(identityARN, configSet string) (string, bool)
1678 ```
1679
1680- [ ] **Step 1: Write the failing test**
1681
1682`internal/mail/walk_domain_test.go` (append):
1683
1684```go
1685// Step 2 associates each account's EXISTING tenant with its identities and
1686// its configuration set (design §7, "Associations are wired in step 2, not
1687// here"). Associating a resource does not modify it and changes no
1688// behaviour on its own: no send carries X-SES-TENANT yet. It is done now so
1689// that step 3 can rely on it, and because SES validates the association on
1690// every tenant-attributed send.
1691func TestEnsureDomainAssociatesTheIdentityAndTheSetWithTheAccountsTenant(t *testing.T) {
1692 ctx := context.Background()
1693 s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
1694 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
1695 if _, err := Declare(ctx, s, "bag", "harbourmail", "HARBOURMAIL_SMTP",
1696 "noreply@harbourmail.bag.oncarlos.com", "owner@example.test", now); err != nil {
1697 t.Fatal(err)
1698 }
1699 rec, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(),
1700 "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now)
1701 if err != nil {
1702 t.Fatal(err)
1703 }
1704 tenant := TenantName("bag")
1705 identity := rec.Regions["eu-west-1"].IdentityARN
1706 set, ok := ConfigSetARN(identity, ConfigSetName("bag"))
1707 if !ok {
1708 t.Fatalf("no configuration-set ARN composed from %q", identity)
1709 }
1710 for _, arn := range []string{identity, set} {
1711 if !p.TenantResources[tenant][arn] {
1712 t.Errorf("%s is not associated with %s: %v", arn, tenant, p.TenantResources[tenant])
1713 }
1714 }
1715 // A re-run associates the same pair again without error: every verb on
1716 // this seam is idempotent, which is what makes the walk re-runnable.
1717 if _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(),
1718 "bag", "harbourmail", "harbourmail.bag.oncarlos.com", now); err != nil {
1719 t.Fatalf("re-run: %v", err)
1720 }
1721
1722 // An identity cannot be deleted while an association exists (design §3),
1723 // so teardown removes the association first. MemProvisioner models that
1724 // refusal, because without it the first real teardown would fail.
1725 if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail"); err != nil {
1726 t.Fatalf("Teardown: %v", err)
1727 }
1728 if p.TenantResources[tenant][identity] {
1729 t.Error("the identity association survived teardown, so the identity could never be deleted")
1730 }
1731 // The configuration set is per ACCOUNT and outlives any one app, so its
1732 // association is never removed by an app's teardown.
1733 if !p.TenantResources[tenant][set] {
1734 t.Error("an app's teardown removed the account's configuration-set association")
1735 }
1736}
1737```
1738
1739`internal/mail/memprovisioner_test.go` (append):
1740
1741```go
1742// The fake refuses to delete an identity that is still associated with a
1743// tenant, because SES does. A walk that got the order wrong would pass
1744// against a permissive fake and fail on the first real teardown.
1745func TestMemProvisionerRefusesToDeleteAnAssociatedIdentity(t *testing.T) {
1746 ctx := context.Background()
1747 p := NewMemProvisioner("1")
1748 id, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com")
1749 if err != nil {
1750 t.Fatal(err)
1751 }
1752 if err := p.AssociateTenantResource(ctx, "carlos-bag", id.ARN); err != nil {
1753 t.Fatal(err)
1754 }
1755 if err := p.DeleteIdentity(ctx, "eu-west-1", "x.com"); err == nil {
1756 t.Fatal("an associated identity was deleted")
1757 }
1758 if err := p.DisassociateTenantResource(ctx, "carlos-bag", id.ARN); err != nil {
1759 t.Fatal(err)
1760 }
1761 // Disassociating twice is a success, like every other verb on this seam.
1762 if err := p.DisassociateTenantResource(ctx, "carlos-bag", id.ARN); err != nil {
1763 t.Fatal(err)
1764 }
1765 if err := p.DeleteIdentity(ctx, "eu-west-1", "x.com"); err != nil {
1766 t.Fatal(err)
1767 }
1768}
1769```
1770
1771- [ ] **Step 2: Run it to verify it fails**
1772
1773```bash
1774go test -run 'TestEnsureDomainAssociates|TestMemProvisionerRefusesToDeleteAnAssociated' ./internal/mail/
1775```
1776
1777Expected: build failure, `p.TenantResources undefined`.
1778
1779- [ ] **Step 3: Write the code**
1780
1781`internal/mail/provisioner.go`, in the `Provisioner` interface after `SetIdentityConfigSet` (`:66`):
1782
1783```go
1784 // AssociateTenantResource associates an identity, a configuration set or
1785 // a template with a tenant. Associating does not modify the resource and
1786 // one resource may belong to many tenants, so this is safe to call on an
1787 // identity another product owns. It has exactly one side effect on the
1788 // owner: an identity cannot be deleted while an association exists,
1789 // which is why DisassociateTenantResource has to run before
1790 // DeleteIdentity. Idempotent, like every verb here.
1791 AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error
1792 // DisassociateTenantResource removes one association. "Already gone" is
1793 // success.
1794 DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error
1795```
1796
1797`internal/mail/memprovisioner.go`:
1798
1799```go
1800 // TenantResources is tenant -> resource ARN -> associated. An identity
1801 // with an association cannot be deleted, mirroring SES, because a walk
1802 // that tore down in the wrong order would pass against a permissive fake
1803 // and fail on the first real teardown.
1804 TenantResources map[string]map[string]bool
1805```
1806
1807initialised in `NewMemProvisioner` as `TenantResources: map[string]map[string]bool{}`, plus:
1808
1809```go
1810func (p *MemProvisioner) AssociateTenantResource(_ context.Context, tenant, resourceARN string) error {
1811 p.mu.Lock()
1812 defer p.mu.Unlock()
1813 if err := p.takeFailure("AssociateTenantResource"); err != nil {
1814 return err
1815 }
1816 if p.TenantResources[tenant] == nil {
1817 p.TenantResources[tenant] = map[string]bool{}
1818 }
1819 p.TenantResources[tenant][resourceARN] = true
1820 return nil
1821}
1822
1823func (p *MemProvisioner) DisassociateTenantResource(_ context.Context, tenant, resourceARN string) error {
1824 p.mu.Lock()
1825 defer p.mu.Unlock()
1826 if err := p.takeFailure("DisassociateTenantResource"); err != nil {
1827 return err
1828 }
1829 delete(p.TenantResources[tenant], resourceARN)
1830 return nil
1831}
1832```
1833
1834and `DeleteIdentity` (`:191`) grows the refusal:
1835
1836```go
1837func (p *MemProvisioner) DeleteIdentity(_ context.Context, region, domain string) error {
1838 p.mu.Lock()
1839 defer p.mu.Unlock()
1840 if err := p.takeFailure("DeleteIdentity"); err != nil {
1841 return err
1842 }
1843 got, ok := p.Identities[identityKey(region, domain)]
1844 if ok {
1845 for tenant, resources := range p.TenantResources {
1846 if resources[got.ARN] {
1847 return fmt.Errorf("mail: identity %s is still associated with tenant %s", domain, tenant)
1848 }
1849 }
1850 }
1851 delete(p.Identities, identityKey(region, domain))
1852 return nil
1853}
1854```
1855
1856`internal/mail/aws.go`: `sesAPI` (`:54`) gains the two operations, and after `EnsureTenant` (`:182`):
1857
1858```go
1859// AssociateTenantResource associates an identity or a configuration set with
1860// a tenant. This is the call the file header said nothing here made yet: SES
1861// validates the association on every tenant-attributed send, so it must exist
1862// before any send carries X-SES-TENANT. Associating changes nothing about the
1863// resource itself, so it is safe on an identity another product owns.
1864func (p *awsProvisioner) AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error {
1865 _, err := p.ses.CreateTenantResourceAssociation(ctx, &sesv2.CreateTenantResourceAssociationInput{
1866 TenantName: aws.String(tenant),
1867 ResourceArn: aws.String(resourceARN),
1868 })
1869 if err != nil && !isAlreadyExists(err) {
1870 return fmt.Errorf("mail: associate %s with tenant %q: %w", resourceARN, tenant, err)
1871 }
1872 return nil
1873}
1874
1875// DisassociateTenantResource removes one association. It has to run before
1876// DeleteEmailIdentity: SES refuses to delete an identity while an
1877// association exists, which is also the cross-team contract for a borrowed
1878// identity (design §7) — while CARLOS's association stands, the owner cannot
1879// delete their own identity.
1880func (p *awsProvisioner) DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error {
1881 _, err := p.ses.DeleteTenantResourceAssociation(ctx, &sesv2.DeleteTenantResourceAssociationInput{
1882 TenantName: aws.String(tenant),
1883 ResourceArn: aws.String(resourceARN),
1884 })
1885 if err != nil && !isNotFound(err) {
1886 return fmt.Errorf("mail: disassociate %s from tenant %q: %w", resourceARN, tenant, err)
1887 }
1888 return nil
1889}
1890```
1891
1892Update the file's `TENANT ATTRIBUTION` header (`:19-25`) and `EnsureTenant`'s `PRECONDITION FOR SENDING, NOT YET MET HERE` paragraph (above `:182`) to say that `EnsureDomain` now wires the associations, and record the spike's answer (design §4.1): the association is inert for the identity's owner in every respect except deletion, which is confirmed against the live API, and `ses:TenantName` IS enforced over SMTP. No send carries `X-SES-TENANT` in this release all the same, because nothing in step 2 composes an outgoing message or writes a tenant-conditioned policy. Step 3 is where both land.
1893
1894`internal/mail/domain.go`, extract the ARN composition `VerifiedConfigSetARNs` (`:170-174`) does inline and call it from there:
1895
1896```go
1897// ConfigSetARN composes a configuration set's ARN from an identity ARN in
1898// the same account and region, which is the only place this package has
1899// those two values. Reports false for an ARN it cannot parse, so a caller
1900// never builds a resource string out of a malformed one.
1901func ConfigSetARN(identityARN, configSet string) (string, bool) {
1902 if identityARN == "" || configSet == "" {
1903 return "", false
1904 }
1905 parts := strings.Split(identityARN, ":")
1906 if len(parts) != 6 {
1907 return "", false
1908 }
1909 return strings.Join(parts[:5], ":") + ":configuration-set/" + configSet, true
1910}
1911```
1912
1913`internal/mail/walk.go`, `EnsureDomain`: BOTH associations go in one block immediately after `PutDomain` succeeds (`:280-282`) and immediately before the `GRANT BEFORE BIND` comment and `RefreshPolicies` (`:297`). `tenant` (`:181`), `ident` (`:202`) and `configSet` (`:213`) are all still in scope there.
1914
1915```go
1916 // The account's tenant gets this identity, and its configuration set
1917 // (design §7). Association does not modify the resource and one resource
1918 // may belong to many tenants, so this changes nothing anyone can observe
1919 // today. It is wired now because SES validates the association on every
1920 // tenant-attributed send and step 3 sends that way.
1921 //
1922 // AFTER PutDomain, not beside EnsureIdentity, and that ordering is the
1923 // whole of what keeps Teardown's orphan-claim sweep working. An
1924 // association is the one thing that stops an identity being deleted, and
1925 // the sweep (walk.go:1288) deletes the identity behind a claim that has
1926 // NO record — so it never runs the disassociate below. Associate before
1927 // the record is durable and a crash between the two leaves an identity
1928 // nothing can ever reap.
1929 if err := p.AssociateTenantResource(ctx, tenant, ident.ARN); err != nil {
1930 return nil, nil, fmt.Errorf("mail: associate identity %s with tenant %s: %w", domain, tenant, err)
1931 }
1932 if configSet != "" {
1933 if setARN, ok := ConfigSetARN(ident.ARN, configSet); ok {
1934 if err := p.AssociateTenantResource(ctx, tenant, setARN); err != nil {
1935 return nil, nil, fmt.Errorf("mail: associate configuration set %s with tenant %s: %w", configSet, tenant, err)
1936 }
1937 }
1938 }
1939```
1940
1941`internal/mail/walk.go`, `Teardown`'s region loop (`:1190-1208`), before `DeleteIdentity`:
1942
1943```go
1944 // SES refuses to delete an identity while a tenant association
1945 // exists, so the association goes first. The account's
1946 // configuration set is NOT disassociated: it is per account and
1947 // outlives any one app.
1948 if st.IdentityARN != "" {
1949 if err := p.DisassociateTenantResource(ctx, TenantName(acct), st.IdentityARN); err != nil {
1950 return err
1951 }
1952 }
1953 if err := p.DeleteIdentity(ctx, region, cur.Domain); err != nil {
1954 return err
1955 }
1956```
1957
1958- [ ] **Step 4: Run it to verify it passes**
1959
1960```bash
1961go test ./internal/mail/ -count=1
1962```
1963
1964Expected: PASS, and in particular these two stay green, because they are the ones the new refusal can break:
1965
1966```
1967--- PASS: TestTeardownOrphanSweepDestroysTheIdentityBeforeReleasing
1968--- PASS: TestTeardownOrphanSweepDoesNotCrossAccountsOnAMatchingAppName
1969```
1970
1971**If either of those goes red, the fix is the ORDERING, never the fake.** Both live in `internal/mail/walk_domainclaim_test.go` (`:493` and `:589`). Each injects `p.FailNext["EnsureConfigSet"]` (`:503`, `:601`) so `EnsureDomain` dies after `EnsureIdentity` and before any record is written, then expects `Teardown` to succeed (`:516`, `:615`) by destroying the orphaned identity. If they fail with "identity is still associated with tenant", the association was written too early: move it back to after `PutDomain`. Do NOT weaken `MemProvisioner.DeleteIdentity`'s refusal, and do not make the orphan sweep disassociate first — the refusal is what SES actually does (design §4.1, confirmed against the live API), and a fake that permits the delete would pass here and fail on the first real teardown.
1972
1973- [ ] **Step 5: Gate and commit**
1974
1975```bash
1976go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1977git add internal/mail
1978git commit -m "mail: associate each account's tenant with its identities and its configuration set (plan 2, task 7)"
1979```
1980
1981---
1982
1983### Task 8: `mail_sender_counts`, its backfill and the legacy drop
1984
1985**Files:**
1986- Modify: `internal/console/store.go` (the create loop at `:92-297`, the ALTER loop at `:300-370`), `internal/console/mailcounts_test.go`
1987
1988**Interfaces:**
1989- Produces:
1990 ```go
1991 func (s *Store) DropLegacyMailCounts(enabled bool) error
1992 ```
1993 plus the `mail_sender_counts` table and a one-shot backfill guarded by `PRAGMA user_version`.
1994
1995- [ ] **Step 1: Write the failing test**
1996
1997`internal/console/mailcounts_test.go` (append):
1998
1999```go
2000// SQLite cannot widen a primary key, so per-sender counts are a new table,
2001// backfilled once from mail_counts with sender = 'app'. The backfill runs at
2002// most once for the life of the database: a second migrate must not
2003// resurrect rows a purge or a rename has since removed.
2004func TestMailSenderCountsAreBackfilledExactlyOnce(t *testing.T) {
2005 dir := t.TempDir()
2006 path := filepath.Join(dir, "console.db")
2007 st, err := Open(path)
2008 if err != nil {
2009 t.Fatal(err)
2010 }
2011 if _, err := st.db.Exec(`INSERT INTO mail_counts (account, app, day, sends, bounces, complaints, rejects, deliveries)
2012VALUES ('bag', 'harbourmail', '2026-09-14', 7, 1, 0, 0, 6)`); err != nil {
2013 t.Fatal(err)
2014 }
2015 // Force the backfill to run again by winding the marker back, which is
2016 // what a console rolled back and forward would look like.
2017 if _, err := st.db.Exec(`PRAGMA user_version = 0`); err != nil {
2018 t.Fatal(err)
2019 }
2020 if err := st.migrate(); err != nil {
2021 t.Fatal(err)
2022 }
2023 var sends, deliveries int
2024 if err := st.db.QueryRow(`SELECT sends, deliveries FROM mail_sender_counts
2025WHERE account = 'bag' AND app = 'harbourmail' AND sender = 'app' AND day = '2026-09-14'`).Scan(&sends, &deliveries); err != nil {
2026 t.Fatalf("the backfill did not land: %v", err)
2027 }
2028 if sends != 7 || deliveries != 6 {
2029 t.Fatalf("backfilled %d sends and %d deliveries, want 7 and 6", sends, deliveries)
2030 }
2031
2032 // A row removed after the backfill stays removed across a restart.
2033 if _, err := st.db.Exec(`DELETE FROM mail_sender_counts WHERE account = 'bag'`); err != nil {
2034 t.Fatal(err)
2035 }
2036 if err := st.migrate(); err != nil {
2037 t.Fatal(err)
2038 }
2039 var n int
2040 if err := st.db.QueryRow(`SELECT count(*) FROM mail_sender_counts WHERE account = 'bag'`).Scan(&n); err != nil {
2041 t.Fatal(err)
2042 }
2043 if n != 0 {
2044 t.Fatalf("the backfill resurrected %d deleted rows", n)
2045 }
2046
2047 // The legacy table is dropped only when an operator asks, a release later.
2048 if err := st.DropLegacyMailCounts(false); err != nil {
2049 t.Fatal(err)
2050 }
2051 if err := st.db.QueryRow(`SELECT count(*) FROM mail_counts`).Scan(&n); err != nil {
2052 t.Fatalf("mail_counts was dropped while the flag was off: %v", err)
2053 }
2054 if err := st.DropLegacyMailCounts(true); err != nil {
2055 t.Fatal(err)
2056 }
2057 if err := st.db.QueryRow(`SELECT count(*) FROM mail_counts`).Scan(&n); err == nil {
2058 t.Fatal("mail_counts survived its own drop")
2059 }
2060 if err := st.Close(); err != nil {
2061 t.Fatal(err)
2062 }
2063}
2064```
2065
2066Add `"path/filepath"` to the test file's imports. The constructor is `Open`, not `OpenStore` (`internal/console/store.go:53`).
2067
2068- [ ] **Step 2: Run it to verify it fails**
2069
2070```bash
2071go test -run TestMailSenderCountsAreBackfilledExactlyOnce ./internal/console/
2072```
2073
2074Expected: `no such table: mail_sender_counts`.
2075
2076- [ ] **Step 3: Write the code**
2077
2078`internal/console/store.go`, in the create loop immediately after the `mail_counts` statement (`:209`):
2079
2080```go
2081 // Per-SENDER counts (senders design §8). A new table rather than a
2082 // widened key, because SQLite cannot ALTER a primary key. Every count
2083 // is written here from now on; mail_counts stays, unwritten, and is
2084 // dropped a release later by DropLegacyMailCounts.
2085 //
2086 // day is a UTC date string, not a timestamp, exactly as mail_counts'
2087 // is — which is why this table cannot join mailAppTables blindly: the
2088 // rename, purge and reap sweeps key on mailAppTimeColumn and there is
2089 // no such column here.
2090 `CREATE TABLE IF NOT EXISTS mail_sender_counts (
2091 account TEXT NOT NULL,
2092 app TEXT NOT NULL,
2093 sender TEXT NOT NULL,
2094 day TEXT NOT NULL, -- UTC YYYY-MM-DD
2095 sends INTEGER NOT NULL DEFAULT 0,
2096 bounces INTEGER NOT NULL DEFAULT 0,
2097 complaints INTEGER NOT NULL DEFAULT 0,
2098 rejects INTEGER NOT NULL DEFAULT 0,
2099 deliveries INTEGER NOT NULL DEFAULT 0,
2100 PRIMARY KEY (account, app, sender, day)
2101);`,
2102```
2103
2104and after the ALTER loop, before `migrateAppsKey()` (`:371`):
2105
2106```go
2107 // The one-shot backfill of mail_sender_counts from mail_counts (senders
2108 // design §8). It runs AFTER the ALTER list because mail_counts.deliveries
2109 // is added there, and it runs at most once for the life of the database:
2110 // a backfill repeated at every start would resurrect rows a purge or a
2111 // rename had since removed, since mail_counts is not swept by anything.
2112 // PRAGMA user_version is the marker because the console has no other
2113 // place to keep one and this is the first thing that has needed it.
2114 if err := s.backfillMailSenderCounts(); err != nil {
2115 return err
2116 }
2117```
2118
2119```go
2120// mailCountsBackfillVersion is the user_version this migration stamps. The
2121// next migration that needs a one-shot marker takes 2 and reads this
2122// constant rather than the literal.
2123const mailCountsBackfillVersion = 1
2124
2125func (s *Store) backfillMailSenderCounts() error {
2126 var version int
2127 if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
2128 return err
2129 }
2130 if version >= mailCountsBackfillVersion {
2131 return nil
2132 }
2133 if _, err := s.db.Exec(`INSERT OR IGNORE INTO mail_sender_counts
2134 (account, app, sender, day, sends, bounces, complaints, rejects, deliveries)
2135SELECT account, app, 'app', day, sends, bounces, complaints, rejects, deliveries FROM mail_counts`); err != nil {
2136 return err
2137 }
2138 // PRAGMA takes no bound parameter, and the value is a constant in this
2139 // file rather than anything a caller supplies.
2140 _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, mailCountsBackfillVersion))
2141 return err
2142}
2143
2144// DropLegacyMailCounts removes the pre-senders counts table. Guarded by a
2145// flag that defaults off and driven by an operator a release after the
2146// backfill, for the same reason mail.SweepLegacyRecords is: the rollback
2147// window has to close before the old shape goes, and dropping it early would
2148// leave a rolled-back console counting into a table that is not there.
2149func (s *Store) DropLegacyMailCounts(enabled bool) error {
2150 if !enabled {
2151 return nil
2152 }
2153 _, err := s.db.Exec(`DROP TABLE IF EXISTS mail_counts`)
2154 return err
2155}
2156```
2157
2158`cmd/console/main.go`, inside the `CARLOS_MAIL_LEGACY_SWEEP == "1"` block from task 4, after the bucket sweep:
2159
2160```go
2161 if err := st.DropLegacyMailCounts(true); err != nil {
2162 log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err)
2163 }
2164```
2165
2166- [ ] **Step 4: Run it to verify it passes**
2167
2168```bash
2169go test -run TestMailSenderCounts ./internal/console/
2170```
2171
2172Expected: PASS.
2173
2174- [ ] **Step 5: Gate and commit**
2175
2176```bash
2177go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
2178git add internal/console cmd/console
2179git commit -m "console: mail_sender_counts, its one-shot backfill and the legacy drop (plan 2, task 8)"
2180```
2181
2182---
2183
2184### Task 9: `RecordMailEvent` and `MailCounts` take a sender
2185
2186**Files:**
2187- Modify: `internal/console/mailcounts.go` (`RecordMailEvent` at `:106`, `MailCounts` at `:120`, and its OWN caller inside `enforceMailLimits` at `:230`), `internal/console/sesevents.go` (`:523`), `internal/console/apimail.go` (`:403`), `internal/console/mailcounts_test.go` (`:83`, `:88`, `:91`), `internal/console/mailevents_test.go` (`:165`, `:306`, `:418`), `internal/console/sesevents_test.go` (`:295`, `:778`)
2188
2189There are FOUR production call sites, not three. `enforceMailLimits` reads the counts itself at `mailcounts.go:230` — `c, err := s.store.MailCounts(ctx, acct, app, day)` — in the same file the signature changes in, and the package does not compile without it.
2190
2191**Interfaces:**
2192- Produces:
2193 ```go
2194 func (s *Store) RecordMailEvent(ctx context.Context, acct, app, sender, day, evType string) error
2195 func (s *Store) MailCounts(ctx context.Context, acct, app, sender, day string) (MailCounts, error) // sender "" sums every sender
2196 ```
2197
2198- [ ] **Step 1: Write the failing test**
2199
2200`internal/console/mailcounts_test.go` (append):
2201
2202```go
2203// Counts are per sender from here on. An empty sender reads the app's whole
2204// day across every sender, which is what the status reply's top-level
2205// figures and the roll-up both need.
2206func TestMailCountsAreKeptAndReadPerSender(t *testing.T) {
2207 fx := newConsoleFixture(t)
2208 ctx := context.Background()
2209 const day = "2026-09-15"
2210 for i := 0; i < 3; i++ {
2211 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
2212 t.Fatal(err)
2213 }
2214 }
2215 for i := 0; i < 2; i++ {
2216 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
2217 t.Fatal(err)
2218 }
2219 }
2220 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventBounce); err != nil {
2221 t.Fatal(err)
2222 }
2223 app, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day)
2224 if err != nil {
2225 t.Fatal(err)
2226 }
2227 if app.Sends != 3 || app.Bounces != 0 {
2228 t.Fatalf("app sender = %+v, want 3 sends and no bounces", app)
2229 }
2230 one, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "book.example.com", day)
2231 if err != nil {
2232 t.Fatal(err)
2233 }
2234 if one.Sends != 2 || one.Bounces != 1 {
2235 t.Fatalf("instance sender = %+v, want 2 sends and 1 bounce", one)
2236 }
2237 all, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "", day)
2238 if err != nil {
2239 t.Fatal(err)
2240 }
2241 if all.Sends != 5 || all.Bounces != 1 {
2242 t.Fatalf("every sender = %+v, want 5 sends and 1 bounce", all)
2243 }
2244 // A day with no rows is a legitimate zero, not an error and not an
2245 // ErrNoRows the caller has to know about.
2246 quiet, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbourmail", "", "2026-01-01")
2247 if err != nil || quiet != (MailCounts{}) {
2248 t.Fatalf("a quiet day = %+v %v", quiet, err)
2249 }
2250}
2251```
2252
2253- [ ] **Step 2: Run it to verify it fails**
2254
2255```bash
2256go test -run TestMailCountsAreKeptAndReadPerSender ./internal/console/
2257```
2258
2259Expected: build failure, `not enough arguments in call to fx.st.RecordMailEvent`.
2260
2261- [ ] **Step 3: Write the code**
2262
2263`internal/console/mailcounts.go`, replacing `RecordMailEvent` and `MailCounts`:
2264
2265```go
2266// RecordMailEvent increments the one counter evType names for (acct, app,
2267// sender, day). An event type this function does not know is silently
2268// ignored — mirrored from mail.ParseEvent's own tolerance of unknown types,
2269// for the same reason: SES adding a type must never turn into a 500 that
2270// makes SNS retry forever.
2271//
2272// Writes mail_sender_counts alone. mail_counts stays on disk, unwritten,
2273// until DropLegacyMailCounts removes it a release later.
2274func (s *Store) RecordMailEvent(ctx context.Context, acct, app, sender, day, evType string) error {
2275 col, ok := mailCountColumn(evType)
2276 if !ok {
2277 return nil
2278 }
2279 if sender == "" {
2280 sender = mail.SenderAppID
2281 }
2282 q := fmt.Sprintf(`INSERT INTO mail_sender_counts (account, app, sender, day, %s)
2283VALUES (?, ?, ?, ?, 1)
2284ON CONFLICT(account, app, sender, day) DO UPDATE SET %s = %s + 1`, col, col, col)
2285 _, err := s.db.ExecContext(ctx, q, acct, app, sender, day)
2286 return err
2287}
2288
2289// MailCounts returns one sender's counts for one UTC day. An EMPTY sender
2290// reads every sender the app has, which is what the status reply's
2291// unchanged top-level figures mean and what a roll-up of an app in
2292// per-instance mode needs. A day with no rows at all is a legitimate zero.
2293func (s *Store) MailCounts(ctx context.Context, acct, app, sender, day string) (MailCounts, error) {
2294 q := `SELECT COALESCE(SUM(sends), 0), COALESCE(SUM(bounces), 0), COALESCE(SUM(complaints), 0),
2295 COALESCE(SUM(rejects), 0), COALESCE(SUM(deliveries), 0)
2296FROM mail_sender_counts WHERE account = ? AND app = ? AND day = ?`
2297 args := []any{acct, app, day}
2298 if sender != "" {
2299 q += ` AND sender = ?`
2300 args = append(args, sender)
2301 }
2302 var c MailCounts
2303 // An aggregate always returns exactly one row, so there is no ErrNoRows
2304 // branch to get wrong and a quiet day reads as zeroes by construction.
2305 if err := s.db.QueryRowContext(ctx, q, args...).Scan(
2306 &c.Sends, &c.Bounces, &c.Complaints, &c.Rejects, &c.Deliveries); err != nil {
2307 return MailCounts{}, err
2308 }
2309 return c, nil
2310}
2311```
2312
2313Call sites, each taking the app sender because that is the only sender any of them can be about today:
2314
2315- `internal/console/sesevents.go:523` -> `s.store.RecordMailEvent(ctx, a.Sqid, app, mail.SenderAppID, day, ev.Type)` (task 11 replaces this line properly).
2316- `internal/console/apimail.go:403` -> `s.store.MailCounts(ctx, acct.Sqid, app, "", day)`: the top-level reply fields describe the app as a whole and keep doing so.
2317- `internal/console/mailcounts.go:230`, inside `enforceMailLimits` -> `s.store.MailCounts(ctx, acct, app, "", day)`. `""` and not `mail.SenderAppID`, deliberately: `enforceMailLimits` still takes no sender in this task, so the only cap it can enforce is the app's whole day, which is exactly what it enforces today. Task 12 gives the function a sender and this line becomes `s.store.MailCounts(ctx, acct, app, sender, day)`.
2318- `internal/console/mailcounts_test.go:83`, `:88`, `:91` -> add `mail.SenderAppID` after the app.
2319- `internal/console/mailevents_test.go:165`, `:306`, `:418` and `internal/console/sesevents_test.go:295`, `:778` -> add `""` after the app, since each asserts the app's whole day.
2320
2321- [ ] **Step 4: Run it to verify it passes**
2322
2323```bash
2324go test ./internal/console/ -count=1
2325```
2326
2327Expected: PASS.
2328
2329- [ ] **Step 5: Gate and commit**
2330
2331```bash
2332go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
2333git add internal/console
2334git commit -m "console: mail counts are recorded and read per sender (plan 2, task 9)"
2335```
2336
2337---
2338
2339### Task 10: The rename, purge and reap sweeps take the new table
2340
2341**Files:**
2342- Modify: `internal/console/mailevents.go` (`mailAppTables` at `:94`, `carryMailApp` at `:114`, `mailAppTimeColumn` at `:170`), `internal/console/mailevents_test.go`
2343
2344**Interfaces:**
2345- Produces: `mailAppTables` gains `"mail_sender_counts"`; `mailAppTimeColumn("mail_sender_counts") == "day"`; `carryMailApp` compares a date string for a `day` table and a unix stamp for every other.
2346
2347- [ ] **Step 1: Write the failing test**
2348
2349`internal/console/mailevents_test.go` (append):
2350
2351```go
2352// A sender's counts belong to the app, not to its name. A rename carries
2353// them; a purge drops them, so the next app of that name starts at zero;
2354// an account reap takes the lot. The comparison is on the day STRING,
2355// because this table has no timestamp column for mailAppTimeColumn to name.
2356func TestSenderCountsFollowTheAppAndDieWithIt(t *testing.T) {
2357 fx := newConsoleFixture(t)
2358 ctx := context.Background()
2359 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2360 yesterday := now.AddDate(0, 0, -1).UTC().Format("2006-01-02")
2361 today := now.UTC().Format("2006-01-02")
2362
2363 seed := func(app, sender, day string) {
2364 t.Helper()
2365 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, app, sender, day, mail.EventSend); err != nil {
2366 t.Fatal(err)
2367 }
2368 }
2369 countRows := func(app string) int {
2370 t.Helper()
2371 var n int
2372 if err := fx.st.db.QueryRow(`SELECT count(*) FROM mail_sender_counts WHERE account = ? AND app = ?`,
2373 release.OpsAccount, app).Scan(&n); err != nil {
2374 t.Fatal(err)
2375 }
2376 return n
2377 }
2378
2379 if _, err := fx.st.CreateAppIn(OpsAccountID, "jb-book", "bill@tito.io", now); err != nil {
2380 t.Fatal(err)
2381 }
2382 seed("jb-book", mail.SenderAppID, today)
2383 seed("jb-book", "book.example.com", today)
2384 // A row from BEFORE the app row was created belongs to a predecessor of
2385 // the name and must not travel, the rule RenameAppIn already applies to
2386 // audit rows.
2387 seed("jb-book", mail.SenderAppID, yesterday)
2388
2389 tx, err := fx.st.db.Begin()
2390 if err != nil {
2391 t.Fatal(err)
2392 }
2393 if err := carryMailApp(ctx, tx, release.OpsAccount, "jb-book", release.OpsAccount, "jb-reader", now.Unix()); err != nil {
2394 t.Fatal(err)
2395 }
2396 if err := tx.Commit(); err != nil {
2397 t.Fatal(err)
2398 }
2399 if got := countRows("jb-reader"); got != 2 {
2400 t.Fatalf("the rename carried %d rows, want the 2 from today", got)
2401 }
2402 if got := countRows("jb-book"); got != 0 {
2403 t.Fatalf("%d rows were left under the old name", got)
2404 }
2405
2406 tx, err = fx.st.db.Begin()
2407 if err != nil {
2408 t.Fatal(err)
2409 }
2410 if err := forgetMailApp(ctx, tx, release.OpsAccount, "jb-reader"); err != nil {
2411 t.Fatal(err)
2412 }
2413 if err := tx.Commit(); err != nil {
2414 t.Fatal(err)
2415 }
2416 if got := countRows("jb-reader"); got != 0 {
2417 t.Fatalf("a purge left %d rows for the next app of that name", got)
2418 }
2419
2420 seed("jb-reader", mail.SenderAppID, today)
2421 tx, err = fx.st.db.Begin()
2422 if err != nil {
2423 t.Fatal(err)
2424 }
2425 if err := forgetMailAccount(ctx, tx, release.OpsAccount); err != nil {
2426 t.Fatal(err)
2427 }
2428 if err := tx.Commit(); err != nil {
2429 t.Fatal(err)
2430 }
2431 if got := countRows("jb-reader"); got != 0 {
2432 t.Fatalf("the account reap left %d rows", got)
2433 }
2434}
2435```
2436
2437- [ ] **Step 2: Run it to verify it fails**
2438
2439```bash
2440go test -run TestSenderCountsFollowTheAppAndDieWithIt ./internal/console/
2441```
2442
2443Expected: `the rename carried 0 rows, want the 2 from today`.
2444
2445- [ ] **Step 3: Write the code**
2446
2447`internal/console/mailevents.go`:
2448
2449```go
2450// mailAppTables are the tables keyed by an app's (account sqid, name) that
2451// belong to that app alone: a name that is freed must not hand them on.
2452//
2453// mail_sender_counts joins the list (senders design §8), which also closes
2454// the gap where a rename left an app's counts behind under its old name.
2455// It cannot join blindly, though: every sweep here keys on a TIMESTAMP
2456// column and this table has a day string instead, so it gets the explicit
2457// case in mailAppTimeColumn and carryMailApp below. mail_counts is
2458// deliberately not here: nothing writes it any more and
2459// DropLegacyMailCounts removes it whole.
2460var mailAppTables = []string{"mail_events", "mail_event_hooks", "mail_event_deliveries", "mail_sender_counts"}
2461```
2462
2463`mailAppTimeColumn` (`:170`):
2464
2465```go
2466// mailAppTimeColumn is the column that says when a row came to be. Most are
2467// unix seconds; mail_sender_counts keys on a UTC date string, which compares
2468// correctly against another ISO date and against nothing else.
2469func mailAppTimeColumn(table string) string {
2470 switch table {
2471 case "mail_events":
2472 return "received_at"
2473 case "mail_event_hooks":
2474 return "set_at"
2475 case "mail_sender_counts":
2476 return "day"
2477 }
2478 return "created_at"
2479}
2480
2481// mailAppTimeSince renders createdAt in whatever mailAppTimeColumn(table)
2482// compares against: a unix stamp for a timestamp column, a UTC date string
2483// for the one day column. Comparing a unix integer against a 'YYYY-MM-DD'
2484// string in SQLite is not an error, it is simply always false, so getting
2485// this wrong would silently carry nothing.
2486func mailAppTimeSince(table string, createdAt int64) any {
2487 if mailAppTimeColumn(table) == "day" {
2488 return time.Unix(createdAt, 0).UTC().Format("2006-01-02")
2489 }
2490 return createdAt
2491}
2492```
2493
2494`carryMailApp`'s UPDATE (`:134-137`):
2495
2496```go
2497 if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET account = ?, app = ? WHERE account = ? AND app = ? AND `+
2498 mailAppTimeColumn(table)+` >= ?`, toSqid, toApp, fromSqid, fromApp, mailAppTimeSince(table, createdAt)); err != nil {
2499 return err
2500 }
2501```
2502
2503- [ ] **Step 4: Run it to verify it passes**
2504
2505```bash
2506go test ./internal/console/ -count=1
2507```
2508
2509Expected: PASS.
2510
2511- [ ] **Step 5: Gate and commit**
2512
2513```bash
2514go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
2515git add internal/console
2516git commit -m "console: the rename, purge and reap sweeps take mail_sender_counts on its day column (plan 2, task 10)"
2517```
2518
2519---
2520
2521### Task 11: Attribution by caller in `recordAndEnforceMail`
2522
2523**Files:**
2524- Modify: `internal/console/sesevents.go` (`recordAndEnforceMail` at `:466-535`), `internal/console/mailevents_test.go` (`:163-168`), `internal/console/sesevents_test.go`
2525
2526**Interfaces:**
2527- Consumes: `mail.SenderOfCaller`, `mail.GetSender`, `s.mailAppLive` (`sesevents.go:558` uses it for the log row).
2528- Produces: no signature change. `recordAndEnforceMail` counts once against the resolved sender, only for a live app, and falls back to the domain loop only when the caller is unknown, skipping any app in per-instance mode.
2529
2530- [ ] **Step 1: Write the failing test**
2531
2532`internal/console/mailevents_test.go`, replace the counts assertion inside `TestTheMailLogGoesToTheAppSESSaysSentIt` (`:163-168`) with:
2533
2534```go
2535 // THE VISIBLE CHANGE, and it belongs in the roll note (design §8).
2536 // Before senders, every app on a shared domain was charged for every
2537 // event on that domain. Now only the app SES named is charged, so a
2538 // sibling's cap stops being consumed by another app's sends.
2539 day := now.UTC().Format("2006-01-02")
2540 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", "", day); c.Bounces != 1 {
2541 t.Errorf("jb-book bounces = %d, want the 1 SES attributed to it", c.Bounces)
2542 }
2543 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-site", "", day); c.Bounces != 0 {
2544 t.Errorf("jb-site bounces = %d, want 0 — a sibling on the same domain is no longer charged", c.Bounces)
2545 }
2546```
2547
2548and, further down in the same test, after the two unnamed-caller events are recorded (`:190`), append:
2549
2550```go
2551 // An unknown caller keeps today's behaviour exactly: the domain is all
2552 // there is to go on, so every app on it is charged.
2553 for _, app := range []string{"jb-book", "jb-site"} {
2554 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, app, "", day); c.Bounces < 2 {
2555 t.Errorf("%s bounces = %d, want the two domain-attributed ones", app, c.Bounces)
2556 }
2557 }
2558```
2559
2560and, at the very end of the same test (before its closing brace at `:206`), pin the gate the stray case now needs. The stray event is already there at `:172-174`: its caller is `mail.UserName(release.OpsAccount, "not-an-app")`, which parses cleanly and so takes the named branch.
2561
2562```go
2563 // The stray caller parses into a well-formed account and app, so it is
2564 // "named" — but "not-an-app" is not a live app, and a count row keyed on
2565 // a name no app answers to is a row nothing reads and nothing reaps.
2566 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "not-an-app", "", day); c.Bounces != 0 {
2567 t.Errorf("a name that is not an app was given %d bounces", c.Bounces)
2568 }
2569```
2570
2571`internal/console/mailevents_test.go` (append a new test):
2572
2573```go
2574// An event whose caller names a slugged sender is charged to that sender
2575// alone, resolved through the caller index rather than by parsing.
2576func TestAnIndexedCallerIsChargedToItsOwnSender(t *testing.T) {
2577 fx := newConsoleFixture(t)
2578 ctx := context.Background()
2579 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2580 if _, err := fx.st.CreateAppIn(OpsAccountID, "jb-book", "bill@tito.io", now); err != nil {
2581 t.Fatal(err)
2582 }
2583 names, err := mail.SenderNames(release.OpsAccount, "jb-book", "book.example.com", "")
2584 if err != nil {
2585 t.Fatal(err)
2586 }
2587 if err := mail.PutCallerIndex(ctx, fx.rel, names.IAMUser, release.OpsAccount, "jb-book", "book.example.com", now); err != nil {
2588 t.Fatal(err)
2589 }
2590 ev := bounceTo("ann@example.com", "m-instance", now)
2591 ev.Caller = names.IAMUser
2592 fx.srv.recordAndEnforceMail(ctx, ev, "sns-instance")
2593
2594 day := now.UTC().Format("2006-01-02")
2595 one, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", "book.example.com", day)
2596 if one.Bounces != 1 {
2597 t.Fatalf("the named sender's counts = %+v, want 1 bounce", one)
2598 }
2599 appSender, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", mail.SenderAppID, day)
2600 if appSender.Bounces != 0 {
2601 t.Fatalf("the app sender was charged for an instance sender's bounce: %+v", appSender)
2602 }
2603}
2604```
2605
2606- [ ] **Step 2: Run it to verify it fails**
2607
2608```bash
2609go test -run 'TestTheMailLogGoesToTheAppSESSaysSentIt|TestAnIndexedCallerIsCharged' ./internal/console/
2610```
2611
2612Expected: `jb-site bounces = 1, want 0`.
2613
2614- [ ] **Step 3: Write the code**
2615
2616`internal/console/sesevents.go`, `recordAndEnforceMail`'s body from `:493` to `:534`:
2617
2618```go
2619 // ATTRIBUTION BY CALLER (senders design §8). Every sender has its own
2620 // IAM user and SES stamps its name on Send, Bounce, Complaint, Delivery
2621 // and Reject, so where the sending domain can only name every app that
2622 // shares it, the caller names the one sender that actually sent. The
2623 // resolver parses before it reads, so an app enabled before senders
2624 // existed still resolves with no index entry of its own.
2625 callerAcct, callerApp, callerSender, named := mail.SenderOfCaller(ctx, s.cfg.Releases, ev.Caller)
2626 if named {
2627 // The COUNT is gated on the app being live, exactly as the log row
2628 // already is (recordMailMessage, sesevents.go:558). A caller name
2629 // can parse into an account and an app that is not a live app —
2630 // mail.UserName(acct, "not-an-app") is a well-formed name for an
2631 // app that was trashed, or never existed — and counting it would
2632 // insert a mail_sender_counts row keyed on a name no app answers
2633 // to, which nothing would ever read or reap. Returning rather than
2634 // falling through is deliberate: SES named a sender, so the
2635 // domain-wide loop is not the right answer for it either.
2636 if !s.mailAppLive(callerAcct, callerApp) {
2637 return
2638 }
2639 s.recordMailMessage(ctx, callerAcct, callerApp, key, ev, true, now)
2640 // Counted ONCE, against the sender SES named, and the domain loop
2641 // below is skipped entirely. This is the visible change in the roll
2642 // note: a sibling app on the same domain is no longer charged for
2643 // this send, which is the intent, but a tenant watching its own
2644 // numbers will see them move.
2645 if err := s.store.RecordMailEvent(ctx, callerAcct, callerApp, callerSender, day, ev.Type); err != nil {
2646 log.Printf("console: ses events: recording %s for %s/%s/%s -> %v", ev.Type, callerAcct, callerApp, callerSender, err)
2647 return
2648 }
2649 if _, reason, err := s.enforceMailLimits(ctx, callerAcct, callerApp, callerSender, at); err != nil {
2650 log.Printf("console: ses events: enforcing limits for %s/%s/%s -> %v", callerAcct, callerApp, callerSender, err)
2651 } else if reason != "" {
2652 log.Printf("console: ses events: paused %s/%s/%s -> %s", callerAcct, callerApp, callerSender, reason)
2653 }
2654 return
2655 }
2656
2657 // The fallback, unchanged: SES named nobody CARLOS minted, so the
2658 // sending domain is all there is to go on and every app on it is
2659 // charged, against its app sender.
2660 for _, a := range accts {
2661 domains, err := mail.ListDomains(ctx, s.cfg.Releases, a.Sqid)
2662 if err != nil {
2663 log.Printf("console: ses events: listing domains for %s: %v", a.Sqid, err)
2664 continue
2665 }
2666 for _, d := range domains {
2667 if !domainMatchesEvent(d, ev.Domain) {
2668 continue
2669 }
2670 logHere := domainVerifiedForEvent(d, ev.Domain)
2671 for _, app := range d.Apps {
2672 // An app in per-instance mode is skipped (design §8). Its
2673 // app sender is deliberately dead — no instance sends
2674 // through it — so charging it for a domain-wide event it
2675 // cannot have caused would pause a credential nobody holds
2676 // and hide the instance that actually sent. A read failure
2677 // is not a reason to drop the event: fall through and
2678 // charge, which is today's behaviour.
2679 if sn, _, err := mail.GetSender(ctx, s.cfg.Releases, a.Sqid, app, mail.SenderAppID); err != nil {
2680 log.Printf("console: ses events: reading the app sender for %s/%s -> %v", a.Sqid, app, err)
2681 } else if sn != nil && sn.PerInstance {
2682 continue
2683 }
2684 if logHere {
2685 s.recordMailMessage(ctx, a.Sqid, app, key, ev, false, now)
2686 }
2687 if err := s.store.RecordMailEvent(ctx, a.Sqid, app, mail.SenderAppID, day, ev.Type); err != nil {
2688 log.Printf("console: ses events: recording %s for %s/%s: %v", ev.Type, a.Sqid, app, err)
2689 continue
2690 }
2691 if _, reason, err := s.enforceMailLimits(ctx, a.Sqid, app, mail.SenderAppID, at); err != nil {
2692 log.Printf("console: ses events: enforcing limits for %s/%s: %v", a.Sqid, app, err)
2693 } else if reason != "" {
2694 log.Printf("console: ses events: paused %s/%s: %s", a.Sqid, app, reason)
2695 }
2696 }
2697 }
2698 }
2699```
2700
2701Update the function's doc comment (`:459-465`): the counts are no longer domain-wide where SES names the sender, and the paragraph that says "The counts keep their domain-wide attribution untouched" is now wrong and must be replaced with the rule above plus the roll note.
2702
2703`enforceMailLimits`'s sender parameter arrives in task 12; until then this task's call sites compile against the four-argument form by dropping the sender argument, or task 12 is merged into this one. Take the first option and keep the tree green: write these two calls as `s.enforceMailLimits(ctx, callerAcct, callerApp, at)` and `s.enforceMailLimits(ctx, a.Sqid, app, at)` in this task, and let task 12 add the argument.
2704
2705- [ ] **Step 4: Run it to verify it passes**
2706
2707```bash
2708go test ./internal/console/ -count=1
2709```
2710
2711Expected: PASS.
2712
2713- [ ] **Step 5: Gate and commit**
2714
2715```bash
2716go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
2717git add internal/console
2718git commit -m "console: SES events are counted against the sender SES named, not every app on the domain (plan 2, task 11)"
2719```
2720
2721---
2722
2723### Task 12: Caps and pauses per sender
2724
2725**Files:**
2726- Modify: `internal/mail/walk.go` (`SetPaused` at `:962`), `internal/console/mailcounts.go` (`resumeStaleCapPause` at `:160`, `enforceMailLimits` at `:204`, and the `MailCounts` read at `:230`), `internal/console/apimail.go` (`handleAPIEmailStatus` at `:652`), `internal/console/sesevents.go` (the two calls from task 11), `internal/mail/walk_lifecycle_test.go`, `internal/console/mailcounts_test.go`
2727
2728**Both signatures have existing test callers, and there are fifteen of them.** They are not listed as an afterthought: `resumeStaleCapPause`'s four do not just need an extra argument, they need rewriting, because each one currently fetches a `*mail.Record` and asserts on it.
2729
2730- `enforceMailLimits`, eleven callers in `internal/console/mailcounts_test.go` at `:98`, `:119`, `:139`, `:169`, `:209`, `:221`, `:264`, `:276`, `:304`, `:311`, `:341`. Each is mechanical: insert `mail.SenderAppID` after `"harbourmail"`, so `fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", now)` becomes `fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, now)`.
2731- `resumeStaleCapPause`, four callers in the same file at `:385`, `:407`, `:426`, `:448`, one per test: `TestResumeStaleCapPauseClearsACapPauseFromYesterday`, `...LeavesATodayCapPauseAlone`, `...NeverTouchesARatePause`, `...NeverTouchesAKindlessPause`. Each is preceded by a `mail.Get` and each asserts on `got.Provisioned`, so the `mail.Get` becomes a `mail.GetSender` and the sender argument goes in beside it. The whole of the first one, rewritten, is the shape for all four:
2732
2733```go
2734func TestResumeStaleCapPauseClearsACapPauseFromYesterday(t *testing.T) {
2735 fx, now := mailFixture(t)
2736 pauseDirectly(t, fx, "harbourmail", mail.PauseKindCap, now.AddDate(0, 0, -1))
2737 sn, _, err := mail.GetSender(context.Background(), fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
2738 if err != nil {
2739 t.Fatal(err)
2740 }
2741 got, resumed, err := fx.srv.resumeStaleCapPause(context.Background(), release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now)
2742 if err != nil {
2743 t.Fatal(err)
2744 }
2745 if !resumed {
2746 t.Fatal("did not resume a cap pause applied on an earlier UTC day")
2747 }
2748 if !got.Provisioned.Active {
2749 t.Fatal("resumed record still reports Active == false")
2750 }
2751 if got.Provisioned.PauseKind != "" {
2752 t.Fatalf("PauseKind = %q after resume, want cleared", got.Provisioned.PauseKind)
2753 }
2754}
2755```
2756
2757 The three assertion bodies are unchanged, because `*mail.Sender` and `*mail.Record` expose `Provisioned` identically. `pauseDirectly` (`:360`) keeps using `mail.Get`/`mail.Put` and needs no change: task 3 made those the app sender's accessors, so it is already writing `senders/app.json`.
2758
2759**Interfaces:**
2760- Produces:
2761 ```go
2762 // mail
2763 func SetSenderPaused(ctx context.Context, s release.Store, p Provisioner, acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error)
2764 func SetPaused(...) (*Record, error) // unchanged signature, delegates with SenderAppID
2765 var ErrPerInstance = errors.New("mail: the app sender is in per-instance mode")
2766 // console
2767 func (s *Server) enforceMailLimits(ctx context.Context, acct, app, sender string, now time.Time) (bool, string, error)
2768 func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app, sender string, sn *mail.Sender, now time.Time) (*mail.Sender, bool, error)
2769 ```
2770
2771- [ ] **Step 1: Write the failing test**
2772
2773`internal/mail/walk_lifecycle_test.go` (append):
2774
2775```go
2776// An app sender in per-instance mode refuses to resume. Nothing sets the
2777// flag in this release, but the refusal has to be in place before anything
2778// can: resumeStaleCapPause reactivates every key it touches, including every
2779// standalone, so a cap-paused app sender would otherwise re-arm on the next
2780// UTC day roll the credential every instance still holds.
2781func TestPerInstanceModeRefusesAResume(t *testing.T) {
2782 ctx, s, p, pub, now := enableFixture(t)
2783 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
2784 t.Fatal(err)
2785 }
2786 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindCap, now); err != nil {
2787 t.Fatal(err)
2788 }
2789 sn, etag, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
2790 if err != nil {
2791 t.Fatal(err)
2792 }
2793 sn.PerInstance = true
2794 sn.Provisioned.PauseKind = PauseKindPerInstance
2795 if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, etag); err != nil {
2796 t.Fatal(err)
2797 }
2798 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", false, "", now); !errors.Is(err, ErrPerInstance) {
2799 t.Fatalf("resume in per-instance mode = %v, want ErrPerInstance", err)
2800 }
2801 // Pausing harder is still allowed: the refusal is about re-arming a
2802 // credential, not about turning one off.
2803 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindRate, now); err != nil {
2804 t.Fatalf("pausing an app sender in per-instance mode was refused: %v", err)
2805 }
2806}
2807```
2808
2809Add `"errors"` to `walk_lifecycle_test.go`'s imports. Its block (`:1-10`) is `context`, `testing`, then `appconfig`, `dnsrec`, `release` — there is no `errors` in it today, so the `errors.Is` above does not compile as written.
2810
2811`internal/console/mailcounts_test.go` (append):
2812
2813```go
2814// The cap applies per sender, so one sender's breach pauses that sender and
2815// leaves its siblings sending.
2816func TestTheCapIsEnforcedPerSender(t *testing.T) {
2817 fx, now := mailFixture(t)
2818 ctx := context.Background()
2819 day := now.UTC().Format("2006-01-02")
2820 for i := 0; i < 50; i++ {
2821 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
2822 t.Fatal(err)
2823 }
2824 }
2825 // A second sender's traffic does not count towards the first one's cap.
2826 for i := 0; i < 50; i++ {
2827 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
2828 t.Fatal(err)
2829 }
2830 }
2831 paused, reason, err := fx.srv.enforceMailLimits(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, now)
2832 if err != nil {
2833 t.Fatal(err)
2834 }
2835 if !paused || !strings.Contains(reason, "daily cap 50") {
2836 t.Fatalf("app sender = %v %q, want a cap pause", paused, reason)
2837 }
2838 sn, _, err := mail.GetSender(ctx, fx.rel, release.OpsAccount, "harbourmail", mail.SenderAppID)
2839 if err != nil || sn.Provisioned.Active || sn.Provisioned.PauseKind != mail.PauseKindCap {
2840 t.Fatalf("the app sender's record = %+v %v", sn.Provisioned, err)
2841 }
2842 // The day roll clears a cap pause, still, and only a cap pause.
2843 fresh, resumed, err := fx.srv.resumeStaleCapPause(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, sn, now.AddDate(0, 0, 1))
2844 if err != nil || !resumed || fresh == nil || !fresh.Provisioned.Active {
2845 t.Fatalf("the day roll did not clear the cap pause: %+v %v %v", fresh, resumed, err)
2846 }
2847}
2848```
2849
2850- [ ] **Step 2: Run it to verify it fails**
2851
2852```bash
2853go test -run 'TestPerInstanceModeRefusesAResume' ./internal/mail/
2854go test -run 'TestTheCapIsEnforcedPerSender' ./internal/console/
2855```
2856
2857Expected: build failures, `undefined: ErrPerInstance` and `too many arguments`.
2858
2859- [ ] **Step 3: Write the code**
2860
2861`internal/mail/walk.go`, `SetPaused` becomes a delegate and `SetSenderPaused` does the work:
2862
2863```go
2864// ErrPerInstance refuses a resume aimed at an app sender whose app has moved
2865// to per-instance senders (design §6.3). Nothing sets PerInstance in this
2866// release; the refusal exists so that nothing can set it later without this
2867// guard already being in place and tested.
2868var ErrPerInstance = errors.New("mail: the app sender is in per-instance mode")
2869
2870// SetPaused stops or resumes the APP sender, which is every sender an app
2871// has until instance senders exist. Its signature is unchanged so the
2872// operator door and the limiter keep calling it exactly as they did.
2873func SetPaused(ctx context.Context, s release.Store, p Provisioner,
2874 acct, app string, paused bool, kind string, now time.Time) (*Record, error) {
2875
2876 sn, err := SetSenderPaused(ctx, s, p, acct, app, SenderAppID, paused, kind, now)
2877 if err != nil {
2878 return nil, err
2879 }
2880 return sn.record(app), nil
2881}
2882
2883// SetSenderPaused toggles one sender's keys, never its policy. A policy
2884// rewrite would have to be undone correctly to resume, and a half-failed
2885// rewrite leaves a grant that is neither the old one nor the new one;
2886// deactivating a key is atomic at IAM and trivially reversible.
2887//
2888// BOTH of the delivered user's keys are toggled, because an app
2889// mid-rotation still holds the previous credential, and every standalone
2890// credential of this sender is toggled too — each is its own IAM user, so
2891// there is no two-key ceiling interaction, and skipping them would leave a
2892// tenant paused for abuse still sending through a laptop credential.
2893//
2894// A RESUME is refused while PerInstance is set: the app's instances still
2895// hold this credential and re-arming it is exactly what per-instance mode
2896// exists to prevent. A pause is always allowed.
2897func SetSenderPaused(ctx context.Context, s release.Store, p Provisioner,
2898 acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error) {
2899
2900 sn, etag, err := GetSender(ctx, s, acct, app, sender)
2901 if err != nil {
2902 return nil, err
2903 }
2904 if sn == nil || sn.Provisioned == nil {
2905 return nil, ErrNotProvisioned
2906 }
2907 if !paused && sn.PerInstance {
2908 return nil, fmt.Errorf("mail: %s/%s: resume its instance senders instead: %w", acct, app, ErrPerInstance)
2909 }
2910 user := sn.Provisioned.IAMUser
2911 for _, id := range []string{sn.Provisioned.KeyID, sn.Provisioned.PrevKeyID} {
2912 if id == "" {
2913 continue
2914 }
2915 if err := p.SetKeyActive(ctx, user, id, !paused); err != nil {
2916 return nil, err
2917 }
2918 }
2919 for _, sa := range sn.Standalone {
2920 if err := p.SetKeyActive(ctx, sa.IAMUser, sa.KeyID, !paused); err != nil {
2921 return nil, err
2922 }
2923 }
2924 sn.Provisioned.Active = !paused
2925 if paused {
2926 sn.Provisioned.PauseKind = kind
2927 } else {
2928 sn.Provisioned.PauseKind = ""
2929 }
2930 sn.Provisioned.At = now
2931 if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
2932 return nil, err
2933 }
2934 return sn, nil
2935}
2936```
2937
2938`internal/console/mailcounts.go`, `resumeStaleCapPause` and `enforceMailLimits` move onto the sender:
2939
2940```go
2941func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app, sender string, sn *mail.Sender, now time.Time) (*mail.Sender, bool, error) {
2942 if sn == nil || sn.Provisioned == nil || sn.Provisioned.Active {
2943 return sn, false, nil
2944 }
2945 // A RATE pause, a MANUAL pause and a PER-INSTANCE pause all fall through
2946 // untouched. The first two never auto-resume by design; the third must
2947 // not, because resuming reactivates every key including the standalones
2948 // and the app credential every instance still holds.
2949 if sn.Provisioned.PauseKind != mail.PauseKindCap || sn.PerInstance {
2950 return sn, false, nil
2951 }
2952 pausedDay := sn.Provisioned.At.UTC().Format("2006-01-02")
2953 today := now.UTC().Format("2006-01-02")
2954 if pausedDay >= today {
2955 return sn, false, nil
2956 }
2957 if _, err := mail.SetSenderPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, sender, false, "", now); err != nil {
2958 return nil, false, err
2959 }
2960 if a, err := s.store.AccountBySqid(acct); err != nil {
2961 log.Printf("console: mail resume audit: resolving account %s: %v", acct, err)
2962 } else if a != nil {
2963 s.audit(a, app, "ses", "mail.resumed", fmt.Sprintf("%s/%s: daily cap pause cleared on the UTC day roll", app, sender))
2964 }
2965 fresh, _, err := mail.GetSender(ctx, s.cfg.Releases, acct, app, sender)
2966 if err != nil {
2967 return nil, false, err
2968 }
2969 return fresh, true, nil
2970}
2971
2972func (s *Server) enforceMailLimits(ctx context.Context, acct, app, sender string, now time.Time) (bool, string, error) {
2973 sn, _, err := mail.GetSender(ctx, s.cfg.Releases, acct, app, sender)
2974 if err != nil {
2975 return false, "", err
2976 }
2977 if sn == nil || sn.Provisioned == nil {
2978 return false, "", nil
2979 }
2980 if !sn.Provisioned.Active {
2981 fresh, resumed, err := s.resumeStaleCapPause(ctx, acct, app, sender, sn, now)
2982 if err != nil {
2983 return false, "", err
2984 }
2985 if !resumed {
2986 return false, "", nil
2987 }
2988 sn = fresh
2989 if sn == nil || sn.Provisioned == nil || !sn.Provisioned.Active {
2990 return false, "", nil
2991 }
2992 }
2993 day := now.UTC().Format("2006-01-02")
2994 c, err := s.store.MailCounts(ctx, acct, app, sender, day)
2995 if err != nil {
2996 return false, "", err
2997 }
2998 cap := TierCap("")
2999 if sn.Grant != nil && sn.Grant.DailyCap > 0 {
3000 cap = sn.Grant.DailyCap
3001 }
3002 // ... the switch over cap, BounceThreshold and ComplaintThreshold is
3003 // unchanged ...
3004 if _, err := mail.SetSenderPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, sender, true, kind, now); err != nil {
3005 return false, "", err
3006 }
3007 if a, err := s.store.AccountBySqid(acct); err != nil {
3008 log.Printf("console: mail pause audit: resolving account %s: %v", acct, err)
3009 } else if a != nil {
3010 s.audit(a, app, "ses", "mail.paused", fmt.Sprintf("%s/%s: %s", app, sender, reason))
3011 }
3012 return true, reason, nil
3013}
3014```
3015
3016`internal/console/apimail.go`, `handleAPIEmailStatus` (`:644-657`): read the app sender, resume it, and re-read the record for `emailReply`.
3017
3018```go
3019 sn, _, err := mail.GetSender(ctx, s.cfg.Releases, acct.Sqid, app, mail.SenderAppID)
3020 if err == nil {
3021 if _, resumed, rerr := s.resumeStaleCapPause(ctx, acct.Sqid, app, mail.SenderAppID, sn, time.Now()); rerr != nil {
3022 err = rerr
3023 } else if resumed {
3024 rec, _, err = mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
3025 }
3026 }
3027 if err != nil {
3028 log.Printf("console: api email status resume %s/%s: %v", acct.Sqid, app, err)
3029 jsonError(w, http.StatusBadGateway, "could not read the email record — try again")
3030 return
3031 }
3032```
3033
3034`internal/console/sesevents.go`: restore the sender argument on the two `enforceMailLimits` calls from task 11 — `s.enforceMailLimits(ctx, callerAcct, callerApp, callerSender, at)` in the named branch and `s.enforceMailLimits(ctx, a.Sqid, app, mail.SenderAppID, at)` in the fallback loop.
3035
3036`internal/console/mailcounts_test.go`: update the fifteen existing callers enumerated under **Files** above — the eleven `enforceMailLimits` calls take `mail.SenderAppID` after the app, and the four `resumeStaleCapPause` tests are rewritten to the shape shown there.
3037
3038- [ ] **Step 4: Run it to verify it passes**
3039
3040```bash
3041go test ./internal/mail/ ./internal/console/ -count=1
3042```
3043
3044Expected: PASS.
3045
3046- [ ] **Step 5: Gate and commit**
3047
3048```bash
3049go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
3050git add internal/mail internal/console
3051git commit -m "mail, console: the cap, the pause and the day-roll resume are per sender (plan 2, task 12)"
3052```
3053
3054---
3055
3056### Task 13: `emailReply` gains a `senders` array
3057
3058**Files:**
3059- Modify: `internal/console/apimail.go` (`emailReply` at `:377-432`), `internal/console/apimail_test.go`
3060
3061**Interfaces:**
3062- Produces: `emailReply`'s map gains `"senders": []map[string]any{...}`, never null, with the top-level fields unchanged.
3063
3064- [ ] **Step 1: Write the failing test**
3065
3066`internal/console/apimail_test.go` (append; if the file does not exist, create it in package `console` with the imports the fixture needs):
3067
3068```go
3069// The reply gains a senders array and changes nothing else, so an older CLI
3070// reading only the top-level fields keeps working byte for byte.
3071func TestEmailReplyCarriesEverySenderAndKeepsItsTopLevelFields(t *testing.T) {
3072 fx, now := mailFixture(t)
3073 ctx := context.Background()
3074 day := now.UTC().Format("2006-01-02")
3075 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", mail.SenderAppID, day, mail.EventSend); err != nil {
3076 t.Fatal(err)
3077 }
3078 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "harbourmail", "book.example.com", day, mail.EventSend); err != nil {
3079 t.Fatal(err)
3080 }
3081 // A second sender, written straight to its own object the way step 5's
3082 // instance enable will.
3083 if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "harbourmail", &mail.Sender{
3084 ID: "book.example.com", Scope: mail.ScopeInstance, Source: mail.SourceDomain, Host: "book.example.com",
3085 Declared: &mail.Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"},
3086 Grant: &mail.GrantBlock{Domains: []string{"x.com"}, DailyCap: 1000, Source: "operator", At: now, By: "op"},
3087 Provisioned: &mail.Provisioned{IAMUser: "u2", KeyID: "AKIA9", Active: true, At: now},
3088 }, ""); err != nil {
3089 t.Fatal(err)
3090 }
3091
3092 acct, err := fx.st.AccountBySqid(release.OpsAccount)
3093 if err != nil || acct == nil {
3094 t.Fatalf("AccountBySqid = %v %v", acct, err)
3095 }
3096 rec, _, err := mail.Get(ctx, fx.rel, release.OpsAccount, "harbourmail")
3097 if err != nil {
3098 t.Fatal(err)
3099 }
3100 out, err := fx.srv.emailReply(ctx, acct, "harbourmail", rec)
3101 if err != nil {
3102 t.Fatal(err)
3103 }
3104
3105 // Unchanged: the top-level fields still describe the app.
3106 if out["active"] != true || out["daily_cap"] != 50 {
3107 t.Fatalf("the top-level fields moved: active=%v daily_cap=%v", out["active"], out["daily_cap"])
3108 }
3109 counts, _ := out["counts"].(map[string]any)
3110 if counts["sends"] != 2 {
3111 t.Fatalf("top-level sends = %v, want the app's whole day across both senders", counts["sends"])
3112 }
3113
3114 senders, ok := out["senders"].([]map[string]any)
3115 if !ok || len(senders) != 2 {
3116 t.Fatalf("senders = %#v, want two", out["senders"])
3117 }
3118 if senders[0]["id"] != mail.SenderAppID || senders[0]["scope"] != mail.ScopeApp {
3119 t.Fatalf("the app sender is not first: %#v", senders[0])
3120 }
3121 if senders[1]["id"] != "book.example.com" || senders[1]["daily_cap"] != 1000 || senders[1]["host"] != "book.example.com" {
3122 t.Fatalf("the second sender = %#v", senders[1])
3123 }
3124 first, _ := senders[0]["counts"].(map[string]any)
3125 second, _ := senders[1]["counts"].(map[string]any)
3126 if first["sends"] != 1 || second["sends"] != 1 {
3127 t.Fatalf("per-sender counts = %v / %v, want one send each", first["sends"], second["sends"])
3128 }
3129 // Never null, so a client reads it rather than testing for absence.
3130 bare := fx.srv.mustEmailReplyForAppWithNoSenders(t, ctx, acct)
3131 if _, ok := bare["senders"].([]map[string]any); !ok {
3132 t.Fatalf("senders on a bare app = %#v, want an empty array", bare["senders"])
3133 }
3134}
3135```
3136
3137Drop the last three lines if no bare-app helper exists; the `senders == nil` case is covered by initialising the slice with `make`.
3138
3139- [ ] **Step 2: Run it to verify it fails**
3140
3141```bash
3142go test -run TestEmailReplyCarriesEverySender ./internal/console/
3143```
3144
3145Expected: `senders = <nil>, want two`.
3146
3147- [ ] **Step 3: Write the code**
3148
3149`internal/console/apimail.go`, in `emailReply` before the returned map:
3150
3151```go
3152 // The senders array (senders design §8). The top-level fields above keep
3153 // describing the APP sender and the app's whole day, so an older CLI
3154 // still works; this is the per-sender view beside them.
3155 all, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app)
3156 if err != nil {
3157 return nil, err
3158 }
3159 senders := make([]map[string]any, 0, len(all))
3160 for _, sn := range all {
3161 sc, err := s.store.MailCounts(ctx, acct.Sqid, app, sn.ID, day)
3162 if err != nil {
3163 return nil, err
3164 }
3165 entry := map[string]any{
3166 "id": sn.ID,
3167 "scope": sn.Scope,
3168 "source": sn.Source,
3169 "active": sn.Provisioned != nil && sn.Provisioned.Active,
3170 "daily_cap": mailEffectiveCap(sn.record(app)),
3171 "counts": map[string]any{
3172 "sends": sc.Sends, "bounces": sc.Bounces,
3173 "complaints": sc.Complaints, "rejects": sc.Rejects,
3174 "deliveries": sc.Deliveries,
3175 },
3176 }
3177 if sn.Host != "" {
3178 entry["host"] = sn.Host
3179 }
3180 if sn.Address != "" {
3181 entry["address"] = sn.Address
3182 }
3183 if sn.Provisioned != nil {
3184 entry["key_id"] = sn.Provisioned.KeyID
3185 if sn.Provisioned.PauseKind != "" {
3186 entry["pause_kind"] = sn.Provisioned.PauseKind
3187 }
3188 }
3189 senders = append(senders, entry)
3190 }
3191```
3192
3193and `"senders": senders,` in the returned map. `mailEffectiveCap` takes a `*mail.Record`, so `sn.record(app)` needs `record` exported as `Record()` on `*mail.Sender`, or `mailEffectiveCap` gains a sibling that takes a `*mail.GrantBlock`. Take the second: add
3194
3195```go
3196// mailCapFor is mailEffectiveCap for one sender's grant. Both read the same
3197// rule, because the cap a status reply names must be the cap the limiter
3198// enforces (mailcounts.go's enforceMailLimits).
3199func mailCapFor(g *mail.GrantBlock) int {
3200 if g != nil && g.DailyCap > 0 {
3201 return g.DailyCap
3202 }
3203 return TierCap("")
3204}
3205```
3206
3207and have `mailEffectiveCap(rec)` call it.
3208
3209- [ ] **Step 4: Run it to verify it passes**
3210
3211```bash
3212go test ./internal/console/ -count=1
3213```
3214
3215Expected: PASS.
3216
3217- [ ] **Step 5: Gate and commit**
3218
3219```bash
3220go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
3221git add internal/console
3222git commit -m "console: the email status reply carries a senders array beside its unchanged fields (plan 2, task 13)"
3223```
3224
3225---
3226
3227### Task 14: `carlos email status` prints a line per sender
3228
3229**Files:**
3230- Modify: `mail_cli.go` (`apiEmailReply` at `:249`, `printEmailStatus` at `:1129`), `mail_cli_test.go`
3231
3232**Interfaces:**
3233- Produces:
3234 ```go
3235 type apiEmailSender struct { ID, Scope, Source, Host, Address string; Active bool; PauseKind string; DailyCap int; KeyID string; Counts apiEmailCounts }
3236 // apiEmailReply gains: Senders []apiEmailSender `json:"senders"`
3237 ```
3238
3239- [ ] **Step 1: Write the failing test**
3240
3241`mail_cli_test.go` (append):
3242
3243```go
3244// A member with more than one sender sees one line each, and a member with
3245// only the app sender sees exactly what they saw before.
3246func TestPrintEmailStatusRendersALinePerSender(t *testing.T) {
3247 var buf bytes.Buffer
3248 printEmailStatus(&buf, apiEmailReply{
3249 App: "harbourmail", DailyCap: 50, Active: true,
3250 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
3251 Counts: apiEmailCounts{Sends: 12},
3252 Senders: []apiEmailSender{
3253 {ID: "app", Scope: "app", Source: "domain", Active: true, DailyCap: 50, KeyID: "AKIA1",
3254 Counts: apiEmailCounts{Sends: 4}},
3255 {ID: "book.example.com", Scope: "instance", Source: "domain", Host: "book.example.com",
3256 Active: false, PauseKind: "cap", DailyCap: 1000, KeyID: "AKIA9",
3257 Counts: apiEmailCounts{Sends: 8, Bounces: 1}},
3258 },
3259 })
3260 out := buf.String()
3261 for _, want := range []string{"app", "book.example.com", "4/50", "8/1000"} {
3262 if !strings.Contains(out, want) {
3263 t.Errorf("status output is missing %q:\n%s", want, out)
3264 }
3265 }
3266 if !strings.Contains(strings.ToUpper(out), "PAUSED") {
3267 t.Errorf("a paused sender is not called out:\n%s", out)
3268 }
3269}
3270
3271// An older console answers with no senders array at all, and the output is
3272// the output it has always been.
3273func TestPrintEmailStatusWithNoSendersArrayIsUnchanged(t *testing.T) {
3274 var buf bytes.Buffer
3275 printEmailStatus(&buf, apiEmailReply{
3276 App: "harbourmail", DailyCap: 50, Active: true,
3277 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
3278 Counts: apiEmailCounts{Sends: 12},
3279 })
3280 if strings.Contains(buf.String(), "senders") {
3281 t.Errorf("a reply with no senders array printed a senders heading:\n%s", buf.String())
3282 }
3283}
3284```
3285
3286- [ ] **Step 2: Run it to verify it fails**
3287
3288```bash
3289go test -run TestPrintEmailStatus ./
3290```
3291
3292Expected: build failure, `unknown field Senders`.
3293
3294- [ ] **Step 3: Write the code**
3295
3296`mail_cli.go`, beside `apiEmailCounts` (`:239`):
3297
3298```go
3299// apiEmailSender is one sender in the status reply (tenant-email senders
3300// design §8). The reply's top-level fields keep describing the app sender
3301// and the app's whole day, so an older console that sends no senders array
3302// leaves this empty and the output is what it has always been.
3303type apiEmailSender struct {
3304 ID string `json:"id"`
3305 Scope string `json:"scope"`
3306 Source string `json:"source"`
3307 Host string `json:"host,omitempty"`
3308 Address string `json:"address,omitempty"`
3309 Active bool `json:"active"`
3310 PauseKind string `json:"pause_kind,omitempty"`
3311 DailyCap int `json:"daily_cap"`
3312 KeyID string `json:"key_id,omitempty"`
3313 Counts apiEmailCounts `json:"counts"`
3314}
3315```
3316
3317`apiEmailReply` gains `Senders []apiEmailSender `json:"senders"`` after `Counts`.
3318
3319`printEmailStatus`, after the `today:` line (`:1231-1232`) and before the drift warning:
3320
3321```go
3322 // One line per sender, and only when there is more than the app sender
3323 // to say something about: an app with one sender has already had its
3324 // whole story told by the lines above, and repeating it would be noise.
3325 if len(res.Senders) > 1 {
3326 fmt.Fprintln(w, " senders:")
3327 for _, sn := range res.Senders {
3328 what := sn.ID
3329 if sn.Host != "" {
3330 what = sn.Host
3331 } else if sn.Address != "" {
3332 what = sn.Address
3333 }
3334 state := c.ok("active")
3335 if !sn.Active {
3336 state = "paused"
3337 if sn.PauseKind != "" {
3338 state += " (" + sn.PauseKind + ")"
3339 }
3340 }
3341 fmt.Fprintf(w, " %s %s: %s, %d/%d sends, %d bounces, %d complaints\n",
3342 sn.Scope, what, state, sn.Counts.Sends, sn.DailyCap, sn.Counts.Bounces, sn.Counts.Complaints)
3343 }
3344 }
3345```
3346
3347The strings this task adds are member-facing. Extract them and run the `copy-review` gate before this commit lands, then apply the reviewed wording verbatim.
3348
3349- [ ] **Step 4: Run it to verify it passes**
3350
3351```bash
3352go test -run TestPrintEmailStatus ./
3353```
3354
3355Expected: PASS.
3356
3357- [ ] **Step 5: Gate and commit**
3358
3359```bash
3360go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
3361git add mail_cli.go mail_cli_test.go
3362git commit -m "carlos email status prints a line per sender (plan 2, task 14)"
3363```
3364
3365---
3366
3367## Self-review
3368
3369**Spec coverage.** §5's record, the slug and `SenderNames` with the IAM and SES bounds, the app-component truncation and the standalone refusal: tasks 1, 2. §5's migration, `senders/app.json` preferred with `email.json` served at an empty etag and `Put` always writing the sender key, plus the flag-guarded sweep a release later: tasks 3, 4. §5's caller reverse index, written after `EnsureUser`, create-only with `claimDomain`'s idempotence rule, the stored salt on collision, deleted by `RevokeStandalone` and `Teardown`: task 5. §8's `SenderOfCaller`, parse first and Get only for a slug: task 6. §7's one step-2 sentence, tenant associations for each account's existing tenant: task 7. §8's `mail_sender_counts`, its DDL, its backfill and the explicit day-based case in the three sweeps: tasks 8, 9, 10. §8's attribution and the roll note: task 11. §8's caps, pauses and the day-roll resume per sender: task 12. §8's `senders` array and the per-sender status line: tasks 13, 14.
3370
3371**Out of scope and deliberately absent.** No instance senders, no per-instance config layer, no `ConfigAck` change, no `svcrole.Mail`, no new doors, no external identities, no address claims, no provisioner refusal wrapper, no `X-SES-TENANT` anywhere, no `<P>_CONFIG_SET`. `PerInstance`, `PauseKindPerInstance`, `Host`, `Address`, `Identity` and `TenantARN` land on the record and nothing sets them.
3372
3373**Three rulings this plan takes.** (0) Task 7's associations go in after `PutDomain`, not beside `EnsureIdentity`. An association is the one thing that stops an identity being deleted, and `Teardown`'s orphan-claim sweep deletes the identity behind a claim that has no record, so it never reaches the disassociate. Associating only once the record is durable is what keeps that sweep able to do its job. (1) `DisassociateTenantResource` is built in task 7 even though §7 lists it among step 3's verbs: SES refuses to delete an identity while an association exists, so associating in step 2 without disassociating would break `Teardown` on the first real run, which is a capability regression and not a deferral. (2) The caller index's "account reap" deletion is `Teardown`'s, because the console refuses to delete an account that still holds apps (`handleDeleteAccount`, `internal/console/ownership.go:82`; `DeleteAccount` re-checks inside its own transaction), so every index entry an account holds goes through a per-app teardown. No separate account sweep is built.
3374
3375**Placeholder scan.** None left. Task 4 hoists `rel := openStore()` itself, with the real lines shown, because `cmd/console/main.go:269` passes `Releases: openStore()` inline and `srv.cfg` is unexported; task 8 names the constructor as `Open` (`internal/console/store.go:53`). Task 13's optional bare-app helper is marked droppable. Everything else carries code.
3376
3377**Type consistency.** `Sender`, `SenderAppID`, `ScopeApp`, `ScopeInstance`, `SourceDomain`, `SourceAddress`, `PauseKindPerInstance`, `SenderKey`, `SendersPrefix`, `ValidSenderID`, `SenderNameSet`, `SenderNames`, `StandaloneUser`, `ErrNameTooLong`, `CallerIndex`, `CallerKey`, `GetCallerIndex`, `PutCallerIndex`, `DeleteCallerIndex`, `ErrCallerClaimed`, `ResolveSenderNames`, `SenderOfCaller`, `LegacyKey`, `GetSender`, `PutSender`, `ListSenders`, `SweepLegacyRecords`, `ConfigSetARN`, `AssociateTenantResource`, `DisassociateTenantResource`, `SetSenderPaused`, `ErrPerInstance`, `mailCapFor`, `mailAppTimeSince`, `DropLegacyMailCounts`, `apiEmailSender` are spelled identically across every task that produces or consumes them.
3378
3379**The roll note this plan owes.** Three lines for the release that carries it. Counts narrow from domain-wide to sender-wide, so a tenant watching its own numbers will see them move. A console rolled back after the first sender write loses the writes made in between, so a pause or a rotation in that window has to be redone. `CARLOS_MAIL_LEGACY_SWEEP` stays unset until the release after this one.
3380