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 3 of 5: External identities and address 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:** Let an app send as one exact address under an SES identity CARLOS does not own and must never modify — `jelly@tito.io` under Tito Classic's `tito.io` — by registering that identity once per deployment, refusing every verb that would rewrite or delete it, claiming single addresses under it non-exclusively so `jam` and `stripe` can both send as `jelly@tito.io`, and giving the sender its own IAM principal whose policy names the borrowed identity, CARLOS's configuration set, the exact From address (case-insensitively) and the account's SES tenant, with `<P>_CONFIG_SET` and `<P>_TENANT` delivered beside the five variables the app already reads.
6
7**Architecture:** `internal/mail` gains `external.go` (the `ExternalIdentity` record, its global key, the parent-walk lookup), `addressclaim.go` (the per-account, non-exclusive claim on one address) and `guard.go` (a `Provisioner` wrapper that refuses `EnsureIdentity`, `SetIdentityConfigSet` and `DeleteIdentity` for a registered domain or any subdomain of one, wired once in `cmd/console/main.go`); `policy.go` gains a second renderer, `SendAddressPolicyJSON`, and the `Provisioner` gains one verb, `EnsureAddressUser`, so the address sender's document never passes through the domain form and the domain form is left byte-identical. `Enable` and `Teardown` branch on the app sender's `Source`: the address branch ensures the tenant and the account's configuration set, associates both with the tenant, refuses an identity whose own default set is not CARLOS's, and delivers the two extra variables; teardown disassociates and drops the claim and never touches the identity. The console gains `apiexternal.go` with four operator doors and one member door, and `svcrole` gains `Mail`, which every email door moves to.
8
9**Tech Stack:** Go 1.26, `internal/mail`, `internal/console` (the email API doors, the operator gate), `internal/svcrole`, `internal/release` (Store, `NewDirStore`, `ErrCASConflict`, `AppPrefix`, `ValidAccount`, `ValidApp`), `internal/appconfig` (`Apply`, `Seal`, `ValidVarValue`), `aws-sdk-go-v2/service/sesv2 v1.66.6`, root-package `mail_cli.go`, `cmd/console/main.go`.
10
11**Spec:** `docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md` — §11 step 3 is the whole of this plan's mandate. §7 (external identities, address claims, the provisioner refusal, the address branch through enable and the policy renderer, `<P>_CONFIG_SET`), §4.1 (the spike's four answers, which this plan builds to with no conditional branches), §6.1's one paragraph on `svcrole.Mail`, and §3's ruling that there is no tenant-level default configuration set are in scope. §6.2–§6.5, §8's pause verbs and counting, and everything about instance senders are **not**: no per-instance config layer, no `ConfigAck` change, no instance doors, no `TenantStatus`, no `SetTenantSending`, no `mail_sender_counts` work beyond what plan 2 already landed.
12
13## Assumed from plan 2
14
15Plan 2 (`docs/superpowers/plans/2026-09-15-tenant-email-2-senders.md`) is merged before this one starts. Every name below is **its** to define; this plan consumes them and redefines none of them.
16
17```go
18// internal/mail/sender.go
19const (ScopeApp, ScopeInstance, SourceDomain, SourceAddress, SenderAppID, PauseKindPerInstance string)
20type Sender struct {
21 Schema int; ID, Slug, Scope, Host, Source string
22 Declared *Declared; Grant *GrantBlock; Provisioned *Provisioned; Standalone []*Standalone
23 Identity, Address, TenantARN string
24 PerInstance bool
25 Salt string
26}
27func SendersPrefix(acct, app string) string
28func SenderKey(acct, app, id string) string
29func ValidSenderID(id string) bool
30type SenderNameSet struct{ Slug, IAMUser, Tenant, ConfigSet string }
31func SenderNames(acct, app, senderID, salt string) (SenderNameSet, error)
32func (n SenderNameSet) StandaloneUser(acct, app, credential string) (string, error)
33var ErrNameTooLong error
34type CallerIndex struct{ Schema int; IAMUser, Account, App, Sender string; CreatedAt time.Time }
35func CallerKey(iamUser string) string
36func GetCallerIndex(ctx context.Context, s release.Store, iamUser string) (*CallerIndex, error)
37func PutCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string, now time.Time) error
38func DeleteCallerIndex(ctx context.Context, s release.Store, iamUser, acct, app, sender string) error
39func ResolveSenderNames(ctx context.Context, s release.Store, acct, app string, sn *Sender, now time.Time) (SenderNameSet, bool, error)
40func SenderOfCaller(ctx context.Context, s release.Store, caller string) (acct, app, sender string, ok bool)
41var ErrCallerClaimed error
42
43// internal/mail/mail.go
44func Key(acct, app string) string // apps/<acct>/<app>/email/senders/app.json
45func LegacyKey(acct, app string) string // apps/<acct>/<app>/email.json
46func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error)
47func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error)
48func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error)
49func PutSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, etag string) (string, error)
50func ListSenders(ctx context.Context, s release.Store, acct, app string) ([]*Sender, error)
51func (r *Record) sender() *Sender // unexported; carries Identity/Address/PerInstance/Salt forward
52func (s *Sender) record(app string) *Record
53
54// internal/mail/provisioner.go, aws.go, memprovisioner.go
55AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error
56DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error
57// MemProvisioner.TenantResources map[string]map[string]bool, and DeleteIdentity refuses an associated identity.
58
59// internal/mail/domain.go
60func ConfigSetARN(identityARN, configSet string) (string, bool)
61
62// internal/mail/walk.go
63func SetSenderPaused(ctx context.Context, s release.Store, p Provisioner, acct, app, sender string, paused bool, kind string, now time.Time) (*Sender, error)
64var ErrPerInstance error
65
66// internal/console
67func (s *Store) RecordMailEvent(ctx context.Context, acct, app, sender, day, evType string) error
68func (s *Store) MailCounts(ctx context.Context, acct, app, sender, day string) (MailCounts, error)
69func (s *Server) enforceMailLimits(ctx context.Context, acct, app, sender string, now time.Time) (bool, string, error)
70func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app, sender string, sn *mail.Sender, now time.Time) (*mail.Sender, bool, error)
71func mailCapFor(g *mail.GrantBlock) int
72// emailReply's map carries "senders": []map[string]any{...} beside its unchanged top-level fields.
73
74// mail_cli.go
75type apiEmailSender struct{ ID, Scope, Source, Host, Address string; Active bool; PauseKind string; DailyCap int; KeyID string; Counts apiEmailCounts }
76// apiEmailReply.Senders []apiEmailSender `json:"senders"`
77```
78
79## Global Constraints
80
81- **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.
82- **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.
83- **The spike is finished and its answers are in §4.1. Build to them, with no conditional branches and no feature flags.** The `ses:TenantName` condition stays in the address policy; the bundle carries both `<P>_CONFIG_SET` and `<P>_TENANT`; enable refuses an external identity whose own default configuration set is not CARLOS's; teardown disassociates before anything else, because SES refuses to delete or release an identity that still has an association.
84- **CARLOS NEVER WRITES A BORROWED IDENTITY.** `CreateEmailIdentity`, `PutEmailIdentityMailFromAttributes` (both inside `EnsureIdentity`), `SetIdentityConfigSet` and `DeleteIdentity` are refused for a registered domain and for every subdomain of one. The wrapper is the enforcement, the `EnsureDomain` refusal is the door, and both are tested.
85- **No behaviour change for a domain sender.** `SendPolicyJSON` renders the same bytes it renders today, with no tenant condition added: nothing delivers `<P>_TENANT` to a domain sender, so a tenant condition on that document would be 554 on every send for the whole fleet. The address form is a second renderer, and a test pins the domain form byte-identical.
86- **Wire-shape changes are additive.** `emailReply`'s existing fields keep their meanings; the address sender appears through plan 2's `senders` array plus the fields this plan adds to each entry. An older CLI keeps working.
87- **Every door refuses the same way it refuses today.** An unknown account, an unknown app and someone else's account are one byte-identical 404 (`apiAccountApp`); the operator doors are `cliUser` -> `IsOperator` -> `mailOperator` -> a bare `http.NotFound`, exactly as `handleAPIEmailPause` (`apimail.go:1290-1304`) and `storeEntitlementOperator` (`apistore_entitlement.go:92`) do.
88- **Every line number here is PRE-PLAN-2.** They were read off the tree as it stands before plan 2 lands, and they are exact against that tree — but plan 2 inserts code above most of them in `walk.go`, `mail.go`, `provisioner.go` and `memprovisioner.go`, so by the time this plan is executed they will all have drifted down. Navigate by FUNCTION NAME and use the number as a hint about where to look. A citation that lands in the wrong place is a citation to re-read, not a reason to edit what is there.
89- 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.
90- Tasks 14 and 15 add 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.
91- Commit after every task with the trailer `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
92
93---
94
95## File Structure
96
97| File | Responsibility |
98|---|---|
99| `internal/mail/external.go` (create) | `ExternalIdentity`, `ExternalIdentityKey`, `Get`/`Put`/`Delete`/`ListExternalIdentities`, `ExternalIdentityFor` (the parent walk) |
100| `internal/mail/external_test.go` (create) | the record, the global key, the subdomain cover, the ARN refusal |
101| `internal/mail/guard.go` (create) | `GuardExternalIdentities`: the `Provisioner` wrapper refusing the four write verbs, `ErrExternalIdentity` |
102| `internal/mail/guard_test.go` (create) | the pinned refusal, including a subdomain, and the pass-through for everything else |
103| `internal/mail/addressclaim.go` (create) | `AddressClaim`, `ValidAddress`, `ClaimAddress`, `ReleaseAddressClaim`, `GetAddressClaim`, `ListAddressClaims`, `ErrAddressClaimed`, `ErrAddressNotOpen` |
104| `internal/mail/policy.go` (modify) | `SendAddressPolicyJSON`: the address form, `StringEqualsIgnoreCase ses:FromAddress` plus `StringEquals ses:TenantName` |
105| `internal/mail/provisioner.go`, `aws.go`, `memprovisioner.go` (modify) | `EnsureAddressUser`; `Status.VerifiedExternal()` |
106| `internal/mail/walk.go` (modify) | `EnsureDomain` refuses before `claimDomain`; `ClaimAddressSender`; `Enable`'s address branch; `writeSMTPEnv`'s extra vars; `EnvDeliveredSender`; `Teardown`'s address branch and the orphan sweep |
107| `internal/svcrole/svcrole.go` (modify) | `Mail` joins the catalogue |
108| `internal/console/svcroutes_test.go` (modify) | `closedDoors` gains the operator doors, the count moves, and the new role's door list is pinned |
109| `internal/console/apimail.go` (modify) | every email door moves to `Allow(svcrole.Mail)`; `mailWriteGates` takes the service budget; the address door; the reply's address fields |
110| `internal/console/apiexternal.go` (create) | the four operator doors and the operator grant |
111| `cmd/console/main.go` (modify) | wrap the SES provisioner in `GuardExternalIdentities` |
112| `mail_cli.go` (modify) | `carlos email external-identity register/deregister/list/claims/grant`, and `enable --from` taking the address path |
113
114---
115
116### Task 1: The external-identity record
117
118**Files:**
119- Create: `internal/mail/external.go`, `internal/mail/external_test.go`
120
121**Interfaces:**
122- Consumes: `ValidDomain` (`domain.go:78`), `Schema` (`mail.go:28`), `release.Store`.
123- Produces:
124 ```go
125 type ExternalIdentity struct {
126 Schema int `json:"schema"`
127 Domain string `json:"domain"`
128 Region string `json:"region"`
129 IdentityARN string `json:"identity_arn"`
130 Open bool `json:"open"`
131 Note string `json:"note,omitempty"`
132 By string `json:"by"`
133 At time.Time `json:"at"`
134 }
135 func ExternalIdentityKey(domain string) string
136 func GetExternalIdentity(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, string, error)
137 func PutExternalIdentity(ctx context.Context, s release.Store, rec *ExternalIdentity, etag string) (string, error)
138 func DeleteExternalIdentity(ctx context.Context, s release.Store, domain string) error
139 func ListExternalIdentities(ctx context.Context, s release.Store) ([]*ExternalIdentity, error)
140 func ExternalIdentityFor(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, error)
141 ```
142
143- [ ] **Step 1: Write the failing test**
144
145`internal/mail/external_test.go` (create):
146
147```go
148package mail
149
150import (
151 "context"
152 "errors"
153 "os"
154 "testing"
155 "time"
156
157 "github.com/carlosframework/platform/internal/release"
158)
159
160// The external identity is registered ONCE PER DEPLOYMENT, not per account:
161// tito.io is one identity at SES whoever asks about it, so its record lives
162// at a global key exactly as the domain claim does (domainclaim.go:42).
163func TestExternalIdentityIsRegisteredGloballyAndReadBack(t *testing.T) {
164 ctx := context.Background()
165 s := release.NewDirStore(t.TempDir())
166 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
167
168 if got := ExternalIdentityKey("tito.io"); got != "email/external-identities/tito.io.json" {
169 t.Fatalf("ExternalIdentityKey = %q", got)
170 }
171 rec := &ExternalIdentity{
172 Domain: "tito.io", Region: "eu-west-1",
173 IdentityARN: "arn:aws:ses:eu-west-1:665106592142:identity/tito.io",
174 Open: true, Note: "tito classic owns this identity", By: "paul@tito.io", At: now,
175 }
176 etag, err := PutExternalIdentity(ctx, s, rec, "")
177 if err != nil || etag == "" {
178 t.Fatalf("PutExternalIdentity = %q %v", etag, err)
179 }
180 got, gotEtag, err := GetExternalIdentity(ctx, s, "tito.io")
181 if err != nil || got == nil {
182 t.Fatalf("GetExternalIdentity = %+v %v", got, err)
183 }
184 if got.IdentityARN != rec.IdentityARN || !got.Open || got.By != "paul@tito.io" || got.Schema != Schema {
185 t.Fatalf("round trip lost something: %+v", got)
186 }
187 // A CAS position, so a second operator editing the record concurrently
188 // loses rather than clobbers.
189 if _, err := PutExternalIdentity(ctx, s, rec, ""); !errors.Is(err, release.ErrCASConflict) {
190 t.Fatalf("a create over an existing record = %v, want ErrCASConflict", err)
191 }
192 if _, err := PutExternalIdentity(ctx, s, rec, gotEtag); err != nil {
193 t.Fatalf("a write at the right etag = %v", err)
194 }
195
196 all, err := ListExternalIdentities(ctx, s)
197 if err != nil || len(all) != 1 || all[0].Domain != "tito.io" {
198 t.Fatalf("ListExternalIdentities = %+v %v", all, err)
199 }
200 if err := DeleteExternalIdentity(ctx, s, "tito.io"); err != nil {
201 t.Fatal(err)
202 }
203 if got, _, err := GetExternalIdentity(ctx, s, "tito.io"); got != nil || err != nil {
204 t.Fatalf("after delete = %+v %v, want (nil, nil)", got, err)
205 }
206 // Absent is a state, not an error, matching every other read here.
207 if _, _, err := GetExternalIdentity(ctx, s, "nobody.example.com"); err != nil && !errors.Is(err, os.ErrNotExist) {
208 t.Fatalf("an absent record errored: %v", err)
209 }
210 // Deleting twice is a quiet no-op, so a retried deregistration is fine.
211 if err := DeleteExternalIdentity(ctx, s, "tito.io"); err != nil {
212 t.Fatal(err)
213 }
214}
215
216// The refusal scope is the identity AND every subdomain of it (design §7): a
217// claim on mail.tito.io would otherwise let CARLOS create, and later delete,
218// an identity underneath one it does not own.
219func TestExternalIdentityCoversEverySubdomain(t *testing.T) {
220 ctx := context.Background()
221 s := release.NewDirStore(t.TempDir())
222 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
223 if _, err := PutExternalIdentity(ctx, s, &ExternalIdentity{
224 Domain: "tito.io", Region: "eu-west-1",
225 IdentityARN: "arn:aws:ses:eu-west-1:665106592142:identity/tito.io", By: "op", At: now,
226 }, ""); err != nil {
227 t.Fatal(err)
228 }
229 for _, under := range []string{"tito.io", "mail.tito.io", "a.b.tito.io"} {
230 got, err := ExternalIdentityFor(ctx, s, under)
231 if err != nil || got == nil || got.Domain != "tito.io" {
232 t.Errorf("ExternalIdentityFor(%q) = %+v %v, want tito.io", under, got, err)
233 }
234 }
235 // A label-aware match, the same correction DomainKindFor carries
236 // (walk.go:61): a name that merely ENDS with the string is a different
237 // zone entirely, and the bare public suffix is nobody's.
238 for _, outside := range []string{"eviltito.io", "example.com", "io.example.com"} {
239 got, err := ExternalIdentityFor(ctx, s, outside)
240 if err != nil || got != nil {
241 t.Errorf("ExternalIdentityFor(%q) = %+v %v, want nothing", outside, got, err)
242 }
243 }
244}
245
246// The ARN is the string a policy Resource is built from, so it is validated
247// at the one gate every registration passes through: its region and its
248// identity name must be the record's own, or the policy would authorise a
249// different identity from the one the operator thinks they registered.
250func TestPutExternalIdentityRefusesAnARNThatDisagreesWithTheRecord(t *testing.T) {
251 ctx := context.Background()
252 s := release.NewDirStore(t.TempDir())
253 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
254 for _, bad := range []*ExternalIdentity{
255 {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "arn:aws:ses:us-east-1:1:identity/tito.io", By: "op", At: now},
256 {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/other.io", By: "op", At: now},
257 {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/*", By: "op", At: now},
258 {Domain: "tito.io", Region: "eu-west-1", IdentityARN: "not-an-arn", By: "op", At: now},
259 {Domain: "tito.io", Region: "", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/tito.io", By: "op", At: now},
260 {Domain: "not a domain", Region: "eu-west-1", IdentityARN: "arn:aws:ses:eu-west-1:1:identity/tito.io", By: "op", At: now},
261 } {
262 if _, err := PutExternalIdentity(ctx, s, bad, ""); err == nil {
263 t.Errorf("PutExternalIdentity accepted %+v", bad)
264 }
265 }
266}
267```
268
269- [ ] **Step 2: Run it to verify it fails**
270
271```bash
272go test -run 'TestExternalIdentity|TestPutExternalIdentity' ./internal/mail/
273```
274
275Expected: build failure, `undefined: ExternalIdentity`.
276
277- [ ] **Step 3: Write the code**
278
279`internal/mail/external.go` (create):
280
281```go
282// The external identity (senders design §7): an SES identity CARLOS does
283// NOT own, registered once per deployment by an operator so that accounts
284// may send as single addresses under it.
285//
286// tito.io is the case this exists for. Tito Classic has owned that identity
287// in the same AWS account for years, and the ordinary walk would adopt it and
288// then rewrite it: EnsureIdentity moves MAIL FROM (aws.go:292),
289// SetIdentityConfigSet binds CARLOS's configuration set as the identity's
290// default (walk.go:301), the domain claim is first-come (domainclaim.go:119),
291// and Teardown deletes the identity (walk.go:1205). Every one of those is a
292// change to somebody else's mail. So a registered identity is read-only to
293// CARLOS for ever: guard.go refuses the four write verbs, EnsureDomain
294// refuses the domain before it claims anything, and the only durable thing
295// CARLOS ever does to it is a tenant association, which the spike confirmed
296// is inert for the owner in every respect except deletion (§4.1).
297package mail
298
299import (
300 "context"
301 "encoding/json"
302 "errors"
303 "fmt"
304 "log"
305 "os"
306 "strings"
307 "time"
308
309 "github.com/carlosframework/platform/internal/release"
310)
311
312// externalIdentitiesPrefix is GLOBAL, not under accounts/<sqid>/, for the
313// reason domainClaimsPrefix is: tito.io is ONE identity at SES whoever is
314// asking, and a per-account copy of that fact would let two accounts hold two
315// different answers about the same identity.
316const externalIdentitiesPrefix = "email/external-identities/"
317
318func ExternalIdentityKey(domain string) string {
319 return externalIdentitiesPrefix + domain + ".json"
320}
321
322// ExternalIdentity is the wire shape at ExternalIdentityKey(Domain).
323//
324// Region must be the deployment's own, because tenants and identities are
325// both per region and an association across regions is not a thing SES has.
326// Open says whether any account in the deployment may claim an address under
327// this identity for itself; when it is false, an operator writes the claim as
328// a grant instead (addressclaim.go). The trade-off Open carries is recorded
329// in the design: with it, any account here can claim billing@tito.io, and the
330// mitigation if that ever bites is a Local allowlist that is deliberately not
331// built now.
332type ExternalIdentity struct {
333 Schema int `json:"schema"` // 1
334 Domain string `json:"domain"`
335 Region string `json:"region"`
336 IdentityARN string `json:"identity_arn"`
337 Open bool `json:"open"`
338 Note string `json:"note,omitempty"`
339 By string `json:"by"`
340 At time.Time `json:"at"`
341}
342
343// GetExternalIdentity reads one registration and its CAS position. Absent is
344// (nil, "", nil): "not registered" is a state, not an error, the same idiom
345// GetDomain uses (domain.go:82).
346func GetExternalIdentity(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, string, error) {
347 if !ValidDomain(domain) {
348 return nil, "", fmt.Errorf("mail: invalid domain %q", domain)
349 }
350 raw, etag, err := s.Get(ctx, ExternalIdentityKey(domain))
351 if err != nil {
352 if errors.Is(err, os.ErrNotExist) {
353 return nil, "", nil
354 }
355 return nil, "", err
356 }
357 var rec ExternalIdentity
358 if err := json.Unmarshal(raw, &rec); err != nil {
359 return nil, "", fmt.Errorf("mail: external identity %s: %w", domain, err)
360 }
361 if rec.Schema > Schema {
362 return nil, "", fmt.Errorf("mail: external identity %s: schema %d is newer than this binary understands, upgrade carlos", domain, rec.Schema)
363 }
364 return &rec, etag, nil
365}
366
367// PutExternalIdentity records a registration at the caller's CAS position;
368// etag "" is create-only.
369//
370// The ARN is validated against the record's own domain and region, and not
371// merely for shape. This string becomes the Resource of an IAM policy
372// (SendAddressPolicyJSON), so an ARN naming a different identity would
373// authorise sending as something nobody registered, and a wildcard would
374// authorise the whole estate. SendPolicyJSON already refuses those at render
375// time; refusing them at registration means the operator hears about it while
376// they are still typing rather than at the first enable.
377func PutExternalIdentity(ctx context.Context, s release.Store, rec *ExternalIdentity, etag string) (string, error) {
378 if !ValidDomain(rec.Domain) {
379 return "", fmt.Errorf("mail: invalid domain %q", rec.Domain)
380 }
381 if rec.Region == "" {
382 return "", fmt.Errorf("mail: external identity %s: a region is required, because tenants and identities are both per region", rec.Domain)
383 }
384 if !validIdentityARNFor(rec.IdentityARN, rec.Region, rec.Domain) {
385 return "", fmt.Errorf("mail: external identity %s: %q is not an SES identity ARN for %s in %s",
386 rec.Domain, rec.IdentityARN, rec.Domain, rec.Region)
387 }
388 rec.Schema = Schema
389 body, err := json.MarshalIndent(rec, "", " ")
390 if err != nil {
391 return "", err
392 }
393 return s.Put(ctx, ExternalIdentityKey(rec.Domain), append(body, '\n'), etag)
394}
395
396// validIdentityARNFor reports whether arn is exactly
397// arn:aws:ses:<region>:<account-id>:identity/<domain>, with no wildcard
398// anywhere: IAM expands * and ? as match patterns in every segment, not only
399// in the identity name (SendPolicyJSON's own reasoning, policy.go:143).
400func validIdentityARNFor(arn, region, domain string) bool {
401 if strings.ContainsAny(arn, "*?") {
402 return false
403 }
404 parts := strings.Split(arn, ":")
405 if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" {
406 return false
407 }
408 if parts[3] != region || parts[4] == "" {
409 return false
410 }
411 return parts[5] == "identity/"+domain
412}
413
414// DeleteExternalIdentity deregisters. Already absent is a quiet no-op, so a
415// retried deregistration is not an error.
416func DeleteExternalIdentity(ctx context.Context, s release.Store, domain string) error {
417 if !ValidDomain(domain) {
418 return fmt.Errorf("mail: invalid domain %q", domain)
419 }
420 return s.Delete(ctx, ExternalIdentityKey(domain))
421}
422
423// ListExternalIdentities is the operator's roster of registrations. An
424// unparseable object is logged and skipped, the discipline ListDomainClaims
425// applies (domainclaim.go:185): one bad record must never take the listing
426// down.
427func ListExternalIdentities(ctx context.Context, s release.Store) ([]*ExternalIdentity, error) {
428 keys, err := s.List(ctx, externalIdentitiesPrefix)
429 if err != nil {
430 return nil, fmt.Errorf("mail: list external identities: %w", err)
431 }
432 out := make([]*ExternalIdentity, 0, len(keys))
433 for _, key := range keys {
434 raw, _, err := s.Get(ctx, key)
435 if err != nil {
436 log.Printf("mail: external identity %s -> %v", key, err)
437 continue
438 }
439 var rec ExternalIdentity
440 if err := json.Unmarshal(raw, &rec); err != nil {
441 log.Printf("mail: external identity %s: unparseable -> %v", key, err)
442 continue
443 }
444 if rec.Schema > Schema || !ValidDomain(rec.Domain) || ExternalIdentityKey(rec.Domain) != key {
445 continue
446 }
447 out = append(out, &rec)
448 }
449 sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
450 return out, nil
451}
452
453// ExternalIdentityFor answers "is this name, or anything above it, an
454// identity CARLOS must not touch". It walks the labels upwards, so
455// mail.tito.io resolves to tito.io's registration.
456//
457// The walk is LABEL-AWARE by construction, which is the correction
458// DomainKindFor's doc comment records for the platform suffix (walk.go:61):
459// eviltito.io ends with the string "tito.io" and is a different zone
460// entirely. It stops before a single label, because a public suffix is
461// nobody's identity and a registration at one would refuse half the internet.
462func ExternalIdentityFor(ctx context.Context, s release.Store, domain string) (*ExternalIdentity, error) {
463 if !ValidDomain(domain) {
464 return nil, fmt.Errorf("mail: invalid domain %q", domain)
465 }
466 for name := domain; ; {
467 rec, _, err := GetExternalIdentity(ctx, s, name)
468 if err != nil {
469 return nil, err
470 }
471 if rec != nil {
472 return rec, nil
473 }
474 i := strings.Index(name, ".")
475 if i < 0 {
476 return nil, nil
477 }
478 name = name[i+1:]
479 if !strings.Contains(name, ".") {
480 return nil, nil
481 }
482 }
483}
484```
485
486Add `"sort"` to the file's imports.
487
488- [ ] **Step 4: Run it to verify it passes**
489
490```bash
491go test -run 'TestExternalIdentity|TestPutExternalIdentity' ./internal/mail/
492```
493
494Expected: PASS.
495
496- [ ] **Step 5: Gate and commit**
497
498```bash
499go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
500git add internal/mail/external.go internal/mail/external_test.go
501git commit -m "mail: the external-identity record, registered once per deployment (plan 3, task 1)"
502```
503
504---
505
506### Task 2: The provisioner wrapper that refuses to touch a borrowed identity
507
508**Files:**
509- Create: `internal/mail/guard.go`, `internal/mail/guard_test.go`
510- Modify: `cmd/console/main.go` (the `mailBackends` call at `:264`, the `Releases` field at `:269`)
511
512**Interfaces:**
513- Consumes: `Provisioner` (`provisioner.go:49`), `ExternalIdentityFor`.
514- Produces:
515 ```go
516 var ErrExternalIdentity = errors.New("mail: identity is registered as external and CARLOS may not change it")
517 func GuardExternalIdentities(p Provisioner, s release.Store) Provisioner
518 ```
519 Refuses `EnsureIdentity`, `SetIdentityConfigSet` and `DeleteIdentity`; every other verb passes through the embedded `Provisioner` unchanged.
520
521- [ ] **Step 1: Write the failing test**
522
523`internal/mail/guard_test.go` (create):
524
525```go
526package mail
527
528import (
529 "context"
530 "errors"
531 "testing"
532 "time"
533
534 "github.com/carlosframework/platform/internal/release"
535)
536
537// registerTitoIO is the fixture every test in this file starts from: an
538// identity somebody else owns, registered here so CARLOS will not touch it.
539func registerTitoIO(t *testing.T, ctx context.Context, s release.Store, open bool) *ExternalIdentity {
540 t.Helper()
541 rec := &ExternalIdentity{
542 Domain: "tito.io", Region: "eu-west-1",
543 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/tito.io",
544 Open: open, By: "paul@tito.io", At: time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC),
545 }
546 if _, err := PutExternalIdentity(ctx, s, rec, ""); err != nil {
547 t.Fatal(err)
548 }
549 return rec
550}
551
552// THE PINNED REFUSAL (design §7). CreateEmailIdentity and
553// PutEmailIdentityMailFromAttributes both live inside EnsureIdentity, which
554// would move tito.io's MAIL FROM to mail-eu-west-1.tito.io;
555// SetIdentityConfigSet would take over the identity's default configuration
556// set, which is Classic's; DeleteIdentity would destroy it outright. All
557// three are refused for the registered domain AND for anything under it.
558func TestGuardRefusesEveryVerbThatWouldChangeABorrowedIdentity(t *testing.T) {
559 ctx := context.Background()
560 s, inner := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
561 registerTitoIO(t, ctx, s, true)
562 p := GuardExternalIdentities(inner, s)
563
564 for _, domain := range []string{"tito.io", "mail.tito.io", "a.b.tito.io"} {
565 if _, err := p.EnsureIdentity(ctx, "eu-west-1", domain, "mail-eu-west-1."+domain); !errors.Is(err, ErrExternalIdentity) {
566 t.Errorf("EnsureIdentity(%q) = %v, want ErrExternalIdentity", domain, err)
567 }
568 if err := p.SetIdentityConfigSet(ctx, "eu-west-1", domain, "carlos-bag"); !errors.Is(err, ErrExternalIdentity) {
569 t.Errorf("SetIdentityConfigSet(%q) = %v, want ErrExternalIdentity", domain, err)
570 }
571 if err := p.DeleteIdentity(ctx, "eu-west-1", domain); !errors.Is(err, ErrExternalIdentity) {
572 t.Errorf("DeleteIdentity(%q) = %v, want ErrExternalIdentity", domain, err)
573 }
574 }
575 // Nothing reached the backend: a refusal that had already called through
576 // would be no refusal at all.
577 if len(inner.Identities) != 0 {
578 t.Fatalf("the guard let an identity be created: %+v", inner.Identities)
579 }
580
581 // An identity SES already holds for the owner is not disturbed by a
582 // refused delete, which is the whole property: Classic's identity
583 // survives whatever CARLOS's walks try to do to it.
584 if _, err := inner.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil {
585 t.Fatal(err)
586 }
587 if err := p.DeleteIdentity(ctx, "eu-west-1", "tito.io"); !errors.Is(err, ErrExternalIdentity) {
588 t.Fatalf("DeleteIdentity on the live identity = %v, want ErrExternalIdentity", err)
589 }
590 if _, ok := inner.Identities[identityKey("eu-west-1", "tito.io")]; !ok {
591 t.Fatal("the guard deleted the identity it exists to protect")
592 }
593}
594
595// Everything else passes straight through, including the verbs this design
596// DOES perform on a borrowed identity: reading its status, and associating
597// it with a tenant, which the spike confirmed is inert for its owner (§4.1).
598func TestGuardPassesThroughEveryOtherVerb(t *testing.T) {
599 ctx := context.Background()
600 s, inner := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
601 registerTitoIO(t, ctx, s, true)
602 p := GuardExternalIdentities(inner, s)
603
604 if _, err := inner.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil {
605 t.Fatal(err)
606 }
607 if _, err := p.IdentityStatus(ctx, "eu-west-1", "tito.io"); err != nil {
608 t.Errorf("IdentityStatus through the guard = %v", err)
609 }
610 if err := p.EnsureTenant(ctx, "carlos-bag"); err != nil {
611 t.Errorf("EnsureTenant through the guard = %v", err)
612 }
613 if err := p.AssociateTenantResource(ctx, "carlos-bag", "arn:aws:ses:eu-west-1:1:identity/tito.io"); err != nil {
614 t.Errorf("AssociateTenantResource through the guard = %v", err)
615 }
616 if err := p.DisassociateTenantResource(ctx, "carlos-bag", "arn:aws:ses:eu-west-1:1:identity/tito.io"); err != nil {
617 t.Errorf("DisassociateTenantResource through the guard = %v", err)
618 }
619 // A domain nobody registered is ordinary in every respect.
620 if _, err := p.EnsureIdentity(ctx, "eu-west-1", "harbourmail.bag.oncarlos.com", "mail-eu-west-1.harbourmail.bag.oncarlos.com"); err != nil {
621 t.Errorf("EnsureIdentity on an ordinary domain = %v", err)
622 }
623 if err := p.DeleteIdentity(ctx, "eu-west-1", "harbourmail.bag.oncarlos.com"); err != nil {
624 t.Errorf("DeleteIdentity on an ordinary domain = %v", err)
625 }
626}
627```
628
629- [ ] **Step 2: Run it to verify it fails**
630
631```bash
632go test -run TestGuard ./internal/mail/
633```
634
635Expected: build failure, `undefined: GuardExternalIdentities`.
636
637- [ ] **Step 3: Write the code**
638
639`internal/mail/guard.go` (create):
640
641```go
642// The guard (senders design §7): a Provisioner that refuses every verb which
643// would change an identity CARLOS does not own.
644//
645// It is a WRAPPER rather than a check inside each walk, and that is the whole
646// point. EnsureDomain's own refusal (walk.go) is the door a member meets, and
647// it is where the good error message lives; this is the floor underneath it,
648// so that a walk added later, or a repair path nobody thought about, cannot
649// reach CreateEmailIdentity for tito.io by a route the door does not cover.
650// One of the two would be a policy; both is an invariant.
651package mail
652
653import (
654 "context"
655 "errors"
656 "fmt"
657
658 "github.com/carlosframework/platform/internal/release"
659)
660
661// ErrExternalIdentity is the refusal. Callers match on it rather than on the
662// message, and the console door turns it into a sentence that says what to do
663// instead (claim an address under the identity).
664var ErrExternalIdentity = errors.New("mail: identity is registered as external and CARLOS may not change it")
665
666// guardedProvisioner embeds the real one, so every verb this file does not
667// name passes through unchanged and a verb ADDED to the interface later is
668// forwarded by construction rather than silently dropped.
669type guardedProvisioner struct {
670 Provisioner
671 store release.Store
672}
673
674// GuardExternalIdentities wraps p so the four write verbs are refused for a
675// registered identity and for every subdomain of one. A nil provisioner or a
676// nil store is returned unwrapped: a deployment with no SES estate has no
677// identities to protect, and one with no bucket has nowhere to have recorded
678// a registration.
679func GuardExternalIdentities(p Provisioner, s release.Store) Provisioner {
680 if p == nil || s == nil {
681 return p
682 }
683 return &guardedProvisioner{Provisioner: p, store: s}
684}
685
686// refuse answers nil when domain is CARLOS's to change. A read error is
687// propagated rather than swallowed: "the bucket is unreachable" must not
688// read as "this identity is ours", which would be the one failure mode worth
689// having a guard about.
690func (g *guardedProvisioner) refuse(ctx context.Context, verb, domain string) error {
691 rec, err := ExternalIdentityFor(ctx, g.store, domain)
692 if err != nil {
693 return fmt.Errorf("mail: checking external identities before %s on %s: %w", verb, domain, err)
694 }
695 if rec == nil {
696 return nil
697 }
698 return fmt.Errorf("mail: %s on %s would change %s, which is registered here as an identity this deployment does not own: %w",
699 verb, domain, rec.Domain, ErrExternalIdentity)
700}
701
702// EnsureIdentity covers BOTH forbidden SES calls: CreateEmailIdentity and
703// PutEmailIdentityMailFromAttributes are the two halves of it (aws.go:261,
704// :292), and the second is the one that would silently move tito.io's
705// envelope domain to mail-eu-west-1.tito.io and break Classic's DMARC
706// alignment.
707func (g *guardedProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string) (Identity, error) {
708 if err := g.refuse(ctx, "ensure identity", domain); err != nil {
709 return Identity{}, err
710 }
711 return g.Provisioner.EnsureIdentity(ctx, region, domain, mailFrom)
712}
713
714// SetIdentityConfigSet would make CARLOS's configuration set the identity's
715// DEFAULT, which is the owner's setting: every message Classic sends without
716// naming a set would start publishing events to CARLOS's destination.
717func (g *guardedProvisioner) SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error {
718 if err := g.refuse(ctx, "set configuration set", domain); err != nil {
719 return err
720 }
721 return g.Provisioner.SetIdentityConfigSet(ctx, region, domain, configSet)
722}
723
724// DeleteIdentity is the one that cannot be undone. SES would also refuse it
725// while CARLOS's own tenant association stands (§4.1), but that refusal is a
726// side effect of an association this deployment might not have yet, and it is
727// not a rule anybody should rely on for this.
728func (g *guardedProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error {
729 if err := g.refuse(ctx, "delete identity", domain); err != nil {
730 return err
731 }
732 return g.Provisioner.DeleteIdentity(ctx, region, domain)
733}
734```
735
736`cmd/console/main.go`: the guard needs the deployment bucket, which is opened inline in the `Config` literal today (`Releases: openStore()`, `:269`). Lift it to a variable beside the other backends (plan 2's task 4 already lifts it for the legacy sweep; use that variable if it is there) and wrap:
737
738```go
739 rel := openStore()
740 mailDep := mailDeployment()
741 mailProvisioner, dnsWriter := mailBackends(mailDep, mail.NewAWSProvisioner, dnsrec.NewRoute53Writer)
742 // The floor under every walk (senders design §7): an identity registered
743 // as external is read-only to CARLOS, whatever asks. Wrapped HERE, at the
744 // one place the real provisioner is constructed, so no console can be
745 // configured with an unguarded one.
746 mailProvisioner = mail.GuardExternalIdentities(mailProvisioner, rel)
747```
748
749and `Releases: rel,` in the literal. The name is `rel` because that is what plan 2's task 4 hoists it as, for its own sweep; if plan 2 is in, this task adds only the wrap.
750
751- [ ] **Step 4: Run it to verify it passes**
752
753```bash
754go test -run TestGuard ./internal/mail/ && go build ./...
755```
756
757Expected: PASS and a clean build.
758
759- [ ] **Step 5: Gate and commit**
760
761```bash
762go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
763git add internal/mail cmd/console
764git commit -m "mail: a provisioner wrapper that refuses to change a borrowed identity (plan 3, task 2)"
765```
766
767---
768
769### Task 3: `EnsureDomain` refuses before it claims, and the orphan sweep never deletes a borrowed identity
770
771**Files:**
772- Modify: `internal/mail/walk.go` (`EnsureDomain` at `:149`, before `claimDomain` at `:171`; `Teardown`'s orphan sweep at `:1262-1294`), `internal/mail/walk_domainclaim_test.go`
773
774**Interfaces:**
775- Consumes: `ExternalIdentityFor`, `ErrExternalIdentity`, `claimDomain` (`domainclaim.go:119`), `releaseDomainClaim` (`:158`), `ListDomainClaims` (`:185`).
776- Produces: no new exported name. `EnsureDomain` refuses a registered domain or any subdomain of one **before** any durable side effect; `Teardown`'s orphan sweep releases such a claim without calling `DeleteIdentity`.
777
778- [ ] **Step 1: Write the failing test**
779
780`internal/mail/walk_domainclaim_test.go` (append):
781
782```go
783// The refusal is BEFORE the claim (design §6.4). Order matters twice over:
784// a claim taken and then refused would leave the domain held by an account
785// that can never use it, and the only thing that could clear it is
786// Teardown's orphan sweep, which would then have to delete an identity
787// belonging to somebody else.
788func TestEnsureDomainRefusesARegisteredExternalIdentityBeforeClaiming(t *testing.T) {
789 ctx := context.Background()
790 s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
791 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
792 registerTitoIO(t, ctx, s, true)
793 if _, err := Declare(ctx, s, "bag", "harbourmail", "HARBOURMAIL_SMTP",
794 "jelly@tito.io", "owner@example.test", now); err != nil {
795 t.Fatal(err)
796 }
797
798 for _, domain := range []string{"tito.io", "mail.tito.io"} {
799 _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail", domain, now)
800 if !errors.Is(err, ErrExternalIdentity) {
801 t.Errorf("EnsureDomain(%q) = %v, want ErrExternalIdentity", domain, err)
802 }
803 if got, err := getDomainClaim(ctx, s, domain); err != nil || got != nil {
804 t.Errorf("EnsureDomain(%q) took a claim it then refused: %+v %v", domain, got, err)
805 }
806 if rec, _, err := GetDomain(ctx, s, "bag", domain); err != nil || rec != nil {
807 t.Errorf("EnsureDomain(%q) wrote a domain record: %+v %v", domain, rec, err)
808 }
809 }
810 if len(p.Identities) != 0 || len(p.Tenants) != 0 {
811 t.Fatalf("a refused EnsureDomain still spent a backend call: %+v %+v", p.Identities, p.Tenants)
812 }
813}
814
815// A claim taken BEFORE the identity was registered is the one shape that can
816// still exist. Teardown's orphan sweep must release it and must NOT call
817// DeleteIdentity on the way: CARLOS created nothing there, so there is
818// nothing of CARLOS's to destroy, and the identity belongs to another
819// product. Releasing the claim all the same is deliberate, because a claim
820// nothing can ever clear would wedge the domain for every other account.
821func TestTeardownNeverDeletesABorrowedIdentityToClearAnOrphanClaim(t *testing.T) {
822 ctx := context.Background()
823 s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
824 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
825 if _, err := Declare(ctx, s, "bag", "harbourmail", "HARBOURMAIL_SMTP",
826 "jelly@tito.io", "owner@example.test", now); err != nil {
827 t.Fatal(err)
828 }
829 // The claim predates the registration, which is exactly how a legacy
830 // orphan comes to exist.
831 if err := claimDomain(ctx, s, "tito.io", "bag", "harbourmail", now); err != nil {
832 t.Fatal(err)
833 }
834 registerTitoIO(t, ctx, s, true)
835 // Classic's identity, live at SES before CARLOS ever heard of it.
836 if _, err := p.EnsureIdentity(ctx, "eu-west-1", "tito.io", "mail.tito.io"); err != nil {
837 t.Fatal(err)
838 }
839
840 if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "bag", "harbourmail"); err != nil {
841 t.Fatalf("Teardown: %v", err)
842 }
843 if _, ok := p.Identities[identityKey("eu-west-1", "tito.io")]; !ok {
844 t.Fatal("Teardown deleted an identity this deployment does not own")
845 }
846 if got, err := getDomainClaim(ctx, s, "tito.io"); err != nil || got != nil {
847 t.Fatalf("the orphan claim survived Teardown: %+v %v", got, err)
848 }
849}
850```
851
852Add `"errors"` to the test file's imports if it is not already there.
853
854- [ ] **Step 2: Run it to verify it fails**
855
856```bash
857go test -run 'TestEnsureDomainRefusesARegisteredExternalIdentity|TestTeardownNeverDeletesABorrowedIdentity' ./internal/mail/
858```
859
860Expected: the first fails with `EnsureDomain("tito.io") = <nil>, want ErrExternalIdentity`; the second fails because the identity is gone.
861
862- [ ] **Step 3: Write the code**
863
864`internal/mail/walk.go`, `EnsureDomain`, between the platform-name check (`:168-170`) and `claimDomain` (`:171`):
865
866```go
867 // A REGISTERED EXTERNAL IDENTITY IS NOT A SENDING DOMAIN (design §7).
868 // Refused here, before the claim and before any backend call, for the
869 // reason the claim itself is the walk's first durable side effect:
870 // nothing the caller named is trustworthy until the gate says so, and a
871 // claim taken and then refused by the provisioner guard would be a claim
872 // only Teardown's orphan sweep could clear, which would in turn have to
873 // delete an identity belonging to another product. The refusal names the
874 // registered identity because the caller supplied a name under it and is
875 // being told what to do instead, not because it discloses anything.
876 if ext, err := ExternalIdentityFor(ctx, s, domain); err != nil {
877 return nil, nil, err
878 } else if ext != nil {
879 return nil, nil, fmt.Errorf(
880 "mail: %s sits under %s, an identity this deployment does not own: claim an address under it instead: %w",
881 domain, ext.Domain, ErrExternalIdentity)
882 }
883```
884
885`internal/mail/walk.go`, `Teardown`'s orphan-claim sweep, inside the loop over `claims` and before the `DeleteIdentity` at `:1288`:
886
887```go
888 // A claim under a registered external identity can only predate the
889 // registration, because EnsureDomain refuses one now. CARLOS never
890 // created an identity there, so the DeleteIdentity below has nothing
891 // of its own to destroy and would instead destroy another product's.
892 // The claim IS released: it protects CARLOS's identity creation, and
893 // there is none to protect here, while a claim nothing can ever clear
894 // would wedge the domain for every account in the deployment.
895 if ext, err := ExternalIdentityFor(ctx, s, c.Domain); err != nil {
896 return err
897 } else if ext != nil {
898 if err := releaseDomainClaim(ctx, s, c.Domain, acct); err != nil {
899 return err
900 }
901 continue
902 }
903```
904
905- [ ] **Step 4: Run it to verify it passes**
906
907```bash
908go test ./internal/mail/ -count=1
909```
910
911Expected: PASS, including every existing claim test unchanged.
912
913- [ ] **Step 5: Gate and commit**
914
915```bash
916go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
917git add internal/mail
918git commit -m "mail: EnsureDomain refuses a borrowed identity before it claims, and teardown never deletes one (plan 3, task 3)"
919```
920
921---
922
923### Task 4: The address claim, non-exclusive by design
924
925**Files:**
926- Create: `internal/mail/addressclaim.go`, `internal/mail/addressclaim_test.go`
927
928**Interfaces:**
929- Consumes: `ValidDomain` (`domain.go:78`), `ExternalIdentity.Open`, `claimDomain`'s idempotence rule (`domainclaim.go:119-142`) as the pattern, `release.ErrCASConflict`.
930- Produces:
931 ```go
932 type AddressClaim struct {
933 Schema int `json:"schema"`
934 Address string `json:"address"`
935 Account string `json:"account"`
936 App string `json:"app"`
937 Sender string `json:"sender"`
938 Grant bool `json:"grant,omitempty"`
939 By string `json:"by"`
940 At time.Time `json:"at"`
941 }
942 func AddressClaimKey(address, acct string) string
943 func ValidAddress(addr string) bool
944 func GetAddressClaim(ctx context.Context, s release.Store, address, acct string) (*AddressClaim, error)
945 func ClaimAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, open bool, now time.Time) error
946 func GrantAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, now time.Time) error
947 func ReleaseAddressClaim(ctx context.Context, s release.Store, address, acct, app, sender string) error
948 func ListAddressClaims(ctx context.Context, s release.Store, address string) ([]*AddressClaim, error)
949 var ErrAddressClaimed = errors.New("mail: address is claimed by another app in this account")
950 var ErrAddressNotOpen = errors.New("mail: this identity is not open for self-serve address claims")
951 ```
952
953- [ ] **Step 1: Write the failing test**
954
955`internal/mail/addressclaim_test.go` (create):
956
957```go
958package mail
959
960import (
961 "context"
962 "errors"
963 "testing"
964 "time"
965
966 "github.com/carlosframework/platform/internal/appconfig"
967 "github.com/carlosframework/platform/internal/release"
968)
969
970// THE CLAIM IS NOT EXCLUSIVE, and that is the requirement, not a compromise
971// (design §7): Jelly runs as an app in two accounts and both must send as
972// jelly@tito.io. The key is per (address, ACCOUNT), so two accounts holding
973// the same address is two objects and no conflict at all.
974func TestAddressClaimIsNotExclusiveAcrossAccounts(t *testing.T) {
975 ctx := context.Background()
976 s := release.NewDirStore(t.TempDir())
977 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
978
979 if got := AddressClaimKey("jelly@tito.io", "jam"); got != "email/address-claims/jelly@tito.io/jam.json" {
980 t.Fatalf("AddressClaimKey = %q", got)
981 }
982 for _, acct := range []string{"jam", "stripe"} {
983 if err := ClaimAddress(ctx, s, "jelly@tito.io", acct, "jelly", SenderAppID, "owner@example.test", true, now); err != nil {
984 t.Fatalf("ClaimAddress(%s): %v", acct, err)
985 }
986 }
987 roster, err := ListAddressClaims(ctx, s, "jelly@tito.io")
988 if err != nil || len(roster) != 2 {
989 t.Fatalf("ListAddressClaims = %+v %v, want two", roster, err)
990 }
991 if roster[0].Account != "jam" || roster[1].Account != "stripe" {
992 t.Fatalf("roster is not sorted by account: %+v", roster)
993 }
994 if roster[0].App != "jelly" || roster[0].Sender != SenderAppID || roster[0].By != "owner@example.test" {
995 t.Fatalf("the claim lost its attribution: %+v", roster[0])
996 }
997}
998
999// Within ONE account it is exclusive, with claimDomain's rule: the same
1000// (app, sender) is idempotent success, anything else is refused. Without
1001// that, a second app in the same account could adopt the first one's From
1002// address and then be torn down, releasing a claim the first app is still
1003// sending under.
1004func TestAddressClaimIsIdempotentForItsOwnSenderAndRefusesAnother(t *testing.T) {
1005 ctx := context.Background()
1006 s := release.NewDirStore(t.TempDir())
1007 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
1008
1009 if err := ClaimAddress(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID, "o@example.test", true, now); err != nil {
1010 t.Fatal(err)
1011 }
1012 if err := ClaimAddress(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID, "o@example.test", true, now); err != nil {
1013 t.Fatalf("a re-claim by the holder was refused by its own claim: %v", err)
1014 }
1015 if err := ClaimAddress(ctx, s, "jelly@tito.io", "jam", "other", SenderAppID, "o@example.test", true, now); !errors.Is(err, ErrAddressClaimed) {
1016 t.Fatalf("a sibling app claiming it = %v, want ErrAddressClaimed", err)
1017 }
1018
1019 // Owner-gated release, the same shape releaseDomainClaim has: a stranger
1020 // cannot drop a claim it does not hold, and dropping an absent one is a
1021 // quiet no-op so a retried teardown is not an error.
1022 if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "other", SenderAppID); err != nil {
1023 t.Fatalf("a mismatched release should be a no-op: %v", err)
1024 }
1025 if got, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); got == nil {
1026 t.Fatal("a mismatched release removed the claim")
1027 }
1028 if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID); err != nil {
1029 t.Fatal(err)
1030 }
1031 if got, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); got != nil {
1032 t.Fatalf("the claim survived its own release: %+v", got)
1033 }
1034 if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID); err != nil {
1035 t.Fatal(err)
1036 }
1037}
1038
1039// Open decides whether an account may help itself. When it is false an
1040// operator writes the same record as a GRANT, and the member's own claim then
1041// succeeds against it, which is what makes the two paths one record rather
1042// than two code paths that could disagree.
1043func TestClaimAddressNeedsOpenUnlessAnOperatorGrantedIt(t *testing.T) {
1044 ctx := context.Background()
1045 s := release.NewDirStore(t.TempDir())
1046 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
1047
1048 if err := ClaimAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "o@example.test", false, now); !errors.Is(err, ErrAddressNotOpen) {
1049 t.Fatalf("a self-serve claim on a closed identity = %v, want ErrAddressNotOpen", err)
1050 }
1051 if err := GrantAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "operator@example.test", now); err != nil {
1052 t.Fatal(err)
1053 }
1054 got, err := GetAddressClaim(ctx, s, "billing@tito.io", "jam")
1055 if err != nil || got == nil || !got.Grant || got.By != "operator@example.test" {
1056 t.Fatalf("the grant = %+v %v", got, err)
1057 }
1058 if err := ClaimAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "o@example.test", false, now); err != nil {
1059 t.Fatalf("a claim against an operator grant was refused: %v", err)
1060 }
1061 // The grant is for ONE app. A sibling is still refused, and refused as a
1062 // conflict rather than as "not open", because the identity is not the
1063 // thing standing in its way.
1064 if err := ClaimAddress(ctx, s, "billing@tito.io", "jam", "other", SenderAppID, "o@example.test", false, now); !errors.Is(err, ErrAddressClaimed) {
1065 t.Fatalf("a sibling app against someone else's grant = %v, want ErrAddressClaimed", err)
1066 }
1067}
1068
1069// The address becomes a bucket key segment AND an IAM policy condition value
1070// AND the delivered _FROM, so it is validated at the one gate every caller
1071// passes through, and the last of those three is asserted rather than
1072// assumed.
1073func TestValidAddress(t *testing.T) {
1074 for _, good := range []string{"jelly@tito.io", "no-reply+jam@mail.tito.io", "a.b_c%d@x.co"} {
1075 if !ValidAddress(good) {
1076 t.Errorf("ValidAddress(%q) = false", good)
1077 }
1078 if err := appconfig.ValidVarValue(good); err != nil {
1079 t.Errorf("%q is not EnvironmentFile-safe, so it could never be delivered as _FROM: %v", good, err)
1080 }
1081 }
1082 for _, bad := range []string{
1083 "", "tito.io", "jelly@", "@tito.io", "jelly@@tito.io", "JELLY@tito.io",
1084 "jelly@tito", "a/b@tito.io", "..@tito.io", "jelly@tito.io/../x", "jelly tito@tito.io",
1085 } {
1086 if ValidAddress(bad) {
1087 t.Errorf("ValidAddress(%q) = true", bad)
1088 }
1089 }
1090}
1091```
1092
1093- [ ] **Step 2: Run it to verify it fails**
1094
1095```bash
1096go test -run 'TestAddressClaim|TestClaimAddress|TestValidAddress' ./internal/mail/
1097```
1098
1099Expected: build failure, `undefined: AddressClaimKey`.
1100
1101- [ ] **Step 3: Write the code**
1102
1103`internal/mail/addressclaim.go` (create):
1104
1105```go
1106// The address claim (senders design §7): who may send as one exact address
1107// under a registered external identity.
1108//
1109// IT IS NOT EXCLUSIVE, and that is the requirement rather than a weakening of
1110// one. Jelly runs as an app in two accounts, jam and stripe, and both must
1111// send as jelly@tito.io. So the key carries the ACCOUNT, and two accounts
1112// holding the same address is two objects with nothing to race over. Within
1113// one account it IS exclusive, with claimDomain's rule (domainclaim.go:119):
1114// the holder re-claiming is idempotent success and any other (app, sender) is
1115// refused, because otherwise a sibling app could adopt the address and then be
1116// torn down, releasing a claim the first app is still sending under.
1117//
1118// The claim is a RECORD OF AUTHORITY, not the enforcement. The enforcement is
1119// the IAM policy's StringEqualsIgnoreCase on ses:FromAddress
1120// (SendAddressPolicyJSON): a credential cannot send as an address its policy
1121// does not name, whatever any object in the bucket says.
1122package mail
1123
1124import (
1125 "context"
1126 "encoding/json"
1127 "errors"
1128 "fmt"
1129 "log"
1130 "os"
1131 "regexp"
1132 "sort"
1133 "strings"
1134 "time"
1135
1136 "github.com/carlosframework/platform/internal/release"
1137)
1138
1139const addressClaimsPrefix = "email/address-claims/"
1140
1141// AddressClaimKey is one account's claim on one address. Global, like the
1142// domain claim and the external identity itself: the address belongs to an
1143// identity the whole deployment shares, so the roster an operator reads has
1144// to be one listing rather than one per account.
1145func AddressClaimKey(address, acct string) string {
1146 return addressClaimsPrefix + address + "/" + acct + ".json"
1147}
1148
1149// AddressClaim is the wire shape at AddressClaimKey(Address, Account).
1150//
1151// Grant records that an OPERATOR wrote this claim on an identity that is not
1152// open for self-serve. It is kept because the two paths write the same record
1153// and an operator reading the roster needs to see which claims they made
1154// themselves.
1155type AddressClaim struct {
1156 Schema int `json:"schema"` // 1
1157 Address string `json:"address"`
1158 Account string `json:"account"`
1159 App string `json:"app"`
1160 Sender string `json:"sender"`
1161 Grant bool `json:"grant,omitempty"`
1162 By string `json:"by"`
1163 At time.Time `json:"at"`
1164}
1165
1166var (
1167 // ErrAddressClaimed is a claim from a different app or sender in the SAME
1168 // account. It never names the holder for a different account, because
1169 // there is no such refusal: a different account is a different key.
1170 ErrAddressClaimed = errors.New("mail: address is claimed by another app in this account")
1171 // ErrAddressNotOpen is a self-serve claim on an identity whose
1172 // registration is closed. The recourse is an operator grant, and the
1173 // console door says so.
1174 ErrAddressNotOpen = errors.New("mail: this identity is not open for self-serve address claims")
1175)
1176
1177// addressLocalRe is the local part, deliberately stricter than RFC 5321
1178// allows: this string becomes a bucket key segment, an IAM policy condition
1179// value and the delivered _FROM variable, so the one safe place to refuse a
1180// separator, a traversal or a space is the gate every caller passes through.
1181// Lowercase only, because the claim key has to be a function of the address
1182// and SES's own StringEquals is case-sensitive (which is why the policy uses
1183// StringEqualsIgnoreCase instead).
1184var addressLocalRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9._%+-]{0,62}[a-z0-9])?$`)
1185
1186// ValidAddress reports whether addr is usable as a sender address.
1187func ValidAddress(addr string) bool {
1188 if len(addr) > 254 {
1189 return false
1190 }
1191 local, domain, found := strings.Cut(addr, "@")
1192 if !found || strings.Contains(domain, "@") {
1193 return false
1194 }
1195 return addressLocalRe.MatchString(local) && ValidDomain(domain)
1196}
1197
1198// AddressDomain is the domain half of a valid address. It is a function
1199// rather than an inline Cut because the policy renderer, the enable branch
1200// and the claim door all have to agree about where the address ends.
1201func AddressDomain(addr string) string {
1202 _, domain, _ := strings.Cut(addr, "@")
1203 return domain
1204}
1205
1206// GetAddressClaim reads one account's claim. Absent is (nil, nil).
1207func GetAddressClaim(ctx context.Context, s release.Store, address, acct string) (*AddressClaim, error) {
1208 if !ValidAddress(address) {
1209 return nil, fmt.Errorf("mail: invalid address %q", address)
1210 }
1211 if !release.ValidAccount(acct) {
1212 return nil, fmt.Errorf("mail: invalid account %q", acct)
1213 }
1214 raw, _, err := s.Get(ctx, AddressClaimKey(address, acct))
1215 if err != nil {
1216 if errors.Is(err, os.ErrNotExist) {
1217 return nil, nil
1218 }
1219 return nil, err
1220 }
1221 var c AddressClaim
1222 if err := json.Unmarshal(raw, &c); err != nil {
1223 return nil, fmt.Errorf("mail: address claim %s/%s: %w", address, acct, err)
1224 }
1225 if c.Schema > Schema {
1226 return nil, fmt.Errorf("mail: address claim %s/%s: schema %d is newer than this binary understands, upgrade carlos", address, acct, c.Schema)
1227 }
1228 return &c, nil
1229}
1230
1231// ClaimAddress is the MEMBER's claim. open is the registration's own Open
1232// flag; when it is false the only way through is an operator grant already
1233// recorded for this account, which is what "otherwise an operator writes the
1234// same record as a grant" means in one sentence of code.
1235func ClaimAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, open bool, now time.Time) error {
1236 cur, err := getOwnAddressClaim(ctx, s, address, acct, app, sender)
1237 if err != nil {
1238 return err
1239 }
1240 if cur != nil {
1241 return nil // already ours: idempotent, and a grant is honoured here
1242 }
1243 if !open {
1244 return fmt.Errorf("mail: %s: %w", address, ErrAddressNotOpen)
1245 }
1246 return putAddressClaim(ctx, s, &AddressClaim{
1247 Address: address, Account: acct, App: app, Sender: sender, By: by, At: now,
1248 })
1249}
1250
1251// GrantAddress is the OPERATOR's, and it writes the same record whatever the
1252// registration says. It is how a closed identity is opened to exactly one
1253// app rather than to the whole deployment.
1254func GrantAddress(ctx context.Context, s release.Store, address, acct, app, sender, by string, now time.Time) error {
1255 cur, err := getOwnAddressClaim(ctx, s, address, acct, app, sender)
1256 if err != nil {
1257 return err
1258 }
1259 if cur != nil {
1260 return nil
1261 }
1262 return putAddressClaim(ctx, s, &AddressClaim{
1263 Address: address, Account: acct, App: app, Sender: sender, Grant: true, By: by, At: now,
1264 })
1265}
1266
1267// getOwnAddressClaim answers the shared question both writers ask: is there
1268// already a claim here, and is it ours. A claim held by another app or sender
1269// in this account is ErrAddressClaimed; ours is returned for the caller to
1270// treat as idempotent success.
1271func getOwnAddressClaim(ctx context.Context, s release.Store, address, acct, app, sender string) (*AddressClaim, error) {
1272 if !ValidAddress(address) {
1273 return nil, fmt.Errorf("mail: invalid address %q", address)
1274 }
1275 if !release.ValidApp(app) || !ValidSenderID(sender) {
1276 return nil, fmt.Errorf("mail: invalid app %q or sender %q", app, sender)
1277 }
1278 cur, err := GetAddressClaim(ctx, s, address, acct)
1279 if err != nil || cur == nil {
1280 return nil, err
1281 }
1282 if cur.App != app || cur.Sender != sender {
1283 return nil, ErrAddressClaimed
1284 }
1285 return cur, nil
1286}
1287
1288// putAddressClaim writes create-only and re-reads on a lost race, so two
1289// requests arriving together end with one winner and one idempotent success
1290// or one refusal, never with a clobbered record.
1291func putAddressClaim(ctx context.Context, s release.Store, c *AddressClaim) error {
1292 c.Schema = Schema
1293 body, err := json.MarshalIndent(c, "", " ")
1294 if err != nil {
1295 return err
1296 }
1297 if _, err := s.Put(ctx, AddressClaimKey(c.Address, c.Account), append(body, '\n'), ""); err != nil {
1298 if !errors.Is(err, release.ErrCASConflict) {
1299 return err
1300 }
1301 if _, err := getOwnAddressClaim(ctx, s, c.Address, c.Account, c.App, c.Sender); err != nil {
1302 return err
1303 }
1304 }
1305 return nil
1306}
1307
1308// ReleaseAddressClaim removes a claim, but only when this sender holds it,
1309// the owner gate releaseDomainClaim applies (domainclaim.go:158). Absent is a
1310// quiet no-op, so a retried teardown is not an error.
1311func ReleaseAddressClaim(ctx context.Context, s release.Store, address, acct, app, sender string) error {
1312 cur, err := GetAddressClaim(ctx, s, address, acct)
1313 if err != nil || cur == nil {
1314 return err
1315 }
1316 if cur.App != app || cur.Sender != sender {
1317 return nil
1318 }
1319 return s.Delete(ctx, AddressClaimKey(address, acct))
1320}
1321
1322// ListAddressClaims is the operator's roster for one address: every account
1323// that may send as it, sorted by account. An unparseable object is logged and
1324// skipped, the discipline ListDomainClaims applies.
1325func ListAddressClaims(ctx context.Context, s release.Store, address string) ([]*AddressClaim, error) {
1326 if !ValidAddress(address) {
1327 return nil, fmt.Errorf("mail: invalid address %q", address)
1328 }
1329 keys, err := s.List(ctx, addressClaimsPrefix+address+"/")
1330 if err != nil {
1331 return nil, fmt.Errorf("mail: list address claims for %s: %w", address, err)
1332 }
1333 out := make([]*AddressClaim, 0, len(keys))
1334 for _, key := range keys {
1335 raw, _, err := s.Get(ctx, key)
1336 if err != nil {
1337 log.Printf("mail: address claim %s -> %v", key, err)
1338 continue
1339 }
1340 var c AddressClaim
1341 if err := json.Unmarshal(raw, &c); err != nil {
1342 log.Printf("mail: address claim %s: unparseable -> %v", key, err)
1343 continue
1344 }
1345 if c.Schema > Schema || AddressClaimKey(c.Address, c.Account) != key {
1346 continue
1347 }
1348 out = append(out, &c)
1349 }
1350 sort.Slice(out, func(i, j int) bool { return out[i].Account < out[j].Account })
1351 return out, nil
1352}
1353```
1354
1355- [ ] **Step 4: Run it to verify it passes**
1356
1357```bash
1358go test -run 'TestAddressClaim|TestClaimAddress|TestValidAddress' ./internal/mail/
1359```
1360
1361Expected: PASS.
1362
1363- [ ] **Step 5: Gate and commit**
1364
1365```bash
1366go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1367git add internal/mail
1368git commit -m "mail: the non-exclusive address claim, with Open and the operator grant (plan 3, task 4)"
1369```
1370
1371---
1372
1373### Task 5: The address form of the send policy
1374
1375**Files:**
1376- Modify: `internal/mail/policy.go` (`SendPolicyJSON` at `:125`, its identity-ARN validation at `:142-179`), `internal/mail/policy_test.go`
1377
1378**Interfaces:**
1379- Consumes: `ValidAddress`, `AddressDomain`, `TenantName` (`policy.go:84`).
1380- Produces:
1381 ```go
1382 func SendAddressPolicyJSON(identityARN, configSetARN, address, tenant string) (string, error)
1383 func identityNameFromARN(arn string) (string, bool) // unexported, shared with SendPolicyJSON
1384 ```
1385
1386- [ ] **Step 1: Write the failing test**
1387
1388`internal/mail/policy_test.go` (append):
1389
1390```go
1391// The address form (design §7, §4.1). Three things about it are load-bearing
1392// and all three come from the spike:
1393//
1394// - StringEqualsIgnoreCase on ses:FromAddress, because SES's StringEquals
1395// is case-sensitive and a From address is not.
1396// - StringEquals on ses:TenantName, because the spike proved the key IS
1397// enforced over SMTP: a send that omits the tenant header is refused with
1398// a 554 rather than silently sent uncounted. That refusal is what makes
1399// the honour system around <P>_CONFIG_SET safe.
1400// - The configuration set as a SECOND resource, for issue #271's reason.
1401func TestSendAddressPolicyNamesTheAddressTheSetAndTheTenant(t *testing.T) {
1402 doc, err := SendAddressPolicyJSON(
1403 "arn:aws:ses:eu-west-1:1:identity/tito.io",
1404 "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam",
1405 "jelly@tito.io", "carlos-jam")
1406 if err != nil {
1407 t.Fatal(err)
1408 }
1409 var got struct {
1410 Statement []struct {
1411 Action []string
1412 Resource []string
1413 Condition map[string]map[string][]string
1414 }
1415 }
1416 if err := json.Unmarshal([]byte(doc), &got); err != nil {
1417 t.Fatalf("unparseable policy %s: %v", doc, err)
1418 }
1419 if len(got.Statement) != 1 || len(got.Statement[0].Action) != 1 || got.Statement[0].Action[0] != "ses:SendRawEmail" {
1420 t.Fatalf("statement = %+v, want one ses:SendRawEmail", got.Statement)
1421 }
1422 st := got.Statement[0]
1423 want := []string{
1424 "arn:aws:ses:eu-west-1:1:identity/tito.io",
1425 "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam",
1426 }
1427 if !reflect.DeepEqual(st.Resource, want) {
1428 t.Fatalf("Resource = %v, want %v", st.Resource, want)
1429 }
1430 if got := st.Condition["StringEqualsIgnoreCase"]["ses:FromAddress"]; !reflect.DeepEqual(got, []string{"jelly@tito.io"}) {
1431 t.Fatalf("FromAddress condition = %v", got)
1432 }
1433 if got := st.Condition["StringEquals"]["ses:TenantName"]; !reflect.DeepEqual(got, []string{"carlos-jam"}) {
1434 t.Fatalf("TenantName condition = %v", got)
1435 }
1436 // A wildcard From would be the whole identity, which is exactly what an
1437 // address sender must NOT have.
1438 if strings.Contains(doc, "*@") || strings.Contains(doc, "StringLike") {
1439 t.Fatalf("the address form widened to a domain pattern: %s", doc)
1440 }
1441}
1442
1443func TestSendAddressPolicyRefusesWhatItCannotScope(t *testing.T) {
1444 const set = "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam"
1445 for _, c := range []struct {
1446 what string
1447 identity, configSet, addr, tenant string
1448 }{
1449 {"an identity that is not the address's domain or a parent", "arn:aws:ses:eu-west-1:1:identity/other.io", set, "jelly@tito.io", "carlos-jam"},
1450 {"a child of the address's domain", "arn:aws:ses:eu-west-1:1:identity/mail.tito.io", set, "jelly@tito.io", "carlos-jam"},
1451 {"a wildcard identity", "arn:aws:ses:eu-west-1:1:identity/*", set, "jelly@tito.io", "carlos-jam"},
1452 {"a malformed identity ARN", "tito.io", set, "jelly@tito.io", "carlos-jam"},
1453 {"a wildcard configuration set", "arn:aws:ses:eu-west-1:1:identity/tito.io", "arn:aws:ses:eu-west-1:1:configuration-set/*", "jelly@tito.io", "carlos-jam"},
1454 {"an invalid address", "arn:aws:ses:eu-west-1:1:identity/tito.io", set, "jelly@", "carlos-jam"},
1455 {"no tenant, which is the enforcement", "arn:aws:ses:eu-west-1:1:identity/tito.io", set, "jelly@tito.io", ""},
1456 } {
1457 if _, err := SendAddressPolicyJSON(c.identity, c.configSet, c.addr, c.tenant); err == nil {
1458 t.Errorf("SendAddressPolicyJSON accepted %s", c.what)
1459 }
1460 }
1461 // An EMPTY configuration set is the ordinary shape on a deployment with
1462 // no SNS topic, exactly as it is for the domain form, and must render.
1463 if _, err := SendAddressPolicyJSON("arn:aws:ses:eu-west-1:1:identity/tito.io", "", "jelly@tito.io", "carlos-jam"); err != nil {
1464 t.Errorf("an SNS-less deployment cannot render an address policy: %v", err)
1465 }
1466 // A subdomain address under the registered parent is the case
1467 // mail.tito.io exists for, and it renders.
1468 if _, err := SendAddressPolicyJSON("arn:aws:ses:eu-west-1:1:identity/tito.io", "", "jelly@mail.tito.io", "carlos-jam"); err != nil {
1469 t.Errorf("an address under a subdomain of the identity was refused: %v", err)
1470 }
1471}
1472
1473// THE DOMAIN FORM DOES NOT MOVE. Nothing delivers <P>_TENANT to a domain
1474// sender, so a tenant condition on that document would be a 554 on every send
1475// for every app in the fleet. The two conditions are named here so a later
1476// edit that "unifies" the renderers has to fail this test first.
1477func TestSendPolicyKeepsItsDomainFormWithNoTenantCondition(t *testing.T) {
1478 doc, err := SendPolicyJSON(
1479 []string{"arn:aws:ses:eu-west-1:1:identity/x.com"},
1480 []string{"arn:aws:ses:eu-west-1:1:configuration-set/carlos-bag"},
1481 []string{"x.com"})
1482 if err != nil {
1483 t.Fatal(err)
1484 }
1485 var got struct {
1486 Statement []struct {
1487 Condition map[string]map[string][]string
1488 }
1489 }
1490 if err := json.Unmarshal([]byte(doc), &got); err != nil {
1491 t.Fatal(err)
1492 }
1493 cond := got.Statement[0].Condition
1494 if !reflect.DeepEqual(cond["StringLike"]["ses:FromAddress"], []string{"*@x.com"}) {
1495 t.Fatalf("the domain form's From condition moved: %v", cond)
1496 }
1497 if _, ok := cond["StringEquals"]; ok {
1498 t.Fatalf("the domain form grew a tenant condition, which would 554 every send in the fleet: %v", cond)
1499 }
1500}
1501```
1502
1503Add `"encoding/json"`, `"reflect"` and `"strings"` to the test file's imports as needed.
1504
1505- [ ] **Step 2: Run it to verify it fails**
1506
1507```bash
1508go test -run 'TestSendAddressPolicy|TestSendPolicyKeepsItsDomainForm' ./internal/mail/
1509```
1510
1511Expected: build failure, `undefined: SendAddressPolicyJSON`.
1512
1513- [ ] **Step 3: Write the code**
1514
1515`internal/mail/policy.go`, extract the identity-ARN parse `SendPolicyJSON` does inline (`:142-165`) so both renderers ask the same question of the same string, then add the address form:
1516
1517```go
1518// identityNameFromARN validates an SES identity ARN and returns the identity
1519// it names. Wildcards are refused BEFORE parsing, anywhere in the string: IAM
1520// expands * and ? as match patterns in every segment, not only in the
1521// identity name, so a wildcard in the region would widen the grant just as
1522// effectively.
1523func identityNameFromARN(arn string) (string, bool) {
1524 if strings.Contains(arn, "*") || strings.Contains(arn, "?") {
1525 return "", false
1526 }
1527 parts := strings.Split(arn, ":")
1528 if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" {
1529 return "", false
1530 }
1531 if parts[3] == "" || parts[4] == "" {
1532 return "", false
1533 }
1534 resource := strings.SplitN(parts[5], "/", 2)
1535 if len(resource) != 2 || resource[0] != "identity" {
1536 return "", false
1537 }
1538 // No `resource[1] == ""` clause, deliberately. The extraction has to
1539 // answer the same question SendPolicyJSON asks today (policy.go:161-164),
1540 // which accepts an empty identity name here and refuses it four lines
1541 // later with "does not match any granted domain" — and this task
1542 // promises the domain form's messages byte for byte. Nothing is let
1543 // through either way: an empty name matches no granted domain in the
1544 // domain form, and in the address form it is neither the address's
1545 // domain nor a parent of it, since HasSuffix(domain, ".") is false for
1546 // every valid domain.
1547 return resource[1], true
1548}
1549
1550// SendAddressPolicyJSON renders the policy for ONE address under an identity
1551// CARLOS does not own (design §7).
1552//
1553// It is a second renderer rather than a mode of SendPolicyJSON, because the
1554// two documents differ in every part that matters and a shared one would have
1555// to be read twice to answer either question. The differences, each of which
1556// the spike (§4.1) settled:
1557//
1558// - StringEqualsIgnoreCase, not StringLike. The grant is one exact address,
1559// and SES's StringEquals is case-sensitive while a From address is not.
1560// - A ses:TenantName condition, which the domain form does not carry. The
1561// spike proved SES DOES populate that key for SMTP sends: tagged sends
1562// were accepted and untagged ones refused with a 554. That makes one
1563// customer's credential useless for another customer's mail, and it makes
1564// a message that omits the tenant header fail loudly rather than send
1565// silently uncounted.
1566// - The identity may be the address's own domain or ANY parent of it, which
1567// is how jelly@mail.tito.io sends under a registration on tito.io.
1568//
1569// The configuration set is the same second resource it is in the domain form
1570// (#271), and an empty one is the ordinary shape on a deployment with no SNS
1571// topic rather than an error.
1572func SendAddressPolicyJSON(identityARN, configSetARN, address, tenant string) (string, error) {
1573 if !ValidAddress(address) {
1574 return "", fmt.Errorf("mail: invalid address %q in send policy", address)
1575 }
1576 if tenant == "" {
1577 return "", fmt.Errorf("mail: refusing to render an address send policy with no tenant: the tenant condition is the isolation")
1578 }
1579 name, ok := identityNameFromARN(identityARN)
1580 if !ok {
1581 return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", identityARN)
1582 }
1583 // The identity must COVER the address: equal to its domain, or a parent
1584 // of it. A child would be an identity that cannot sign for this address
1585 // at all, and an unrelated one would be a grant on somebody else's mail.
1586 domain := AddressDomain(address)
1587 if name != domain && !strings.HasSuffix(domain, "."+name) {
1588 return "", fmt.Errorf("mail: identity %q in ARN %q does not cover the address %q", name, identityARN, address)
1589 }
1590 resources := []string{identityARN}
1591 if configSetARN != "" {
1592 if _, ok := configSetNameFromARN(configSetARN); !ok {
1593 return "", fmt.Errorf("mail: invalid configuration-set ARN %q in send policy", configSetARN)
1594 }
1595 resources = append(resources, configSetARN)
1596 }
1597 doc := map[string]any{
1598 "Version": "2012-10-17",
1599 "Statement": []map[string]any{{
1600 "Effect": "Allow",
1601 "Action": []string{"ses:SendRawEmail"},
1602 "Resource": resources,
1603 "Condition": map[string]any{
1604 "StringEqualsIgnoreCase": map[string]any{"ses:FromAddress": []string{address}},
1605 "StringEquals": map[string]any{"ses:TenantName": []string{tenant}},
1606 },
1607 }},
1608 }
1609 b, err := json.Marshal(doc)
1610 if err != nil {
1611 return "", err
1612 }
1613 return string(b), nil
1614}
1615```
1616
1617Extract the configuration-set ARN check `SendPolicyJSON` performs inline (`:185-200`) the same way, as `configSetNameFromARN(arn string) (string, bool)`, and have both renderers call it. `SendPolicyJSON`'s own loops call the two helpers and keep their existing error messages verbatim, so the domain form renders the same bytes it renders today.
1618
1619- [ ] **Step 4: Run it to verify it passes**
1620
1621```bash
1622go test ./internal/mail/ -count=1
1623```
1624
1625Expected: PASS, including every existing policy test unchanged.
1626
1627- [ ] **Step 5: Gate and commit**
1628
1629```bash
1630go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1631git add internal/mail
1632git commit -m "mail: the address send policy, scoped by address and tenant (plan 3, task 5)"
1633```
1634
1635---
1636
1637### Task 6: `EnsureAddressUser` on the provisioner seam
1638
1639**Files:**
1640- Modify: `internal/mail/provisioner.go` (the interface at `:49`, after `EnsureUser` at `:77`), `internal/mail/aws.go` (`EnsureUser` at `:348`), `internal/mail/memprovisioner.go` (`EnsureUser` at `:201`), `internal/mail/memprovisioner_test.go`
1641
1642**Interfaces:**
1643- Consumes: `SendAddressPolicyJSON`, `PolicyName` (`policy.go:20`), `UserPath` (`:24`).
1644- Produces:
1645 ```go
1646 // Provisioner
1647 EnsureAddressUser(ctx context.Context, name, path, identityARN, configSetARN, address, tenant string) (arn string, err error)
1648 ```
1649 The guard (task 2) forwards it by embedding, so no change there.
1650
1651- [ ] **Step 1: Write the failing test**
1652
1653`internal/mail/memprovisioner_test.go` (append):
1654
1655```go
1656// An address sender's principal carries the ADDRESS form of the policy, and
1657// the fake writes it the same way the real backend does, so a walk test can
1658// assert what a credential may actually send as.
1659func TestMemEnsureAddressUserWritesTheAddressPolicy(t *testing.T) {
1660 ctx, p := context.Background(), NewMemProvisioner("1")
1661 arn, err := p.EnsureAddressUser(ctx, "carlos-mail-jam-jelly", UserPath,
1662 "arn:aws:ses:eu-west-1:1:identity/tito.io",
1663 "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam",
1664 "jelly@tito.io", "carlos-jam")
1665 if err != nil {
1666 t.Fatal(err)
1667 }
1668 if arn == "" {
1669 t.Fatal("EnsureAddressUser returned no ARN")
1670 }
1671 doc := p.Users["carlos-mail-jam-jelly"].Policy
1672 for _, want := range []string{"jelly@tito.io", "carlos-jam", "StringEqualsIgnoreCase", "ses:TenantName"} {
1673 if !strings.Contains(doc, want) {
1674 t.Errorf("policy is missing %q: %s", want, doc)
1675 }
1676 }
1677 if strings.Contains(doc, "*@") {
1678 t.Errorf("an address principal was granted a whole domain: %s", doc)
1679 }
1680 // The refusal is modelled because the real backend refuses too: a
1681 // principal with an unrenderable policy must not exist at all.
1682 if _, err := p.EnsureAddressUser(ctx, "carlos-mail-jam-broken", UserPath,
1683 "arn:aws:ses:eu-west-1:1:identity/other.io", "", "jelly@tito.io", "carlos-jam"); err == nil {
1684 t.Error("EnsureAddressUser created a principal for an identity that does not cover the address")
1685 }
1686 if _, ok := p.Users["carlos-mail-jam-broken"]; ok {
1687 t.Error("a refused EnsureAddressUser left a principal with no policy behind")
1688 }
1689}
1690```
1691
1692Add `"strings"` to the test file's imports.
1693
1694- [ ] **Step 2: Run it to verify it fails**
1695
1696```bash
1697go test -run TestMemEnsureAddressUser ./internal/mail/
1698```
1699
1700Expected: build failure, `p.EnsureAddressUser undefined`.
1701
1702- [ ] **Step 3: Write the code**
1703
1704`internal/mail/provisioner.go`, in the interface after `EnsureUser` (`:77`):
1705
1706```go
1707 // EnsureAddressUser is EnsureUser for a sender that owns ONE address
1708 // under an identity CARLOS does not own (design §7). It exists as its own
1709 // verb rather than as a flag on EnsureUser because the document is a
1710 // different document: one exact address matched case-insensitively, plus
1711 // the tenant condition the spike proved SES enforces over SMTP (§4.1).
1712 // Folding the two into one signature would mean a caller could pass a
1713 // domain list and an address and get whichever the implementation
1714 // preferred. Idempotent, like every verb here: the policy is rewritten
1715 // wholesale on every call.
1716 EnsureAddressUser(ctx context.Context, name, path, identityARN, configSetARN, address, tenant string) (arn string, err error)
1717```
1718
1719`internal/mail/aws.go`: `EnsureUser` (`:348`) keeps its body but hands the rendered document to a shared tail, and the new verb renders the other document into the same tail:
1720
1721```go
1722func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) {
1723 // Render the policy FIRST. SendPolicyJSON refuses an empty grant, and
1724 // refusing before CreateUser is what stops a principal existing with no
1725 // policy at all — an unscoped sending user, briefly, is still one.
1726 doc, err := SendPolicyJSON(identityARNs, configSetARNs, domains)
1727 if err != nil {
1728 return "", err
1729 }
1730 return p.ensurePrincipal(ctx, name, path, doc)
1731}
1732
1733// EnsureAddressUser is EnsureUser with the address document. The render-first
1734// discipline is the same and for the same reason.
1735func (p *awsProvisioner) EnsureAddressUser(ctx context.Context, name, path, identityARN, configSetARN, address, tenant string) (string, error) {
1736 doc, err := SendAddressPolicyJSON(identityARN, configSetARN, address, tenant)
1737 if err != nil {
1738 return "", err
1739 }
1740 return p.ensurePrincipal(ctx, name, path, doc)
1741}
1742
1743// ensurePrincipal is the IAM half both share: create if absent, then write
1744// the inline policy wholesale.
1745func (p *awsProvisioner) ensurePrincipal(ctx context.Context, name, path, doc string) (string, error) {
1746 var arn string
1747 out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{
1748 UserName: aws.String(name), Path: aws.String(path),
1749 })
1750 if err != nil {
1751 if !isAlreadyExists(err) {
1752 return "", fmt.Errorf("mail: create user %q: %w", name, err)
1753 }
1754 got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)})
1755 if gerr != nil {
1756 return "", fmt.Errorf("mail: get existing user %q: %w", name, gerr)
1757 }
1758 arn = aws.ToString(got.User.Arn)
1759 } else {
1760 arn = aws.ToString(out.User.Arn)
1761 }
1762 if _, err := p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{
1763 UserName: aws.String(name),
1764 PolicyName: aws.String(PolicyName),
1765 PolicyDocument: aws.String(doc),
1766 }); err != nil {
1767 return "", fmt.Errorf("mail: put policy for user %q: %w", name, err)
1768 }
1769 return arn, nil
1770}
1771```
1772
1773`internal/mail/memprovisioner.go`, beside `EnsureUser` (`:201`):
1774
1775```go
1776func (p *MemProvisioner) EnsureAddressUser(_ context.Context, name, path, identityARN, configSetARN, address, tenant string) (string, error) {
1777 p.mu.Lock()
1778 defer p.mu.Unlock()
1779 if err := p.takeFailure("EnsureAddressUser"); err != nil {
1780 return "", err
1781 }
1782 doc, err := SendAddressPolicyJSON(identityARN, configSetARN, address, tenant)
1783 if err != nil {
1784 return "", err
1785 }
1786 u, ok := p.Users[name]
1787 if !ok {
1788 u = &MemUser{ARN: fmt.Sprintf("arn:aws:iam::%s:user%s%s", p.account, path, name), Keys: map[string]MemKey{}}
1789 p.Users[name] = u
1790 }
1791 u.Policy = doc
1792 return u.ARN, nil
1793}
1794```
1795
1796- [ ] **Step 4: Run it to verify it passes**
1797
1798```bash
1799go test -run TestMemEnsureAddressUser ./internal/mail/ && go build ./...
1800```
1801
1802Expected: PASS and a clean build.
1803
1804- [ ] **Step 5: Gate and commit**
1805
1806```bash
1807go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1808git add internal/mail
1809git commit -m "mail: EnsureAddressUser, the principal that may send as one address (plan 3, task 6)"
1810```
1811
1812---
1813
1814### Task 7: The relaxed verification predicate, and the refusal for a foreign default configuration set
1815
1816**Files:**
1817- Modify: `internal/mail/provisioner.go` (`Status.Verified` at `:45`), `internal/mail/external.go`, `internal/mail/external_test.go`
1818
1819**Interfaces:**
1820- Consumes: `IdentityStatus` (`provisioner.go:71`), `Status.ConfigSet` (`:41`), `ConfigSetName` (`walk.go:375`).
1821- Produces:
1822 ```go
1823 func (s Status) VerifiedExternal() bool
1824 func CheckExternalIdentity(ctx context.Context, p Provisioner, ext *ExternalIdentity, configSet string) error
1825 var ErrExternalNotVerified = errors.New("mail: the external identity is not verified for sending")
1826 var ErrForeignConfigSet = errors.New("mail: the external identity's default configuration set belongs to its owner")
1827 ```
1828
1829- [ ] **Step 1: Write the failing test**
1830
1831`internal/mail/external_test.go` (append):
1832
1833```go
1834// Status.Verified demands MailFrom == SUCCESS (provisioner.go:45), which an
1835// identity with no CUSTOM MAIL FROM never reaches — and CARLOS must never
1836// give a borrowed identity one. tito.io would therefore read as permanently
1837// unverified under the ordinary predicate, which is why an external identity
1838// gets its own: DKIM and sending, MAIL FROM ignored.
1839func TestVerifiedExternalIgnoresMailFrom(t *testing.T) {
1840 ok := Status{DKIM: "SUCCESS", MailFrom: "NOT_STARTED", Sending: "SUCCESS"}
1841 if ok.Verified() {
1842 t.Fatal("the ordinary predicate accepted an identity with no custom MAIL FROM")
1843 }
1844 if !ok.VerifiedExternal() {
1845 t.Fatal("the external predicate refused a DKIM-verified, sending-enabled identity")
1846 }
1847 for _, bad := range []Status{
1848 {DKIM: "PENDING", MailFrom: "NOT_STARTED", Sending: "SUCCESS"},
1849 {DKIM: "FAILED", MailFrom: "NOT_STARTED", Sending: "SUCCESS"},
1850 {DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "PENDING"},
1851 {},
1852 } {
1853 if bad.VerifiedExternal() {
1854 t.Errorf("VerifiedExternal accepted %+v", bad)
1855 }
1856 }
1857}
1858
1859// THE SPIKE'S SECOND ANSWER, made into a refusal (§4.1). There is no
1860// tenant-level default configuration set, so a tenant-tagged send falls back
1861// to the IDENTITY's default — and if that default is the owner's set rather
1862// than CARLOS's, the send is refused outright with a 554 naming a
1863// configuration set the sender has never heard of. Enable refuses first, with
1864// the explanation, rather than delivering a credential that cannot send.
1865func TestCheckExternalIdentityRefusesTheOwnersDefaultConfigurationSet(t *testing.T) {
1866 ctx := context.Background()
1867 s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
1868 ext := registerTitoIO(t, ctx, s, true)
1869 if _, err := p.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil {
1870 t.Fatal(err)
1871 }
1872 p.SetIdentityStatus("eu-west-1", "tito.io", Status{DKIM: "SUCCESS", MailFrom: "NOT_STARTED", Sending: "SUCCESS"})
1873
1874 // No default set at all: nothing to collide with, and the message's own
1875 // X-SES-CONFIGURATION-SET decides.
1876 if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); err != nil {
1877 t.Fatalf("an identity with no default set was refused: %v", err)
1878 }
1879 // The owner's set, which is the shape the spike ran against.
1880 if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", "classic-events"); err != nil {
1881 t.Fatal(err)
1882 }
1883 if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); !errors.Is(err, ErrForeignConfigSet) {
1884 t.Fatalf("CheckExternalIdentity = %v, want ErrForeignConfigSet", err)
1885 }
1886 // CARLOS's own set on the identity is the one case that is fine, because
1887 // that IS the set associated with this account's tenant.
1888 if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", "carlos-jam"); err != nil {
1889 t.Fatal(err)
1890 }
1891 if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); err != nil {
1892 t.Fatalf("CARLOS's own set was treated as foreign: %v", err)
1893 }
1894 // An identity SES has not verified is refused before any of that.
1895 p.SetIdentityStatus("eu-west-1", "tito.io", Status{DKIM: "PENDING", MailFrom: "NOT_STARTED", Sending: "PENDING"})
1896 if err := CheckExternalIdentity(ctx, p, ext, "carlos-jam"); !errors.Is(err, ErrExternalNotVerified) {
1897 t.Fatalf("CheckExternalIdentity on an unverified identity = %v, want ErrExternalNotVerified", err)
1898 }
1899}
1900```
1901
1902- [ ] **Step 2: Run it to verify it fails**
1903
1904```bash
1905go test -run 'TestVerifiedExternal|TestCheckExternalIdentity' ./internal/mail/
1906```
1907
1908Expected: build failure, `ok.VerifiedExternal undefined`.
1909
1910- [ ] **Step 3: Write the code**
1911
1912`internal/mail/provisioner.go`, beside `Verified` (`:44-47`):
1913
1914```go
1915// VerifiedExternal is Verified for an identity CARLOS does not own.
1916//
1917// MAIL FROM is deliberately ignored. Verified demands MailFrom == "SUCCESS"
1918// because CARLOS sets a custom MAIL FROM on every identity it creates, and an
1919// unaligned envelope domain fails DMARC. A borrowed identity gets no custom
1920// MAIL FROM, because setting one is a change to somebody else's mail and the
1921// guard refuses it — so it never reaches SUCCESS and would read as
1922// permanently unverified under the ordinary predicate. DKIM and sending are
1923// still both required: they are what SES actually needs to accept a message.
1924func (s Status) VerifiedExternal() bool {
1925 return s.DKIM == "SUCCESS" && s.Sending == "SUCCESS"
1926}
1927```
1928
1929`internal/mail/external.go` (append):
1930
1931```go
1932var (
1933 // ErrExternalNotVerified is a registered identity SES has not finished
1934 // verifying, or has stopped accepting sends for. It is the owner's to
1935 // fix, so the message says which axis and stops there.
1936 ErrExternalNotVerified = errors.New("mail: the external identity is not verified for sending")
1937 // ErrForeignConfigSet is the spike's second answer made into a refusal
1938 // (§4.1). SES has no tenant-level default configuration set, so a
1939 // tenant-tagged send with no X-SES-CONFIGURATION-SET header falls back to
1940 // the IDENTITY's default — and when that default belongs to the
1941 // identity's owner, the send is refused with a 554 naming a set the
1942 // sender has no authority over.
1943 ErrForeignConfigSet = errors.New("mail: the external identity's default configuration set belongs to its owner")
1944)
1945
1946// CheckExternalIdentity is what Enable asks before it mints anything:
1947// is this identity usable, and can a tenant-tagged send through it actually
1948// reach CARLOS's configuration set.
1949//
1950// configSet is the account's own set, ConfigSetName(acct). The comparison is
1951// against that NAME rather than against a listing of the tenant's resources,
1952// and the reason is exact: CARLOS associates exactly one configuration set
1953// with an account's tenant, its own, so "is the identity's default set
1954// associated with this tenant" and "is it CARLOS's set for this account" are
1955// the same question. A set CARLOS did not create was never associated with
1956// this tenant by anything.
1957func CheckExternalIdentity(ctx context.Context, p Provisioner, ext *ExternalIdentity, configSet string) error {
1958 st, err := p.IdentityStatus(ctx, ext.Region, ext.Domain)
1959 if err != nil {
1960 return fmt.Errorf("mail: identity status for %s in %s: %w", ext.Domain, ext.Region, err)
1961 }
1962 if !st.VerifiedExternal() {
1963 return fmt.Errorf("mail: %s at SES: DKIM %s, sending %s: %w",
1964 ext.Domain, st.DKIM, st.Sending, ErrExternalNotVerified)
1965 }
1966 if st.ConfigSet != "" && st.ConfigSet != configSet {
1967 return fmt.Errorf(
1968 "mail: %s has %s as its default configuration set, which belongs to its owner and is not associated with this account's tenant, so every tenant-tagged send that does not name %s itself would be refused: %w",
1969 ext.Domain, st.ConfigSet, configSet, ErrForeignConfigSet)
1970 }
1971 return nil
1972}
1973```
1974
1975- [ ] **Step 4: Run it to verify it passes**
1976
1977```bash
1978go test -run 'TestVerifiedExternal|TestCheckExternalIdentity' ./internal/mail/
1979```
1980
1981Expected: PASS.
1982
1983- [ ] **Step 5: Gate and commit**
1984
1985```bash
1986go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
1987git add internal/mail
1988git commit -m "mail: the external verification predicate and the foreign configuration-set refusal (plan 3, task 7)"
1989```
1990
1991---
1992
1993### Task 8: `ClaimAddressSender`, where claiming and declaring an address are one call
1994
1995**Files:**
1996- Modify: `internal/mail/walk.go` (beside `Declare` at `:487`, and inside it at `:521`)
1997- Create: `internal/mail/walk_address_test.go`
1998
1999**Interfaces:**
2000- Consumes: `ExternalIdentityFor`, `AddressDomain`, `ValidAddress`, `ClaimAddress`, `ErrAddressNotOpen`, `ErrAddressClaimed`, `ValidEnvPrefix` (`mail.go:118`), `DefaultEnvPrefix`, `appconfig.ValidVarValue`, and plan 2's `GetSender`, `PutSender`, `Sender`, `SourceAddress`, `SourceDomain`, `ScopeApp`, `SenderAppID`, `ValidSenderID`.
2001- Produces:
2002 ```go
2003 func ClaimAddressSender(ctx context.Context, s release.Store, acct, app, sender, address, envPrefix, actor string, now time.Time) (*Sender, error)
2004 var ErrNoExternalIdentity = errors.New("mail: no external identity is registered for that address's domain")
2005 ```
2006
2007- [ ] **Step 1: Write the failing test**
2008
2009`internal/mail/walk_address_test.go` (create):
2010
2011```go
2012package mail
2013
2014import (
2015 "context"
2016 "errors"
2017 "testing"
2018 "time"
2019
2020 "github.com/carlosframework/platform/internal/release"
2021)
2022
2023// CLAIMING AND DECLARING ARE ONE CALL (design §7): "it is Declare for an
2024// address sender, so declaring a From address and claiming it are one call
2025// and cannot disagree." Two calls could: a claim with no declaration is an
2026// address nothing sends as, and a declaration with no claim is a From address
2027// no roster knows about.
2028func TestClaimAddressSenderDeclaresTheAddressAndTakesTheClaim(t *testing.T) {
2029 ctx := context.Background()
2030 s := release.NewDirStore(t.TempDir())
2031 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2032 ext := registerTitoIO(t, ctx, s, true)
2033
2034 sn, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2035 "Jelly@Tito.io", "JELLY_SMTP", "owner@example.test", now)
2036 if err != nil {
2037 t.Fatalf("ClaimAddressSender: %v", err)
2038 }
2039 // Lowercased on the way in: the claim key is a function of the address,
2040 // and two cases of one address must not be two claims.
2041 if sn.Source != SourceAddress || sn.Address != "jelly@tito.io" || sn.Identity != ext.IdentityARN {
2042 t.Fatalf("the sender did not become an address sender: %+v", sn)
2043 }
2044 if sn.Declared == nil || sn.Declared.From != "jelly@tito.io" || sn.Declared.EnvPrefix != "JELLY_SMTP" {
2045 t.Fatalf("the declaration disagrees with the claim: %+v", sn.Declared)
2046 }
2047 claim, err := GetAddressClaim(ctx, s, "jelly@tito.io", "jam")
2048 if err != nil || claim == nil || claim.App != "jelly" || claim.Sender != SenderAppID {
2049 t.Fatalf("the claim = %+v %v", claim, err)
2050 }
2051 // Idempotent, so a re-run of the door repairs rather than refuses.
2052 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2053 "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now); err != nil {
2054 t.Fatalf("a second identical claim: %v", err)
2055 }
2056}
2057
2058// An address under no registered identity is refused, and refused with its
2059// own error: "nobody registered tito.io here" and "tito.io is registered but
2060// closed to you" are different problems with different recourses, and a
2061// caller that could not tell them apart would send the member to the wrong
2062// person.
2063func TestClaimAddressSenderRefusesAnAddressUnderNoRegisteredIdentity(t *testing.T) {
2064 ctx := context.Background()
2065 s := release.NewDirStore(t.TempDir())
2066 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2067 registerTitoIO(t, ctx, s, true)
2068
2069 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2070 "jelly@example.com", "", "owner@example.test", now); !errors.Is(err, ErrNoExternalIdentity) {
2071 t.Fatalf("an unregistered domain = %v, want ErrNoExternalIdentity", err)
2072 }
2073 // Not an address at all, and a traversal dressed as one, are both refused
2074 // before any lookup: the string becomes a claim key and a policy value.
2075 for _, bad := range []string{"", "tito.io", "jelly@", "jelly@tito.io/../x"} {
2076 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2077 bad, "", "owner@example.test", now); err == nil {
2078 t.Errorf("ClaimAddressSender accepted %q", bad)
2079 }
2080 }
2081}
2082
2083// THE CLAIM IS TAKEN BEFORE THE RECORD IS WRITTEN, the order EnsureDomain
2084// takes for the domain claim and for the same reason: a sender record naming
2085// an address this account may not hold is a record that can never be enabled,
2086// and nothing would ever clear it.
2087func TestClaimAddressSenderWritesNoRecordWhenTheClaimIsRefused(t *testing.T) {
2088 ctx := context.Background()
2089 s := release.NewDirStore(t.TempDir())
2090 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2091 registerTitoIO(t, ctx, s, false) // closed to self-serve
2092
2093 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2094 "billing@tito.io", "", "owner@example.test", now); !errors.Is(err, ErrAddressNotOpen) {
2095 t.Fatalf("a self-serve claim on a closed identity = %v, want ErrAddressNotOpen", err)
2096 }
2097 if sn, _, err := GetSender(ctx, s, "jam", "jelly", SenderAppID); err != nil || sn != nil {
2098 t.Fatalf("a refused claim still wrote a sender: %+v %v", sn, err)
2099 }
2100 // The operator's grant is the recourse, and the member's own call then
2101 // succeeds against it — one record, not two code paths.
2102 if err := GrantAddress(ctx, s, "billing@tito.io", "jam", "jelly", SenderAppID, "operator@example.test", now); err != nil {
2103 t.Fatal(err)
2104 }
2105 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2106 "billing@tito.io", "", "owner@example.test", now); err != nil {
2107 t.Fatalf("a claim against an operator grant: %v", err)
2108 }
2109 // A sibling app in the same account is refused as a conflict.
2110 if _, err := ClaimAddressSender(ctx, s, "jam", "other", SenderAppID,
2111 "billing@tito.io", "", "owner@example.test", now); !errors.Is(err, ErrAddressClaimed) {
2112 t.Fatalf("a sibling app = %v, want ErrAddressClaimed", err)
2113 }
2114}
2115
2116// The env prefix is locked once delivered, exactly as Declare locks it
2117// (walk.go:516): moving it would leave the old variables standing in every
2118// instance's config and the app would keep reading the stale pair.
2119func TestClaimAddressSenderKeepsDeclaresEnvPrefixLock(t *testing.T) {
2120 ctx := context.Background()
2121 s := release.NewDirStore(t.TempDir())
2122 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2123 registerTitoIO(t, ctx, s, true)
2124
2125 sn, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2126 "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now)
2127 if err != nil {
2128 t.Fatal(err)
2129 }
2130 sn.Provisioned = &Provisioned{IAMUser: "carlos-mail-jam-jelly", KeyID: "AKIA", Active: true, At: now}
2131 if _, err := PutSender(ctx, s, "jam", "jelly", sn, ""); err != nil {
2132 // The etag is whatever GetSender would hand back; a test that cannot
2133 // write the fixture is a test failure, not a lock assertion.
2134 got, etag, gerr := GetSender(ctx, s, "jam", "jelly", SenderAppID)
2135 if gerr != nil {
2136 t.Fatal(gerr)
2137 }
2138 got.Provisioned = sn.Provisioned
2139 if _, err := PutSender(ctx, s, "jam", "jelly", got, etag); err != nil {
2140 t.Fatal(err)
2141 }
2142 }
2143 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2144 "jelly@tito.io", "OTHER_SMTP", "owner@example.test", now); err == nil {
2145 t.Fatal("the env prefix moved after delivery")
2146 }
2147}
2148
2149// A SENDER DOES NOT MOVE TO A DIFFERENT ADDRESS, and this is the test that
2150// pins why. Overwriting Address would leave the old one claimed by this
2151// account with nothing naming it: teardown releases only the address the
2152// record currently carries, so the orphan would refuse every sibling app in
2153// the account for ever and hold the identity's tenant association open by
2154// itself. `carlos email enable --from other@tito.io` against a sender
2155// already sending as jelly@tito.io is the exact command that would do it.
2156func TestClaimAddressSenderRefusesASecondAddressOnTheSameSender(t *testing.T) {
2157 ctx := context.Background()
2158 s := release.NewDirStore(t.TempDir())
2159 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2160 registerTitoIO(t, ctx, s, true)
2161
2162 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2163 "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now); err != nil {
2164 t.Fatal(err)
2165 }
2166 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2167 "other@tito.io", "JELLY_SMTP", "owner@example.test", now); err == nil {
2168 t.Fatal("a second address was adopted in place, stranding the first claim")
2169 }
2170 // Both halves of the leak: the first claim still stands and is still
2171 // this sender's, and the second was never taken.
2172 if c, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); c == nil || c.App != "jelly" {
2173 t.Fatalf("the refusal disturbed the live claim: %+v", c)
2174 }
2175 if c, _ := GetAddressClaim(ctx, s, "other@tito.io", "jam"); c != nil {
2176 t.Fatalf("a refused claim was written anyway: %+v", c)
2177 }
2178 // The record is untouched, so the credential that is live keeps matching
2179 // the address its policy names.
2180 sn, _, err := GetSender(ctx, s, "jam", "jelly", SenderAppID)
2181 if err != nil || sn.Address != "jelly@tito.io" || sn.Declared.From != "jelly@tito.io" {
2182 t.Fatalf("the sender moved underneath its own policy: %+v %v", sn, err)
2183 }
2184}
2185```
2186
2187**This closes a design gap the spec is being amended to match.** §7 describes the claim door as "Declare for an address sender" and Declare freely rewrites its From; for an address sender that is a claim leak rather than an edit, so both doors refuse a change of address and point at teardown. Task 9's `ensureAddressPrincipal` re-reads the claim on every enable, which is what makes "the record and the roster agree" an invariant rather than a hope.
2188
2189**`Declare` is the same hole through the other door, and it is closed in this task too.** `Declare` overwrites `Declared.From` unconditionally (`walk.go:521`) and knows nothing about claims. On an address sender that splits the record in two: `_FROM` is delivered from `Declared.From` while the policy is rendered from `sn.Address`, so the app sends as an address its credential may not use and gets a 554 on every message — and `EnvDeliveredSender` keeps reading healthy, because it compares `_FROM` against `Declared.From`, which is exactly the value that moved. Add the refusal beside the env-prefix lock:
2190
2191```go
2192 // An address sender's From is its CLAIM, not a field. Changing it here
2193 // would leave the delivered _FROM and the rendered policy describing
2194 // different addresses, which is a 554 on every message with nothing in
2195 // the record disagreeing about it. The address door is the only way in,
2196 // and it refuses a move of its own (ClaimAddressSender).
2197 if sn := rec.sender(); sn.Source == SourceAddress && sn.Address != "" && from != sn.Address {
2198 return nil, fmt.Errorf(
2199 "mail: %s/%s sends as %s under an identity this deployment does not own, so its From address is not declared here: claim a different address instead, or tear the sender down",
2200 acct, app, sn.Address)
2201 }
2202```
2203
2204with a test beside the one above: declaring `other@tito.io` over a claimed `jelly@tito.io` is refused, and `Declared.From` still reads `jelly@tito.io` afterwards. A declare that repeats the SAME address is still success, so a re-run of any flow that declares before it claims is unaffected.
2205
2206- [ ] **Step 2: Run it to verify it fails**
2207
2208```bash
2209go test -run TestClaimAddressSender ./internal/mail/
2210```
2211
2212Expected: build failure, `undefined: ClaimAddressSender`.
2213
2214- [ ] **Step 3: Write the code**
2215
2216`internal/mail/walk.go`, after `Declare` (`:487-526`):
2217
2218```go
2219// ErrNoExternalIdentity is an address whose domain nobody registered. It is
2220// its own error because the recourse differs from every other refusal here:
2221// an operator has to register the identity, which is a conversation with the
2222// identity's owner, not something the member can retry into.
2223var ErrNoExternalIdentity = errors.New("mail: no external identity is registered for that address's domain")
2224
2225// ClaimAddressSender is Declare for a sender that sends as ONE address under
2226// an identity CARLOS does not own (design §7).
2227//
2228// Claiming and declaring are ONE call, deliberately. Two calls could
2229// disagree: a claim with no declaration is an address nothing sends as, and a
2230// declaration with no claim is a From address no roster knows about and no
2231// policy will ever be rendered for. So this door sets Source, Address,
2232// Identity and Declared together or sets none of them.
2233//
2234// THE CLAIM IS TAKEN FIRST, before the record is written, which is the order
2235// EnsureDomain takes for the domain claim (walk.go:171) and for the same
2236// reason: a sender record naming an address this account may not hold could
2237// never be enabled, and nothing in the system would ever clear it. The
2238// reverse order buys nothing — the record write is the cheap half.
2239//
2240// It does NOT reach a backend. Like Declare, this is the member's half:
2241// nothing is provisioned, nothing is associated, and no credential exists
2242// when it returns. Enable is where the address, the identity and the tenant
2243// meet, and where SES first hears about any of it.
2244func ClaimAddressSender(ctx context.Context, s release.Store, acct, app, sender,
2245 address, envPrefix, actor string, now time.Time) (*Sender, error) {
2246
2247 if !release.ValidAccount(acct) || !release.ValidApp(app) || !ValidSenderID(sender) {
2248 return nil, fmt.Errorf("mail: invalid account %q, app %q or sender %q", acct, app, sender)
2249 }
2250 // Lowercased before anything else looks at it: the claim key is a
2251 // function of the address, so two cases of one address must not become
2252 // two claims with two different holders.
2253 address = strings.ToLower(strings.TrimSpace(address))
2254 if !ValidAddress(address) {
2255 return nil, fmt.Errorf("mail: %q is not a sender address", address)
2256 }
2257 // Checked here for Declare's own reason (walk.go:503): the address is
2258 // delivered verbatim as <P>_FROM, and an unrenderable byte caught only
2259 // inside writeSMTPEnv would fail AFTER MintKey has run.
2260 if err := appconfig.ValidVarValue(address); err != nil {
2261 return nil, fmt.Errorf("mail: From %q is not EnvironmentFile-safe: %w", address, err)
2262 }
2263 if envPrefix == "" {
2264 envPrefix = DefaultEnvPrefix
2265 }
2266 if !ValidEnvPrefix(envPrefix) {
2267 return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix)
2268 }
2269 ext, err := ExternalIdentityFor(ctx, s, AddressDomain(address))
2270 if err != nil {
2271 return nil, err
2272 }
2273 if ext == nil {
2274 return nil, fmt.Errorf("mail: %s: %w", address, ErrNoExternalIdentity)
2275 }
2276 sn, etag, err := GetSender(ctx, s, acct, app, sender)
2277 if err != nil {
2278 return nil, err
2279 }
2280 if sn == nil {
2281 sn = &Sender{ID: sender, Scope: ScopeApp}
2282 }
2283 if sn.Declared != nil && sn.Provisioned != nil && sn.Declared.EnvPrefix != envPrefix {
2284 return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery", sn.Declared.EnvPrefix)
2285 }
2286 // A PROVISIONED DOMAIN SENDER DOES NOT CONVERT. Its live policy names
2287 // granted domains and its delivered bundle carries five variables; moving
2288 // Source underneath it would leave a credential whose policy and whose
2289 // delivered environment describe two different senders until the next
2290 // enable, and the failure in between is a 554 on every message. Tear the
2291 // sender down and claim the address instead.
2292 if sn.Source == SourceDomain && sn.Provisioned != nil {
2293 return nil, fmt.Errorf(
2294 "mail: %s/%s already sends at a verified domain, so it cannot be moved onto %s in place: tear it down first",
2295 acct, app, address)
2296 }
2297 // AND NEITHER DOES AN ADDRESS SENDER MOVE TO A DIFFERENT ADDRESS. This
2298 // is the same rule as the line above, and it is here because the
2299 // alternative leaks a claim for ever: overwriting Address would leave the
2300 // OLD address still claimed by this account, with nothing left in the
2301 // system naming it. Teardown releases only the address the record
2302 // currently carries, so nothing would ever release the old one, and it
2303 // would refuse every sibling app in the account with ErrAddressClaimed
2304 // while holding the identity's tenant association open on its own.
2305 // Releasing it here instead was the other option and is worse: the
2306 // release would have to happen before the new claim is taken, so a
2307 // failure in between would leave a sender that has given up an address
2308 // it is still sending as.
2309 if sn.Source == SourceAddress && sn.Address != "" && sn.Address != address {
2310 return nil, fmt.Errorf(
2311 "mail: %s/%s already sends as %s, so it cannot be moved onto %s in place: tear it down first",
2312 acct, app, sn.Address, address)
2313 }
2314 if err := ClaimAddress(ctx, s, address, acct, app, sender, actor, ext.Open, now); err != nil {
2315 return nil, err
2316 }
2317 sn.Source, sn.Address, sn.Identity = SourceAddress, address, ext.IdentityARN
2318 sn.Declared = &Declared{EnvPrefix: envPrefix, From: address, At: now, By: actor}
2319 if _, err := PutSender(ctx, s, acct, app, sn, etag); err != nil {
2320 return nil, err
2321 }
2322 return sn, nil
2323}
2324```
2325
2326- [ ] **Step 4: Run it to verify it passes**
2327
2328```bash
2329go test -run TestClaimAddressSender ./internal/mail/ && go test ./internal/mail/ -count=1
2330```
2331
2332Expected: PASS, with every existing declare test unchanged.
2333
2334- [ ] **Step 5: Gate and commit**
2335
2336```bash
2337go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
2338git add internal/mail
2339git commit -m "mail: ClaimAddressSender, where claiming an address and declaring it are one call (plan 3, task 8)"
2340```
2341
2342---
2343
2344### Task 9: `Enable`'s address branch, and the seven variables an address sender is delivered
2345
2346**Files:**
2347- Modify: `internal/mail/walk.go` (`EnvDelivered` at `:576`, `Enable` at `:616`, `writeSMTPEnv` at `:795` and its two call sites at `:728` and `:902`), `internal/mail/walk_address_test.go`
2348
2349**Interfaces:**
2350- Consumes: `ExternalIdentityFor`, `GetAddressClaim`, `CheckExternalIdentity`, `ErrForeignConfigSet`, `EnsureAddressUser`, `ConfigSetName` (`walk.go:375`), `TenantName` (`policy.go:84`), `UserPath`/`UserName` (`policy.go:24`, `:27`), and plan 2's `ConfigSetARN`, `AssociateTenantResource`, `(r *Record) sender()`.
2351- Produces:
2352 ```go
2353 func EnvNamesAddress(prefix string) (configSet, tenant string)
2354 func EnvDeliveredSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error)
2355 func ensureAddressPrincipal(ctx context.Context, s release.Store, p Provisioner, d Deployment, acct, app string, sn *Sender) (user, arn, configSet, tenant string, err error) // unexported
2356 ```
2357 `writeSMTPEnv` gains a trailing `sn *Sender`. `Enable`'s signature does not change: plan 2 owns it, and this task only branches inside it.
2358
2359- [ ] **Step 1: Write the failing test**
2360
2361`internal/mail/walk_address_test.go` (append):
2362
2363```go
2364// addressEnableFixture is the state an address sender's enable expects to
2365// find: a registered identity, a claim, and SES reporting the identity
2366// verified under the EXTERNAL predicate (no custom MAIL FROM, because CARLOS
2367// must never give it one).
2368func addressEnableFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *ecdh.PublicKey, time.Time) {
2369 t.Helper()
2370 ctx := context.Background()
2371 s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
2372 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
2373 _, pubB64, err := appconfig.GenerateKey()
2374 if err != nil {
2375 t.Fatal(err)
2376 }
2377 pub, err := appconfig.ParsePublicKey(pubB64, "test")
2378 if err != nil {
2379 t.Fatal(err)
2380 }
2381 registerTitoIO(t, ctx, s, true)
2382 if _, err := ClaimAddressSender(ctx, s, "jam", "jelly", SenderAppID,
2383 "jelly@tito.io", "JELLY_SMTP", "owner@example.test", now); err != nil {
2384 t.Fatal(err)
2385 }
2386 if _, err := p.EnsureIdentity(ctx, "eu-west-1", "tito.io", ""); err != nil {
2387 t.Fatal(err)
2388 }
2389 p.SetIdentityStatus("eu-west-1", "tito.io", Status{DKIM: "SUCCESS", MailFrom: "NOT_STARTED", Sending: "SUCCESS"})
2390 return ctx, s, p, pub, now
2391}
2392
2393// SEVEN VARIABLES, NOT FIVE (design §7, spike answer 2). There is no
2394// tenant-level default configuration set, so a tenant-tagged send that does
2395// not name CARLOS's set falls back to the IDENTITY's default, which belongs
2396// to its owner, and is refused with a 554. And the policy's ses:TenantName
2397// condition means a send that omits the tenant header is refused outright.
2398// So the app must set both headers, and it can only do that if it is told
2399// both values.
2400func TestEnableDeliversTheSevenVariablesForAnAddressSender(t *testing.T) {
2401 ctx, s, p, pub, now := addressEnableFixture(t)
2402 rec, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now)
2403 if err != nil {
2404 t.Fatalf("Enable: %v", err)
2405 }
2406 if rec.Provisioned == nil || rec.Provisioned.KeyID == "" {
2407 t.Fatalf("nothing provisioned: %+v", rec)
2408 }
2409 man, err := appconfig.Get(ctx, s, "jam", "jelly")
2410 if err != nil {
2411 t.Fatal(err)
2412 }
2413 for name, want := range map[string]string{
2414 "JELLY_SMTP_HOST": "email-smtp.eu-west-1.amazonaws.com",
2415 "JELLY_SMTP_PORT": "587",
2416 "JELLY_SMTP_FROM": "jelly@tito.io",
2417 "JELLY_SMTP_USER": rec.Provisioned.KeyID,
2418 "JELLY_SMTP_CONFIG_SET": "carlos-jam",
2419 "JELLY_SMTP_TENANT": "carlos-jam",
2420 } {
2421 if got := man.Vars[name]; got.Kind != "env" || got.Value != want {
2422 t.Errorf("%s = %+v, want env %q", name, got, want)
2423 }
2424 }
2425 if man.Vars["JELLY_SMTP_PASS"].Kind != "secret" {
2426 t.Error("PASS was not delivered as a sealed secret")
2427 }
2428 // The credential may send as ONE address, under an identity with no
2429 // DomainRecord anywhere in this account.
2430 doc := p.Users["carlos-mail-jam-jelly"].Policy
2431 for _, want := range []string{"jelly@tito.io", "carlos-jam", "StringEqualsIgnoreCase", "ses:TenantName"} {
2432 if !strings.Contains(doc, want) {
2433 t.Errorf("policy is missing %q: %s", want, doc)
2434 }
2435 }
2436 if strings.Contains(doc, "*@") {
2437 t.Errorf("an address sender was granted a whole domain: %s", doc)
2438 }
2439}
2440
2441// The tenant is what the ses:TenantName condition names, so both resources a
2442// tenant-tagged send touches have to be associated with it: the identity, and
2443// the configuration set the message's own header names. Association is inert
2444// for the identity's owner in every respect except deletion (§4.1), which is
2445// the cross-team contract the registration step states.
2446func TestEnableAssociatesTheIdentityAndTheSetWithTheSendersTenant(t *testing.T) {
2447 ctx, s, p, pub, now := addressEnableFixture(t)
2448 if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil {
2449 t.Fatal(err)
2450 }
2451 res := p.TenantResources["carlos-jam"]
2452 for _, want := range []string{
2453 "arn:aws:ses:eu-west-1:1:identity/tito.io",
2454 "arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam",
2455 } {
2456 if !res[want] {
2457 t.Errorf("%s is not associated with carlos-jam: %+v", want, res)
2458 }
2459 }
2460 // And CARLOS never wrote the identity itself: no MAIL FROM was set and
2461 // its default configuration set is untouched.
2462 if st, _ := p.IdentityStatus(ctx, "eu-west-1", "tito.io"); st.ConfigSet != "" {
2463 t.Errorf("enable bound a configuration set to a borrowed identity: %q", st.ConfigSet)
2464 }
2465}
2466
2467// The idempotence short-circuit counts the two extra variables, because a
2468// bundle missing either one is a credential that cannot send at all. A
2469// repair walk re-delivers rather than short-circuiting on the five.
2470func TestEnableRepairsAMissingTenantVariable(t *testing.T) {
2471 ctx, s, p, pub, now := addressEnableFixture(t)
2472 rec, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now)
2473 if err != nil {
2474 t.Fatal(err)
2475 }
2476 first := rec.Provisioned.KeyID
2477 // A second enable with everything delivered is a no-op: no fresh key.
2478 again, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now)
2479 if err != nil {
2480 t.Fatal(err)
2481 }
2482 if again.Provisioned.KeyID != first {
2483 t.Fatalf("an idempotent enable minted a second key: %q -> %q", first, again.Provisioned.KeyID)
2484 }
2485 if _, err := appconfig.Apply(ctx, s, "jam", "jelly", nil, []string{"JELLY_SMTP_TENANT"}); err != nil {
2486 t.Fatal(err)
2487 }
2488 sn, _, err := GetSender(ctx, s, "jam", "jelly", SenderAppID)
2489 if err != nil {
2490 t.Fatal(err)
2491 }
2492 missing, err := EnvDeliveredSender(ctx, s, "jam", "jelly", sn, again.Provisioned.KeyID)
2493 if err != nil || len(missing) != 1 || missing[0] != "JELLY_SMTP_TENANT" {
2494 t.Fatalf("EnvDeliveredSender = %v %v, want [JELLY_SMTP_TENANT]", missing, err)
2495 }
2496 repaired, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now)
2497 if err != nil {
2498 t.Fatal(err)
2499 }
2500 man, err := appconfig.Get(ctx, s, "jam", "jelly")
2501 if err != nil {
2502 t.Fatal(err)
2503 }
2504 if man.Vars["JELLY_SMTP_TENANT"].Value != "carlos-jam" {
2505 t.Fatalf("the repair walk did not re-deliver the tenant: %+v", man.Vars["JELLY_SMTP_TENANT"])
2506 }
2507 if repaired.Provisioned.KeyID == first {
2508 t.Log("the repair re-delivered without a fresh key, which is also correct")
2509 }
2510}
2511
2512// Enable refuses BEFORE it mints, on each of the three things that would
2513// make the delivered credential useless.
2514func TestEnableRefusesAnAddressSenderItCannotMakeWork(t *testing.T) {
2515 ctx, s, p, pub, now := addressEnableFixture(t)
2516
2517 // The identity's own default set belongs to its owner (§4.1, answer 2).
2518 if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", "classic-events"); err != nil {
2519 t.Fatal(err)
2520 }
2521 if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); !errors.Is(err, ErrForeignConfigSet) {
2522 t.Fatalf("Enable = %v, want ErrForeignConfigSet", err)
2523 }
2524 if _, ok := p.Users["carlos-mail-jam-jelly"]; ok {
2525 t.Fatal("a refused enable still created a principal")
2526 }
2527 if err := p.SetIdentityConfigSet(ctx, "eu-west-1", "tito.io", ""); err != nil {
2528 t.Fatal(err)
2529 }
2530
2531 // The claim has gone: the record says the address is this app's, the
2532 // roster says it is not, and the roster is the authority.
2533 if err := ReleaseAddressClaim(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID); err != nil {
2534 t.Fatal(err)
2535 }
2536 if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); !errors.Is(err, ErrAddressClaimed) {
2537 t.Fatalf("Enable with no claim = %v, want ErrAddressClaimed", err)
2538 }
2539 if err := GrantAddress(ctx, s, "jelly@tito.io", "jam", "jelly", SenderAppID, "op@example.test", now); err != nil {
2540 t.Fatal(err)
2541 }
2542
2543 // A deployment with no SNS topic has no configuration set, and an
2544 // address sender must name one on every send or SES falls back to the
2545 // owner's.
2546 noSNS := testDeployment()
2547 noSNS.SNSTopicARN = ""
2548 if _, err := Enable(ctx, s, p, pub, noSNS, "jam", "jelly", now); err == nil {
2549 t.Fatal("Enable delivered an address sender on a deployment with no configuration set")
2550 }
2551}
2552
2553// THE DOMAIN PATH DOES NOT MOVE. Its refusals are the same refusals, and a
2554// domain sender is still delivered exactly five variables.
2555func TestEnableLeavesTheDomainSenderAlone(t *testing.T) {
2556 ctx, s, p, pub, now := enableFixture(t)
2557 if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "harbourmail", now); err != nil {
2558 t.Fatal(err)
2559 }
2560 man, err := appconfig.Get(ctx, s, "bag", "harbourmail")
2561 if err != nil {
2562 t.Fatal(err)
2563 }
2564 for _, name := range []string{"HARBOURMAIL_SMTP_CONFIG_SET", "HARBOURMAIL_SMTP_TENANT"} {
2565 if _, ok := man.Vars[name]; ok {
2566 t.Errorf("%s was delivered to a domain sender, whose policy has no tenant condition: every send would 554", name)
2567 }
2568 }
2569}
2570```
2571
2572Add `"crypto/ecdh"`, `"strings"` and `"github.com/carlosframework/platform/internal/appconfig"` to this test file's imports.
2573
2574- [ ] **Step 2: Run it to verify it fails**
2575
2576```bash
2577go test -run 'TestEnableDeliversTheSeven|TestEnableAssociates|TestEnableRepairs|TestEnableRefusesAnAddressSender|TestEnableLeavesTheDomainSender' ./internal/mail/
2578```
2579
2580Expected: build failure, `undefined: EnvDeliveredSender`.
2581
2582- [ ] **Step 3: Write the code**
2583
2584`internal/mail/walk.go`, beside `EnvDelivered` (`:576`):
2585
2586```go
2587// EnvNamesAddress renders the two variables ONLY an address sender is
2588// delivered (design §7). They are not part of EnvNames because delivering
2589// them to a domain sender would be worse than useless: that sender's policy
2590// carries no tenant condition, so a tenant header would name a tenant the
2591// credential has no authority over, and the send would 554.
2592func EnvNamesAddress(prefix string) (configSet, tenant string) {
2593 return prefix + "_CONFIG_SET", prefix + "_TENANT"
2594}
2595
2596// EnvDeliveredSender is EnvDelivered for one sender, and it is what the walk
2597// and the console both ask now.
2598//
2599// For an address sender it checks SEVEN variables rather than five, and it
2600// compares the two extra VALUES rather than just their kinds, for the reason
2601// _USER's value is compared (EnvDelivered's own doc comment): a bundle
2602// carrying the wrong tenant or the wrong configuration set is not a bundle
2603// that sends badly, it is one that does not send at all, and nothing else
2604// ever re-checks it.
2605//
2606// The two expected values are computed from the ACCOUNT rather than read off
2607// the record, because both are per-account by design (§5: "configuration sets
2608// stay per account") and an address sender is always the app sender until
2609// instance senders land in step 5. When they do, both come from
2610// SenderNames instead, and this is the one place that has to change.
2611func EnvDeliveredSender(ctx context.Context, s release.Store, acct, app string, sn *Sender, wantKeyID string) ([]string, error) {
2612 if sn == nil || sn.Declared == nil {
2613 return nil, ErrNotDeclared
2614 }
2615 missing, err := EnvDelivered(ctx, s, acct, app, sn.Declared.EnvPrefix, wantKeyID, sn.Declared.From)
2616 if err != nil || sn.Source != SourceAddress {
2617 return missing, err
2618 }
2619 man, err := appconfig.Get(ctx, s, acct, app)
2620 if err != nil {
2621 return nil, err
2622 }
2623 configSetName, tenantName := EnvNamesAddress(sn.Declared.EnvPrefix)
2624 if v, ok := man.Vars[configSetName]; !ok || v.Kind != "env" || v.Value != ConfigSetName(acct) {
2625 missing = append(missing, configSetName)
2626 }
2627 if v, ok := man.Vars[tenantName]; !ok || v.Kind != "env" || v.Value != TenantName(acct) {
2628 missing = append(missing, tenantName)
2629 }
2630 sort.Strings(missing)
2631 return missing, nil
2632}
2633```
2634
2635`internal/mail/walk.go`, `ensureAddressPrincipal`, above `Enable`:
2636
2637```go
2638// ensureAddressPrincipal is Enable's address half: everything from "this
2639// sender says it sends as jelly@tito.io" to "a principal exists that may".
2640//
2641// The order is refuse-before-write, the discipline EnsureDomain follows. The
2642// two reads that can refuse — the registration and the claim — happen before
2643// any backend call at all, and CheckExternalIdentity is next because it too
2644// only reads. Nothing durable happens until every one of them has passed.
2645//
2646// A CONFIGURATION SET IS REQUIRED, unlike the domain path where an empty one
2647// is the ordinary shape on a deployment with no SNS topic. The spike settled
2648// it (§4.1, answer 2): a tenant-tagged send that names no set falls back to
2649// the IDENTITY's default, which belongs to the identity's owner, and is
2650// refused with a 554 naming a set this credential has no authority over. A
2651// domain sender has no such problem because CARLOS owns its identity and
2652// binds its own set as that identity's default.
2653func ensureAddressPrincipal(ctx context.Context, s release.Store, p Provisioner, d Deployment,
2654 acct, app string, sn *Sender) (user, arn, configSet, tenant string, err error) {
2655
2656 ext, err := ExternalIdentityFor(ctx, s, AddressDomain(sn.Address))
2657 if err != nil {
2658 return "", "", "", "", err
2659 }
2660 if ext == nil {
2661 return "", "", "", "", fmt.Errorf("mail: %s: %w", sn.Address, ErrNoExternalIdentity)
2662 }
2663 if ext.Region != d.Region {
2664 return "", "", "", "", fmt.Errorf(
2665 "mail: %s is registered in %s and this deployment sends from %s: tenants and identities are both per region",
2666 ext.Domain, ext.Region, d.Region)
2667 }
2668 // THE CLAIM IS THE AUTHORITY, not the sender record. The record is this
2669 // app's own file and this app can write it; the claim is the global
2670 // roster an operator reads and a deregistration clears. Re-checked on
2671 // every enable so that dropping a claim actually takes a credential's
2672 // authority away at the next walk.
2673 claim, err := GetAddressClaim(ctx, s, sn.Address, acct)
2674 if err != nil {
2675 return "", "", "", "", err
2676 }
2677 if claim == nil || claim.App != app || claim.Sender != sn.ID {
2678 return "", "", "", "", fmt.Errorf("mail: %s is not claimed by %s/%s: %w", sn.Address, acct, app, ErrAddressClaimed)
2679 }
2680 if d.SNSTopicARN == "" {
2681 return "", "", "", "", fmt.Errorf(
2682 "mail: this deployment has no SNS topic and therefore no configuration set, and a sender under %s must name one on every message or SES falls back to that identity's own default",
2683 ext.Domain)
2684 }
2685 configSet = ConfigSetName(acct)
2686 if err := CheckExternalIdentity(ctx, p, ext, configSet); err != nil {
2687 return "", "", "", "", err
2688 }
2689 if err := p.EnsureConfigSet(ctx, d.Region, configSet, d.SNSTopicARN); err != nil {
2690 return "", "", "", "", fmt.Errorf("mail: ensure config set %s: %w", configSet, err)
2691 }
2692 tenant = TenantName(acct)
2693 if err := p.EnsureTenant(ctx, tenant); err != nil {
2694 return "", "", "", "", fmt.Errorf("mail: ensure tenant %s: %w", tenant, err)
2695 }
2696 csARN, ok := ConfigSetARN(ext.IdentityARN, configSet)
2697 if !ok {
2698 return "", "", "", "", fmt.Errorf("mail: cannot build a configuration-set ARN from %q", ext.IdentityARN)
2699 }
2700 // BOTH resources, because a tenant-tagged send touches both and SES
2701 // authorises each in its own right (#271). Associating the identity is
2702 // also the cross-team contract: while it stands, its owner cannot delete
2703 // the identity, and teardown is what releases it.
2704 for _, res := range []string{ext.IdentityARN, csARN} {
2705 if err := p.AssociateTenantResource(ctx, tenant, res); err != nil {
2706 return "", "", "", "", fmt.Errorf("mail: associate %s with tenant %s: %w", res, tenant, err)
2707 }
2708 }
2709 user = UserName(acct, app)
2710 arn, err = p.EnsureAddressUser(ctx, user, UserPath, ext.IdentityARN, csARN, sn.Address, tenant)
2711 if err != nil {
2712 return "", "", "", "", fmt.Errorf("mail: ensure address user %s: %w", user, err)
2713 }
2714 return user, arn, configSet, tenant, nil
2715}
2716```
2717
2718`internal/mail/walk.go`, `Enable`: the granted-domain walk (`:627-671`) moves inside an `else`, untouched otherwise, and the tail below it is shared:
2719
2720```go
2721 // THE BRANCH, and it is the only one (design §7). Everything below it —
2722 // the idempotence short-circuit, the reap, the mint, the pause
2723 // enforcement, the delivery and the record write — is identical for both
2724 // kinds of sender, which is the point: an address sender differs in which
2725 // principal it gets and what its bundle carries, not in how a credential
2726 // is minted or delivered.
2727 sn := rec.sender()
2728 var user, arn, configSet, tenant string
2729 if sn.Source == SourceAddress {
2730 user, arn, configSet, tenant, err = ensureAddressPrincipal(ctx, s, p, d, acct, app, sn)
2731 if err != nil {
2732 return nil, err
2733 }
2734 } else {
2735 // ... :627-671 verbatim: grantedDomains, ErrNoDomains, the ARN and
2736 // name gather, the "From is not at any verified domain" refusal, and
2737 // EnsureUser. It assigns user and arn instead of declaring them.
2738 }
2739 // PLAN 2'S CALLER INDEX STAYS HERE, BELOW THE BRANCH, not inside the
2740 // else. Plan 2 task 5 writes it "right after EnsureUser and before
2741 // MintKey"; an address sender's principal is ensured in the other arm,
2742 // and a version of this that carried the PutCallerIndex call into the
2743 // domain branch along with the rest of :627-671 would leave every
2744 // address sender's IAM user unindexed. SES events name the bare IAM user
2745 // (§4.1), so an unindexed caller is a sender whose sends are attributed
2746 // to nothing and counted against nothing.
2747 if err := PutCallerIndex(ctx, s, user, acct, app, sn.ID, now); err != nil {
2748 return nil, fmt.Errorf("mail: index caller %s: %w", user, err)
2749 }
2750```
2751
2752and the short-circuit at `:677-685` reads:
2753
2754```go
2755 if rec.Provisioned != nil {
2756 missing, err := EnvDeliveredSender(ctx, s, acct, app, sn, rec.Provisioned.KeyID)
2757 if err != nil {
2758 return nil, err
2759 }
2760 if len(missing) == 0 {
2761 return rec, nil
2762 }
2763 }
2764```
2765
2766`internal/mail/walk.go`, `writeSMTPEnv` (`:795`) gains a trailing `sn *Sender` and, after the `sets` map is built:
2767
2768```go
2769 // An address sender is delivered TWO MORE variables, and only an address
2770 // sender is (design §7). Its policy carries a ses:TenantName condition,
2771 // so a message that does not name the tenant is refused outright; and
2772 // because there is no tenant-level default configuration set (§3), a
2773 // message that does not name CARLOS's set is attributed to the identity's
2774 // own default, which belongs to its owner, and is refused too. The app
2775 // sets both as headers. Neither value needs validating: both are
2776 // "carlos-" plus an account sqid, which is three to sixteen lowercase
2777 // letters.
2778 if sn != nil && sn.Source == SourceAddress {
2779 configSetName, tenantName := EnvNamesAddress(prefix)
2780 sets[configSetName] = appconfig.Var{Kind: "env", Value: ConfigSetName(acct)}
2781 sets[tenantName] = appconfig.Var{Kind: "env", Value: TenantName(acct)}
2782 }
2783```
2784
2785Both call sites pass the sender the walk already holds: `Enable` (`:728`) passes `sn`, `Rotate` (`:902`) passes `rec.sender()`. A standalone credential is never an address sender in this plan, so `CreateStandalone` is untouched.
2786
2787- [ ] **Step 4: Run it to verify it passes**
2788
2789```bash
2790go test ./internal/mail/ -count=1
2791```
2792
2793Expected: PASS, including every existing enable and rotate test unchanged.
2794
2795- [ ] **Step 5: Gate and commit**
2796
2797```bash
2798go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
2799git add internal/mail
2800git commit -m "mail: enable's address branch, and the config-set and tenant variables it delivers (plan 3, task 9)"
2801```
2802
2803---
2804
2805### Task 10: `Teardown` disassociates and releases an address sender, and never deletes the identity
2806
2807**Files:**
2808- Modify: `internal/mail/walk.go` (`Teardown` at `:1132`, before the `DeleteUser` calls at `:1139-1149`), `internal/mail/addressclaim.go`, `internal/mail/walk_address_test.go`
2809
2810**Interfaces:**
2811- Consumes: `ReleaseAddressClaim`, `ExternalIdentityFor`, `AddressDomain`, `TenantName`, plan 2's `DisassociateTenantResource` and `(r *Record) sender()`.
2812- Produces: no new exported name.
2813 ```go
2814 func accountHoldsAddressUnder(ctx context.Context, s release.Store, domain, acct, exceptApp, exceptSender string) (bool, error) // unexported
2815 func teardownAddressSender(ctx context.Context, s release.Store, p Provisioner, acct, app string, sn *Sender) error // unexported
2816 ```
2817
2818- [ ] **Step 1: Write the failing test**
2819
2820`internal/mail/walk_address_test.go` (append):
2821
2822```go
2823// TEARDOWN NEVER DELETES THE IDENTITY, and it disassociates BEFORE anything
2824// else (§4.1, answer 3). The association is the cross-team contract: while it
2825// stands, tito.io's owner cannot delete their own identity. So it goes first,
2826// before the claim and before the IAM user, and it goes even if the rest of
2827// the teardown then fails — a crash after it leaves the owner unblocked,
2828// which is the failure mode worth choosing.
2829func TestTeardownReleasesTheAssociationAndTheClaimAndKeepsTheIdentity(t *testing.T) {
2830 ctx, s, p, pub, now := addressEnableFixture(t)
2831 if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil {
2832 t.Fatal(err)
2833 }
2834 if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jelly"); err != nil {
2835 t.Fatalf("Teardown: %v", err)
2836 }
2837 if _, ok := p.Identities[identityKey("eu-west-1", "tito.io")]; !ok {
2838 t.Fatal("Teardown deleted an identity this deployment does not own")
2839 }
2840 if p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] {
2841 t.Fatal("the tenant association survived teardown, so Classic still cannot delete its own identity")
2842 }
2843 // The account's own configuration set stays associated: it is CARLOS's,
2844 // it is shared with every other sender in this account, and it was never
2845 // the borrowed thing.
2846 if !p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:configuration-set/carlos-jam"] {
2847 t.Fatal("teardown disassociated the account's own configuration set")
2848 }
2849 if claim, err := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); err != nil || claim != nil {
2850 t.Fatalf("the address claim survived teardown: %+v %v", claim, err)
2851 }
2852 if _, ok := p.Users["carlos-mail-jam-jelly"]; ok {
2853 t.Fatal("the sending principal survived teardown")
2854 }
2855}
2856
2857// THE ASSOCIATION IS PER TENANT, AND THE TENANT IS PER ACCOUNT. Two apps in
2858// one account may both send under tito.io, and they share carlos-jam. Tearing
2859// one down must not take the identity association away from the other, which
2860// would 554 every message the survivor sends from the moment it happened.
2861func TestTeardownKeepsTheAssociationWhileASiblingAppStillHoldsAClaim(t *testing.T) {
2862 ctx, s, p, pub, now := addressEnableFixture(t)
2863 if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil {
2864 t.Fatal(err)
2865 }
2866 if _, err := ClaimAddressSender(ctx, s, "jam", "jam-billing", SenderAppID,
2867 "receipts@tito.io", "BILLING_SMTP", "owner@example.test", now); err != nil {
2868 t.Fatal(err)
2869 }
2870 if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jam-billing", now); err != nil {
2871 t.Fatal(err)
2872 }
2873 if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jelly"); err != nil {
2874 t.Fatal(err)
2875 }
2876 if !p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] {
2877 t.Fatal("tearing one app down cut the sibling's identity association")
2878 }
2879 // The departing app's own claim is gone; the sibling's is not.
2880 if claim, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); claim != nil {
2881 t.Fatalf("the departing app's claim survived: %+v", claim)
2882 }
2883 if claim, _ := GetAddressClaim(ctx, s, "receipts@tito.io", "jam"); claim == nil {
2884 t.Fatal("the sibling's claim was released by someone else's teardown")
2885 }
2886 // And now the sibling goes: the last claim in the account releases the
2887 // association.
2888 if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jam-billing"); err != nil {
2889 t.Fatal(err)
2890 }
2891 if p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] {
2892 t.Fatal("the last sender's teardown left the association standing")
2893 }
2894}
2895
2896// A deregistration between enable and teardown must not strand either the
2897// association or the claim: the sender record carries the identity ARN it was
2898// built with, so teardown needs the registration only to scope the sibling
2899// question.
2900func TestTeardownOfAnAddressSenderSurvivesDeregistration(t *testing.T) {
2901 ctx, s, p, pub, now := addressEnableFixture(t)
2902 if _, err := Enable(ctx, s, p, pub, testDeployment(), "jam", "jelly", now); err != nil {
2903 t.Fatal(err)
2904 }
2905 if err := DeleteExternalIdentity(ctx, s, "tito.io"); err != nil {
2906 t.Fatal(err)
2907 }
2908 if err := Teardown(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), "jam", "jelly"); err != nil {
2909 t.Fatalf("Teardown after deregistration: %v", err)
2910 }
2911 if p.TenantResources["carlos-jam"]["arn:aws:ses:eu-west-1:1:identity/tito.io"] {
2912 t.Fatal("a deregistered identity was left associated, blocking its owner for ever")
2913 }
2914 if claim, _ := GetAddressClaim(ctx, s, "jelly@tito.io", "jam"); claim != nil {
2915 t.Fatalf("the claim survived: %+v", claim)
2916 }
2917 if _, ok := p.Identities[identityKey("eu-west-1", "tito.io")]; !ok {
2918 t.Fatal("teardown deleted an identity this deployment never owned")
2919 }
2920}
2921```
2922
2923Add `"github.com/carlosframework/platform/internal/dnsrec"` to this test file's imports.
2924
2925- [ ] **Step 2: Run it to verify it fails**
2926
2927```bash
2928go test -run 'TestTeardownReleasesTheAssociation|TestTeardownKeepsTheAssociation|TestTeardownOfAnAddressSender' ./internal/mail/
2929```
2930
2931Expected: the association survives and the claim survives — both assertions fail.
2932
2933- [ ] **Step 3: Write the code**
2934
2935`internal/mail/addressclaim.go` (append):
2936
2937```go
2938// accountHoldsAddressUnder answers the one question teardown has to ask
2939// before it disassociates: does anything ELSE in this account still send
2940// under this identity.
2941//
2942// It has to be asked because the tenant is per ACCOUNT (§5) and the
2943// association hangs off the tenant, not off the sender. Two apps in one
2944// account may both send under tito.io, and cutting the association when the
2945// first one leaves would 554 every message the second one sends from that
2946// moment on.
2947//
2948// The match is LABEL-AWARE, the same correction ExternalIdentityFor's walk
2949// carries: a claim on jelly@eviltito.io is a different zone entirely and must
2950// not hold tito.io's association open.
2951func accountHoldsAddressUnder(ctx context.Context, s release.Store, domain, acct, exceptApp, exceptSender string) (bool, error) {
2952 keys, err := s.List(ctx, addressClaimsPrefix)
2953 if err != nil {
2954 return false, fmt.Errorf("mail: list address claims: %w", err)
2955 }
2956 for _, key := range keys {
2957 raw, _, err := s.Get(ctx, key)
2958 if err != nil {
2959 // Loud, not skipped. A read failure here would otherwise read as
2960 // "no sibling", and the association would be cut out from under
2961 // an app that is still sending.
2962 return false, fmt.Errorf("mail: address claim %s: %w", key, err)
2963 }
2964 var c AddressClaim
2965 if err := json.Unmarshal(raw, &c); err != nil {
2966 log.Printf("mail: address claim %s: unparseable -> %v", key, err)
2967 continue
2968 }
2969 if c.Account != acct || (c.App == exceptApp && c.Sender == exceptSender) {
2970 continue
2971 }
2972 if d := AddressDomain(c.Address); d == domain || strings.HasSuffix(d, "."+domain) {
2973 return true, nil
2974 }
2975 }
2976 return false, nil
2977}
2978```
2979
2980`internal/mail/walk.go`, `teardownAddressSender`, above `Teardown`:
2981
2982```go
2983// teardownAddressSender is everything Teardown does for a sender under an
2984// identity CARLOS does not own, and it is defined as much by what it does not
2985// do: it never calls DeleteIdentity. CARLOS created nothing there. The
2986// ordinary teardown path destroys the identity because CARLOS made it; here
2987// that same call would destroy another product's mail.
2988//
2989// DISASSOCIATE FIRST (§4.1, answer 3). While the association stands, the
2990// identity's owner cannot delete their own identity — that is the cross-team
2991// contract the registration step states — so releasing it is the obligation
2992// this function owes somebody outside CARLOS, and it goes before the two
2993// things it owes CARLOS. A crash after it leaves a claim and an IAM user for
2994// a retry to clear; a crash before it would leave the owner blocked with
2995// nothing left in the system to point at, because the record is about to go.
2996//
2997// The identity ARN comes from the SENDER, not from the registration: a
2998// deregistration between enable and teardown must not strand the association.
2999// The registration is consulted only to scope the sibling question to the
3000// whole identity rather than to one address's domain.
3001func teardownAddressSender(ctx context.Context, s release.Store, p Provisioner, acct, app string, sn *Sender) error {
3002 domain := AddressDomain(sn.Address)
3003 identity := sn.Identity
3004 ext, err := ExternalIdentityFor(ctx, s, domain)
3005 if err != nil {
3006 return err
3007 }
3008 if ext != nil {
3009 domain = ext.Domain
3010 if identity == "" {
3011 identity = ext.IdentityARN
3012 }
3013 }
3014 if identity != "" {
3015 held, err := accountHoldsAddressUnder(ctx, s, domain, acct, app, sn.ID)
3016 if err != nil {
3017 return err
3018 }
3019 if !held {
3020 tenant := TenantName(acct)
3021 if err := p.DisassociateTenantResource(ctx, tenant, identity); err != nil {
3022 return fmt.Errorf("mail: disassociate %s from tenant %s: %w", identity, tenant, err)
3023 }
3024 }
3025 // The account's own configuration set stays associated whatever
3026 // happens here. It is CARLOS's, every other sender in the account
3027 // shares it, and it was never the borrowed thing.
3028 }
3029 return ReleaseAddressClaim(ctx, s, sn.Address, acct, app, sn.ID)
3030}
3031```
3032
3033`internal/mail/walk.go`, `Teardown`, inside the `if rec != nil` block at `:1139` and **before** the `DeleteUser` calls:
3034
3035```go
3036 // Before the principal goes, because everything this needs is read
3037 // off the record and the record's own delete is the last thing this
3038 // function does.
3039 if sn := rec.sender(); sn.Source == SourceAddress {
3040 if err := teardownAddressSender(ctx, s, p, acct, app, sn); err != nil {
3041 return err
3042 }
3043 }
3044```
3045
3046The domain loop below needs no change: an address sender has no `DomainRecord` in the account by design, so `ListDomains(acct)` never names tito.io and `DeleteIdentity` is never reached for it. The orphan-claim sweep is already guarded (task 3).
3047
3048- [ ] **Step 4: Run it to verify it passes**
3049
3050```bash
3051go test ./internal/mail/ -count=1
3052```
3053
3054Expected: PASS, including every existing teardown test unchanged.
3055
3056- [ ] **Step 5: Gate and commit**
3057
3058```bash
3059go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
3060git add internal/mail
3061git commit -m "mail: teardown disassociates and releases an address sender, and never deletes the identity (plan 3, task 10)"
3062```
3063
3064---
3065
3066### Task 11: `svcrole.Mail`, and the service write budget behind the email doors
3067
3068**Files:**
3069- Modify: `internal/svcrole/svcrole.go` (the const block at `:30-56`, `catalogue` at `:59`), `internal/console/apimail.go` (`mailWriteGates` at `:136`, its ten call sites, `registerEmailRoutes` at `:501` and the role table above it at `:448-500`), `internal/console/svcroutes_test.go`, `services.go` (`servicesUsage` at `:33`, the `--role` flag's usage at `:157`, `usageMsg` at `:164`)
3070- Create: `internal/svcrole/mail_role_test.go`
3071
3072**The catalogue is spelled out by hand in three places in the root CLI, and they are the reason `services.go` is on this list.** `svcrole.Parse` (`services.go:174`) accepts anything in the catalogue, and the refusal beside it already lists `roleNames()`, which reads `svcrole.All()` — so the moment `Mail` joins, `carlos services create --role mail` works while all three strings a person reads say the role does not exist. Each becomes `<publish|instance|operate|service|mail|admin>` (`:33`, `:164`) and `publish, instance, operate, service, mail or admin` (`:157`). They are member-facing, so they go through the `copy-review` gate the same way tasks 14 and 15's strings do, before they land.
3073
3074**Interfaces:**
3075- Consumes: `svcrole.Allow` (`svcrole.go:101`), `principalFrom` (`authcli.go:385`), `allowWrite` (`svcprincipal.go:394`), `(s *Server) doorsFor` (`apiservices.go:132`).
3076- Produces:
3077 ```go
3078 const Mail Role = "mail" // internal/svcrole
3079 func (s *Server) mailWriteGates(w http.ResponseWriter, r *http.Request) bool // was (w, addr string)
3080 ```
3081
3082- [ ] **Step 1: Write the failing test**
3083
3084`internal/svcrole/mail_role_test.go` (create), the shape `service_role_test.go` already has:
3085
3086```go
3087package svcrole
3088
3089import "testing"
3090
3091// Mail is mintable the moment it is in the catalogue: the mint door, the
3092// browser mint form and doorsFor all read All(), so nothing else has to
3093// change for an account owner to issue one (service_role_test.go's own
3094// observation, and the reason the catalogue is the single source).
3095func TestMailRoleIsMintable(t *testing.T) {
3096 got, ok := Parse("mail")
3097 if !ok || got != Mail {
3098 t.Fatalf("Parse(\"mail\") = %q, %v", got, ok)
3099 }
3100 var found bool
3101 for _, r := range All() {
3102 if r == Mail {
3103 found = true
3104 }
3105 }
3106 if !found {
3107 t.Fatalf("Mail is not in All(): %v", All())
3108 }
3109}
3110
3111// A Mail door admits Mail — and Admin, by construction (Allow adds it to
3112// every set, svcrole.go:120). That is what makes this change safe to land
3113// without touching a single existing credential: today's Admin-role service
3114// principals keep reaching every email door they reach now.
3115func TestMailDoorAdmitsMailAndAdmin(t *testing.T) {
3116 s := Allow(Mail)
3117 if !s.Permits(Mail) || !s.Permits(Admin) {
3118 t.Fatalf("a Mail door permits %v", s.Roles())
3119 }
3120 for _, r := range []Role{Publish, Instance, Operate, Service} {
3121 if s.Permits(r) {
3122 t.Fatalf("a Mail door admits %q", r)
3123 }
3124 }
3125}
3126
3127// The mirror, and the whole point of the role: a Mail credential onboards
3128// customer domains and does nothing else. It must not gain a deploy, an
3129// instance verb, or the platform's runtime services.
3130func TestMailReachesNoOtherRolesDoors(t *testing.T) {
3131 for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} {
3132 if door.Permits(Mail) {
3133 t.Fatalf("%v admits Mail", door.Roles())
3134 }
3135 }
3136}
3137```
3138
3139`internal/console/svcroutes_test.go` (append), the pin §6.1 asks for:
3140
3141```go
3142// TestMailRoleReachesExactlyTheEmailDoors is the new role's door list,
3143// spelled out rather than derived, for closedDoors' own reason: a door that
3144// joined the family by accident and one that left it by accident pull in
3145// opposite directions, and only an equality catches both.
3146//
3147// The three closed email doors are deliberately absent. credentials
3148// create/revoke hand a plaintext sending secret to their caller and pause is
3149// the abuse brake — no role reaches either, and naming Mail on them would be
3150// a registration that lies about who gets in.
3151func TestMailRoleReachesExactlyTheEmailDoors(t *testing.T) {
3152 want := []string{
3153 "POST /api/cli/apps/{account}/{app}/email",
3154 "GET /api/cli/apps/{account}/{app}/email",
3155 "GET /api/cli/apps/{account}/{app}/email/events",
3156 "GET /api/cli/apps/{account}/{app}/email/events/push",
3157 "PUT /api/cli/apps/{account}/{app}/email/events/push",
3158 "DELETE /api/cli/apps/{account}/{app}/email/events/push",
3159 "POST /api/cli/apps/{account}/{app}/email/domains",
3160 "POST /api/cli/apps/{account}/{app}/email/domains/{domain}/verify",
3161 "POST /api/cli/apps/{account}/{app}/email/enable",
3162 "POST /api/cli/apps/{account}/{app}/email/rotate",
3163 }
3164 sort.Strings(want)
3165 got := newConsoleFixture(t).srv.doorsFor(svcrole.Mail)
3166 if !reflect.DeepEqual(got, want) {
3167 t.Errorf("doorsFor(mail):\n got %q\nwant %q\n"+
3168 "A door that joined this family by accident is a widening; one that left it is a hub that cannot onboard.",
3169 got, want)
3170 }
3171 // Every one of them must still be reachable by admin, because Allow adds
3172 // Admin to every set and today's credentials hold exactly that.
3173 admin := map[string]bool{}
3174 for _, d := range newConsoleFixture(t).srv.doorsFor(svcrole.Admin) {
3175 admin[d] = true
3176 }
3177 for _, d := range want {
3178 if !admin[d] {
3179 t.Errorf("%s stopped being reachable by admin; every existing email credential just broke", d)
3180 }
3181 }
3182}
3183```
3184
3185`internal/console/apimail_test.go` (append), the budget half:
3186
3187```go
3188// §6.1's rate-budget correction. mailWriteGates keyed the PERSON's limiter on
3189// the audit string, so a hub bulk-onboarding customers drew down a budget
3190// sized for someone typing commands. The write gate now picks the limiter
3191// from the principal, which is what every other write door in the package
3192// already does (apiscan.go:235).
3193func TestMailWriteGatesSpendsTheServiceBudgetForAServicePrincipal(t *testing.T) {
3194 fx := newConsoleFixture(t)
3195 tok := serviceToken(t, fx.st, OpsAccountID, "hub", "jelly", svcrole.Mail)
3196
3197 // The two budgets differ by four times (cliWriteBurst 30,
3198 // svcWriteBurst 120), which is what makes this observable from outside
3199 // without reaching into a limiter: a hub held to the person's budget
3200 // stops on the thirty-first declare.
3201 for i := 0; i < cliWriteBurst+2; i++ {
3202 if code := declareEmailAs(t, fx, tok); code == http.StatusTooManyRequests {
3203 t.Fatalf("declare %d of %d was refused: a service credential is drawing down the person-sized budget",
3204 i+1, cliWriteBurst+2)
3205 }
3206 }
3207}
3208```
3209
3210`declareEmailAs` is whatever the package's existing email API tests already use to POST `.../email` with a bearer, returning the status code; add it beside them if there is no such helper yet. The assertion that matters is behavioural — `s.cli.write` is untouched and `s.cli.svcWrite` is the one that moves — and it is made with no access to a limiter's internals.
3211
3212- [ ] **Step 2: Run it to verify it fails**
3213
3214```bash
3215go test ./internal/svcrole/ && go test -run 'TestMailRole|TestMailWriteGates' ./internal/console/
3216```
3217
3218Expected: build failure, `undefined: svcrole.Mail`.
3219
3220- [ ] **Step 3: Write the code**
3221
3222`internal/svcrole/svcrole.go`, in the const block before `Admin`:
3223
3224```go
3225 // Mail is the email family and nothing else: declare a sender, bring a
3226 // sending domain into existence and verify it, enable, rotate, read the
3227 // status and the events.
3228 //
3229 // It exists because the email doors were Admin (apimail.go's role table)
3230 // and Admin is the union — so a credential that could onboard a
3231 // customer's sending domain could also ship that app, move its
3232 // instances, and mint its object store. For a hub whose only job is
3233 // onboarding (senders design §6.1), that inverts the reason those doors
3234 // were made Admin in the first place: Admin was chosen to be NARROWER
3235 // than Publish, so a deploy credential could not acquire a sending
3236 // identity. This role is the other half of that thought — a sending
3237 // credential that cannot deploy.
3238 //
3239 // It does NOT reach credentials create/revoke or pause: those are shut
3240 // to every role, for reasons about the doors' subjects rather than about
3241 // any role (svcroutes_test.go's mintDoors and policyDoors).
3242 Mail Role = "mail"
3243```
3244
3245and `catalogue`:
3246
3247```go
3248var catalogue = []Role{Publish, Instance, Operate, Service, Mail, Admin}
3249```
3250
3251`internal/console/apimail.go`, `mailWriteGates` (`:136`):
3252
3253```go
3254// mailWriteGates is mailAvailable plus the shared write budget, picked from
3255// the PRINCIPAL rather than keyed on the audit string.
3256//
3257// It used to key s.cli.write — the person's limiter — on the same address
3258// every other gate here takes, and for a person that is still exactly what
3259// happens (allowWrite's person branch keys on the address, svcprincipal.go).
3260// For a SERVICE it was wrong in both directions: a hub bulk-onboarding
3261// customers drew down a budget sized for someone typing commands, and it
3262// drew it down under a key that reads like a person's (senders design §6.1).
3263// allowWrite picks the right limiter from the principal, which is what every
3264// other write door in this package already does.
3265func (s *Server) mailWriteGates(w http.ResponseWriter, r *http.Request) bool {
3266 p, _ := principalFrom(r.Context())
3267 if !s.allowWrite(p, time.Now()) {
3268 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
3269 return false
3270 }
3271 return s.mailAvailable(w)
3272}
3273```
3274
3275Every call site becomes `if !s.mailWriteGates(w, r) {` — `:539`, `:799`, `:862`, `:910`, `:981`, `:1041`, `:1112`, `:1176`, `:1226` and `:1309`. Several of them then no longer read `addr` before the gate; leave `addr` where the handler still audits with it, and switch the one-use ones to `_` only where the compiler insists. The pause door (`:1309`) keeps its own `addr` from `cliUser` — its principal is a person by construction (`mailOperator` resolves an address), so the picked limiter is the same one it spends today.
3276
3277`internal/console/apimail.go`, `registerEmailRoutes` (`:501`): every `svcrole.Allow(svcrole.Admin)` becomes `svcrole.Allow(svcrole.Mail)`. The three `svcrole.Set{}` doors do not move. The role table above it (`:448-500`) is rewritten to say so, and its two stale counts are corrected while they are being touched — there are thirteen doors here, not nine, and ten of them are open, not six:
3278
3279```go
3280// registerEmailRoutes wires this file's thirteen doors onto s.mux, through
3281// the role gate (cliRoute) like every other /api/cli/ door.
3282//
3283// THE ROLE TABLE, and why each door reads as it does.
3284//
3285// The ten OPEN doors take Allow(svcrole.Mail) — the email family and nothing
3286// else. Because Allow adds Admin to every set (svcrole.go:120), every
3287// credential that reaches them today still does; what changes is that a hub
3288// which onboards customers can hold a credential that does this work and no
3289// other (senders design §6.1). Admin remains the union, so the reasoning
3290// that made these doors Admin rather than Publish is unchanged and is now
3291// stated by the role's own name.
3292//
3293// ... (the existing per-door paragraphs are unchanged: GET .../email names
3294// the estate, GET .../email/events names the recipients, the push doors
3295// decide whether those addresses leave the console, and the three CLOSED
3296// doors are closed for reasons about their subjects rather than any role.)
3297```
3298
3299- [ ] **Step 4: Run it to verify it passes**
3300
3301```bash
3302go test ./internal/svcrole/ ./internal/console/ -count=1
3303```
3304
3305Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` (`closedDoors` is unchanged: the three closed email doors stay closed) and `TestNoRoleReachesAMintFleetOrPolicyDoor` (which now iterates Mail too, and finds it reaching none of them).
3306
3307- [ ] **Step 5: Gate and commit**
3308
3309```bash
3310go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
3311git add internal/svcrole internal/console services.go
3312git commit -m "console: svcrole.Mail, and the email write gate takes the service budget (plan 3, task 11)"
3313```
3314
3315---
3316
3317### Task 12: `apiexternal.go`, the operator's five doors
3318
3319**Files:**
3320- Create: `internal/console/apiexternal.go`, `internal/console/apiexternal_test.go`
3321- Modify: `internal/mail/addressclaim.go` (`ListIdentityClaims`, and `accountHoldsAddressUnder` folded onto it), `internal/console/apimail.go` (`registerEmailRoutes` at `:501`), `internal/console/svcroutes_test.go` (`policyDoors` at `:77`, `closedDoors` at `:91`)
3322
3323**Interfaces:**
3324- Consumes: `cliUser`, `store.IsOperator`, `mailOperator` (`apimail.go:190`), `AccountBySqid` (the viewer-independent lookup `mailPauseTarget` uses, `apimail.go:215`), `allowWrite` (`svcprincipal.go:394`), `auditSystem` (`apisystemupdate.go:134`), `mail.PutExternalIdentity`, `GetExternalIdentity`, `DeleteExternalIdentity`, `ListExternalIdentities`, `GrantAddress`.
3325- Produces:
3326 ```go
3327 // internal/mail
3328 func ListIdentityClaims(ctx context.Context, s release.Store, domain string) ([]*AddressClaim, error)
3329
3330 // internal/console
3331 func (s *Server) mailExternalOperator(w http.ResponseWriter, r *http.Request) (string, bool)
3332 func (s *Server) handleAPIExternalRegister(w http.ResponseWriter, r *http.Request)
3333 func (s *Server) handleAPIExternalDeregister(w http.ResponseWriter, r *http.Request)
3334 func (s *Server) handleAPIExternalList(w http.ResponseWriter, r *http.Request)
3335 func (s *Server) handleAPIExternalClaims(w http.ResponseWriter, r *http.Request)
3336 func (s *Server) handleAPIExternalGrant(w http.ResponseWriter, r *http.Request)
3337 ```
3338
3339 ```
3340 PUT /api/cli/email/external-identities/{domain} register
3341 DELETE /api/cli/email/external-identities/{domain} deregister
3342 GET /api/cli/email/external-identities list
3343 GET /api/cli/email/external-identities/{domain}/claims roster
3344 POST /api/cli/email/external-identities/{domain}/claims grant
3345 ```
3346
3347- [ ] **Step 1: Write the failing test**
3348
3349`internal/console/apiexternal_test.go` (create):
3350
3351```go
3352package console
3353
3354import (
3355 "net/http"
3356 "testing"
3357)
3358
3359// THE REFUSAL IS THE SAME 404 THE PAUSE DOOR GIVES, and for the same reason
3360// (apimail.go's file comment): a 403 here would tell a prober "this route
3361// exists and you lack authority for it", which is the leak the byte-identical
3362// 404 exists to prevent everywhere else in this family. A signed-in member, a
3363// fleet operator who is not on CARLOS_MAIL_OPERATORS, and a service
3364// credential of every role must all read the same.
3365func TestExternalIdentityDoorsRefuseEveryoneButAMailOperator(t *testing.T) {
3366 // member token, operator-not-mail-operator token, and a service
3367 // credential holding svcrole.Mail: every door, every method, 404 or the
3368 // cliRoute 403 a closed door gives a service principal.
3369 // Registration with a valid body must not have happened either — assert
3370 // the bucket is still empty afterwards.
3371}
3372
3373// Registering is the operator's attestation that this deployment does NOT own
3374// the identity. The record is validated the same way mail.PutExternalIdentity
3375// validates it (the ARN must name this domain in this region), so the
3376// operator hears about a typo while they are still typing.
3377func TestExternalIdentityRegisterWritesTheRecordAndIsIdempotent(t *testing.T) {
3378 // PUT with {region, identity_arn, open, note} -> 200, the record reads
3379 // back, By is the operator's address, At is set.
3380 // A second PUT with a changed Open flips it rather than 409ing: the door
3381 // reads the current etag and writes at it, so re-registering is how an
3382 // identity is opened or closed.
3383 // A PUT naming a different region's ARN is a 400 that names the mismatch.
3384}
3385
3386// Deregistering is refused while any account still holds a claim under the
3387// identity. Without that, deregistration would silently remove the only
3388// record saying "CARLOS must not touch tito.io" while senders are still
3389// sending under it — and the NEXT EnsureDomain on that name would sail
3390// straight through the guard and adopt somebody else's identity.
3391func TestExternalIdentityDeregisterRefusesWhileClaimsStand(t *testing.T) {
3392 // claim jelly@tito.io for one account -> DELETE is a 409 naming the
3393 // count (never the accounts: this reply goes to an operator, but the
3394 // roster door is where that belongs).
3395 // release the claim -> DELETE is a 200, and a second DELETE is a 200
3396 // too, because a retried deregistration is not an error.
3397}
3398
3399// The roster is the whole identity's, not one address's: an operator asking
3400// "who sends as anything under tito.io" is asking one question.
3401func TestExternalIdentityClaimsListsEveryAddressUnderTheIdentity(t *testing.T) {
3402 // claims on jelly@tito.io (two accounts) and receipts@mail.tito.io (one)
3403 // all appear, sorted by address then account, each naming account, app,
3404 // sender, whether it was a grant, and who made it.
3405}
3406
3407// The grant is the recourse when Open is false, and it writes the same record
3408// the member's own claim would. The member's claim then succeeds against it,
3409// which is what keeps the two paths one record rather than two code paths
3410// that could disagree.
3411func TestExternalIdentityGrantOpensOneAppOnAClosedIdentity(t *testing.T) {
3412 // register closed -> POST {address, account, app, sender} -> 200, the
3413 // claim reads back with grant=true and By = the operator.
3414 // An unknown account is the family's byte-identical 404.
3415 // An address whose domain is not under this identity is a 400.
3416}
3417```
3418
3419Fill each body in the shape `apimail_test.go`'s operator tests already use (the fixture, the operator allowlist, `serviceToken`), asserting status codes and the bucket's own state rather than reply prose.
3420
3421- [ ] **Step 2: Run it to verify it fails**
3422
3423```bash
3424go test -run TestExternalIdentity ./internal/console/
3425```
3426
3427Expected: failures at every door — the routes do not exist, so each call is a 404 for the wrong reason.
3428
3429- [ ] **Step 3: Write the code**
3430
3431`internal/mail/addressclaim.go` (append), the listing both the roster door and teardown need:
3432
3433```go
3434// ListIdentityClaims is every claim under one registered identity, whatever
3435// the address: an operator asking "who sends as anything under tito.io" is
3436// asking one question, and mail.tito.io is part of the answer.
3437//
3438// Sorted by address, then by account, so a roster reads the same way twice
3439// running. An unparseable object is logged and skipped, the discipline every
3440// listing in this package applies.
3441func ListIdentityClaims(ctx context.Context, s release.Store, domain string) ([]*AddressClaim, error) {
3442 if !ValidDomain(domain) {
3443 return nil, fmt.Errorf("mail: invalid domain %q", domain)
3444 }
3445 keys, err := s.List(ctx, addressClaimsPrefix)
3446 if err != nil {
3447 return nil, fmt.Errorf("mail: list address claims: %w", err)
3448 }
3449 out := make([]*AddressClaim, 0, len(keys))
3450 for _, key := range keys {
3451 raw, _, err := s.Get(ctx, key)
3452 if err != nil {
3453 // Loud, not skipped, unlike the per-address listing: this answer
3454 // decides whether an identity may be deregistered, and a read
3455 // failure that read as "no claims" would take the guard away
3456 // from an identity that is still in use.
3457 return nil, fmt.Errorf("mail: address claim %s: %w", key, err)
3458 }
3459 var c AddressClaim
3460 if err := json.Unmarshal(raw, &c); err != nil {
3461 log.Printf("mail: address claim %s: unparseable -> %v", key, err)
3462 continue
3463 }
3464 if c.Schema > Schema || AddressClaimKey(c.Address, c.Account) != key {
3465 continue
3466 }
3467 // Label-aware, the same correction ExternalIdentityFor's walk
3468 // carries: jelly@eviltito.io is a different zone entirely.
3469 if d := AddressDomain(c.Address); d == domain || strings.HasSuffix(d, "."+domain) {
3470 out = append(out, &c)
3471 }
3472 }
3473 sort.Slice(out, func(i, j int) bool {
3474 if out[i].Address != out[j].Address {
3475 return out[i].Address < out[j].Address
3476 }
3477 return out[i].Account < out[j].Account
3478 })
3479 return out, nil
3480}
3481```
3482
3483`accountHoldsAddressUnder` (task 10) becomes a filter over this rather than a second walk of the same prefix — one function owns the label-aware match, so the two cannot drift about what "under tito.io" means:
3484
3485```go
3486func accountHoldsAddressUnder(ctx context.Context, s release.Store, domain, acct, exceptApp, exceptSender string) (bool, error) {
3487 claims, err := ListIdentityClaims(ctx, s, domain)
3488 if err != nil {
3489 return false, err
3490 }
3491 for _, c := range claims {
3492 if c.Account == acct && !(c.App == exceptApp && c.Sender == exceptSender) {
3493 return true, nil
3494 }
3495 }
3496 return false, nil
3497}
3498```
3499
3500`internal/console/apiexternal.go` (create):
3501
3502```go
3503// The operator's external-identity doors (senders design §7): registering an
3504// SES identity this deployment does NOT own, so that accounts may send as
3505// single addresses under it.
3506//
3507// EVERY DOOR HERE IS AN OPERATOR'S, and none of them is apiAccountApp's
3508// shape. Registration is a statement about the whole deployment — "CARLOS
3509// must never write this identity" — and there is no {account} it could be
3510// scoped to. The grant is about one account, but it is authority being handed
3511// out rather than a member's own verb, and the account it names is resolved
3512// WITHOUT requiring the operator to be a member of it (AccountBySqid, the
3513// viewer-independent lookup handleAPIEmailPause uses for the same reason).
3514//
3515// The gate is handleAPIEmailPause's, exactly: cliUser -> store.IsOperator ->
3516// mailOperator -> a bare, byte-identical http.NotFound. Both refusals give
3517// the SAME silent 404, never a speaking 403, for the reason that file's
3518// comment records: a 403 would be an oracle telling a prober "this route
3519// exists and you lack authority for it".
3520//
3521// Registered with svcrole.Set{} — closed to every role, admin included —
3522// because they already resolve an ADDRESS against CARLOS_MAIL_OPERATORS,
3523// which no service principal can ever hold. Naming a role would be a
3524// registration that lies about who gets in.
3525package console
3526
3527// mailExternalOperator is the gate. Identical in shape to
3528// storeEntitlementOperator (apistore_entitlement.go:92) and to
3529// handleAPIEmailPause's inline version, and deliberately a helper here
3530// because five doors share it and five copies would drift.
3531func (s *Server) mailExternalOperator(w http.ResponseWriter, r *http.Request) (string, bool) {
3532 addr, ok := s.cliUser(w, r)
3533 if !ok {
3534 return "", false
3535 }
3536 isOp, err := s.store.IsOperator(addr)
3537 if err != nil {
3538 log.Printf("console: api external identity: is-operator: %v", err)
3539 http.NotFound(w, r)
3540 return "", false
3541 }
3542 if !isOp || !s.mailOperator(addr) {
3543 http.NotFound(w, r)
3544 return "", false
3545 }
3546 return addr, true
3547}
3548```
3549
3550The five handlers, each: gate, then `s.allowWrite(Principal{Address: addr}, time.Now())` for the three that write, then `s.mailAvailable(w)`, then the work.
3551
3552- **register** (`PUT`) reads `{region, identity_arn, open, note}`, defaults `region` to `s.cfg.MailDeployment.Region` (`apimail.go:1002` reads it the same way), reads the current record for its etag so a re-registration EDITS rather than 409s (that is how `Open` is flipped), and hands the rest to `mail.PutExternalIdentity`, whose own validation is the one that refuses an ARN disagreeing with the domain. `auditSystem(addr, "email-external-register", domain+" -> "+arn)`. This is the deployment-wide act, so it audits against the ops account like every other one.
3553
3554 It also refuses a region that is not this deployment's, with a 400, rather than leaving that to enable:
3555
3556 ```go
3557 if region != s.cfg.MailDeployment.Region {
3558 jsonError(w, http.StatusBadRequest, fmt.Sprintf(
3559 "this deployment sends from %s. an identity in %s cannot be associated with a tenant here, because tenants and identities are both per region",
3560 s.cfg.MailDeployment.Region, region))
3561 return
3562 }
3563 ```
3564
3565 §7 is explicit that "the region must match the deployment's", and without this the mismatch is caught by `ensureAddressPrincipal` (task 9) instead: an operator registers, a member claims an address, and the first enable is what finally says the registration was never usable. Refusing at the registration puts the refusal in front of the person who can fix it, while they are still typing.
3566- **deregister** (`DELETE`) calls `mail.ListIdentityClaims` FIRST and refuses with a 409 while any claim stands. Deregistering underneath live senders would remove the only record saying "CARLOS must not touch this", and the next `EnsureDomain` on the name would sail through the guard and adopt somebody else's identity. The 409 names the count, not the accounts: the roster door is where those belong. An already-absent registration is a 200, because a retried deregistration is not an error.
3567- **list** (`GET`) answers `{"identities": [...]}`, never null.
3568- **claims** (`GET .../claims`) answers `{"domain": d, "claims": [...]}` from `ListIdentityClaims`, never null.
3569- **grant** (`POST .../claims`) reads `{address, account, app, sender}`, resolves the account with `AccountBySqid` (the family's 404 for an unknown one), refuses an address whose domain is not under `{domain}` with a 400, defaults `sender` to `mail.SenderAppID`, calls `mail.GrantAddress`, and audits against the TARGET account with `s.audit(acct, app, addr, "email-address-grant", ...)` — the account whose authority changed is the one whose trail should show it.
3570
3571`internal/console/apimail.go`, `registerEmailRoutes`:
3572
3573```go
3574 // The operator's external-identity family (apiexternal.go). No {account}
3575 // segment on four of the five, because registering an identity is a
3576 // statement about the deployment. Closed to every role, like pause and
3577 // for the same reason: the gate is an ADDRESS on CARLOS_MAIL_OPERATORS.
3578 s.cliRoute("PUT /api/cli/email/external-identities/{domain}", svcrole.Set{}, s.handleAPIExternalRegister)
3579 s.cliRoute("DELETE /api/cli/email/external-identities/{domain}", svcrole.Set{}, s.handleAPIExternalDeregister)
3580 s.cliRoute("GET /api/cli/email/external-identities", svcrole.Set{}, s.handleAPIExternalList)
3581 s.cliRoute("GET /api/cli/email/external-identities/{domain}/claims", svcrole.Set{}, s.handleAPIExternalClaims)
3582 s.cliRoute("POST /api/cli/email/external-identities/{domain}/claims", svcrole.Set{}, s.handleAPIExternalGrant)
3583```
3584
3585`internal/console/svcroutes_test.go`: the five patterns join `closedDoors` (the list is an EQUALITY, so the suite fails until they are there), with the reason written beside them:
3586
3587```go
3588 // Registering an identity CARLOS does not own, and granting an account
3589 // the right to send as an address under it, are deployment-wide operator
3590 // acts with no {account} to scope a credential to. They already resolve
3591 // an address against CARLOS_MAIL_OPERATORS, which no service principal
3592 // can hold, so a role named here would be a registration that lies about
3593 // who gets in. The grant is also an authority handover, which is the
3594 // same cut the store's approve door takes.
3595 "PUT /api/cli/email/external-identities/{domain}",
3596 "DELETE /api/cli/email/external-identities/{domain}",
3597 "GET /api/cli/email/external-identities",
3598 "GET /api/cli/email/external-identities/{domain}/claims",
3599 "POST /api/cli/email/external-identities/{domain}/claims",
3600```
3601
3602and `policyDoors` gains one substring covering all five, because these are the INTENT-level pin that survives someone editing `closedDoors`:
3603
3604```go
3605 policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe", "/email/external-identities"}
3606```
3607
3608**`closedDoors` is walked by a second test, and three more things in that file have to move with it.** `TestEveryClosedDoorRefusesAServiceCredential` (`svcroutes_test.go:753`) dials every pattern on the list over HTTP, so adding an entry is never bookkeeping alone:
3609
3610- the wildcard filler at `:765-773` is a `strings.NewReplacer` that knows `{account} {app} {fleet} {label} {name} {host} {id}` and `t.Fatalf`s on anything left over (`:774-776`). Three of the five new entries carry `{domain}`, so add `"{domain}", "tito.io",` to the replacer. The value only has to be a well-formed domain: the role gate refuses before any handler looks at it, which is the whole point of the test.
3611- the count at `:790` reads `seen != len(closedDoors) || seen != 31`. Move `31` to `36` in both the condition and the message.
3612- the prose list in that same message enumerates the 31 by family. Extend it with `five external-identity doors`, in the place the reader expects it, beside the two email credentials and email pause.
3613- while the file is open, correct the stale count at `:40`: the comment says closedDoors says "these twelve are registered with the zero Set" and it has said thirty-one for a while. Make it "these are registered with the zero Set", with no number, so the sentence stops going stale every time a door is closed.
3614
3615with the paragraph above the list extended to say why: the registration decides which identities CARLOS may never write, and the grant decides who may send as an address under somebody else's domain. Both are policy about the deployment, not work inside one app.
3616
3617- [ ] **Step 4: Run it to verify it passes**
3618
3619```bash
3620go test ./internal/console/ ./internal/mail/ -count=1
3621```
3622
3623Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` (the five new patterns are in `closedDoors`) and `TestNoRoleReachesAMintFleetOrPolicyDoor`.
3624
3625- [ ] **Step 5: Gate and commit**
3626
3627```bash
3628go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
3629git add internal/console internal/mail
3630git commit -m "console: the operator's external-identity doors, register through grant (plan 3, task 12)"
3631```
3632
3633---
3634
3635### Task 13: the member's address door, and the reply that describes an address sender
3636
3637**Files:**
3638- Modify: `internal/console/apimail.go` (`emailReply` at `:377`, `registerEmailRoutes` at `:501`, a new handler beside `handleAPIEmailDeclare` at `:534`), `internal/console/apimail_test.go`, `internal/console/svcroutes_test.go`
3639
3640**Interfaces:**
3641- Consumes: `apiAccountApp` (`apicli.go:40`), `mailWriteGates` (task 11), `mail.ClaimAddressSender`, `mail.ErrNoExternalIdentity`, `mail.ErrAddressNotOpen`, `mail.ErrAddressClaimed`, `mail.EnvDeliveredSender`, `mail.EnvNamesAddress`, `mailEffectivePrefix` (`:253`).
3642- Produces:
3643 ```go
3644 func (s *Server) handleAPIEmailAddress(w http.ResponseWriter, r *http.Request)
3645 // POST /api/cli/apps/{account}/{app}/email/address svcrole.Allow(svcrole.Mail)
3646 ```
3647 `emailReply` gains `env_names` entries for an address sender, an `identity` on the sender entry, and computes `missing_vars` through `EnvDeliveredSender`.
3648
3649- [ ] **Step 1: Write the failing test**
3650
3651`internal/console/apimail_test.go` (append):
3652
3653```go
3654// The claim-and-declare door is ONE call (design §7): it sets Source,
3655// Address and Identity together, so a declared From address and the claim
3656// that authorises it can never disagree. It is Declare for an address
3657// sender, and enable, rotate, status and teardown are the doors that already
3658// exist — they read Source off the record.
3659func TestEmailAddressDoorClaimsAndDeclaresInOneCall(t *testing.T) {
3660 // register tito.io open, then POST .../email/address
3661 // {"address":"jelly@tito.io","env_prefix":"JELLY_SMTP"} as an account
3662 // member -> 200.
3663 // The reply's sender entry carries source=address, address=jelly@tito.io
3664 // and the identity ARN; env_names carries seven names including
3665 // JELLY_SMTP_CONFIG_SET and JELLY_SMTP_TENANT; missing_vars names all
3666 // seven, because nothing is delivered until enable runs.
3667 // The claim reads back from the bucket naming this account and app.
3668}
3669
3670// Each refusal is its own answer, because each has a different recourse and
3671// a member who cannot tell them apart asks the wrong person.
3672func TestEmailAddressDoorRefusalsAreDistinct(t *testing.T) {
3673 // no registration for that domain -> 409, "ask an operator"
3674 // registered but Open=false -> 409, "ask an operator to
3675 // grant this app"
3676 // a sibling app in this account holds it -> 409, names neither the
3677 // sibling's owner nor any
3678 // other account
3679 // "jelly@" / "" / "jelly@tito.io/../x" -> 400 before any lookup
3680 // someone else's account, an unknown app -> the family's byte-
3681 // identical 404
3682}
3683
3684// The wire shape is ADDITIVE. An older CLI reading this reply sees every
3685// field it already reads, unchanged and meaning what it meant.
3686func TestEmailReplyKeepsItsShapeForADomainSender(t *testing.T) {
3687 // declare + enable a domain sender, then assert env_names has exactly
3688 // the five, missing_vars is empty, and no address/identity field appears
3689 // on the sender entry.
3690}
3691
3692// missing_vars is computed through EnvDeliveredSender, so the two extra
3693// variables are part of "can this app actually send" rather than a detail
3694// only the walk knows about.
3695func TestEmailStatusReportsAMissingTenantVariable(t *testing.T) {
3696 // enable an address sender, unset <P>_TENANT through appconfig, then GET
3697 // .../email -> missing_vars is exactly [<P>_TENANT].
3698}
3699```
3700
3701`internal/console/svcroutes_test.go`: add the new pattern to `TestMailRoleReachesExactlyTheEmailDoors`'s `want` (task 11's list), which fails until the route is registered with `Allow(svcrole.Mail)`.
3702
3703- [ ] **Step 2: Run it to verify it fails**
3704
3705```bash
3706go test -run 'TestEmailAddressDoor|TestEmailReplyKeeps|TestEmailStatusReportsAMissing|TestMailRoleReaches' ./internal/console/
3707```
3708
3709Expected: 404 at the address door, and `doorsFor(mail)` short by one.
3710
3711- [ ] **Step 3: Write the code**
3712
3713`internal/console/apimail.go`, the handler beside `handleAPIEmailDeclare`:
3714
3715```go
3716// ---- address (claim and declare) -------------------------------------------
3717
3718// handleAPIEmailAddress answers the member's first step for a sender under an
3719// identity this deployment does not own: claim one exact address and declare
3720// it, in one call.
3721//
3722// It is DECLARE for an address sender, not a second kind of declaration.
3723// Claiming and declaring separately could disagree — a claim with no
3724// declaration is an address nothing sends as, a declaration with no claim is
3725// a From address no roster knows about — so mail.ClaimAddressSender sets
3726// Source, Address, Identity and Declared together or sets none of them.
3727//
3728// The doors, in order, are handleAPIEmailDeclare's exactly: token — account
3729// and app 404 — the write budget — mailAvailable — the body's own validation.
3730// Nothing here reaches a backend: like declare, this is the member's half,
3731// and enable is where SES first hears about any of it.
3732//
3733// EACH REFUSAL IS ITS OWN ANSWER, because each has a different recourse. "No
3734// operator has registered that domain here" needs a conversation with the
3735// identity's owner. "Registered, but not open" needs an operator grant. "A
3736// sibling app already holds it" needs a different address. A single 409 for
3737// all three would send the member to the wrong person. None of them names
3738// another ACCOUNT: a claim held elsewhere in the deployment is not a refusal
3739// at all (the claim is not exclusive across accounts), so there is nothing to
3740// disclose and nothing to ask about.
3741func (s *Server) handleAPIEmailAddress(w http.ResponseWriter, r *http.Request) {
3742 addr, acct, app, ok := s.apiAccountApp(w, r)
3743 if !ok {
3744 return
3745 }
3746 if !s.mailWriteGates(w, r) {
3747 return
3748 }
3749 var req struct {
3750 Address string `json:"address"`
3751 EnvPrefix string `json:"env_prefix"`
3752 }
3753 if !readJSON(w, r, &req) {
3754 return
3755 }
3756 ctx, cancel := context.WithTimeout(r.Context(), mailTimeout)
3757 defer cancel()
3758
3759 sn, err := mail.ClaimAddressSender(ctx, s.cfg.Releases, acct.Sqid, app, mail.SenderAppID,
3760 req.Address, strings.TrimSpace(req.EnvPrefix), addr, time.Now())
3761 switch {
3762 case errors.Is(err, mail.ErrNoExternalIdentity):
3763 jsonError(w, http.StatusConflict,
3764 "no identity is registered here for that address. ask a deployment operator to register it")
3765 return
3766 case errors.Is(err, mail.ErrAddressNotOpen):
3767 jsonError(w, http.StatusConflict,
3768 "that identity is not open for self-serve claims. ask a deployment operator to grant this app the address")
3769 return
3770 case errors.Is(err, mail.ErrAddressClaimed):
3771 jsonError(w, http.StatusConflict,
3772 "another app in this account already sends as that address")
3773 return
3774 case errors.Is(err, release.ErrCASConflict):
3775 jsonError(w, http.StatusConflict, "the email record changed underneath you. try again")
3776 return
3777 case err != nil:
3778 // The remaining failures are all about the body the caller just
3779 // sent: an address that is not one, an env prefix that is not one,
3780 // a prefix that cannot move after delivery. Safe to hand back
3781 // verbatim, the same judgement handleAPIEmailEnable makes for its
3782 // own "is not at any verified domain".
3783 jsonError(w, http.StatusBadRequest, err.Error())
3784 return
3785 }
3786 s.audit(acct, app, addr, "email-address", "claimed "+sn.Address+" under "+mail.AddressDomain(sn.Address)+" (cli)")
3787 // No bumpSerial: a claim and a declaration touch nothing a box reads.
3788 // Enable is what delivers, and it bumps.
3789 rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
3790 if err != nil || rec == nil {
3791 log.Printf("console: api email address %s/%s: re-read: %v", acct.Sqid, app, err)
3792 jsonError(w, http.StatusBadGateway,
3793 "the address was claimed, but its status could not be read. run `carlos email status`")
3794 return
3795 }
3796 s.mailWalkReply(ctx, w, acct, app, rec)
3797}
3798```
3799
3800registered beside the other member doors:
3801
3802```go
3803 s.cliRoute("POST /api/cli/apps/{account}/{app}/email/address", svcrole.Allow(svcrole.Mail), s.handleAPIEmailAddress)
3804```
3805
3806`internal/console/apimail.go`, `emailReply` (`:377`): the names and the missing set both come from the sender now.
3807
3808```go
3809 prefix := mailEffectivePrefix(rec)
3810 host, port, user, pass, from := mail.EnvNames(prefix)
3811 names := []string{host, port, user, pass, from}
3812 // The app sender, read the way this function already reads senders:
3813 // mail.ListSenders. Record.sender is unexported and this is package
3814 // console, so hoist plan 2's own ListSenders call (its task 13 puts it
3815 // just before the returned map) up to here and let the senders loop
3816 // below reuse `all` rather than listing twice.
3817 all, err := mail.ListSenders(ctx, s.cfg.Releases, acct.Sqid, app)
3818 if err != nil {
3819 return nil, err
3820 }
3821 var sn *mail.Sender
3822 for _, cand := range all {
3823 if cand.ID == mail.SenderAppID {
3824 sn = cand
3825 break
3826 }
3827 }
3828 // An address sender is delivered two more, and they are part of "can
3829 // this app send" rather than a detail only the walk knows: its policy
3830 // carries a tenant condition, so a message that names no tenant is
3831 // refused outright, and a message that names no configuration set is
3832 // attributed to the borrowed identity's own default and refused too
3833 // (senders design §7). A nil sn keeps today's five-name answer: an app
3834 // with a record always has an app sender (ListSenders synthesises one
3835 // for an unmigrated record, plan 2 task 3), so this is a guard rather
3836 // than a branch anything reaches.
3837 if sn != nil && sn.Source == mail.SourceAddress {
3838 configSetName, tenantName := mail.EnvNamesAddress(prefix)
3839 names = append(names, configSetName, tenantName)
3840 }
3841 missing := []string{}
3842 if rec.Provisioned != nil && sn != nil {
3843 got, err := mail.EnvDeliveredSender(ctx, s.cfg.Releases, acct.Sqid, app, sn, rec.Provisioned.KeyID)
3844 if err != nil {
3845 return nil, err
3846 }
3847 missing = append(missing, got...)
3848 }
3849```
3850
3851The `senders` entry each sender contributes gains `"identity": sn.Identity` beside the `source` and `address` plan 2 already puts there. Every other top-level field keeps its meaning, so an older CLI reads this reply exactly as it reads today.
3852
3853- [ ] **Step 4: Run it to verify it passes**
3854
3855```bash
3856go test ./internal/console/ -count=1
3857```
3858
3859Expected: PASS.
3860
3861- [ ] **Step 5: Gate and commit**
3862
3863```bash
3864go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
3865git add internal/console
3866git commit -m "console: the address door, where claiming and declaring are one call (plan 3, task 13)"
3867```
3868
3869---
3870
3871### Task 14: `carlos email external-identity`, the operator's five verbs
3872
3873**Files:**
3874- Modify: `mail_cli.go` (`emailUsage` at `:60`, `runEmail`'s switch at `:137-159`, the `apiSession` wire calls from `:352`), `apimode.go` (the reply types, beside the store operator family at `:1786-1810`), `mail_cli_test.go`
3875
3876**Interfaces:**
3877- Consumes: `requireMailAPISession` (`mail_cli.go:166`), `consoleOption` (`flags.go:39`), `appFlag`, `accountFlags`, `flagUsage`, `s.getJSON`/`postJSON`/`putJSON`/`deleteJSON` (`apimode.go:760`, `:822`, `:2360`, `:2710`), `pathEscape` (`mail_cli.go:517`).
3878- Produces:
3879 ```go
3880 func runEmailExternalIdentity(args []string) // register|deregister|list|claims|grant
3881 type apiExternalIdentity struct{ Domain, Region, IdentityARN, Note, By string; Open bool; At time.Time }
3882 type apiAddressClaim struct{ Address, Account, App, Sender, By string; Grant bool; At time.Time }
3883 func (s *apiSession) externalIdentityRegister(ctx context.Context, domain, region, arn, note string, open bool) (apiExternalIdentity, error)
3884 func (s *apiSession) externalIdentityDeregister(ctx context.Context, domain string) error
3885 func (s *apiSession) externalIdentityList(ctx context.Context) ([]apiExternalIdentity, error)
3886 func (s *apiSession) externalIdentityClaims(ctx context.Context, domain string) ([]apiAddressClaim, error)
3887 func (s *apiSession) externalIdentityGrant(ctx context.Context, domain, address, account, app string) (apiAddressClaim, error)
3888 ```
3889
3890**Note on the flag helper.** `main` has moved since this branch's base: every `fs.String("console", ...)` in this file is now `consoleOption(fs, ...)`, which registers `--destination`/`-d` alongside. New verbs use `consoleOption`. Read the current shape of each neighbouring verb rather than trusting a line number from an earlier task.
3891
3892- [ ] **Step 1: Write the failing test**
3893
3894`mail_cli_test.go` (append), in the shape this file's existing table tests use (a `httptest` console that records the method and path it was asked for):
3895
3896```go
3897// Every verb hits the door §7 names, with the domain escaped into the path
3898// and nothing else invented. This is the whole contract between this binary
3899// and the console for the operator family.
3900func TestEmailExternalIdentityCallsTheOperatorDoors(t *testing.T) {
3901 // register -> PUT /api/cli/email/external-identities/tito.io
3902 // deregister-> DELETE /api/cli/email/external-identities/tito.io
3903 // list -> GET /api/cli/email/external-identities
3904 // claims -> GET /api/cli/email/external-identities/tito.io/claims
3905 // grant -> POST /api/cli/email/external-identities/tito.io/claims
3906 // and the register body carries region, identity_arn, open and note.
3907}
3908
3909// A 404 from any of these doors is the operator gate, not a missing domain:
3910// the doors refuse a non-operator with a byte-identical 404 on purpose, so
3911// the CLI has to say what that means rather than "not found".
3912func TestEmailExternalIdentityExplainsTheOperatorRefusal(t *testing.T) {
3913 // a 404 with no body -> an error naming CARLOS_MAIL_OPERATORS.
3914 // a 409 with a body -> the console's own sentence, verbatim.
3915}
3916
3917// The domain is the one argument that becomes a path segment, so it is
3918// validated here too rather than only at the console.
3919func TestEmailExternalIdentityRefusesANonDomain(t *testing.T) {
3920 // "", "tito.io/../x", "not a domain" -> usage, no request made.
3921}
3922```
3923
3924- [ ] **Step 2: Run it to verify it fails**
3925
3926```bash
3927go test -run TestEmailExternalIdentity ./
3928```
3929
3930Expected: build failure, `undefined: runEmailExternalIdentity`.
3931
3932- [ ] **Step 3: Take the new strings through the copy-review gate**
3933
3934Every string below is read by a person: the usage block, the printed tables, and each error sentence. Extract them, run the `copy-review` skill, and apply the result verbatim before writing the file. The repo's rules apply and are the reason the drafts below read as they do: no em dashes, full stop and a new sentence, lower case. Do not skip this because the audience is an operator — `carlos email pause`'s strings went through it too.
3935
3936The strings to review:
3937
3938- the `external-identity` block of `emailUsage`, five verbs;
3939- `registered <domain> in <region>. this deployment will never write that identity`;
3940- the cross-team contract, printed by `register` and by nothing else, because §7 says the registration step is where it is stated and this is that step: `while an app here is sending under <domain>, its owner cannot delete that identity at SES. the association is what blocks it, and `carlos email enable` is what creates one. tearing the last sender down releases it.` An operator who registers an identity is making a promise to somebody outside CARLOS, and this is the only place they are told what it is;
3941- `deregistered <domain>`;
3942- `<n> accounts still hold addresses under <domain>. run `carlos email external-identity claims <domain>` to see them`;
3943- `granted <address> to <account>/<app>`;
3944- `nothing registered. run `carlos email external-identity register <domain> --arn <arn>` to add one`;
3945- `no addresses are claimed under <domain>`;
3946- `not found. check the domain, and that your login is on this deployment's CARLOS_MAIL_OPERATORS list`;
3947- the column headings for the two tables: `domain region open registered by when` and `address account app sender how when`, where `how` reads `claimed` or `granted`.
3948
3949- [ ] **Step 4: Write the code**
3950
3951`mail_cli.go`, the dispatch (`:137`) gains one case and the usage line gains the verb:
3952
3953```go
3954 case "external-identity":
3955 runEmailExternalIdentity(args[1:])
3956```
3957
3958`emailUsage`'s new block, after `resume`:
3959
3960```
3961 external-identity register <domain> --arn <arn> [--region <r>] [--open] [--note <n>]
3962 record that this deployment may send under an SES
3963 identity it does not own. CARLOS never creates,
3964 rewrites or deletes a registered identity, and
3965 `carlos email domains add` refuses the name and
3966 everything under it. --open lets any account here
3967 claim an address under it for itself; without it,
3968 grant is the only way in. Operator only.
3969 external-identity deregister <domain>
3970 remove the registration. refused while any account
3971 still holds an address under it.
3972 external-identity list the registrations on this deployment.
3973 external-identity claims <domain>
3974 every address claimed under the identity, and who
3975 holds each one.
3976 external-identity grant <domain> --address <a> --account <sqid> --app <app>
3977 let one app send as one address under a
3978 registration that is not open.
3979```
3980
3981The five sub-verbs, each its own `flag.FlagSet` named `email external-identity <verb>`, each taking `consoleOption(fs, ...)`, and `grant` taking `appFlag` and `accountFlags` as well. `register` takes `--arn` (required), `--region` (defaults to empty, which the console fills from its own deployment), `--open` and `--note`. Each resolves its session with `requireMailAPISession(*consoleFlag)` and prints through `stdout` with `text/tabwriter`, the way `runEmailCredentialsList` already does.
3982
3983`apimode.go`, beside the store operator family, the wire calls and one error translator:
3984
3985```go
3986// externalIdentityError is storeOperatorError's counterpart for the email
3987// operator family, and it exists for the same reason: these doors refuse a
3988// non-operator with a byte-identical 404 (apiexternal.go's gate), so "not
3989// found" on its own would send someone hunting for a typo in a domain name
3990// when the real answer is that their address is not on the deployment's
3991// operator list.
3992func externalIdentityError(status int, body []byte) error {
3993 var e struct {
3994 Error string `json:"error"`
3995 }
3996 if json.Unmarshal(body, &e) == nil && e.Error != "" {
3997 return errors.New(e.Error)
3998 }
3999 if status == http.StatusNotFound {
4000 return errors.New("not found. check the domain, and that your login is on this deployment's CARLOS_MAIL_OPERATORS list")
4001 }
4002 return fmt.Errorf("unexpected response (status %d)", status)
4003}
4004```
4005
4006and `externalIdentityPath(domain, tail string) string` returning
4007`"/api/cli/email/external-identities/" + pathEscape(domain) + tail`, so the five calls cannot disagree about the path. `pathEscape`'s existing doc comment holds: a valid domain carries no `/`, and the escape is exact for the reserved set anyway.
4008
4009- [ ] **Step 5: Run it to verify it passes**
4010
4011```bash
4012go test -run TestEmailExternalIdentity ./ && go build ./...
4013```
4014
4015Expected: PASS and a clean build.
4016
4017- [ ] **Step 6: Gate and commit**
4018
4019```bash
4020go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
4021git add mail_cli.go apimode.go mail_cli_test.go
4022git commit -m "carlos: email external-identity register, deregister, list, claims and grant (plan 3, task 14)"
4023```
4024
4025---
4026
4027### Task 15: `carlos email enable --from jelly@tito.io` takes the address path
4028
4029**Files:**
4030- Modify: `internal/console/apimail.go` (`handleAPIEmailAddress`, task 13), `mail_cli.go` (`runEmailEnableFlow` at `:652`, `printEmailStatus` at `:1129`, `printEmailEnabled` at `:1253`, `emailUsage`'s `enable` block at `:62`), `mail_cli_test.go`
4031
4032**Interfaces:**
4033- Consumes: `sess.emailStatus`, `sess.emailEnable`, `apiMailError` (`mail_cli.go:312`), `apiEmailReply.Domains`/`DefaultHost`.
4034- Produces:
4035 ```go
4036 func (s *apiSession) emailAddress(ctx context.Context, a apiAcct, app, address, envPrefix string) (apiEmailReply, error)
4037 func mailRefusalReason(err error) string // "unregistered" | "not_open" | "claimed" | ""
4038 ```
4039 The address door's four refusals gain a machine-readable `reason` beside their `error`.
4040
4041**Why the `reason` field lands here and not in task 13.** It has exactly one consumer: this flow, deciding whether the From address it was handed belongs to a registered identity or to an ordinary sending domain. Adding it in task 13 would have been a field with no reader, which is the kind of speculative surface this repo's wire-shape rule exists to prevent. It is additive: the body still carries `error`, and an older CLI reads the sentence exactly as it does today.
4042
4043- [ ] **Step 1: Write the failing test**
4044
4045`mail_cli_test.go` (append):
4046
4047```go
4048// `carlos email enable --from jelly@tito.io` is the one command the whole of
4049// #147 exists to make work. It must NOT go near `domains add`: tito.io is an
4050// identity CARLOS does not own, EnsureDomain refuses it outright (task 3),
4051// and the only path that ends in a working credential is claim, then enable.
4052func TestEnableWithAnExternalAddressClaimsInsteadOfAddingADomain(t *testing.T) {
4053 // a console that answers the address door 200 and records every path:
4054 // want GET .../email, POST .../email/address, POST .../email/enable
4055 // and no POST .../email/domains anywhere in the trace.
4056 // The address door's body carries the address and the env prefix.
4057}
4058
4059// An ordinary custom domain is untouched by any of this. The address door is
4060// tried, answers "unregistered", and the existing domain path runs exactly as
4061// it does today, including the verify wait.
4062func TestEnableFallsBackToTheDomainPathWhenNothingIsRegistered(t *testing.T) {
4063 // address door -> 409 {"error": "...", "reason": "unregistered"}
4064 // want POST .../email/domains, the verify poll, then POST .../email.
4065}
4066
4067// A domain the app ALREADY has a record for skips the address door entirely:
4068// no wasted round trip on the path every existing app takes.
4069func TestEnableDoesNotAskTheAddressDoorForAKnownDomain(t *testing.T) {
4070 // status answers with domains: [harbourmail.bag.oncarlos.com]
4071 // want no POST .../email/address at all.
4072}
4073
4074// The two refusals a member can act on are surfaced as themselves, with the
4075// console's own sentence, and nothing is attempted afterwards: adding tito.io
4076// as a sending domain is exactly what must not happen next.
4077func TestEnableSurfacesAClosedIdentityWithoutFallingBack(t *testing.T) {
4078 // address door -> 409 reason "not_open" -> error names the operator
4079 // grant, no domains call.
4080 // address door -> 409 reason "claimed" -> error names the sibling app
4081 // refusal, no domains call.
4082}
4083
4084// An address sender is delivered seven variables, and the two extra ones are
4085// useless unless the member is told what to do with them: the app has to set
4086// both as headers on every message, or SES refuses the send outright (§4.1).
4087func TestEnabledOutputNamesTheTwoHeadersAnAddressSenderMustSet(t *testing.T) {
4088 // printEmailEnabled over a reply whose sender is an address sender names
4089 // <P>_CONFIG_SET and <P>_TENANT and says which header each one goes in.
4090 // Over a domain sender it prints exactly what it prints today.
4091}
4092
4093// The honour system, said out loud (design §7). An address sender's events
4094// reach CARLOS only when the message names CARLOS's configuration set, and
4095// nothing here can enforce that, so zero counts have two explanations and the
4096// status must not quietly pick one. A domain sender's zero is not ambiguous
4097// and gains no line.
4098func TestStatusSaysWhenAnAddressSenderHasCountedNothing(t *testing.T) {
4099 // a provisioned address sender with all-zero counts -> the line appears.
4100 // the same sender with one send -> it does not.
4101 // a provisioned DOMAIN sender with all-zero counts -> it does not.
4102}
4103```
4104
4105- [ ] **Step 2: Run it to verify it fails**
4106
4107```bash
4108go test -run 'TestEnableWithAnExternalAddress|TestEnableFallsBack|TestEnableDoesNotAskTheAddressDoor|TestEnableSurfacesAClosedIdentity|TestEnabledOutputNames|TestStatusSaysWhenAnAddressSender' ./
4109```
4110
4111Expected: the trace shows `POST .../email/domains` for the external address, `printEmailEnabled` says nothing about headers, and the status prints no line about what it has not counted.
4112
4113- [ ] **Step 3: Take the new strings through the copy-review gate**
4114
4115This is member-facing copy, so the gate is not optional. Extract, review, apply verbatim. The drafts:
4116
4117- `<domain> is an identity this deployment does not own, so <address> was claimed rather than added as a sending domain.`
4118- `your app must set two headers on every message it sends. X-SES-CONFIGURATION-SET from <P>_CONFIG_SET, and X-SES-TENANT from <P>_TENANT. a message missing either one is refused by SES, so this fails loudly rather than sending uncounted.`
4119- the status line for a provisioned address sender that has no counts at all: `nothing has been counted for this sender. either it has sent nothing, or it is sending without the X-SES-CONFIGURATION-SET header, and only the app can tell you which.` §7 asks for exactly this, and it is the honest half of the honour system: a domain sender with no counts has sent nothing, but an address sender with no counts might be sending perfectly well into somebody else's event destination, and a status that said "0 sends" flat would be stating something this deployment cannot know;
4120- the `enable` usage block's new sentence: `--from at a registered external identity claims the single address instead of bringing a sending domain into existence, and skips the verification wait entirely.`
4121
4122- [ ] **Step 4: Write the code**
4123
4124`internal/console/apimail.go`, `handleAPIEmailAddress`'s four refusals write the reason beside the sentence:
4125
4126```go
4127 // The reason is for one caller: `carlos email enable`, deciding whether
4128 // the From address it was given belongs to a registered identity or to
4129 // an ordinary sending domain. It cannot tell from the sentence without
4130 // matching prose, and it cannot tell from the status code because all
4131 // four of these are conflicts. Additive: the body still carries error.
4132 case errors.Is(err, mail.ErrNoExternalIdentity):
4133 writeJSON(w, http.StatusConflict, map[string]string{
4134 "error": "no identity is registered here for that address. ask a deployment operator to register it",
4135 "reason": "unregistered",
4136 })
4137 return
4138```
4139
4140and likewise `not_open` for `ErrAddressNotOpen` and `claimed` for `ErrAddressClaimed`.
4141
4142`mail_cli.go`, the decoder and the flow:
4143
4144```go
4145// mailRefusalReason reads the address door's machine-readable reason off a
4146// refusal. Empty for every other error, including a transport failure, so a
4147// caller switching on it treats "something else went wrong" as itself rather
4148// than as any of the four it knows about.
4149func mailRefusalReason(err error) string {
4150 var me *apiMailError
4151 if !errors.As(err, &me) {
4152 return ""
4153 }
4154 return me.reason
4155}
4156```
4157
4158`apiMailError` gains a `reason string` field, set by `mailAPIError` from the same body it already reads for the message.
4159
4160`runEmailEnableFlow` (`:652`), after `from` and `domain` are resolved and before the `domains add` half:
4161
4162```go
4163 // THE ADDRESS PATH (senders design §7). Asked only when the From
4164 // address's domain is one this app has no record of: every app that
4165 // already sends, and every app whose From is its own platform host,
4166 // takes the path it takes today with no extra round trip.
4167 //
4168 // "Unregistered" is the ORDINARY answer here, not a failure: it means
4169 // the member named a custom domain, which is what the rest of this
4170 // function has always been about. The other two refusals stop the flow
4171 // dead, deliberately: falling through to `domains add tito.io` after
4172 // being told the address is somebody else's would take a member who
4173 // needs an operator and hand them EnsureDomain's refusal instead.
4174 if from != "" && !knownSendingDomain(cur, domain) {
4175 res, err := sess.emailAddress(ctx, acct, app, from, envPrefix)
4176 switch mailRefusalReason(err) {
4177 case "":
4178 if err != nil {
4179 return fmt.Errorf("claiming %s: %w", from, err)
4180 }
4181 fmt.Fprintf(w, "%s is an identity this deployment does not own, so %s was claimed rather than added as a sending domain.\n",
4182 domain, from)
4183 return finishEmailEnable(ctx, w, sess, acct, app, res)
4184 case "unregistered":
4185 // An ordinary custom domain. Carry on below.
4186 default:
4187 return err
4188 }
4189 }
4190```
4191
4192where `knownSendingDomain(cur apiEmailReply, domain string) bool` answers from `cur.Domains` and `cur.DefaultHost`, and `finishEmailEnable` is the existing tail: `sess.emailEnable` then `printEmailEnabled`. The address path skips `domains add` and the verification poll entirely, because there is nothing of CARLOS's to verify: the identity's owner verified it years ago, and `CheckExternalIdentity` is what confirms it at enable time.
4193
4194`printEmailEnabled` (`:1253`) gains the header sentence when the reply's sender is an address sender, after the delivered names it already prints. It is the one thing a member cannot discover from anywhere else: the two variables arrive in the bundle, and nothing in the bundle says they are headers.
4195
4196`printEmailStatus` (`:1129`) gains the counted-nothing line, on an address sender that is provisioned and whose counts are all zero. It reads off the sender entry plan 2's task 14 already prints a line per sender from, so it is one `if` inside that loop rather than a second pass.
4197
4198`emailUsage`'s `enable` block gains the reviewed sentence.
4199
4200- [ ] **Step 5: Run it to verify it passes**
4201
4202```bash
4203go test ./ ./internal/console/ -count=1
4204```
4205
4206Expected: PASS, including every existing enable-flow test unchanged.
4207
4208- [ ] **Step 6: Gate and commit**
4209
4210```bash
4211go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .
4212git add mail_cli.go mail_cli_test.go internal/console
4213git commit -m "carlos: enable --from at a registered identity claims the address instead of adding a domain (plan 3, task 15)"
4214```
4215
4216---
4217
4218## What this plan does not do
4219
4220Step 3 ends here, with Jelly sending as `jelly@tito.io` from two accounts. The
4221things it stops short of are steps 4 and 5, and they are left out on purpose
4222rather than forgotten.
4223
4224**The per-instance config layer (step 4, spec §6.2, §6.5).** There is no
4225third bundle layer at `apps/<acct>/<app>/instances/<host>.json`, no change to
4226`ConfigAck`, and no way for one instance of an app to be delivered different
4227variables from its siblings. Every sender this plan builds delivers into the
4228app's ONE bundle, which every instance renders, so an address sender is an
4229app-wide fact. That is exactly right for Jelly, which is one app sending as
4230one address, and it is the thing that has to change before an app can hold
4231more than one sender at a time.
4232
4233**Instance senders and per-instance mode (step 5, spec §6.3, §6.4).** No
4234sender in this plan has `Scope == ScopeInstance`. `Sender.Slug` is always
4235empty and `SenderNames` therefore always returns today's names, which is why
4236`ensureAddressPrincipal` takes `UserName(acct, app)` and `TenantName(acct)`
4237directly and says so in a comment: when instance senders land, those two
4238values come from `SenderNames` instead, and `EnvDeliveredSender`'s two
4239expected values come from the sender rather than from the account. Nothing
4240here enters or leaves per-instance mode, there are no instance doors, and
4241`PerInstance` is carried forward by plan 2's record and read by nothing.
4242
4243**The §8 pause verbs.** The spike proved a tenant can be paused directly and
4244that CARLOS can tell an SES-imposed pause from its own (§4.1, answer 4), but
4245this plan adds no `TenantStatus`, no `SetTenantSending` and no
4246`ses:caller-identity` attribution. An address sender pauses the way every
4247sender pauses today, through `SetKeyActive` on its own principal, and its
4248counts are the app's counts.
4249
4250**The honour system around counting.** An address sender's events reach
4251CARLOS only when the message names CARLOS's configuration set, and nothing
4252here can force that. What this plan does instead is make the failure LOUD:
4253the `ses:TenantName` condition means a message missing either header is
4254refused outright rather than sent uncounted, and `<P>_CONFIG_SET` and
4255`<P>_TENANT` are delivered so the app has no excuse. The relay is what
4256removes the honour system, and it is out of scope (spec §10, §13).
4257
4258**A `Local` allowlist on a registration.** `Open` is all-or-nothing per
4259identity, and the trade-off is recorded in §7: with it, any account in the
4260deployment can claim `billing@tito.io`. The mitigation is an allowlist on the
4261external-identity record, and it is deliberately not built now. The operator
4262grant is the answer until it bites.
4263