tenant email: implementation plan — 20 tasks, TDD throughout
Plan for docs/superpowers/specs/2026-08-19-tenant-email-design.md. Two new
packages (internal/mail, internal/dnsrec), console doors, SNS ingest, and
`carlos email`, each task ending in an independently testable deliverable.
Self-review caught three real gaps and fixed them inline:
- Nothing wrote the grant block, so the limiter had no cap to read and no
record of why an app was allowed to send. Task 12a adds it, with a
tier-never-clobbers-operator rule.
- The console API paths were wrong (/api/apps/... rather than the CLI
surface's /api/cli/apps/{sqid}/{app}/...).
- Spec §8's per-instance region-local delivery is DEFERRED, not done —
recorded in Global Constraints so §8 is not closed by mistake.
Tests carry their reason, not just their assertion: the SigningCertURL
suffix check is label-aware because the bare HasSuffix form accepts
sns.eu-west-1.amazonaws.com.evil.com; rates are zero rather than NaN on a
day with no sends because NaN compares false against every threshold and
never trips; nothing auto-resumes because a pause that lifts at midnight is
a schedule, not a limit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+5505
−0
docs/superpowers/plans/2026-08-19-tenant-email.md+5505 −0
diff --git a/docs/superpowers/plans/2026-08-19-tenant-email.md b/docs/superpowers/plans/2026-08-19-tenant-email.md| new file mode 100644 |
| index 000000000..461f941ff |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-19-tenant-email.md |
| @@ -0,0 +1,5505 @@ |
| +# Tenant Email Implementation Plan |
| + |
| +> **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. |
| + |
| +**Goal:** An app declares "I send email"; CARLOS provisions an SES identity on a domain it can prove, mints SMTP credentials scoped to exactly that identity, publishes every DNS record it can publish itself, and delivers the coordinates to the app's instances as env. |
| + |
| +**Architecture:** Two new packages. `internal/mail` mirrors `internal/objstore` verb for verb — record shapes with CAS read-modify-write, a `Provisioner` seam with an AWS implementation and an in-memory fake, and idempotent walks that are re-runnable after a half-failure. `internal/dnsrec` is a small durable-DNS-record seam (Route53 + fake), separate from `internal/edge/dns.go`, which writes only transient ACME challenge TXT records. Console doors and `carlos email` sit on top; SES bounce/complaint events arrive over SNS at a console endpoint. |
| + |
| +**Tech Stack:** Go, AWS SDK v2 (`sesv2`, `iam`, `route53`), SQLite (console), the deployment bucket as control plane. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-19-tenant-email-design.md` — read it before Task 1. Every task below cites the spec section it implements; where they disagree, the spec wins and the plan is wrong. |
| + |
| +## Global Constraints |
| + |
| +- **Gate, before every commit:** `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean, every package. Plus `gofmt -l` clean for files you touch (three pre-existing unformatted files elsewhere are left alone). |
| +- **Sandbox:** use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. Go commands may need the sandbox disabled ("read-only file system" from the toolchain is the tell). **Exception:** Task 8 adds a new module dependency and therefore needs `GOPROXY` unset for that one `go get` — it is the only network step in this plan. |
| +- **Cross-compile:** CI also runs `GOOS=darwin go build ./...`. `internal/mail` is linked into the CLI, which people run on Macs. No Linux-only type assumptions. |
| +- **Wire shapes are additive.** Live records exist in production buckets; anything written before your change must still parse. |
| +- **Every change lands through a PR.** Never merge to `main` locally, never offer it. |
| +- **Env prefix charset** is `^[A-Z][A-Z0-9_]{0,31}$` — reuse, do not re-derive. |
| +- **Region-qualified MAIL FROM** is `mail-<region>.<domain>`, full region name, no abbreviation table. Spec §7. This is the hardest thing in the design to change later. |
| +- **DMARC on the platform domain always carries `adkim=s; aspf=s`.** Spec §7 — it is the tenant boundary on a shared registrable domain, not hygiene. |
| +- **No sending credential is ever written to the bucket record.** Only the access key **id** (which is the SMTP username). Spec §4. |
| +- **`CARLOS_DMARC_RUA`** holds the aggregate-report mailto target; unset means publish no `rua`. Never a constant. |
| +- **Region-local delivery is deferred, deliberately.** Spec §8 says an instance should receive the SMTP host local to *it*, resolved through route-scoped config delivery (#206). This plan delivers one deployment-wide `*_SMTP_HOST` instead, because v1 provisions a single region and a per-instance lookup would have exactly one answer. Everything that makes the per-instance version cheap is already here — the region map in §6a, `SMTPHost(region)` in Task 12 — so it is a delivery change, not a redesign. **Do not close spec §8 as done.** |
| + |
| +--- |
| + |
| +## File Structure |
| + |
| +**New package `internal/mail`** — the primitive: |
| + |
| +| File | Responsibility | |
| +|---|---| |
| +| `smtppass.go` | SMTP password derivation from an IAM secret. Pure. | |
| +| `mail.go` | `apps/<sqid>/<app>/email.json` — record types, key, CAS Get/Put, env names, validators. | |
| +| `domain.go` | `accounts/<sqid>/email/domains/<domain>.json` — identity record, region map, CAS Get/Put. | |
| +| `dnsrecords.go` | Pure: domain + region + DKIM tokens → the record set. | |
| +| `policy.go` | Pure: identity ARNs + domains → the inline IAM policy document. | |
| +| `provisioner.go` | The backend seam (spec §5). | |
| +| `memprovisioner.go` | In-memory fake for every walk test. | |
| +| `aws.go` | SESv2 + IAM behind the seam. | |
| +| `walk.go` | Enable, AddDomain, Verify, Rotate, Pause/Resume, Teardown. | |
| +| `events.go` | SES event parsing and per-app counters. | |
| + |
| +**New package `internal/dnsrec`** — durable DNS records: |
| + |
| +| File | Responsibility | |
| +|---|---| |
| +| `dnsrec.go` | `Record`, the `Writer` seam, `MemWriter` fake. | |
| +| `route53.go` | Route53 implementation with zone discovery. | |
| + |
| +**Modified:** |
| + |
| +| File | Change | |
| +|---|---| |
| +| `internal/console/apimail.go` (create) | The email doors. | |
| +| `internal/console/sesevents.go` (create) | SNS endpoint + signature verification. | |
| +| `internal/console/server.go` | `Config.MailProvisioner`, `Config.DNSWriter`, `Config.DMARCRua`; route registration. | |
| +| `mail_cli.go` (create, repo root) | `carlos email …`. | |
| +| `cli.go` | Register the `email` verb. | |
| +| `go.mod` / `go.sum` | `github.com/aws/aws-sdk-go-v2/service/sesv2`. | |
| + |
| +--- |
| + |
| +## Task 1: SMTP password derivation |
| + |
| +The SMTP username **is** the IAM access key id; the password is a pure local derivation from the IAM secret. No API call. Spec §3. |
| + |
| +**Files:** |
| +- Create: `internal/mail/smtppass.go` |
| +- Test: `internal/mail/smtppass_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- Produces: `func SMTPPassword(secretKey, region string) string` |
| + |
| +- [ ] **Step 1: Write the failing property tests** |
| + |
| +`internal/mail/smtppass_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "encoding/base64" |
| + "testing" |
| +) |
| + |
| +// The derivation is structurally pinned: AWS's algorithm prefixes a |
| +// one-byte version (0x04) to a 32-byte HMAC-SHA256, so every password |
| +// decodes to exactly 33 bytes starting 0x04. A change that breaks either |
| +// invariant produces credentials SES rejects at auth time — hours later, |
| +// in someone else's deployment. |
| +func TestSMTPPasswordShape(t *testing.T) { |
| + got := SMTPPassword("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "eu-west-1") |
| + raw, err := base64.StdEncoding.DecodeString(got) |
| + if err != nil { |
| + t.Fatalf("password is not base64: %v", err) |
| + } |
| + if len(raw) != 33 { |
| + t.Fatalf("decoded length = %d, want 33", len(raw)) |
| + } |
| + if raw[0] != 0x04 { |
| + t.Fatalf("version byte = %#x, want 0x04", raw[0]) |
| + } |
| +} |
| + |
| +// Region is part of the signing chain, so the same secret yields a |
| +// DIFFERENT password per region. This is why §8's multi-region delivery |
| +// must derive per region rather than reuse one string. |
| +func TestSMTPPasswordIsRegionScoped(t *testing.T) { |
| + secret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" |
| + if SMTPPassword(secret, "eu-west-1") == SMTPPassword(secret, "us-east-1") { |
| + t.Fatal("password did not change with region") |
| + } |
| +} |
| + |
| +func TestSMTPPasswordIsDeterministic(t *testing.T) { |
| + secret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" |
| + if SMTPPassword(secret, "eu-west-1") != SMTPPassword(secret, "eu-west-1") { |
| + t.Fatal("derivation is not deterministic") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run TestSMTPPassword -v` |
| +Expected: FAIL — `undefined: SMTPPassword` |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/mail/smtppass.go`: |
| + |
| +```go |
| +// SMTP credential derivation. SES's SMTP password is not issued by an API — |
| +// it is a deterministic function of the IAM secret access key and the |
| +// region, which is why this package can hand an app working SMTP |
| +// credentials without a second AWS call, and why the secret itself never |
| +// needs to be stored anywhere (spec §3, §4). |
| +package mail |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/base64" |
| +) |
| + |
| +// The four constants of AWS's published derivation. smtpDate is a literal |
| +// "11111111" and NOT today's date — SES fixed it so the password does not |
| +// expire; using a real date silently produces a credential SES rejects. |
| +const ( |
| + smtpDate = "11111111" |
| + smtpService = "ses" |
| + smtpTerminal = "aws4_request" |
| + smtpMessage = "SendRawEmail" |
| + smtpVersion = 0x04 |
| +) |
| + |
| +func hmac256(key []byte, data string) []byte { |
| + h := hmac.New(sha256.New, key) |
| + h.Write([]byte(data)) |
| + return h.Sum(nil) |
| +} |
| + |
| +// SMTPPassword derives the SES SMTP password for an IAM secret access key |
| +// in region. The matching SMTP username is the access key ID, unmodified. |
| +// |
| +// The signing chain is date → region → service → terminal → message, seeded |
| +// with "AWS4"+secret, and the result is the version byte followed by the |
| +// HMAC. Every step is load-bearing and the order is not negotiable. |
| +func SMTPPassword(secretKey, region string) string { |
| + sig := []byte("AWS4" + secretKey) |
| + for _, step := range []string{smtpDate, region, smtpService, smtpTerminal, smtpMessage} { |
| + sig = hmac256(sig, step) |
| + } |
| + return base64.StdEncoding.EncodeToString(append([]byte{smtpVersion}, sig...)) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run TestSMTPPassword -v` |
| +Expected: PASS (3 tests) |
| + |
| +- [ ] **Step 5: Add the external-oracle golden test** |
| + |
| +The properties above cannot catch a *wrong but well-formed* chain (e.g. region and service transposed). Only an implementation you did not write can. AWS publishes a reference implementation in the SES Developer Guide ("Obtaining Amazon SES SMTP credentials" → "Converting an existing secret access key"). Run it as the oracle: |
| + |
| +```bash |
| +cat > /tmp/claude-1001/ses_oracle.py <<'PY' |
| +import hmac, hashlib, base64 |
| +def sign(key, msg): return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() |
| +def smtp_password(secret, region): |
| + sig = ('AWS4' + secret).encode('utf-8') |
| + for step in ['11111111', region, 'ses', 'aws4_request', 'SendRawEmail']: |
| + sig = sign(sig, step) |
| + return base64.b64encode(bytes([0x04]) + sig).decode('utf-8') |
| +print(smtp_password('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 'eu-west-1')) |
| +PY |
| +python3 /tmp/claude-1001/ses_oracle.py |
| +``` |
| + |
| +**Before pasting the output**, open the SES Developer Guide page above and confirm the Python there matches this chain step for step. If it differs, **the AWS documentation wins** — fix `smtppass.go` and re-run the oracle. Then paste the printed string as `want` in: |
| + |
| +```go |
| +// A golden value produced by AWS's own reference implementation, not by |
| +// this package. The property tests above cannot tell a correct chain from |
| +// a wrong-but-well-formed one (transpose region and service and every |
| +// property still holds), so this is the only test here with an independent |
| +// oracle. Regenerate it from the SES Developer Guide, never from our code. |
| +func TestSMTPPasswordGolden(t *testing.T) { |
| + const want = "PASTE_ORACLE_OUTPUT_HERE" |
| + got := SMTPPassword("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "eu-west-1") |
| + if got != want { |
| + t.Fatalf("SMTPPassword = %q, want %q", got, want) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run the full package and the gate** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS (4 tests). Then `gofmt -l internal/mail/` → no output. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/mail/smtppass.go internal/mail/smtppass_test.go |
| +git commit -m "mail: derive SES SMTP passwords from IAM secrets |
| + |
| +The SMTP username is the access key id and the password is a pure function |
| +of the secret and the region, so credentials need no second AWS call and |
| +the secret needs no storage. Golden test uses AWS's own reference |
| +implementation as the oracle — the property tests cannot distinguish a |
| +correct signing chain from a transposed one." |
| +``` |
| + |
| +--- |
| + |
| +## Task 2: The app record — `apps/<sqid>/<app>/email.json` |
| + |
| +Spec §6b. Mirrors `internal/objstore/objstore.go` in shape and CAS discipline. |
| + |
| +**Files:** |
| +- Create: `internal/mail/mail.go` |
| +- Test: `internal/mail/mail_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `release.Store`, `release.AppPrefix`, `release.ValidAccount`, `release.ValidApp` (all existing). |
| +- Produces: |
| + - `const Schema = 1`, `const DefaultEnvPrefix = "CARLOS_SMTP"` |
| + - types `Declared`, `GrantBlock`, `Provisioned`, `Standalone`, `Record` |
| + - `func Key(acct, app string) string` |
| + - `func ValidEnvPrefix(p string) bool` |
| + - `func EnvNames(prefix string) (host, port, user, pass, from string)` |
| + - `func Get(ctx, s release.Store, acct, app string) (*Record, string, error)` |
| + - `func Put(ctx, s release.Store, acct, app string, rec *Record, etag string) (string, error)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/mail_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestKeyIsUnderTheAppPrefix(t *testing.T) { |
| + if got, want := Key("bag", "seapointish"), "apps/bag/seapointish/email.json"; got != want { |
| + t.Fatalf("Key = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestEnvNamesMatchSeapointishsContract(t *testing.T) { |
| + // The v1 criterion is zero seapointish code changes, and seapointish |
| + // reads exactly these five names (internal/app/signinflow.go). If this |
| + // test changes, the criterion is no longer met. |
| + host, port, user, pass, from := EnvNames("SEAPOINTISH_SMTP") |
| + for _, c := range []struct{ got, want string }{ |
| + {host, "SEAPOINTISH_SMTP_HOST"}, {port, "SEAPOINTISH_SMTP_PORT"}, |
| + {user, "SEAPOINTISH_SMTP_USER"}, {pass, "SEAPOINTISH_SMTP_PASS"}, |
| + {from, "SEAPOINTISH_SMTP_FROM"}, |
| + } { |
| + if c.got != c.want { |
| + t.Fatalf("EnvNames gave %q, want %q", c.got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestValidEnvPrefixAcceptsSeapointishAndRefusesJunk(t *testing.T) { |
| + for _, ok := range []string{"CARLOS_SMTP", "SEAPOINTISH_SMTP", "A"} { |
| + if !ValidEnvPrefix(ok) { |
| + t.Fatalf("ValidEnvPrefix(%q) = false, want true", ok) |
| + } |
| + } |
| + for _, bad := range []string{"", "lower", "1LEADING", "HAS-DASH", "THIS_PREFIX_IS_WAY_TOO_LONG_TO_BE_OK"} { |
| + if ValidEnvPrefix(bad) { |
| + t.Fatalf("ValidEnvPrefix(%q) = true, want false", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestGetAbsentIsNotAnError(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + rec, etag, err := Get(context.Background(), s, "bag", "seapointish") |
| + if err != nil || rec != nil || etag != "" { |
| + t.Fatalf("Get on absent = (%v, %q, %v), want (nil, \"\", nil)", rec, etag, err) |
| + } |
| +} |
| + |
| +func TestPutThenGetRoundTrips(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + in := &Record{Declared: &Declared{ |
| + EnvPrefix: "SEAPOINTISH_SMTP", |
| + From: "noreply@seapointish.bag.oncarlos.com", |
| + At: now, By: "paul@rslw.com", |
| + }} |
| + if _, err := Put(ctx, s, "bag", "seapointish", in, ""); err != nil { |
| + t.Fatalf("Put: %v", err) |
| + } |
| + out, etag, err := Get(ctx, s, "bag", "seapointish") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if etag == "" { |
| + t.Fatal("Get returned an empty etag for a present record") |
| + } |
| + if out.Schema != Schema || out.App != "seapointish" { |
| + t.Fatalf("Put did not stamp schema/app: %+v", out) |
| + } |
| + if out.Declared.EnvPrefix != "SEAPOINTISH_SMTP" { |
| + t.Fatalf("declared did not round-trip: %+v", out.Declared) |
| + } |
| +} |
| + |
| +// The secret must never be in the record. This asserts on the marshalled |
| +// BYTES rather than the struct, because a future field added with the wrong |
| +// json tag would pass a struct-level check and still leak (spec §4). |
| +func TestRecordBytesCarryNoSecret(t *testing.T) { |
| + rec := &Record{Provisioned: &Provisioned{ |
| + IAMUser: "carlos-mail-bag-seapointish", |
| + KeyID: "AKIAEXAMPLE", |
| + Active: true, |
| + }} |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + for _, forbidden := range []string{"secret", "password", "pass"} { |
| + if containsFold(string(body), forbidden) { |
| + t.Fatalf("record JSON mentions %q: %s", forbidden, body) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Add the helper at the bottom of the test file: |
| + |
| +```go |
| +func containsFold(haystack, needle string) bool { |
| + return len(needle) > 0 && len(haystack) >= len(needle) && |
| + strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) |
| +} |
| +``` |
| + |
| +and add `"strings"` to that file's imports. |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestKey|TestEnv|TestValid|TestGet|TestPut|TestRecord' -v` |
| +Expected: FAIL — undefined `Key`, `EnvNames`, `Record`, … |
| + |
| +First confirm the mem store constructor's real name — the test above assumes `release.NewDirStore(t.TempDir())`: |
| + |
| +```bash |
| +grep -rn "func NewMemStore\|func NewMemoryStore" internal/release/*.go |
| +``` |
| + |
| +If it differs, use the real name throughout this plan's tests. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/mail/mail.go`: |
| + |
| +```go |
| +// Package mail is the tenant-email primitive: an app declares that it sends |
| +// email, the platform provisions an SES identity on a domain it can prove, |
| +// mints SMTP credentials scoped to exactly that identity, and delivers the |
| +// coordinates as env. |
| +// |
| +// See docs/superpowers/specs/2026-08-19-tenant-email-design.md — §4 (what |
| +// actually isolates one tenant from another: the IAM policy, not the SES |
| +// tenant), §6 (these record shapes), §7 (DNS and the three irreversible |
| +// choices), §9 (bounces, caps, the pause). |
| +// |
| +// The shape follows internal/objstore almost verb for verb, because it is |
| +// the same problem with a scarcer resource: a bucket spends money, a |
| +// sending identity spends reputation shared across every tenant. |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "regexp" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const Schema = 1 |
| + |
| +// DefaultEnvPrefix names the delivered env set when a declaration does not |
| +// choose its own. |
| +const DefaultEnvPrefix = "CARLOS_SMTP" |
| + |
| +// Declared is the member's half: the one delivery knob and the From address. |
| +type Declared struct { |
| + EnvPrefix string `json:"env_prefix"` |
| + From string `json:"from"` |
| + At time.Time `json:"at"` |
| + By string `json:"by"` |
| +} |
| + |
| +// GrantBlock is the authorisation half. Source records WHY the grant exists |
| +// ("tier:free", "tier:pro", "operator") so a cap can be recomputed when a |
| +// tier changes without silently discarding an operator's deliberate |
| +// override (spec §10). |
| +type GrantBlock struct { |
| + Domains []string `json:"domains"` |
| + DailyCap int `json:"daily_cap"` |
| + Source string `json:"source"` |
| + At time.Time `json:"at"` |
| + By string `json:"by"` |
| +} |
| + |
| +// Provisioned is the walk's half. KeyID is the SMTP USERNAME and is not a |
| +// secret; the password is derived at delivery time and lands only in the |
| +// app's sealed config (spec §4). Active is the pause state (spec §9). |
| +type Provisioned struct { |
| + IAMUser string `json:"iam_user"` |
| + UserARN string `json:"user_arn"` |
| + KeyID string `json:"key_id"` |
| + PrevKeyID string `json:"prev_key_id,omitempty"` |
| + Active bool `json:"active"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +// Standalone is a displayed credential for something not running on CARLOS. |
| +// |
| +// It gets its OWN IAM principal, and that is forced rather than stylistic: |
| +// IAM allows two access keys per user, and both slots are already spoken |
| +// for by the delivered key plus PrevKeyID during a rotation overlap (the |
| +// same ceiling internal/objstore/walk.go's Rotate documents). A standalone |
| +// key on the shared user would appear to work and would make the first |
| +// rotation impossible — the worst kind of coupling, because it surfaces |
| +// only later and elsewhere. |
| +type Standalone struct { |
| + Name string `json:"name"` |
| + IAMUser string `json:"iam_user"` |
| + KeyID string `json:"key_id"` |
| + At time.Time `json:"at"` |
| + By string `json:"by"` |
| +} |
| + |
| +// Record is apps/<acct>/<app>/email.json — three blocks for three actors, |
| +// plus the standalone credentials, which have their own lifetime. |
| +type Record struct { |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + Declared *Declared `json:"declared,omitempty"` |
| + Grant *GrantBlock `json:"grant,omitempty"` |
| + Provisioned *Provisioned `json:"provisioned,omitempty"` |
| + Standalone []*Standalone `json:"standalone,omitempty"` |
| +} |
| + |
| +func Key(acct, app string) string { return release.AppPrefix(acct, app) + "email.json" } |
| + |
| +var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`) |
| + |
| +func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) } |
| + |
| +// EnvNames renders the delivered variable names for a prefix. The five |
| +// names are exactly what seapointish already reads, which is what makes the |
| +// v1 criterion a zero-diff proof (spec §1). |
| +func EnvNames(prefix string) (host, port, user, pass, from string) { |
| + return prefix + "_HOST", prefix + "_PORT", prefix + "_USER", |
| + prefix + "_PASS", prefix + "_FROM" |
| +} |
| + |
| +// Get reads the record. Absent is (nil, "", nil) — "no email" is a state, |
| +// not an error, the same idiom objstore.Get uses. |
| +func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| + if !release.ValidAccount(acct) { |
| + return nil, "", fmt.Errorf("mail: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return nil, "", fmt.Errorf("mail: invalid app %q", app) |
| + } |
| + raw, etag, err := s.Get(ctx, Key(acct, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + var rec Record |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + return nil, "", fmt.Errorf("mail: %s: %w", Key(acct, app), err) |
| + } |
| + return &rec, etag, nil |
| +} |
| + |
| +// Put writes the record at the caller's CAS position; etag "" is |
| +// create-only. Every writer is a read-modify-write, so a lost race is |
| +// release.ErrCASConflict for the caller to surface, never to absorb. |
| +func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { |
| + rec.Schema = Schema |
| + rec.App = app |
| + body, err := json.MarshalIndent(rec, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, Key(acct, app), append(body, '\n'), etag) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS (all Task 1 and Task 2 tests) |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/mail.go internal/mail/mail_test.go |
| +git commit -m "mail: the app record, apps/<acct>/<app>/email.json |
| + |
| +Three blocks for three actors plus standalone credentials, CAS |
| +read-modify-write, mirroring objstore. Standalone credentials carry their |
| +own IAM user because IAM's two-keys-per-user ceiling is already spent on |
| +the delivered key and the rotation overlap. A test asserts on the |
| +marshalled bytes that no secret is ever in the record." |
| +``` |
| + |
| +--- |
| + |
| +## Task 3: The identity record — `accounts/<sqid>/email/domains/<domain>.json` |
| + |
| +Spec §6a. Account-scoped so two apps sharing one custom domain cannot both create the SES identity. `regions` is a **map**, not a scalar field. |
| + |
| +**Files:** |
| +- Create: `internal/mail/domain.go` |
| +- Test: `internal/mail/domain_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `release.Store`, `release.ValidAccount`. |
| +- Produces: |
| + - types `RegionState`, `DomainRecord` |
| + - `const StatusPending = "pending"`, `StatusVerified = "verified"`, `StatusFailed = "failed"` |
| + - `const KindPlatform = "platform"`, `KindCustom = "custom"` |
| + - `func DomainKey(acct, domain string) string` |
| + - `func DomainsPrefix(acct string) string` |
| + - `func ValidDomain(d string) bool` |
| + - `func GetDomain(ctx, s, acct, domain) (*DomainRecord, string, error)` |
| + - `func PutDomain(ctx, s, acct string, rec *DomainRecord, etag string) (string, error)` |
| + - `func ListDomains(ctx, s, acct string) ([]*DomainRecord, error)` |
| + - `func (r *DomainRecord) VerifiedARNs() []string` |
| + - `func (r *DomainRecord) AddApp(app string) bool` |
| + - `func (r *DomainRecord) RemoveApp(app string) bool` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/domain_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestDomainKeyIsAccountScoped(t *testing.T) { |
| + got := DomainKey("bag", "seapointish.bag.oncarlos.com") |
| + want := "accounts/bag/email/domains/seapointish.bag.oncarlos.com.json" |
| + if got != want { |
| + t.Fatalf("DomainKey = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestValidDomainRefusesWhatWouldEscapeTheKey(t *testing.T) { |
| + for _, ok := range []string{"seapointish.bag.oncarlos.com", "example.com", "a-b.example.co.uk"} { |
| + if !ValidDomain(ok) { |
| + t.Fatalf("ValidDomain(%q) = false, want true", ok) |
| + } |
| + } |
| + // A domain becomes a bucket KEY and an IAM policy RESOURCE. Anything |
| + // that could traverse or inject must be refused here, at the only |
| + // place every caller passes through. |
| + for _, bad := range []string{"", ".", "../etc", "a/b.com", "UPPER.com", "-lead.com", "trail-.com", "a..b.com"} { |
| + if ValidDomain(bad) { |
| + t.Fatalf("ValidDomain(%q) = true, want false", bad) |
| + } |
| + } |
| +} |
| + |
| +// Regions is a MAP, and this test is the reason: a domain verified in two |
| +// regions holds two independent identities, ARNs and DKIM token sets, and |
| +// a scalar region field would have to be migrated the day a second region |
| +// appears (spec §6a, §8). |
| +func TestRegionsAreIndependent(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + rec := &DomainRecord{ |
| + Domain: "seapointish.bag.oncarlos.com", |
| + Kind: KindPlatform, |
| + Apps: []string{"seapointish"}, |
| + Regions: map[string]*RegionState{ |
| + "eu-west-1": {IdentityARN: "arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com", |
| + DKIMTokens: []string{"a", "b", "c"}, |
| + MailFrom: "mail-eu-west-1.seapointish.bag.oncarlos.com", |
| + Status: StatusVerified, CheckedAt: now}, |
| + "us-east-1": {IdentityARN: "arn:aws:ses:us-east-1:1:identity/seapointish.bag.oncarlos.com", |
| + DKIMTokens: []string{"d", "e", "f"}, |
| + MailFrom: "mail-us-east-1.seapointish.bag.oncarlos.com", |
| + Status: StatusPending, CheckedAt: now}, |
| + }, |
| + CreatedAt: now, |
| + } |
| + if _, err := PutDomain(ctx, s, "bag", rec, ""); err != nil { |
| + t.Fatalf("PutDomain: %v", err) |
| + } |
| + out, _, err := GetDomain(ctx, s, "bag", "seapointish.bag.oncarlos.com") |
| + if err != nil { |
| + t.Fatalf("GetDomain: %v", err) |
| + } |
| + if len(out.Regions) != 2 { |
| + t.Fatalf("regions = %d, want 2", len(out.Regions)) |
| + } |
| + if out.Regions["us-east-1"].DKIMTokens[0] != "d" { |
| + t.Fatal("per-region DKIM tokens did not round-trip independently") |
| + } |
| +} |
| + |
| +// Only a VERIFIED region contributes an ARN, so a pending domain grants |
| +// nothing (spec §6a). |
| +func TestVerifiedARNsSkipsPendingAndFailed(t *testing.T) { |
| + rec := &DomainRecord{Regions: map[string]*RegionState{ |
| + "eu-west-1": {IdentityARN: "arn-ok", Status: StatusVerified}, |
| + "us-east-1": {IdentityARN: "arn-pending", Status: StatusPending}, |
| + "ap-south-1": {IdentityARN: "arn-failed", Status: StatusFailed}, |
| + }} |
| + got := rec.VerifiedARNs() |
| + if len(got) != 1 || got[0] != "arn-ok" { |
| + t.Fatalf("VerifiedARNs = %v, want [arn-ok]", got) |
| + } |
| +} |
| + |
| +// The apps set is what makes account-scoping worth its cost: teardown of |
| +// one app must never revoke a sibling's verified domain (spec §6a). |
| +func TestAppsSetGuardsSiblingTeardown(t *testing.T) { |
| + rec := &DomainRecord{Apps: []string{"seapointish"}} |
| + if !rec.AddApp("marketing") { |
| + t.Fatal("AddApp returned false for a new app") |
| + } |
| + if rec.AddApp("marketing") { |
| + t.Fatal("AddApp returned true for an app already present") |
| + } |
| + if !rec.RemoveApp("seapointish") { |
| + t.Fatal("RemoveApp returned false for a present app") |
| + } |
| + if len(rec.Apps) != 1 || rec.Apps[0] != "marketing" { |
| + t.Fatalf("Apps = %v, want [marketing] — the sibling must survive", rec.Apps) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestDomain|TestValidDomain|TestRegions|TestVerified|TestApps' -v` |
| +Expected: FAIL — undefined `DomainKey`, `DomainRecord`, … |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/mail/domain.go`: |
| + |
| +```go |
| +// The identity record (spec §6a): accounts/<sqid>/email/domains/<domain>.json. |
| +// |
| +// ACCOUNT-scoped, not app-scoped, and the reason is narrow: two apps in one |
| +// account may legitimately share one custom domain, and only one of them may |
| +// create the SES identity and write its DNS. A double-create is the failure |
| +// this split exists to prevent. In the common case there is exactly one such |
| +// record per app, auto-created, and nobody looks at it. |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The closed status set. Only StatusVerified puts an identity's ARN into a |
| +// credential's policy, so a pending domain grants nothing. |
| +const ( |
| + StatusPending = "pending" |
| + StatusVerified = "verified" |
| + StatusFailed = "failed" |
| +) |
| + |
| +// KindPlatform means CARLOS owns the zone and publishes DNS itself; |
| +// KindCustom means the customer does. It is the one bit that decides who |
| +// publishes (spec §7). |
| +const ( |
| + KindPlatform = "platform" |
| + KindCustom = "custom" |
| +) |
| + |
| +// RegionState is one region's identity. Everything in here is regional: |
| +// SES issues a distinct identity, distinct DKIM tokens and a distinct |
| +// MAIL FROM MX target per region (spec §8). |
| +type RegionState struct { |
| + IdentityARN string `json:"identity_arn"` |
| + DKIMTokens []string `json:"dkim_tokens"` |
| + MailFrom string `json:"mail_from"` |
| + ConfigSet string `json:"config_set"` |
| + Status string `json:"status"` |
| + CheckedAt time.Time `json:"checked_at"` |
| +} |
| + |
| +// DomainRecord is one sending domain. |
| +// |
| +// Regions is a MAP rather than a scalar field. Every SES dimension that |
| +// matters is regional, so a scalar would have to be migrated across the |
| +// fleet the first time a second region appears. Keying costs nothing today |
| +// and is the difference between adding a region and a migration. |
| +type DomainRecord struct { |
| + Schema int `json:"schema"` |
| + Domain string `json:"domain"` |
| + Kind string `json:"kind"` |
| + Apps []string `json:"apps"` |
| + Regions map[string]*RegionState `json:"regions"` |
| + CreatedAt time.Time `json:"created_at"` |
| +} |
| + |
| +func DomainsPrefix(acct string) string { return "accounts/" + acct + "/email/domains/" } |
| + |
| +func DomainKey(acct, domain string) string { return DomainsPrefix(acct) + domain + ".json" } |
| + |
| +// A conservative LDH hostname: lowercase labels of alphanumerics and inner |
| +// hyphens, at least two labels. Deliberately stricter than DNS allows, |
| +// because this string becomes a bucket key AND an IAM policy resource, and |
| +// the only safe place to refuse a traversal or an injection is the one |
| +// gate every caller passes through. |
| +var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) |
| + |
| +func ValidDomain(d string) bool { |
| + return len(d) <= 253 && domainRe.MatchString(d) |
| +} |
| + |
| +func GetDomain(ctx context.Context, s release.Store, acct, domain string) (*DomainRecord, string, error) { |
| + if !release.ValidAccount(acct) { |
| + return nil, "", fmt.Errorf("mail: invalid account %q", acct) |
| + } |
| + if !ValidDomain(domain) { |
| + return nil, "", fmt.Errorf("mail: invalid domain %q", domain) |
| + } |
| + raw, etag, err := s.Get(ctx, DomainKey(acct, domain)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + var rec DomainRecord |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + return nil, "", fmt.Errorf("mail: %s: %w", DomainKey(acct, domain), err) |
| + } |
| + return &rec, etag, nil |
| +} |
| + |
| +func PutDomain(ctx context.Context, s release.Store, acct string, rec *DomainRecord, etag string) (string, error) { |
| + if !ValidDomain(rec.Domain) { |
| + return "", fmt.Errorf("mail: invalid domain %q", rec.Domain) |
| + } |
| + rec.Schema = Schema |
| + body, err := json.MarshalIndent(rec, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, DomainKey(acct, rec.Domain), append(body, '\n'), etag) |
| +} |
| + |
| +// ListDomains reads every identity record for an account. A key that fails |
| +// to parse is skipped rather than failing the listing: one malformed record |
| +// must not hide an account's other domains from the console. |
| +func ListDomains(ctx context.Context, s release.Store, acct string) ([]*DomainRecord, error) { |
| + keys, err := s.List(ctx, DomainsPrefix(acct)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []*DomainRecord |
| + for _, k := range keys { |
| + if !strings.HasSuffix(k, ".json") { |
| + continue |
| + } |
| + raw, _, err := s.Get(ctx, k) |
| + if err != nil { |
| + continue |
| + } |
| + var rec DomainRecord |
| + if json.Unmarshal(raw, &rec) != nil { |
| + continue |
| + } |
| + out = append(out, &rec) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain }) |
| + return out, nil |
| +} |
| + |
| +// VerifiedARNs is every identity ARN this domain contributes to a |
| +// credential's policy. Only verified regions count. |
| +func (r *DomainRecord) VerifiedARNs() []string { |
| + var out []string |
| + for _, st := range r.Regions { |
| + if st.Status == StatusVerified && st.IdentityARN != "" { |
| + out = append(out, st.IdentityARN) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// AddApp records an app as a referrer. Reports whether it changed anything. |
| +func (r *DomainRecord) AddApp(app string) bool { |
| + for _, a := range r.Apps { |
| + if a == app { |
| + return false |
| + } |
| + } |
| + r.Apps = append(r.Apps, app) |
| + sort.Strings(r.Apps) |
| + return true |
| +} |
| + |
| +// RemoveApp drops a referrer. The SES identity and its DNS are deleted only |
| +// when the set empties — without this, one app's teardown would silently |
| +// revoke a sibling app's verified domain. |
| +func (r *DomainRecord) RemoveApp(app string) bool { |
| + for i, a := range r.Apps { |
| + if a == app { |
| + r.Apps = append(r.Apps[:i], r.Apps[i+1:]...) |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/domain.go internal/mail/domain_test.go |
| +git commit -m "mail: the identity record, account-scoped with a region map |
| + |
| +Account-scoped so two apps sharing a custom domain cannot both create the |
| +SES identity; the apps set is what stops one app's teardown revoking a |
| +sibling's verified domain. Regions is a map rather than a field because |
| +every SES dimension that matters is regional, and a scalar would need a |
| +fleet-wide migration the day a second region appears." |
| +``` |
| + |
| +--- |
| + |
| +*(Tasks 4–15 continue below.)* |
| + |
| +## Task 4: `internal/dnsrec` — the durable record seam and its fake |
| + |
| +Spec §7. `internal/edge/dns.go` writes **transient** ACME challenge TXT records behind a `present(fqdn, value) (cleanup, error)` contract. Durable CNAME/MX/TXT records have a different lifetime and a different shape, so they get their own seam rather than overloading one whose contract is "present this, then call cleanup". |
| + |
| +**Files:** |
| +- Create: `internal/dnsrec/dnsrec.go` |
| +- Test: `internal/dnsrec/dnsrec_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- Produces: |
| + - `type Record struct { Name, Type string; Values []string; TTL int64 }` |
| + - `const TypeCNAME = "CNAME"`, `TypeMX = "MX"`, `TypeTXT = "TXT"` |
| + - `type Writer interface { EnsureRecord(ctx, r Record) error; DeleteRecord(ctx, r Record) error }` |
| + - `type MemWriter struct { Records map[string]Record; FailNext map[string]error }` |
| + - `func NewMemWriter() *MemWriter` |
| + - `func (r Record) Key() string` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/dnsrec/dnsrec_test.go`: |
| + |
| +```go |
| +package dnsrec |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +// A record is identified by (name, type), not by value. That is what makes |
| +// EnsureRecord an UPSERT rather than a create, and an upsert is what makes |
| +// every walk in internal/mail re-runnable after a half-failure. |
| +func TestKeyIsNameAndTypeOnly(t *testing.T) { |
| + a := Record{Name: "mail-eu-west-1.example.com", Type: TypeTXT, Values: []string{"v=spf1 -all"}} |
| + b := Record{Name: "mail-eu-west-1.example.com", Type: TypeTXT, Values: []string{"different"}} |
| + if a.Key() != b.Key() { |
| + t.Fatal("Key must not depend on Values — an upsert has to find the record it replaces") |
| + } |
| + c := Record{Name: "mail-eu-west-1.example.com", Type: TypeMX, Values: []string{"10 x"}} |
| + if a.Key() == c.Key() { |
| + t.Fatal("Key must depend on Type — MX and TXT coexist at one name") |
| + } |
| +} |
| + |
| +func TestMemWriterUpsertsAndDeletes(t *testing.T) { |
| + ctx, w := context.Background(), NewMemWriter() |
| + r := Record{Name: "x._domainkey.example.com", Type: TypeCNAME, |
| + Values: []string{"x.dkim.amazonses.com"}, TTL: 300} |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + t.Fatalf("EnsureRecord: %v", err) |
| + } |
| + if got := w.Records[r.Key()]; got.Values[0] != "x.dkim.amazonses.com" { |
| + t.Fatalf("record not stored: %+v", got) |
| + } |
| + // Re-ensuring with a new value overwrites rather than duplicating. |
| + r.Values = []string{"y.dkim.amazonses.com"} |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + t.Fatalf("re-EnsureRecord: %v", err) |
| + } |
| + if len(w.Records) != 1 || w.Records[r.Key()].Values[0] != "y.dkim.amazonses.com" { |
| + t.Fatalf("upsert did not replace: %+v", w.Records) |
| + } |
| + if err := w.DeleteRecord(ctx, r); err != nil { |
| + t.Fatalf("DeleteRecord: %v", err) |
| + } |
| + if len(w.Records) != 0 { |
| + t.Fatalf("delete left %d records", len(w.Records)) |
| + } |
| +} |
| + |
| +// "Already gone" is a success, because teardown must be re-runnable. |
| +func TestMemWriterDeleteAbsentIsSuccess(t *testing.T) { |
| + w := NewMemWriter() |
| + if err := w.DeleteRecord(context.Background(), |
| + Record{Name: "nope.example.com", Type: TypeTXT}); err != nil { |
| + t.Fatalf("deleting an absent record returned %v, want nil", err) |
| + } |
| +} |
| + |
| +// FailNext is one-shot, so a test can drive a walk through a half-failure |
| +// at an exact step and then let the retry succeed. |
| +func TestMemWriterFailNextIsOneShot(t *testing.T) { |
| + ctx, w := context.Background(), NewMemWriter() |
| + boom := errors.New("boom") |
| + w.FailNext["EnsureRecord"] = boom |
| + r := Record{Name: "a.example.com", Type: TypeTXT, Values: []string{"v"}} |
| + if err := w.EnsureRecord(ctx, r); !errors.Is(err, boom) { |
| + t.Fatalf("first EnsureRecord = %v, want boom", err) |
| + } |
| + if len(w.Records) != 0 { |
| + t.Fatal("a failed call must leave state untouched") |
| + } |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + t.Fatalf("second EnsureRecord = %v, want nil", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/dnsrec/ -v` |
| +Expected: FAIL — `undefined: Record`, `NewMemWriter`, … |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/dnsrec/dnsrec.go`: |
| + |
| +```go |
| +// Package dnsrec writes DURABLE DNS records — the DKIM CNAMEs, MAIL FROM MX |
| +// and SPF/DMARC TXT records a sending identity needs (tenant-email spec §7). |
| +// |
| +// It is deliberately NOT internal/edge/dns.go. That package's dnsProvider |
| +// publishes a transient ACME challenge TXT and hands back a cleanup closure; |
| +// its whole contract is "present this, then remove it". These records outlive |
| +// the process that wrote them, are upserted rather than presented, and are |
| +// deleted only on teardown. Putting them behind that interface would mean a |
| +// caller holding a cleanup closure for a record that must never be cleaned up. |
| +package dnsrec |
| + |
| +import ( |
| + "context" |
| + "sync" |
| +) |
| + |
| +const ( |
| + TypeCNAME = "CNAME" |
| + TypeMX = "MX" |
| + TypeTXT = "TXT" |
| +) |
| + |
| +// Record is one resource record set. Values is a set, not a single string, |
| +// because a TXT or MX name can legitimately carry several. |
| +type Record struct { |
| + Name string |
| + Type string |
| + Values []string |
| + TTL int64 |
| +} |
| + |
| +// Key identifies the record SET — name and type, never the values. DNS |
| +// replaces a whole set at a time, and identifying by value would make |
| +// EnsureRecord a create (which collides with a stranded record from a |
| +// crashed earlier attempt) instead of an upsert (which repairs it). |
| +func (r Record) Key() string { return r.Name + "|" + r.Type } |
| + |
| +// Writer is the backend seam. Both methods are idempotent: an EnsureRecord |
| +// over an existing set replaces it, and deleting an absent set succeeds. |
| +// Every walk that drives one must be re-runnable after a half-failure. |
| +type Writer interface { |
| + EnsureRecord(ctx context.Context, r Record) error |
| + DeleteRecord(ctx context.Context, r Record) error |
| +} |
| + |
| +// MemWriter is the in-memory fake: no AWS, no network, and a one-shot |
| +// failure injector so a test can drive a walk through a half-failure at an |
| +// exact step. |
| +type MemWriter struct { |
| + mu sync.Mutex |
| + |
| + // Records maps Record.Key() -> the stored set. |
| + Records map[string]Record |
| + // FailNext, keyed by method name, is consumed one-shot: the next call |
| + // to that method fails, then the entry is gone. Consumption happens |
| + // BEFORE any state change, so a failed call leaves state untouched. |
| + FailNext map[string]error |
| +} |
| + |
| +func NewMemWriter() *MemWriter { |
| + return &MemWriter{Records: map[string]Record{}, FailNext: map[string]error{}} |
| +} |
| + |
| +func (w *MemWriter) takeFailure(method string) error { |
| + if err, ok := w.FailNext[method]; ok { |
| + delete(w.FailNext, method) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func (w *MemWriter) EnsureRecord(_ context.Context, r Record) error { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + if err := w.takeFailure("EnsureRecord"); err != nil { |
| + return err |
| + } |
| + w.Records[r.Key()] = r |
| + return nil |
| +} |
| + |
| +func (w *MemWriter) DeleteRecord(_ context.Context, r Record) error { |
| + w.mu.Lock() |
| + defer w.mu.Unlock() |
| + if err := w.takeFailure("DeleteRecord"); err != nil { |
| + return err |
| + } |
| + delete(w.Records, r.Key()) |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/dnsrec/ -count=1 -v` |
| +Expected: PASS (4 tests) |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/dnsrec/ |
| +git add internal/dnsrec/dnsrec.go internal/dnsrec/dnsrec_test.go |
| +git commit -m "dnsrec: a seam for durable DNS records, with its fake |
| + |
| +Separate from internal/edge/dns.go on purpose: that package presents a |
| +transient ACME challenge and hands back a cleanup closure, and these records |
| +must outlive the process that wrote them. Records are keyed by name and type |
| +so EnsureRecord is an upsert, which is what repairs a set stranded by a |
| +crashed earlier attempt." |
| +``` |
| + |
| +--- |
| + |
| +## Task 5: `internal/dnsrec` — the Route53 implementation |
| + |
| +**Files:** |
| +- Create: `internal/dnsrec/route53.go` |
| +- Test: `internal/dnsrec/route53_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Record`, `Writer` (Task 4). |
| +- Produces: |
| + - `func NewRoute53Writer(ctx context.Context) (Writer, error)` |
| + - `func quoteTXT(v string) string` (unexported; tested via the exported path) |
| + |
| +**Read first:** `internal/edge/route53dns.go:130-176` — `hostedZoneID` and `dnsFQDN`. Its long comment explains why `ListHostedZonesByName` cannot be used as a filter and why the parent-suffix walk is the only correct discovery. That reasoning transfers verbatim; do not re-derive it and do not "simplify" it back into a single listing. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/dnsrec/route53_test.go`: |
| + |
| +```go |
| +package dnsrec |
| + |
| +import "testing" |
| + |
| +// Route53 stores a TXT value as a quoted character-string. SPF and DMARC |
| +// values contain spaces and semicolons, so an unquoted value is either |
| +// rejected or silently stored as something no resolver will match — which |
| +// looks like "DNS published, verification still pending" forever. |
| +func TestTXTValuesAreQuoted(t *testing.T) { |
| + got := quoteTXT(`v=DMARC1; p=none; adkim=s; aspf=s`) |
| + want := `"v=DMARC1; p=none; adkim=s; aspf=s"` |
| + if got != want { |
| + t.Fatalf("quoteTXT = %s, want %s", got, want) |
| + } |
| +} |
| + |
| +// A TXT value with an embedded quote must be escaped, not truncated. |
| +func TestTXTQuotingEscapes(t *testing.T) { |
| + if got := quoteTXT(`a"b`); got != `"a\"b"` { |
| + t.Fatalf("quoteTXT = %s, want %s", got, `"a\"b"`) |
| + } |
| +} |
| + |
| +// A CNAME or MX value is NOT quoted — quoting one produces a record Route53 |
| +// accepts and no resolver can follow. |
| +func TestNonTXTValuesAreNotQuoted(t *testing.T) { |
| + for _, typ := range []string{TypeCNAME, TypeMX} { |
| + if got := renderValue(typ, "x.dkim.amazonses.com"); got != "x.dkim.amazonses.com" { |
| + t.Fatalf("renderValue(%s) = %q, want unquoted", typ, got) |
| + } |
| + } |
| + if got := renderValue(TypeTXT, "hello"); got != `"hello"` { |
| + t.Fatalf("renderValue(TXT) = %q, want quoted", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/dnsrec/ -run 'TestTXT|TestNonTXT' -v` |
| +Expected: FAIL — `undefined: quoteTXT`, `undefined: renderValue` |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/dnsrec/route53.go`: |
| + |
| +```go |
| +// The Route53 backend. Zone discovery is the parent-suffix walk from |
| +// internal/edge/route53dns.go's hostedZoneID — see that function's comment |
| +// for why ListHostedZonesByName cannot be used as a filter. The reasoning is |
| +// load-bearing and is repeated here rather than shared, because edge's copy |
| +// is unexported and internal/edge is a box-side package this one must not |
| +// depend on. |
| +package dnsrec |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/route53" |
| + r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| +) |
| + |
| +type route53Writer struct { |
| + client *route53.Client |
| + mu sync.Mutex |
| + zoneIDs map[string]string |
| +} |
| + |
| +// NewRoute53Writer builds a Writer against Route53, authed by the standard |
| +// AWS credential chain — no DNS token on disk. |
| +func NewRoute53Writer(ctx context.Context) (Writer, error) { |
| + var opts []func(*awsconfig.LoadOptions) error |
| + if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" { |
| + opts = append(opts, awsconfig.WithRegion("us-east-1")) |
| + } |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + if err != nil { |
| + return nil, fmt.Errorf("dnsrec: load aws config: %w", err) |
| + } |
| + return &route53Writer{client: route53.NewFromConfig(cfg), zoneIDs: map[string]string{}}, nil |
| +} |
| + |
| +// quoteTXT renders a TXT value as Route53's quoted character-string. SPF and |
| +// DMARC values carry spaces and semicolons, and an unquoted one is either |
| +// refused or stored as something no resolver matches — which presents as |
| +// "published, still pending" indefinitely. |
| +func quoteTXT(v string) string { return strconv.Quote(v) } |
| + |
| +// renderValue quotes TXT values and leaves every other type alone. Quoting a |
| +// CNAME or MX target produces a record Route53 accepts and nothing can follow. |
| +func renderValue(typ, v string) string { |
| + if typ == TypeTXT { |
| + return quoteTXT(v) |
| + } |
| + return v |
| +} |
| + |
| +func (w *route53Writer) rrset(r Record) *r53types.ResourceRecordSet { |
| + ttl := r.TTL |
| + if ttl == 0 { |
| + ttl = 300 |
| + } |
| + recs := make([]r53types.ResourceRecord, 0, len(r.Values)) |
| + for _, v := range r.Values { |
| + recs = append(recs, r53types.ResourceRecord{Value: aws.String(renderValue(r.Type, v))}) |
| + } |
| + return &r53types.ResourceRecordSet{ |
| + Name: aws.String(fqdn(r.Name)), |
| + Type: r53types.RRType(r.Type), |
| + TTL: aws.Int64(ttl), |
| + ResourceRecords: recs, |
| + } |
| +} |
| + |
| +func (w *route53Writer) EnsureRecord(ctx context.Context, r Record) error { |
| + zoneID, err := w.hostedZoneID(ctx, r.Name) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = w.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ |
| + HostedZoneId: aws.String(zoneID), |
| + ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{{ |
| + Action: r53types.ChangeActionUpsert, ResourceRecordSet: w.rrset(r), |
| + }}}, |
| + }) |
| + if err != nil { |
| + return fmt.Errorf("dnsrec: upsert %s %s: %w", r.Type, r.Name, err) |
| + } |
| + return nil |
| +} |
| + |
| +// DeleteRecord resubmits the same set with DELETE. A set that is already |
| +// gone answers InvalidChangeBatch ("not found"), which is a SUCCESS here — |
| +// teardown must be re-runnable. |
| +func (w *route53Writer) DeleteRecord(ctx context.Context, r Record) error { |
| + zoneID, err := w.hostedZoneID(ctx, r.Name) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = w.client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{ |
| + HostedZoneId: aws.String(zoneID), |
| + ChangeBatch: &r53types.ChangeBatch{Changes: []r53types.Change{{ |
| + Action: r53types.ChangeActionDelete, ResourceRecordSet: w.rrset(r), |
| + }}}, |
| + }) |
| + if err != nil { |
| + if isNotFoundChange(err) { |
| + return nil |
| + } |
| + return fmt.Errorf("dnsrec: delete %s %s: %w", r.Type, r.Name, err) |
| + } |
| + return nil |
| +} |
| + |
| +// isNotFoundChange recognises Route53's answer for deleting a record set |
| +// that is not there. It is reported as InvalidChangeBatch with a message |
| +// naming the absence rather than as a typed "no such record" error, so the |
| +// message is the only thing to match on. |
| +func isNotFoundChange(err error) bool { |
| + s := err.Error() |
| + return strings.Contains(s, "not found") || strings.Contains(s, "but it was not found") |
| +} |
| + |
| +// fqdn appends the root label Route53 expects. |
| +func fqdn(name string) string { |
| + if strings.HasSuffix(name, ".") { |
| + return name |
| + } |
| + return name + "." |
| +} |
| + |
| +// hostedZoneID resolves the zone authoritative for name, caching the lookup. |
| +// Candidates are walked longest-first so the FIRST exact hit is automatically |
| +// the most specific zone. See internal/edge/route53dns.go's hostedZoneID for |
| +// why each candidate needs its own ListHostedZonesByName probe. |
| +func (w *route53Writer) hostedZoneID(ctx context.Context, name string) (string, error) { |
| + w.mu.Lock() |
| + if id, ok := w.zoneIDs[name]; ok { |
| + w.mu.Unlock() |
| + return id, nil |
| + } |
| + w.mu.Unlock() |
| + |
| + labels := strings.Split(strings.TrimSuffix(name, "."), ".") |
| + for i := 0; i < len(labels)-1; i++ { |
| + cand := strings.Join(labels[i:], ".") |
| + out, err := w.client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{ |
| + DNSName: aws.String(cand), |
| + }) |
| + if err != nil { |
| + return "", fmt.Errorf("dnsrec: list hosted zones for %q: %w", cand, err) |
| + } |
| + for _, z := range out.HostedZones { |
| + if strings.TrimSuffix(aws.ToString(z.Name), ".") != cand { |
| + continue |
| + } |
| + id := strings.TrimPrefix(aws.ToString(z.Id), "/hostedzone/") |
| + w.mu.Lock() |
| + w.zoneIDs[name] = id |
| + w.mu.Unlock() |
| + return id, nil |
| + } |
| + } |
| + return "", fmt.Errorf("dnsrec: no hosted zone found for %q", name) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/dnsrec/ -count=1 -v` |
| +Expected: PASS (7 tests) |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... |
| +gofmt -l internal/dnsrec/ |
| +git add internal/dnsrec/route53.go internal/dnsrec/route53_test.go |
| +git commit -m "dnsrec: Route53 backend with parent-suffix zone discovery |
| + |
| +Zone discovery repeats internal/edge/route53dns.go's walk rather than |
| +sharing it — edge's copy is unexported and is a box-side package this one |
| +must not depend on. TXT values are quoted and CNAME/MX values are not; |
| +getting that backwards yields a record Route53 accepts and no resolver can |
| +use, which presents as verification pending forever." |
| +``` |
| + |
| +--- |
| + |
| +## Task 6: The DNS record set for a sending identity |
| + |
| +Spec §7. Pure function. This task pins the two choices that are expensive-to-impossible to change later, so its golden test is the most valuable test in the plan. |
| + |
| +**Files:** |
| +- Create: `internal/mail/dnsrecords.go` |
| +- Test: `internal/mail/dnsrecords_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `dnsrec.Record`, `dnsrec.TypeCNAME/TypeMX/TypeTXT` (Task 4). |
| +- Produces: |
| + - `func MailFromDomain(domain, region string) string` |
| + - `func RecordSet(domain, region string, dkimTokens []string, rua string) []dnsrec.Record` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/dnsrecords_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| +) |
| + |
| +// The MAIL FROM subdomain carries its region from the FIRST domain ever |
| +// verified. For a custom domain this record lives in the CUSTOMER's zone, |
| +// so it is the one thing we cannot republish for them — and the MX target |
| +// is region-specific, so an unqualified "mail." could never hold a second |
| +// region without ambiguity (spec §7, §8). |
| +func TestMailFromDomainCarriesTheRegion(t *testing.T) { |
| + got := MailFromDomain("seapointish.bag.oncarlos.com", "eu-west-1") |
| + want := "mail-eu-west-1.seapointish.bag.oncarlos.com" |
| + if got != want { |
| + t.Fatalf("MailFromDomain = %q, want %q", got, want) |
| + } |
| + if MailFromDomain("x.com", "eu-west-1") == MailFromDomain("x.com", "us-east-1") { |
| + t.Fatal("two regions collided on one MAIL FROM subdomain") |
| + } |
| +} |
| + |
| +// Nothing is EVER written at the app host itself. The edge owns an A record |
| +// there, and an email record sharing that name could fight it — this is a |
| +// permanent invariant, not an implementation detail (spec §7). |
| +func TestNoRecordSitsAtTheDomainApex(t *testing.T) { |
| + const domain = "seapointish.bag.oncarlos.com" |
| + for _, r := range RecordSet(domain, "eu-west-1", []string{"t1", "t2", "t3"}, "") { |
| + if r.Name == domain { |
| + t.Fatalf("record %s %s sits at the apex — it would fight the edge's A record", r.Type, r.Name) |
| + } |
| + } |
| +} |
| + |
| +func TestRecordSetGolden(t *testing.T) { |
| + got := RecordSet("seapointish.bag.oncarlos.com", "eu-west-1", |
| + []string{"tok1", "tok2", "tok3"}, "") |
| + want := []dnsrec.Record{ |
| + {Name: "tok1._domainkey.seapointish.bag.oncarlos.com", Type: dnsrec.TypeCNAME, |
| + Values: []string{"tok1.dkim.amazonses.com"}, TTL: 300}, |
| + {Name: "tok2._domainkey.seapointish.bag.oncarlos.com", Type: dnsrec.TypeCNAME, |
| + Values: []string{"tok2.dkim.amazonses.com"}, TTL: 300}, |
| + {Name: "tok3._domainkey.seapointish.bag.oncarlos.com", Type: dnsrec.TypeCNAME, |
| + Values: []string{"tok3.dkim.amazonses.com"}, TTL: 300}, |
| + {Name: "mail-eu-west-1.seapointish.bag.oncarlos.com", Type: dnsrec.TypeMX, |
| + Values: []string{"10 feedback-smtp.eu-west-1.amazonses.com"}, TTL: 300}, |
| + {Name: "mail-eu-west-1.seapointish.bag.oncarlos.com", Type: dnsrec.TypeTXT, |
| + Values: []string{"v=spf1 include:amazonses.com ~all"}, TTL: 300}, |
| + {Name: "_dmarc.seapointish.bag.oncarlos.com", Type: dnsrec.TypeTXT, |
| + Values: []string{"v=DMARC1; p=none; adkim=s; aspf=s"}, TTL: 300}, |
| + } |
| + if len(got) != len(want) { |
| + t.Fatalf("got %d records, want %d:\n%+v", len(got), len(want), got) |
| + } |
| + for i := range want { |
| + if got[i].Name != want[i].Name || got[i].Type != want[i].Type || |
| + got[i].TTL != want[i].TTL || len(got[i].Values) != len(want[i].Values) || |
| + got[i].Values[0] != want[i].Values[0] { |
| + t.Fatalf("record %d:\n got %+v\nwant %+v", i, got[i], want[i]) |
| + } |
| + } |
| +} |
| + |
| +// adkim=s is the TENANT BOUNDARY on a shared registrable domain, not |
| +// hygiene. DMARC's default relaxed alignment compares ORGANIZATIONAL |
| +// domains, so without it tenant bag's DKIM signature authenticates a From |
| +// address at bae.oncarlos.com (spec §7). |
| +func TestDMARCIsStrictlyAligned(t *testing.T) { |
| + var dmarc string |
| + for _, r := range RecordSet("x.bag.oncarlos.com", "eu-west-1", []string{"t"}, "") { |
| + if r.Name == "_dmarc.x.bag.oncarlos.com" { |
| + dmarc = r.Values[0] |
| + } |
| + } |
| + if dmarc == "" { |
| + t.Fatal("no _dmarc record emitted") |
| + } |
| + for _, want := range []string{"adkim=s", "aspf=s"} { |
| + if !contains(dmarc, want) { |
| + t.Fatalf("DMARC %q is missing %q — tenants are not isolated", dmarc, want) |
| + } |
| + } |
| +} |
| + |
| +// rua is CONFIGURATION. Unset means publish no rua at all: a reporting |
| +// address is only honoured by receivers when the destination authorises it |
| +// (RFC 7489 §7.1), so emitting one by default would publish a record that |
| +// looks right and yields nothing. |
| +func TestRUAIsOmittedWhenUnset(t *testing.T) { |
| + for _, r := range RecordSet("x.com", "eu-west-1", []string{"t"}, "") { |
| + if r.Name == "_dmarc.x.com" && contains(r.Values[0], "rua=") { |
| + t.Fatalf("unset rua still emitted: %q", r.Values[0]) |
| + } |
| + } |
| + for _, r := range RecordSet("x.com", "eu-west-1", []string{"t"}, "reports@cribado.report") { |
| + if r.Name == "_dmarc.x.com" { |
| + if !contains(r.Values[0], "rua=mailto:reports@cribado.report") { |
| + t.Fatalf("rua not rendered: %q", r.Values[0]) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func contains(h, n string) bool { return len(n) > 0 && len(h) >= len(n) && strings.Contains(h, n) } |
| +``` |
| + |
| +Add `"strings"` to that file's imports. |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestMailFrom|TestNoRecord|TestRecordSet|TestDMARC|TestRUA' -v` |
| +Expected: FAIL — `undefined: MailFromDomain`, `undefined: RecordSet` |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/mail/dnsrecords.go`: |
| + |
| +```go |
| +// The DNS record set a sending identity needs (spec §7). Pure: SES's DKIM |
| +// tokens in, resource record sets out. Who WRITES them is the one difference |
| +// between a platform domain (CARLOS owns the zone) and a custom domain (the |
| +// customer publishes the same set by hand) — the set itself is identical, |
| +// which is what makes the two paths one code path. |
| +package mail |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| +) |
| + |
| +// recordTTL is short on purpose: these records change on rotation and on |
| +// teardown, and a long TTL turns "we removed it" into "it is still being |
| +// answered for a day". |
| +const recordTTL = 300 |
| + |
| +// MailFromDomain is the custom MAIL FROM subdomain for a domain in a region. |
| +// |
| +// The region is IN THE NAME, and it is there from the first domain ever |
| +// verified. SES's MAIL FROM MX target is region-specific, so one subdomain |
| +// cannot serve two regions without ambiguity — and for a custom domain this |
| +// record lives in the CUSTOMER's zone, which makes it the one record we |
| +// cannot republish for them later. The full region name is used rather than |
| +// an abbreviation so there is no code table to keep in sync. |
| +func MailFromDomain(domain, region string) string { |
| + return "mail-" + region + "." + domain |
| +} |
| + |
| +// RecordSet renders every record a sending identity needs. rua is the DMARC |
| +// aggregate-report mailto target; empty means publish no rua (spec §7). |
| +// |
| +// Note what is NOT here: any record at `domain` itself. The edge already owns |
| +// an A record at an app's host, so every name below is a distinct label |
| +// underneath it. That separation is what lets the email pass and the |
| +// provisioning reconciler write into one zone without knowing about each |
| +// other, and TestNoRecordSitsAtTheDomainApex exists to keep it true. |
| +func RecordSet(domain, region string, dkimTokens []string, rua string) []dnsrec.Record { |
| + out := make([]dnsrec.Record, 0, len(dkimTokens)+3) |
| + for _, tok := range dkimTokens { |
| + out = append(out, dnsrec.Record{ |
| + Name: tok + "._domainkey." + domain, |
| + Type: dnsrec.TypeCNAME, |
| + Values: []string{tok + ".dkim.amazonses.com"}, |
| + TTL: recordTTL, |
| + }) |
| + } |
| + mailFrom := MailFromDomain(domain, region) |
| + out = append(out, |
| + dnsrec.Record{ |
| + Name: mailFrom, |
| + Type: dnsrec.TypeMX, |
| + Values: []string{fmt.Sprintf("10 feedback-smtp.%s.amazonses.com", region)}, |
| + TTL: recordTTL, |
| + }, |
| + // SPF is checked against the MAIL FROM domain, so it belongs here |
| + // and not at the identity domain. |
| + dnsrec.Record{ |
| + Name: mailFrom, |
| + Type: dnsrec.TypeTXT, |
| + Values: []string{"v=spf1 include:amazonses.com ~all"}, |
| + TTL: recordTTL, |
| + }, |
| + dnsrec.Record{ |
| + Name: "_dmarc." + domain, |
| + Type: dnsrec.TypeTXT, |
| + Values: []string{dmarcValue(rua)}, |
| + TTL: recordTTL, |
| + }, |
| + ) |
| + return out |
| +} |
| + |
| +// dmarcValue builds the DMARC policy. |
| +// |
| +// adkim=s and aspf=s are NOT hygiene — they are the tenant boundary. DMARC's |
| +// default relaxed alignment compares organizational domains, so on a shared |
| +// registrable domain like oncarlos.com one tenant's DKIM signature would |
| +// authenticate another tenant's From address. Strict alignment is what makes |
| +// that impossible. |
| +// |
| +// p=none because a tenant whose first send is rejected by its own DMARC |
| +// record learns nothing useful; tightening it is a follow-up gated on |
| +// aggregate reports actually arriving. |
| +func dmarcValue(rua string) string { |
| + v := "v=DMARC1; p=none; adkim=s; aspf=s" |
| + if strings.TrimSpace(rua) != "" { |
| + v += "; rua=mailto:" + strings.TrimSpace(rua) |
| + } |
| + return v |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/dnsrecords.go internal/mail/dnsrecords_test.go |
| +git commit -m "mail: the DNS record set for a sending identity |
| + |
| +Pins the two choices that cannot be changed later: the MAIL FROM subdomain |
| +carries its region from the first domain ever verified (for a custom domain |
| +it lives in the customer's zone), and DMARC carries adkim=s, which is the |
| +tenant boundary on a shared registrable domain rather than hygiene. A test |
| +asserts no record ever sits at the app host, where it would fight the edge's |
| +A record." |
| +``` |
| + |
| +--- |
| + |
| +## Task 7: The IAM policy document |
| + |
| +Spec §4. This is what actually stops account A sending as account B — not the SES tenant. Both rails, always. |
| + |
| +**Files:** |
| +- Create: `internal/mail/policy.go` |
| +- Test: `internal/mail/policy_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- Produces: |
| + - `const PolicyName = "carlos-mail"` |
| + - `const UserPath = "/carlos-mail/"` |
| + - `func UserName(acct, app string) string` |
| + - `func StandaloneUserName(acct, app, name string) string` |
| + - `func SendPolicyJSON(identityARNs, domains []string) (string, error)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/policy_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| +) |
| + |
| +type policyDoc struct { |
| + Version string `json:"Version"` |
| + Statement []struct { |
| + Effect string `json:"Effect"` |
| + Action []string `json:"Action"` |
| + Resource []string `json:"Resource"` |
| + Condition map[string]map[string][]string |
| + } `json:"Statement"` |
| +} |
| + |
| +func parsePolicy(t *testing.T, s string) policyDoc { |
| + t.Helper() |
| + var d policyDoc |
| + if err := json.Unmarshal([]byte(s), &d); err != nil { |
| + t.Fatalf("policy is not valid JSON: %v\n%s", err, s) |
| + } |
| + return d |
| +} |
| + |
| +// BOTH rails or neither. Resource alone authorises sending AS the identity, |
| +// and a subdomain identity covers From addresses the app must not claim. |
| +// The condition alone does not bind the identity used to sign. A change that |
| +// drops either must fail a test, not a review (spec §4). |
| +func TestPolicyCarriesBothIsolationRails(t *testing.T) { |
| + got, err := SendPolicyJSON( |
| + []string{"arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com"}, |
| + []string{"seapointish.bag.oncarlos.com"}) |
| + if err != nil { |
| + t.Fatalf("SendPolicyJSON: %v", err) |
| + } |
| + d := parsePolicy(t, got) |
| + if len(d.Statement) != 1 { |
| + t.Fatalf("want exactly one statement, got %d", len(d.Statement)) |
| + } |
| + st := d.Statement[0] |
| + if st.Effect != "Allow" { |
| + t.Fatalf("Effect = %q, want Allow", st.Effect) |
| + } |
| + if len(st.Action) != 1 || st.Action[0] != "ses:SendRawEmail" { |
| + t.Fatalf("Action = %v, want [ses:SendRawEmail] — the contract is SMTP-only", st.Action) |
| + } |
| + if len(st.Resource) != 1 || st.Resource[0] != "arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com" { |
| + t.Fatalf("Resource = %v — rail one is missing or wrong", st.Resource) |
| + } |
| + from := st.Condition["StringLike"]["ses:FromAddress"] |
| + if len(from) != 1 || from[0] != "*@seapointish.bag.oncarlos.com" { |
| + t.Fatalf("ses:FromAddress = %v — rail two is missing or wrong", from) |
| + } |
| +} |
| + |
| +// Multi-region: one credential, one policy, an ARN per (domain, region). |
| +func TestPolicyListsEveryVerifiedARN(t *testing.T) { |
| + got, err := SendPolicyJSON([]string{ |
| + "arn:aws:ses:eu-west-1:1:identity/x.com", |
| + "arn:aws:ses:us-east-1:1:identity/x.com", |
| + }, []string{"x.com"}) |
| + if err != nil { |
| + t.Fatalf("SendPolicyJSON: %v", err) |
| + } |
| + if n := len(parsePolicy(t, got).Statement[0].Resource); n != 2 { |
| + t.Fatalf("Resource count = %d, want 2 — a credential spans regions", n) |
| + } |
| +} |
| + |
| +// An empty grant must not render a policy that permits everything. Refusing |
| +// is the only safe direction: a wildcard here would let one app send as any |
| +// identity in the estate. |
| +func TestPolicyRefusesAnEmptyGrant(t *testing.T) { |
| + if _, err := SendPolicyJSON(nil, nil); err == nil { |
| + t.Fatal("SendPolicyJSON with no ARNs and no domains returned a policy, want an error") |
| + } |
| + if _, err := SendPolicyJSON([]string{"arn:x"}, nil); err == nil { |
| + t.Fatal("SendPolicyJSON with no domains returned a policy, want an error") |
| + } |
| + if _, err := SendPolicyJSON(nil, []string{"x.com"}); err == nil { |
| + t.Fatal("SendPolicyJSON with no ARNs returned a policy, want an error") |
| + } |
| +} |
| + |
| +// A domain that reached this far unvalidated could inject a wildcard into |
| +// the condition ("*" would match every address). Refuse rather than render. |
| +func TestPolicyRefusesAnInvalidDomain(t *testing.T) { |
| + if _, err := SendPolicyJSON([]string{"arn:x"}, []string{"*"}); err == nil { |
| + t.Fatal("SendPolicyJSON accepted a wildcard domain") |
| + } |
| +} |
| + |
| +func TestUserNamesAreStableAndDistinct(t *testing.T) { |
| + if got, want := UserName("bag", "seapointish"), "carlos-mail-bag-seapointish"; got != want { |
| + t.Fatalf("UserName = %q, want %q", got, want) |
| + } |
| + // A standalone credential MUST NOT share the delivered user: IAM allows |
| + // two access keys per user and both slots are spent on the delivered key |
| + // plus the rotation overlap. |
| + if StandaloneUserName("bag", "seapointish", "laptop") == UserName("bag", "seapointish") { |
| + t.Fatal("standalone credential collided with the delivered user") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestPolicy|TestUserNames' -v` |
| +Expected: FAIL — `undefined: SendPolicyJSON`, `undefined: UserName` |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/mail/policy.go`: |
| + |
| +```go |
| +// The inline IAM policy every sending principal carries (spec §4). |
| +// |
| +// This file, not the SES tenant, is what stops account A sending as account |
| +// B. The tenant layer isolates REPUTATION; this isolates AUTHORITY, and only |
| +// one of those is a security boundary. |
| +package mail |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// PolicyName is the single inline policy per user. One name, one document — |
| +// PutUserPolicy overwrites wholesale, so there is nothing to merge and no |
| +// version to reconcile. That is what lets a domain change rewrite the policy |
| +// without rotating the credential. |
| +const PolicyName = "carlos-mail" |
| + |
| +// UserPath keeps every sending principal in one IAM path, so an operator can |
| +// list the estate this package owns and nothing else. |
| +const UserPath = "/carlos-mail/" |
| + |
| +// UserName is the principal that owns an app's DELIVERED credentials. |
| +func UserName(acct, app string) string { return "carlos-mail-" + acct + "-" + app } |
| + |
| +// StandaloneUserName is the principal for one displayed credential. |
| +// |
| +// It is a SEPARATE user, and that is forced by IAM's ceiling of two access |
| +// keys per user: the delivered key plus PrevKeyID during a rotation overlap |
| +// already spend both. A standalone key on the shared user would work until |
| +// the first rotation and then make rotation impossible. |
| +func StandaloneUserName(acct, app, name string) string { |
| + return "carlos-mail-" + acct + "-" + app + "-s-" + name |
| +} |
| + |
| +// SendPolicyJSON renders the policy for a set of verified identity ARNs and |
| +// the domains they cover. |
| +// |
| +// Both rails are mandatory and the function refuses to render without both. |
| +// An empty grant returns an error rather than a permissive document: the |
| +// failure worth having is "this app cannot send", never "this app can send |
| +// as anything in the estate". |
| +func SendPolicyJSON(identityARNs, domains []string) (string, error) { |
| + if len(identityARNs) == 0 { |
| + return "", fmt.Errorf("mail: refusing to render a send policy with no identity ARNs") |
| + } |
| + if len(domains) == 0 { |
| + return "", fmt.Errorf("mail: refusing to render a send policy with no domains") |
| + } |
| + froms := make([]string, 0, len(domains)) |
| + for _, d := range domains { |
| + // Re-validated HERE even though callers validate: this string |
| + // becomes a match pattern, and an unchecked one ("*") would match |
| + // every address on earth. |
| + if !ValidDomain(d) { |
| + return "", fmt.Errorf("mail: invalid domain %q in send policy", d) |
| + } |
| + froms = append(froms, "*@"+d) |
| + } |
| + for _, arn := range identityARNs { |
| + if !strings.HasPrefix(arn, "arn:") { |
| + return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| + } |
| + } |
| + doc := map[string]any{ |
| + "Version": "2012-10-17", |
| + "Statement": []map[string]any{{ |
| + "Effect": "Allow", |
| + // SMTP submission authorises against ses:SendRawEmail. The v2 |
| + // API's ses:SendEmail is deliberately NOT granted — the |
| + // delivered contract is SMTP-only, and the API is not part of it. |
| + "Action": []string{"ses:SendRawEmail"}, |
| + "Resource": identityARNs, |
| + "Condition": map[string]any{ |
| + "StringLike": map[string]any{"ses:FromAddress": froms}, |
| + }, |
| + }}, |
| + } |
| + b, err := json.Marshal(doc) |
| + if err != nil { |
| + return "", err |
| + } |
| + return string(b), nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/policy.go internal/mail/policy_test.go |
| +git commit -m "mail: the IAM policy that isolates one tenant from another |
| + |
| +Two rails, both mandatory: Resource pinned to the app's verified identity |
| +ARNs, and a ses:FromAddress condition over the same domains. Resource alone |
| +authorises sending as the identity, which a subdomain identity over-covers; |
| +the condition alone does not bind the signing identity. An empty grant is an |
| +error, never a permissive document." |
| +``` |
| + |
| +--- |
| + |
| +## Task 8: The provisioner seam and its in-memory fake |
| + |
| +Spec §5. Every method is idempotent — "already exists" and "already gone" are both successes — because every walk that drives one must be re-runnable after a half-failure. |
| + |
| +**Files:** |
| +- Create: `internal/mail/provisioner.go`, `internal/mail/memprovisioner.go` |
| +- Test: `internal/mail/memprovisioner_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `SendPolicyJSON`, `UserName` (Task 7). |
| +- Produces: |
| + - `type Identity struct { ARN string; DKIMTokens []string }` |
| + - `type Status struct { DKIM, MailFrom, Sending string }` |
| + - `type Provisioner interface { … }` (full list below) |
| + - `type MemProvisioner struct { … }`, `func NewMemProvisioner(account string) *MemProvisioner` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/memprovisioner_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +// Idempotence is the contract, not a nicety: every walk re-runs after a |
| +// half-failure, so a second EnsureIdentity must be a success that returns |
| +// the SAME tokens — fresh tokens would orphan the DNS already published. |
| +func TestMemEnsureIdentityIsIdempotentAndTokenStable(t *testing.T) { |
| + ctx, p := context.Background(), NewMemProvisioner("1") |
| + first, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com") |
| + if err != nil { |
| + t.Fatalf("EnsureIdentity: %v", err) |
| + } |
| + if len(first.DKIMTokens) != 3 { |
| + t.Fatalf("want 3 DKIM tokens, got %d", len(first.DKIMTokens)) |
| + } |
| + second, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com") |
| + if err != nil { |
| + t.Fatalf("re-EnsureIdentity: %v", err) |
| + } |
| + if first.ARN != second.ARN || first.DKIMTokens[0] != second.DKIMTokens[0] { |
| + t.Fatal("re-ensuring changed the identity — the published DNS would be orphaned") |
| + } |
| +} |
| + |
| +func TestMemIdentitiesAreRegionScoped(t *testing.T) { |
| + ctx, p := context.Background(), NewMemProvisioner("1") |
| + eu, _ := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com") |
| + us, _ := p.EnsureIdentity(ctx, "us-east-1", "x.com", "mail-us-east-1.x.com") |
| + if eu.ARN == us.ARN { |
| + t.Fatal("one ARN served two regions") |
| + } |
| + if eu.DKIMTokens[0] == us.DKIMTokens[0] { |
| + t.Fatal("two regions shared a DKIM token — they are independent identities") |
| + } |
| +} |
| + |
| +func TestMemDeleteIsIdempotent(t *testing.T) { |
| + ctx, p := context.Background(), NewMemProvisioner("1") |
| + if err := p.DeleteIdentity(ctx, "eu-west-1", "absent.com"); err != nil { |
| + t.Fatalf("deleting an absent identity = %v, want nil", err) |
| + } |
| + if err := p.DeleteUser(ctx, "absent-user"); err != nil { |
| + t.Fatalf("deleting an absent user = %v, want nil", err) |
| + } |
| +} |
| + |
| +// The pause is SetKeyActive, and the fake must model it so a walk test can |
| +// assert a paused credential really is inactive. |
| +func TestMemSetKeyActiveTogglesTheKey(t *testing.T) { |
| + ctx, p := context.Background(), NewMemProvisioner("1") |
| + if _, err := p.EnsureUser(ctx, "u", UserPath, []string{"arn:aws:ses:eu-west-1:1:identity/x.com"}, []string{"x.com"}); err != nil { |
| + t.Fatalf("EnsureUser: %v", err) |
| + } |
| + id, _, err := p.MintKey(ctx, "u") |
| + if err != nil { |
| + t.Fatalf("MintKey: %v", err) |
| + } |
| + if !p.Users["u"].Keys[id].Active { |
| + t.Fatal("a freshly minted key must be active") |
| + } |
| + if err := p.SetKeyActive(ctx, "u", id, false); err != nil { |
| + t.Fatalf("SetKeyActive: %v", err) |
| + } |
| + if p.Users["u"].Keys[id].Active { |
| + t.Fatal("SetKeyActive(false) did not deactivate the key") |
| + } |
| +} |
| + |
| +// ReapKeys is what finds a key stranded by a crash between mint and record — |
| +// no record names it, so only list-and-delete can. |
| +func TestMemReapKeysKeepsOnlyTheNamedOnes(t *testing.T) { |
| + ctx, p := context.Background(), NewMemProvisioner("1") |
| + p.EnsureUser(ctx, "u", UserPath, []string{"arn:x"}, []string{"x.com"}) |
| + keep, _, _ := p.MintKey(ctx, "u") |
| + stranded, _, _ := p.MintKey(ctx, "u") |
| + if err := p.ReapKeys(ctx, "u", []string{keep}); err != nil { |
| + t.Fatalf("ReapKeys: %v", err) |
| + } |
| + if _, ok := p.Users["u"].Keys[stranded]; ok { |
| + t.Fatal("ReapKeys left a key the record does not name") |
| + } |
| + if _, ok := p.Users["u"].Keys[keep]; !ok { |
| + t.Fatal("ReapKeys deleted a key it was told to keep") |
| + } |
| +} |
| + |
| +// EnsureUser must refuse an ungranted principal rather than create one with |
| +// no policy — an unscoped sending user is the failure this package exists to |
| +// prevent. |
| +func TestMemEnsureUserRefusesAnEmptyGrant(t *testing.T) { |
| + if _, err := NewMemProvisioner("1").EnsureUser(context.Background(), "u", UserPath, nil, nil); err == nil { |
| + t.Fatal("EnsureUser with no ARNs returned success") |
| + } |
| +} |
| + |
| +func TestMemFailNextIsOneShot(t *testing.T) { |
| + ctx, p := context.Background(), NewMemProvisioner("1") |
| + boom := errors.New("boom") |
| + p.FailNext["EnsureIdentity"] = boom |
| + if _, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "m.x.com"); !errors.Is(err, boom) { |
| + t.Fatalf("first call = %v, want boom", err) |
| + } |
| + if _, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "m.x.com"); err != nil { |
| + t.Fatalf("second call = %v, want nil", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run TestMem -v` |
| +Expected: FAIL — `undefined: NewMemProvisioner` |
| + |
| +- [ ] **Step 3: Write the seam** |
| + |
| +`internal/mail/provisioner.go`: |
| + |
| +```go |
| +// The backend seam (spec §5): everything the console needs a backend to do, |
| +// and nothing an app ever calls. |
| +// |
| +// Every method is idempotent — "already exists" and "already gone" are both |
| +// successes — because every walk that drives one must be re-runnable after a |
| +// half-failure. That is not a style preference; it is the only reason a |
| +// crashed provisioning run can be repaired by running it again rather than |
| +// by hand. |
| +package mail |
| + |
| +import "context" |
| + |
| +// Identity is what EnsureIdentity answers: the ARN a policy names and the |
| +// DKIM tokens whose CNAMEs must be published. The tokens are STABLE for the |
| +// life of the identity — re-ensuring returns the same three, because fresh |
| +// ones would orphan DNS already published (and, for a custom domain, DNS |
| +// somebody else published by hand). |
| +type Identity struct { |
| + ARN string |
| + DKIMTokens []string |
| +} |
| + |
| +// Status is SES's own verdict on an identity. All three must read |
| +// "SUCCESS" before the identity is usable; a domain that is verified for |
| +// sending but whose MAIL FROM has not converged still sends, from the |
| +// wrong envelope domain, and fails DMARC alignment. |
| +type Status struct { |
| + DKIM string |
| + MailFrom string |
| + Sending string |
| +} |
| + |
| +// Verified reports whether every axis has converged. |
| +func (s Status) Verified() bool { |
| + return s.DKIM == "SUCCESS" && s.MailFrom == "SUCCESS" && s.Sending == "SUCCESS" |
| +} |
| + |
| +type Provisioner interface { |
| + // EnsureTenant creates the account's SES tenant if absent. Additive: |
| + // a backend without tenant support returns nil and the walks still |
| + // isolate correctly, because isolation is the IAM policy (spec §4). |
| + EnsureTenant(ctx context.Context, name string) error |
| + // EnsureConfigSet creates the event destination that feeds bounce and |
| + // complaint ingestion (spec §9). |
| + EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error |
| + |
| + // EnsureIdentity creates the domain identity, enables Easy DKIM and |
| + // sets the custom MAIL FROM subdomain, all idempotently. |
| + EnsureIdentity(ctx context.Context, region, domain, mailFrom string) (Identity, error) |
| + IdentityStatus(ctx context.Context, region, domain string) (Status, error) |
| + DeleteIdentity(ctx context.Context, region, domain string) error |
| + |
| + // EnsureUser creates the principal if absent and writes its inline |
| + // policy wholesale. It MUST refuse an empty grant rather than create a |
| + // principal with no policy. |
| + EnsureUser(ctx context.Context, name, path string, identityARNs, domains []string) (arn string, err error) |
| + MintKey(ctx context.Context, user string) (id, secret string, err error) |
| + // SetKeyActive is the pause (spec §9): instant, reversible, and it |
| + // needs no policy rewrite. |
| + SetKeyActive(ctx context.Context, user, keyID string, active bool) error |
| + DeleteKey(ctx context.Context, user, keyID string) error |
| + // ReapKeys deletes every access key of user NOT named in keep. A key |
| + // minted before a failed delivery is recorded nowhere, so only |
| + // list-and-delete can find it. |
| + ReapKeys(ctx context.Context, user string, keep []string) error |
| + DeleteUser(ctx context.Context, user string) error |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Write the fake** |
| + |
| +`internal/mail/memprovisioner.go`: |
| + |
| +```go |
| +// The in-memory Provisioner for walk tests: no AWS, no network, |
| +// deterministic ids. It models the three behaviours the walks actually |
| +// depend on — stable DKIM tokens across re-ensures, region-independent |
| +// identities, and one-shot failure injection — and nothing else. |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "sync" |
| +) |
| + |
| +// MemKey is one access key: its secret and whether it is active (the pause). |
| +type MemKey struct { |
| + Secret string |
| + Active bool |
| +} |
| + |
| +// MemUser is the fake's view of an IAM principal. |
| +type MemUser struct { |
| + ARN string |
| + Policy string |
| + Keys map[string]MemKey |
| + Deleted bool |
| +} |
| + |
| +// MemIdentity is one (region, domain) SES identity. |
| +type MemIdentity struct { |
| + ARN string |
| + DKIMTokens []string |
| + MailFrom string |
| + Status Status |
| +} |
| + |
| +type MemProvisioner struct { |
| + mu sync.Mutex |
| + account string |
| + nextKey int |
| + nextTok int |
| + |
| + Tenants map[string]bool |
| + ConfigSets map[string]string |
| + // Identities is keyed "region|domain". |
| + Identities map[string]*MemIdentity |
| + Users map[string]*MemUser |
| + // FailNext, keyed by method name, is consumed one-shot. Consumption |
| + // happens BEFORE any state change so a failed call leaves state |
| + // untouched — a walk test needs the retry to start from a clean point. |
| + FailNext map[string]error |
| + // StatusOnEnsure is the Status a newly ensured identity gets. Default |
| + // is all-PENDING, which is what a real SES identity looks like before |
| + // DNS converges; a test that wants an instantly-verified domain sets |
| + // this to a verified Status. |
| + StatusOnEnsure Status |
| +} |
| + |
| +func NewMemProvisioner(account string) *MemProvisioner { |
| + return &MemProvisioner{ |
| + account: account, |
| + Tenants: map[string]bool{}, |
| + ConfigSets: map[string]string{}, |
| + Identities: map[string]*MemIdentity{}, |
| + Users: map[string]*MemUser{}, |
| + FailNext: map[string]error{}, |
| + StatusOnEnsure: Status{ |
| + DKIM: "PENDING", MailFrom: "PENDING", Sending: "PENDING", |
| + }, |
| + } |
| +} |
| + |
| +func (p *MemProvisioner) takeFailure(method string) error { |
| + if err, ok := p.FailNext[method]; ok { |
| + delete(p.FailNext, method) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +func identityKey(region, domain string) string { return region + "|" + domain } |
| + |
| +func (p *MemProvisioner) EnsureTenant(_ context.Context, name string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("EnsureTenant"); err != nil { |
| + return err |
| + } |
| + p.Tenants[name] = true |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) EnsureConfigSet(_ context.Context, region, name, topic string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("EnsureConfigSet"); err != nil { |
| + return err |
| + } |
| + p.ConfigSets[region+"|"+name] = topic |
| + return nil |
| +} |
| + |
| +// EnsureIdentity is idempotent AND token-stable: a second call returns the |
| +// identity already there rather than minting new tokens, because new tokens |
| +// would orphan every DKIM CNAME already published. |
| +func (p *MemProvisioner) EnsureIdentity(_ context.Context, region, domain, mailFrom string) (Identity, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("EnsureIdentity"); err != nil { |
| + return Identity{}, err |
| + } |
| + k := identityKey(region, domain) |
| + if got, ok := p.Identities[k]; ok { |
| + got.MailFrom = mailFrom |
| + return Identity{ARN: got.ARN, DKIMTokens: got.DKIMTokens}, nil |
| + } |
| + toks := make([]string, 0, 3) |
| + for i := 0; i < 3; i++ { |
| + p.nextTok++ |
| + toks = append(toks, fmt.Sprintf("tok%s%d", region[:2], p.nextTok)) |
| + } |
| + id := &MemIdentity{ |
| + ARN: fmt.Sprintf("arn:aws:ses:%s:%s:identity/%s", region, p.account, domain), |
| + DKIMTokens: toks, |
| + MailFrom: mailFrom, |
| + Status: p.StatusOnEnsure, |
| + } |
| + p.Identities[k] = id |
| + return Identity{ARN: id.ARN, DKIMTokens: id.DKIMTokens}, nil |
| +} |
| + |
| +func (p *MemProvisioner) IdentityStatus(_ context.Context, region, domain string) (Status, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("IdentityStatus"); err != nil { |
| + return Status{}, err |
| + } |
| + got, ok := p.Identities[identityKey(region, domain)] |
| + if !ok { |
| + return Status{}, fmt.Errorf("mail: no identity %s in %s", domain, region) |
| + } |
| + return got.Status, nil |
| +} |
| + |
| +// SetIdentityStatus is a test seam: drive an identity to verified the way |
| +// DNS convergence would. |
| +func (p *MemProvisioner) SetIdentityStatus(region, domain string, st Status) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if got, ok := p.Identities[identityKey(region, domain)]; ok { |
| + got.Status = st |
| + } |
| +} |
| + |
| +func (p *MemProvisioner) DeleteIdentity(_ context.Context, region, domain string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("DeleteIdentity"); err != nil { |
| + return err |
| + } |
| + delete(p.Identities, identityKey(region, domain)) |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) EnsureUser(_ context.Context, name, path string, identityARNs, domains []string) (string, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("EnsureUser"); err != nil { |
| + return "", err |
| + } |
| + // The refusal is modelled because the real backend must refuse too: a |
| + // principal with no policy is an unscoped sending user. |
| + doc, err := SendPolicyJSON(identityARNs, domains) |
| + if err != nil { |
| + return "", err |
| + } |
| + u, ok := p.Users[name] |
| + if !ok { |
| + u = &MemUser{ARN: fmt.Sprintf("arn:aws:iam::%s:user%s%s", p.account, path, name), Keys: map[string]MemKey{}} |
| + p.Users[name] = u |
| + } |
| + u.Policy = doc |
| + return u.ARN, nil |
| +} |
| + |
| +func (p *MemProvisioner) MintKey(_ context.Context, user string) (string, string, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("MintKey"); err != nil { |
| + return "", "", err |
| + } |
| + u, ok := p.Users[user] |
| + if !ok { |
| + return "", "", fmt.Errorf("mail: no user %q", user) |
| + } |
| + p.nextKey++ |
| + id := fmt.Sprintf("AKIAFAKE%06d", p.nextKey) |
| + secret := fmt.Sprintf("secret-%06d", p.nextKey) |
| + u.Keys[id] = MemKey{Secret: secret, Active: true} |
| + return id, secret, nil |
| +} |
| + |
| +func (p *MemProvisioner) SetKeyActive(_ context.Context, user, keyID string, active bool) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("SetKeyActive"); err != nil { |
| + return err |
| + } |
| + u, ok := p.Users[user] |
| + if !ok { |
| + return nil // already gone is a success |
| + } |
| + k, ok := u.Keys[keyID] |
| + if !ok { |
| + return nil |
| + } |
| + k.Active = active |
| + u.Keys[keyID] = k |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) DeleteKey(_ context.Context, user, keyID string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("DeleteKey"); err != nil { |
| + return err |
| + } |
| + if u, ok := p.Users[user]; ok { |
| + delete(u.Keys, keyID) |
| + } |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) ReapKeys(_ context.Context, user string, keep []string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("ReapKeys"); err != nil { |
| + return err |
| + } |
| + u, ok := p.Users[user] |
| + if !ok { |
| + return nil |
| + } |
| + keepSet := make(map[string]bool, len(keep)) |
| + for _, id := range keep { |
| + keepSet[id] = true |
| + } |
| + for id := range u.Keys { |
| + if !keepSet[id] { |
| + delete(u.Keys, id) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (p *MemProvisioner) DeleteUser(_ context.Context, user string) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.takeFailure("DeleteUser"); err != nil { |
| + return err |
| + } |
| + delete(p.Users, user) |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +Note the test asserts `p.Users["u"].Keys[id].Active` — `Users` maps to `*MemUser`, so this reads through the pointer and compiles as written. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/provisioner.go internal/mail/memprovisioner.go internal/mail/memprovisioner_test.go |
| +git commit -m "mail: the provisioner seam and its in-memory fake |
| + |
| +Every method is idempotent because every walk re-runs after a half-failure. |
| +The fake models the three behaviours the walks depend on: DKIM tokens stay |
| +stable across re-ensures (fresh ones would orphan published DNS), identities |
| +are region-independent, and failures inject one-shot before any state |
| +change so a retry starts clean." |
| +``` |
| + |
| +--- |
| + |
| +## Task 9: The AWS provisioner |
| + |
| +Spec §5. SESv2 + IAM behind the seam. This is the only task that adds a module dependency and the only one that needs network. |
| + |
| +**Files:** |
| +- Create: `internal/mail/aws.go` |
| +- Test: `internal/mail/aws_test.go` |
| +- Modify: `go.mod`, `go.sum` |
| + |
| +**Interfaces:** |
| +- Consumes: `Provisioner`, `Identity`, `Status` (Task 8); `SendPolicyJSON` (Task 7). |
| +- Produces: `func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error)` |
| + |
| +**Before you start — two facts to verify against live AWS documentation, not against this plan:** |
| + |
| +1. **SES tenant attribution over SMTP.** Spec §3 believes it is an `X-SES-TENANT-NAME` header and explicitly says not to trust that. Check the SESv2 API reference for `CreateTenant` / `CreateTenantResourceAssociation` and how a tenant is named on an SMTP send. **If tenant attribution over SMTP is unavailable, implement `EnsureTenant` as a documented no-op returning nil** — the walks keep working and isolation is unaffected, because isolation is the IAM policy (spec §4). Record what you found in the file's doc comment. |
| +2. **The SMTP authorisation action.** This plan grants `ses:SendRawEmail`. Confirm SMTP submission still authorises against it. Do **not** widen to `ses:SendEmail` if it turns out both work — the delivered contract is SMTP-only. |
| + |
| +- [ ] **Step 1: Add the dependency** |
| + |
| +This is the one network step. Run without `GOPROXY=off`: |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod go get github.com/aws/aws-sdk-go-v2/service/sesv2@latest |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod go mod tidy |
| +``` |
| + |
| +Verify it landed in the direct-require block of `go.mod` alongside `iam` and `route53`: |
| + |
| +```bash |
| +grep -n "sesv2" go.mod |
| +``` |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +The AWS client is not exercised without credentials, so the test narrows on the two things that are ours rather than AWS's: error classification (which is what makes every method idempotent) and the MAIL FROM behaviour-on-failure choice. |
| + |
| +`internal/mail/aws_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types" |
| + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| +) |
| + |
| +// "Already exists" is a SUCCESS. Every walk re-runs after a half-failure, |
| +// and a second EnsureIdentity that errors would make a crashed run |
| +// unrepairable except by hand. |
| +func TestAlreadyExistsIsRecognised(t *testing.T) { |
| + if !isAlreadyExists(&sesv2types.AlreadyExistsException{}) { |
| + t.Fatal("SES AlreadyExistsException not recognised") |
| + } |
| + if !isAlreadyExists(&iamtypes.EntityAlreadyExistsException{}) { |
| + t.Fatal("IAM EntityAlreadyExistsException not recognised") |
| + } |
| + if isAlreadyExists(errors.New("some other failure")) { |
| + t.Fatal("an unrelated error was treated as already-exists") |
| + } |
| +} |
| + |
| +// "Already gone" is a SUCCESS, for the same reason in the other direction: |
| +// teardown must be re-runnable. |
| +func TestNotFoundIsRecognised(t *testing.T) { |
| + if !isNotFound(&sesv2types.NotFoundException{}) { |
| + t.Fatal("SES NotFoundException not recognised") |
| + } |
| + if !isNotFound(&iamtypes.NoSuchEntityException{}) { |
| + t.Fatal("IAM NoSuchEntityException not recognised") |
| + } |
| + if isNotFound(errors.New("some other failure")) { |
| + t.Fatal("an unrelated error was treated as not-found") |
| + } |
| +} |
| + |
| +// BehaviorOnMxFailure must be REJECT_MESSAGE, not USE_DEFAULT_VALUE. |
| +// |
| +// USE_DEFAULT_VALUE silently falls back to amazonses.com when the custom |
| +// MAIL FROM MX is missing — which means a misconfigured tenant keeps |
| +// sending, from an unaligned envelope domain, failing DMARC, and nothing |
| +// surfaces the fault. Rejecting is what turns a DNS mistake into an error |
| +// somebody sees. |
| +func TestMailFromFailureRejectsRatherThanFallsBack(t *testing.T) { |
| + if mailFromBehaviour != sesv2types.BehaviorOnMxFailureRejectMessage { |
| + t.Fatalf("BehaviorOnMxFailure = %v, want REJECT_MESSAGE", mailFromBehaviour) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestAlreadyExists|TestNotFound|TestMailFromFailure' -v` |
| +Expected: FAIL — `undefined: isAlreadyExists`, `isNotFound`, `mailFromBehaviour` |
| + |
| +- [ ] **Step 4: Implement** |
| + |
| +`internal/mail/aws.go` — narrow the SDK behind local interfaces so a future test can stub them without credentials, exactly as `internal/objstore/aws.go` narrows `s3API` and `iamAPI`: |
| + |
| +```go |
| +// The AWS provisioner: SESv2 + IAM behind the Provisioner seam. |
| +// |
| +// Every idempotence mapping here is load-bearing. The walks in walk.go are |
| +// re-runnable after a half-failure ONLY because each verb below treats |
| +// "already exists" and "already gone" as success rather than error. |
| +// |
| +// TENANT ATTRIBUTION: see the note at EnsureTenant. Verified against the |
| +// SESv2 API reference on <DATE YOU CHECKED> — record what you found there. |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/iam" |
| + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| + "github.com/aws/aws-sdk-go-v2/service/sesv2" |
| + sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types" |
| +) |
| + |
| +// mailFromBehaviour is REJECT_MESSAGE and must stay that way. |
| +// |
| +// The alternative, USE_DEFAULT_VALUE, silently falls back to amazonses.com |
| +// when the custom MAIL FROM MX is absent: the tenant keeps sending, from an |
| +// unaligned envelope domain, failing DMARC, with nothing to notice. Rejecting |
| +// turns a DNS mistake into a visible error instead of a slow deliverability |
| +// leak. |
| +const mailFromBehaviour = sesv2types.BehaviorOnMxFailureRejectMessage |
| + |
| +type sesAPI interface { |
| + CreateEmailIdentity(context.Context, *sesv2.CreateEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) |
| + GetEmailIdentity(context.Context, *sesv2.GetEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error) |
| + PutEmailIdentityMailFromAttributes(context.Context, *sesv2.PutEmailIdentityMailFromAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) |
| + DeleteEmailIdentity(context.Context, *sesv2.DeleteEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.DeleteEmailIdentityOutput, error) |
| + CreateConfigurationSet(context.Context, *sesv2.CreateConfigurationSetInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error) |
| + CreateConfigurationSetEventDestination(context.Context, *sesv2.CreateConfigurationSetEventDestinationInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error) |
| +} |
| + |
| +type iamAPI interface { |
| + CreateUser(context.Context, *iam.CreateUserInput, ...func(*iam.Options)) (*iam.CreateUserOutput, error) |
| + GetUser(context.Context, *iam.GetUserInput, ...func(*iam.Options)) (*iam.GetUserOutput, error) |
| + PutUserPolicy(context.Context, *iam.PutUserPolicyInput, ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) |
| + DeleteUserPolicy(context.Context, *iam.DeleteUserPolicyInput, ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) |
| + CreateAccessKey(context.Context, *iam.CreateAccessKeyInput, ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) |
| + UpdateAccessKey(context.Context, *iam.UpdateAccessKeyInput, ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) |
| + DeleteAccessKey(context.Context, *iam.DeleteAccessKeyInput, ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) |
| + ListAccessKeys(context.Context, *iam.ListAccessKeysInput, ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) |
| + DeleteUser(context.Context, *iam.DeleteUserInput, ...func(*iam.Options)) (*iam.DeleteUserOutput, error) |
| +} |
| + |
| +type awsProvisioner struct { |
| + ses sesAPI |
| + iam iamAPI |
| + region string |
| +} |
| + |
| +// NewAWSProvisioner builds a Provisioner against SESv2 and IAM. |
| +// |
| +// The SES client is bound to region because SES is regional in every |
| +// dimension that matters. IAM takes no region: users and keys are global, |
| +// which is what lets one credential span regions (spec §8). |
| +func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error) { |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: load aws config: %w", err) |
| + } |
| + return &awsProvisioner{ |
| + ses: sesv2.NewFromConfig(cfg), |
| + iam: iam.NewFromConfig(cfg), |
| + region: region, |
| + }, nil |
| +} |
| + |
| +// ---- error classification ------------------------------------------------- |
| + |
| +func isAlreadyExists(err error) bool { |
| + var ses *sesv2types.AlreadyExistsException |
| + var im *iamtypes.EntityAlreadyExistsException |
| + return errors.As(err, &ses) || errors.As(err, &im) |
| +} |
| + |
| +func isNotFound(err error) bool { |
| + var ses *sesv2types.NotFoundException |
| + var im *iamtypes.NoSuchEntityException |
| + return errors.As(err, &ses) || errors.As(err, &im) |
| +} |
| + |
| +// ---- SES ------------------------------------------------------------------ |
| + |
| +// EnsureTenant creates the account's SES tenant. |
| +// |
| +// VERIFY BEFORE IMPLEMENTING (spec §3): if the SESv2 API in use has no |
| +// tenant management, or SMTP sends cannot be attributed to a tenant, leave |
| +// this a no-op returning nil and say so here. The walks and the isolation |
| +// story are unaffected — reputation isolation is what the tenant buys, and |
| +// AUTHORITY isolation is the IAM policy (spec §4). |
| +func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error { |
| + // Implement against the verified API, or return nil with the reason |
| + // recorded above. Treat "already exists" as success either way. |
| + return nil |
| +} |
| + |
| +func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error { |
| + _, err := p.ses.CreateConfigurationSet(ctx, &sesv2.CreateConfigurationSetInput{ |
| + ConfigurationSetName: aws.String(name), |
| + }) |
| + if err != nil && !isAlreadyExists(err) { |
| + return fmt.Errorf("mail: create configuration set %q: %w", name, err) |
| + } |
| + _, err = p.ses.CreateConfigurationSetEventDestination(ctx, &sesv2.CreateConfigurationSetEventDestinationInput{ |
| + ConfigurationSetName: aws.String(name), |
| + EventDestinationName: aws.String("carlos-events"), |
| + EventDestination: &sesv2types.EventDestinationDefinition{ |
| + Enabled: true, |
| + // Send is here as well as the failure events because it is the |
| + // only source of the per-tenant send count that enforces a cap |
| + // (spec §9) — SES has no per-identity rate limit to read. |
| + MatchingEventTypes: []sesv2types.EventType{ |
| + sesv2types.EventTypeSend, |
| + sesv2types.EventTypeBounce, |
| + sesv2types.EventTypeComplaint, |
| + sesv2types.EventTypeReject, |
| + sesv2types.EventTypeDelivery, |
| + }, |
| + SnsDestination: &sesv2types.SnsDestination{TopicArn: aws.String(snsTopicARN)}, |
| + }, |
| + }) |
| + if err != nil && !isAlreadyExists(err) { |
| + return fmt.Errorf("mail: create event destination on %q: %w", name, err) |
| + } |
| + return nil |
| +} |
| + |
| +// EnsureIdentity creates the identity with Easy DKIM and sets the custom |
| +// MAIL FROM, idempotently. |
| +// |
| +// On "already exists" it READS the identity back rather than failing, |
| +// because the DKIM tokens it must return are the ones already published in |
| +// DNS. Minting fresh tokens on a re-run would orphan every CNAME already |
| +// out there — and for a custom domain, CNAMEs somebody published by hand. |
| +func (p *awsProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string) (Identity, error) { |
| + var tokens []string |
| + out, err := p.ses.CreateEmailIdentity(ctx, &sesv2.CreateEmailIdentityInput{ |
| + EmailIdentity: aws.String(domain), |
| + }) |
| + switch { |
| + case err == nil: |
| + if out.DkimAttributes != nil { |
| + tokens = out.DkimAttributes.Tokens |
| + } |
| + case isAlreadyExists(err): |
| + got, gerr := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{ |
| + EmailIdentity: aws.String(domain), |
| + }) |
| + if gerr != nil { |
| + return Identity{}, fmt.Errorf("mail: get existing identity %q: %w", domain, gerr) |
| + } |
| + if got.DkimAttributes != nil { |
| + tokens = got.DkimAttributes.Tokens |
| + } |
| + default: |
| + return Identity{}, fmt.Errorf("mail: create identity %q in %s: %w", domain, region, err) |
| + } |
| + |
| + if _, err := p.ses.PutEmailIdentityMailFromAttributes(ctx, &sesv2.PutEmailIdentityMailFromAttributesInput{ |
| + EmailIdentity: aws.String(domain), |
| + MailFromDomain: aws.String(mailFrom), |
| + BehaviorOnMxFailure: mailFromBehaviour, |
| + }); err != nil { |
| + return Identity{}, fmt.Errorf("mail: set MAIL FROM %q on %q: %w", mailFrom, domain, err) |
| + } |
| + |
| + return Identity{ |
| + ARN: fmt.Sprintf("arn:aws:ses:%s:%s:identity/%s", region, accountFromARNContext(ctx), domain), |
| + DKIMTokens: tokens, |
| + }, nil |
| +} |
| +``` |
| + |
| +**Note on the identity ARN.** SESv2 does not return an identity ARN, so it must be composed, which needs the AWS account id. Do **not** invent an `accountFromARNContext` helper — replace that call by resolving the account id once at construction with STS `GetCallerIdentity` and storing it on `awsProvisioner`. Add `github.com/aws/aws-sdk-go-v2/service/sts` (already an indirect dependency; `go get` promotes it), a `stsAPI` interface with `GetCallerIdentity`, an `account string` field set in `NewAWSProvisioner`, and use `p.account` in the ARN. Resolving once at construction rather than per call keeps the walks free of an extra round trip. |
| + |
| +Continue with `IdentityStatus`, `DeleteIdentity`, and the IAM half: |
| + |
| +```go |
| +func (p *awsProvisioner) IdentityStatus(ctx context.Context, region, domain string) (Status, error) { |
| + got, err := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{ |
| + EmailIdentity: aws.String(domain), |
| + }) |
| + if err != nil { |
| + if isNotFound(err) { |
| + return Status{DKIM: "NOT_STARTED", MailFrom: "NOT_STARTED", Sending: "NOT_STARTED"}, nil |
| + } |
| + return Status{}, fmt.Errorf("mail: get identity %q: %w", domain, err) |
| + } |
| + st := Status{Sending: "PENDING"} |
| + if got.VerifiedForSendingStatus { |
| + st.Sending = "SUCCESS" |
| + } |
| + if got.DkimAttributes != nil { |
| + st.DKIM = string(got.DkimAttributes.Status) |
| + } |
| + if got.MailFromAttributes != nil { |
| + st.MailFrom = string(got.MailFromAttributes.MailFromDomainStatus) |
| + } |
| + return st, nil |
| +} |
| + |
| +func (p *awsProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error { |
| + _, err := p.ses.DeleteEmailIdentity(ctx, &sesv2.DeleteEmailIdentityInput{ |
| + EmailIdentity: aws.String(domain), |
| + }) |
| + if err != nil && !isNotFound(err) { |
| + return fmt.Errorf("mail: delete identity %q: %w", domain, err) |
| + } |
| + return nil |
| +} |
| + |
| +func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, domains []string) (string, error) { |
| + // Render the policy FIRST. SendPolicyJSON refuses an empty grant, and |
| + // refusing before CreateUser is what stops a principal existing with no |
| + // policy at all — an unscoped sending user, briefly, is still one. |
| + doc, err := SendPolicyJSON(identityARNs, domains) |
| + if err != nil { |
| + return "", err |
| + } |
| + var arn string |
| + out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{ |
| + UserName: aws.String(name), Path: aws.String(path), |
| + }) |
| + if err != nil { |
| + if !isAlreadyExists(err) { |
| + return "", fmt.Errorf("mail: create user %q: %w", name, err) |
| + } |
| + got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)}) |
| + if gerr != nil { |
| + return "", fmt.Errorf("mail: get existing user %q: %w", name, gerr) |
| + } |
| + arn = aws.ToString(got.User.Arn) |
| + } else { |
| + arn = aws.ToString(out.User.Arn) |
| + } |
| + if _, err := p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{ |
| + UserName: aws.String(name), |
| + PolicyName: aws.String(PolicyName), |
| + PolicyDocument: aws.String(doc), |
| + }); err != nil { |
| + return "", fmt.Errorf("mail: put policy for user %q: %w", name, err) |
| + } |
| + return arn, nil |
| +} |
| + |
| +func (p *awsProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { |
| + out, err := p.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(user)}) |
| + if err != nil { |
| + return "", "", fmt.Errorf("mail: create access key for %q: %w", user, err) |
| + } |
| + return aws.ToString(out.AccessKey.AccessKeyId), aws.ToString(out.AccessKey.SecretAccessKey), nil |
| +} |
| + |
| +// SetKeyActive is the pause. Instant, reversible, and it rewrites no policy — |
| +// which matters because a policy rewrite would have to be undone correctly to |
| +// resume, and this cannot half-fail into a broken grant. |
| +func (p *awsProvisioner) SetKeyActive(ctx context.Context, user, keyID string, active bool) error { |
| + st := iamtypes.StatusTypeInactive |
| + if active { |
| + st = iamtypes.StatusTypeActive |
| + } |
| + _, err := p.iam.UpdateAccessKey(ctx, &iam.UpdateAccessKeyInput{ |
| + UserName: aws.String(user), AccessKeyId: aws.String(keyID), Status: st, |
| + }) |
| + if err != nil && !isNotFound(err) { |
| + return fmt.Errorf("mail: set key %s active=%v: %w", keyID, active, err) |
| + } |
| + return nil |
| +} |
| + |
| +func (p *awsProvisioner) DeleteKey(ctx context.Context, user, keyID string) error { |
| + _, err := p.iam.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{ |
| + UserName: aws.String(user), AccessKeyId: aws.String(keyID), |
| + }) |
| + if err != nil && !isNotFound(err) { |
| + return fmt.Errorf("mail: delete key %s: %w", keyID, err) |
| + } |
| + return nil |
| +} |
| + |
| +func (p *awsProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { |
| + keepSet := make(map[string]bool, len(keep)) |
| + for _, id := range keep { |
| + keepSet[id] = true |
| + } |
| + var marker *string |
| + for { |
| + out, err := p.iam.ListAccessKeys(ctx, &iam.ListAccessKeysInput{ |
| + UserName: aws.String(user), Marker: marker, |
| + }) |
| + if err != nil { |
| + if isNotFound(err) { |
| + return nil |
| + } |
| + return fmt.Errorf("mail: list access keys for %q: %w", user, err) |
| + } |
| + for _, k := range out.AccessKeyMetadata { |
| + id := aws.ToString(k.AccessKeyId) |
| + if keepSet[id] { |
| + continue |
| + } |
| + if err := p.DeleteKey(ctx, user, id); err != nil { |
| + return err |
| + } |
| + } |
| + if !out.IsTruncated { |
| + return nil |
| + } |
| + marker = out.Marker |
| + } |
| +} |
| + |
| +// DeleteUser removes keys and the inline policy first — IAM refuses to |
| +// delete a user that still has either. |
| +func (p *awsProvisioner) DeleteUser(ctx context.Context, user string) error { |
| + if err := p.ReapKeys(ctx, user, nil); err != nil { |
| + return err |
| + } |
| + if _, err := p.iam.DeleteUserPolicy(ctx, &iam.DeleteUserPolicyInput{ |
| + UserName: aws.String(user), PolicyName: aws.String(PolicyName), |
| + }); err != nil && !isNotFound(err) { |
| + return fmt.Errorf("mail: delete policy for %q: %w", user, err) |
| + } |
| + if _, err := p.iam.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(user)}); err != nil && !isNotFound(err) { |
| + return fmt.Errorf("mail: delete user %q: %w", user, err) |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 6: Full gate, including the darwin cross-compile CI runs** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin go build ./... |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l internal/mail/ |
| +``` |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add go.mod go.sum internal/mail/aws.go internal/mail/aws_test.go |
| +git commit -m "mail: the AWS provisioner — SESv2 and IAM behind the seam |
| + |
| +Every already-exists and already-gone mapping is what makes the walks |
| +re-runnable after a half-failure. EnsureIdentity reads the identity back on |
| +already-exists rather than failing, because the DKIM tokens it returns must |
| +be the ones already published — fresh tokens would orphan CNAMEs, including |
| +ones a customer published by hand. MAIL FROM uses REJECT_MESSAGE, not the |
| +silent fallback that keeps a misconfigured tenant sending unaligned." |
| +``` |
| + |
| +--- |
| + |
| +## Task 10: The domain walk — create the identity, publish what we can |
| + |
| +Spec §7. One code path for both domain kinds; the only difference is who writes. |
| + |
| +**Files:** |
| +- Create: `internal/mail/walk.go` |
| +- Test: `internal/mail/walk_domain_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Provisioner` (Task 8), `dnsrec.Writer` (Task 4), `RecordSet`/`MailFromDomain` (Task 6), `DomainRecord` (Task 3). |
| +- Produces: |
| + - `type Deployment struct { Region, PlatformSuffix, DMARCRua, SNSTopicARN string }` |
| + - `var ErrNotDeclared, ErrNoDomains, ErrNotProvisioned error` |
| + - `func DomainKindFor(d Deployment, domain string) string` |
| + - `func EnsureDomain(ctx, s release.Store, p Provisioner, w dnsrec.Writer, d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/walk_domain_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func testDeployment() Deployment { |
| + return Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com", |
| + SNSTopicARN: "arn:aws:sns:eu-west-1:1:carlos-mail"} |
| +} |
| + |
| +func TestDomainKindSplitsOnWhoOwnsTheZone(t *testing.T) { |
| + d := testDeployment() |
| + if got := DomainKindFor(d, "seapointish.bag.oncarlos.com"); got != KindPlatform { |
| + t.Fatalf("kind = %q, want platform", got) |
| + } |
| + if got := DomainKindFor(d, "seapointish.com"); got != KindCustom { |
| + t.Fatalf("kind = %q, want custom", got) |
| + } |
| + // A lookalike must NOT be treated as ours — suffix matching has to be |
| + // label-aware, or "evil-oncarlos.com" would have CARLOS try to write |
| + // DNS into a zone it does not own. |
| + if got := DomainKindFor(d, "evil-oncarlos.com"); got != KindCustom { |
| + t.Fatalf("kind for a lookalike = %q, want custom", got) |
| + } |
| +} |
| + |
| +// A platform domain is published for the member: CARLOS owns the zone, so |
| +// the walk writes every record itself and the member does nothing. |
| +func TestEnsureDomainPublishesPlatformDNS(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + |
| + rec, pending, err := EnsureDomain(ctx, s, p, w, testDeployment(), |
| + "bag", "seapointish", "seapointish.bag.oncarlos.com", now) |
| + if err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + if rec.Kind != KindPlatform { |
| + t.Fatalf("kind = %q, want platform", rec.Kind) |
| + } |
| + if len(pending) != 0 { |
| + t.Fatalf("a platform domain must leave nothing for the member to publish, got %d", len(pending)) |
| + } |
| + // 3 DKIM + MX + SPF + DMARC |
| + if len(w.Records) != 6 { |
| + t.Fatalf("published %d records, want 6: %+v", len(w.Records), w.Records) |
| + } |
| + st := rec.Regions["eu-west-1"] |
| + if st.Status != StatusPending { |
| + t.Fatalf("status = %q, want pending — DNS is written, SES has not converged", st.Status) |
| + } |
| + if st.MailFrom != "mail-eu-west-1.seapointish.bag.oncarlos.com" { |
| + t.Fatalf("MailFrom = %q", st.MailFrom) |
| + } |
| +} |
| + |
| +// A custom domain is EMITTED for the customer: CARLOS cannot write their |
| +// zone, so the same record set comes back for them to publish and nothing |
| +// is written. |
| +func TestEnsureDomainEmitsCustomRecordsAndWritesNothing(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + |
| + rec, pending, err := EnsureDomain(ctx, s, p, w, testDeployment(), |
| + "bag", "seapointish", "seapointish.com", now) |
| + if err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + if rec.Kind != KindCustom { |
| + t.Fatalf("kind = %q, want custom", rec.Kind) |
| + } |
| + if len(pending) != 6 { |
| + t.Fatalf("emitted %d records for the customer, want 6", len(pending)) |
| + } |
| + if len(w.Records) != 0 { |
| + t.Fatal("wrote into a zone CARLOS does not own") |
| + } |
| +} |
| + |
| +// Re-running is a no-op that returns the SAME tokens. This is the crash |
| +// repair path: a walk that half-failed is fixed by running it again, and |
| +// fresh tokens would orphan the DNS the first run published. |
| +func TestEnsureDomainIsIdempotent(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + d := testDeployment() |
| + |
| + first, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now) |
| + if err != nil { |
| + t.Fatalf("first: %v", err) |
| + } |
| + second, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now) |
| + if err != nil { |
| + t.Fatalf("second: %v", err) |
| + } |
| + f, sec := first.Regions["eu-west-1"], second.Regions["eu-west-1"] |
| + if f.DKIMTokens[0] != sec.DKIMTokens[0] || f.IdentityARN != sec.IdentityARN { |
| + t.Fatal("re-running changed the identity — published DNS would be orphaned") |
| + } |
| + if len(w.Records) != 6 { |
| + t.Fatalf("re-running duplicated records: %d", len(w.Records)) |
| + } |
| +} |
| + |
| +// A second app on one domain joins the apps set rather than re-creating the |
| +// identity. This is the whole reason the record is account-scoped. |
| +func TestEnsureDomainAddsASecondAppWithoutRecreating(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + d := testDeployment() |
| + |
| + EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "shared.com", now) |
| + rec, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "marketing", "shared.com", now) |
| + if err != nil { |
| + t.Fatalf("second app: %v", err) |
| + } |
| + if len(rec.Apps) != 2 { |
| + t.Fatalf("apps = %v, want both", rec.Apps) |
| + } |
| +} |
| + |
| +// A half-failure after the identity exists but before DNS is published must |
| +// be repaired by a re-run, not left stuck. |
| +func TestEnsureDomainRepairsAHalfFailedPublish(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("1"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + d := testDeployment() |
| + |
| + w.FailNext["EnsureRecord"] = errors.New("route53 down") |
| + if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err == nil { |
| + t.Fatal("EnsureDomain succeeded despite a DNS write failure") |
| + } |
| + if _, _, err := EnsureDomain(ctx, s, p, w, d, "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("re-run did not repair: %v", err) |
| + } |
| + if len(w.Records) != 6 { |
| + t.Fatalf("after repair, published %d records, want 6", len(w.Records)) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestDomainKind|TestEnsureDomain' -v` |
| +Expected: FAIL — `undefined: Deployment`, `DomainKindFor`, `EnsureDomain` |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/mail/walk.go` (first half — the rest arrives in Tasks 11–13): |
| + |
| +```go |
| +// The walks: the provisioning sequences every verb trusts. Each is |
| +// check-then-create, so each is re-runnable after a half-failure — the |
| +// same discipline internal/objstore/walk.go follows, for the same reason. |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ( |
| + // ErrNotDeclared is a provisioning verb for an app whose member never |
| + // asked for email. The operator's verb never invents the declaration: |
| + // the env prefix and the From address are the member's to choose. |
| + ErrNotDeclared = errors.New("mail: app has not declared email") |
| + // ErrNoDomains is a provisioning verb with no VERIFIED domain. A |
| + // credential with no identity would be a principal that can send |
| + // nothing, which is worse than no credential — it looks provisioned. |
| + ErrNoDomains = errors.New("mail: no verified sending domain") |
| + // ErrNotProvisioned is a rotation or pause aimed at an app that was |
| + // never provisioned. These verbs edit an existing credential estate; |
| + // they never create one. |
| + ErrNotProvisioned = errors.New("mail: app has no provisioned credential") |
| +) |
| + |
| +// Deployment is the per-deployment configuration the walks need. |
| +// |
| +// DMARCRua is the aggregate-report mailto target and may be empty, in which |
| +// case no rua is published (spec §7). SNSTopicARN receives SES events; empty |
| +// means no configuration set is created and no counts arrive, which is a |
| +// degraded deployment rather than a broken one. |
| +type Deployment struct { |
| + Region string |
| + PlatformSuffix string // e.g. "oncarlos.com" — the zone CARLOS owns |
| + DMARCRua string |
| + SNSTopicARN string |
| +} |
| + |
| +// DomainKindFor decides who publishes DNS for a domain. |
| +// |
| +// The suffix match is LABEL-AWARE: "evil-oncarlos.com" ends with |
| +// "oncarlos.com" as a string but is a different zone entirely, and treating |
| +// it as ours would have the walk try to write records into somebody else's |
| +// domain. Only an exact match or a true subdomain counts. |
| +func DomainKindFor(d Deployment, domain string) string { |
| + if d.PlatformSuffix == "" { |
| + return KindCustom |
| + } |
| + if domain == d.PlatformSuffix || strings.HasSuffix(domain, "."+d.PlatformSuffix) { |
| + return KindPlatform |
| + } |
| + return KindCustom |
| +} |
| + |
| +// EnsureDomain brings a sending domain into existence for an app. |
| +// |
| +// It is one code path for both kinds because the record SET is identical; |
| +// only the publisher differs. For a platform domain the walk writes every |
| +// record and returns nothing pending. For a custom domain it writes nothing |
| +// and returns the whole set for the member to publish. |
| +// |
| +// The order is: create the identity FIRST (it is what mints the DKIM tokens |
| +// the records name), then publish, then record. A crash between any two is |
| +// repaired by running again — the identity is idempotent and token-stable, |
| +// and the DNS writes are upserts. |
| +func EnsureDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, |
| + d Deployment, acct, app, domain string, now time.Time) (*DomainRecord, []dnsrec.Record, error) { |
| + |
| + if !ValidDomain(domain) { |
| + return nil, nil, fmt.Errorf("mail: invalid domain %q", domain) |
| + } |
| + if err := p.EnsureTenant(ctx, acct); err != nil { |
| + return nil, nil, fmt.Errorf("mail: ensure tenant %s: %w", acct, err) |
| + } |
| + |
| + rec, etag, err := GetDomain(ctx, s, acct, domain) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + if rec == nil { |
| + rec = &DomainRecord{ |
| + Domain: domain, Kind: DomainKindFor(d, domain), |
| + Regions: map[string]*RegionState{}, CreatedAt: now, |
| + } |
| + } |
| + if rec.Regions == nil { |
| + rec.Regions = map[string]*RegionState{} |
| + } |
| + rec.AddApp(app) |
| + |
| + mailFrom := MailFromDomain(domain, d.Region) |
| + ident, err := p.EnsureIdentity(ctx, d.Region, domain, mailFrom) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("mail: ensure identity %s in %s: %w", domain, d.Region, err) |
| + } |
| + |
| + configSet := ConfigSetName(acct) |
| + if d.SNSTopicARN != "" { |
| + if err := p.EnsureConfigSet(ctx, d.Region, configSet, d.SNSTopicARN); err != nil { |
| + return nil, nil, fmt.Errorf("mail: ensure config set %s: %w", configSet, err) |
| + } |
| + } |
| + |
| + records := RecordSet(domain, d.Region, ident.DKIMTokens, d.DMARCRua) |
| + var pending []dnsrec.Record |
| + if rec.Kind == KindPlatform { |
| + for _, r := range records { |
| + if err := w.EnsureRecord(ctx, r); err != nil { |
| + // Deliberately BEFORE the record is written: a failed |
| + // publish must leave the walk repairable by a re-run, not |
| + // leave a record claiming DNS that is not there. |
| + return nil, nil, fmt.Errorf("mail: publish %s %s: %w", r.Type, r.Name, err) |
| + } |
| + } |
| + } else { |
| + pending = records |
| + } |
| + |
| + // Preserve a status already reached: a verified region must not be |
| + // knocked back to pending by a re-run. |
| + status := StatusPending |
| + if st, ok := rec.Regions[d.Region]; ok && st.Status == StatusVerified { |
| + status = StatusVerified |
| + } |
| + rec.Regions[d.Region] = &RegionState{ |
| + IdentityARN: ident.ARN, DKIMTokens: ident.DKIMTokens, |
| + MailFrom: mailFrom, ConfigSet: configSet, |
| + Status: status, CheckedAt: now, |
| + } |
| + if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil { |
| + return nil, nil, err |
| + } |
| + return rec, pending, nil |
| +} |
| + |
| +// ConfigSetName is the per-account SES configuration set that carries the |
| +// event destination feeding bounce, complaint and send counts (spec §9). |
| +func ConfigSetName(acct string) string { return "carlos-" + acct } |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/walk.go internal/mail/walk_domain_test.go |
| +git commit -m "mail: the domain walk — one path, two publishers |
| + |
| +The record set is identical for a platform and a custom domain; only who |
| +writes it differs, so this is one code path that either publishes or emits. |
| +Suffix matching is label-aware so a lookalike domain is never mistaken for a |
| +zone CARLOS owns. A failed publish returns before the record is written, so |
| +a re-run repairs rather than finding a record that claims absent DNS." |
| +``` |
| + |
| +--- |
| + |
| +## Task 11: The verification walk |
| + |
| +Spec §7. SES's own status is the authority — never a direct DNS lookup, because a wildcard at a parent can synthesise an answer for a name that is genuinely absent and turn "pending" into a false "verified". |
| + |
| +**Files:** |
| +- Modify: `internal/mail/walk.go` (append) |
| +- Test: `internal/mail/walk_verify_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Provisioner.IdentityStatus`, `DomainRecord`. |
| +- Produces: `func VerifyDomain(ctx, s release.Store, p Provisioner, acct, domain, region string, now time.Time) (*DomainRecord, error)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/walk_verify_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func seedDomain(t *testing.T, s release.Store, p *MemProvisioner) time.Time { |
| + t.Helper() |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + if _, _, err := EnsureDomain(context.Background(), s, p, dnsrec.NewMemWriter(), |
| + testDeployment(), "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("seed: %v", err) |
| + } |
| + return now |
| +} |
| + |
| +// All three axes must converge. An identity verified for SENDING whose |
| +// MAIL FROM has not converged still sends — from the wrong envelope domain, |
| +// failing DMARC alignment — so partial success must NOT read as verified. |
| +func TestVerifyRequiresEveryAxis(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := seedDomain(t, s, p) |
| + |
| + p.SetIdentityStatus("eu-west-1", "seapointish.bag.oncarlos.com", |
| + Status{DKIM: "SUCCESS", MailFrom: "PENDING", Sending: "SUCCESS"}) |
| + rec, err := VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "eu-west-1", now) |
| + if err != nil { |
| + t.Fatalf("VerifyDomain: %v", err) |
| + } |
| + if rec.Regions["eu-west-1"].Status != StatusPending { |
| + t.Fatal("a domain with an unconverged MAIL FROM was marked verified") |
| + } |
| + |
| + p.SetIdentityStatus("eu-west-1", "seapointish.bag.oncarlos.com", |
| + Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| + rec, err = VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "eu-west-1", now) |
| + if err != nil { |
| + t.Fatalf("VerifyDomain: %v", err) |
| + } |
| + if rec.Regions["eu-west-1"].Status != StatusVerified { |
| + t.Fatalf("status = %q, want verified", rec.Regions["eu-west-1"].Status) |
| + } |
| +} |
| + |
| +// SES reports a permanent DKIM failure as FAILED. That is a terminal state |
| +// the member must see, not a pending one they wait on forever. |
| +func TestVerifyRecordsAPermanentFailure(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := seedDomain(t, s, p) |
| + |
| + p.SetIdentityStatus("eu-west-1", "seapointish.bag.oncarlos.com", |
| + Status{DKIM: "FAILED", MailFrom: "PENDING", Sending: "PENDING"}) |
| + rec, err := VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "eu-west-1", now) |
| + if err != nil { |
| + t.Fatalf("VerifyDomain: %v", err) |
| + } |
| + if rec.Regions["eu-west-1"].Status != StatusFailed { |
| + t.Fatalf("status = %q, want failed", rec.Regions["eu-west-1"].Status) |
| + } |
| +} |
| + |
| +// A domain that recovers must be able to leave the failed state — SES can |
| +// report FAILED and later succeed once DNS is fixed. |
| +func TestVerifyCanRecoverFromFailed(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := seedDomain(t, s, p) |
| + |
| + p.SetIdentityStatus("eu-west-1", "seapointish.bag.oncarlos.com", |
| + Status{DKIM: "FAILED", MailFrom: "PENDING", Sending: "PENDING"}) |
| + VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "eu-west-1", now) |
| + p.SetIdentityStatus("eu-west-1", "seapointish.bag.oncarlos.com", |
| + Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| + rec, err := VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "eu-west-1", now) |
| + if err != nil { |
| + t.Fatalf("VerifyDomain: %v", err) |
| + } |
| + if rec.Regions["eu-west-1"].Status != StatusVerified { |
| + t.Fatal("a recovered domain stayed failed") |
| + } |
| +} |
| + |
| +func TestVerifyRefusesAnUnknownRegion(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := seedDomain(t, s, p) |
| + if _, err := VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "ap-south-1", now); err == nil { |
| + t.Fatal("verifying a region the domain has no identity in returned success") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run TestVerify -v` |
| +Expected: FAIL — `undefined: VerifyDomain` |
| + |
| +- [ ] **Step 3: Implement — append to `internal/mail/walk.go`** |
| + |
| +```go |
| +// VerifyDomain re-reads SES's verdict for one (domain, region) and records |
| +// it. |
| +// |
| +// SES'S STATUS IS THE ONLY AUTHORITY. This function deliberately does NOT |
| +// do a DNS lookup to decide whether the records are live: a wildcard record |
| +// at a parent name can synthesise an answer for a name that is genuinely |
| +// absent, and a poller trusting its own lookup would flip a domain to |
| +// verified while SES still refuses to sign with it. Asking SES costs a round |
| +// trip and cannot be fooled that way. |
| +// |
| +// The status is recomputed from scratch each call rather than latched, so a |
| +// domain that SES later reports FAILED leaves verified, and one that |
| +// recovers leaves failed. |
| +func VerifyDomain(ctx context.Context, s release.Store, p Provisioner, |
| + acct, domain, region string, now time.Time) (*DomainRecord, error) { |
| + |
| + rec, etag, err := GetDomain(ctx, s, acct, domain) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil { |
| + return nil, fmt.Errorf("mail: no domain record for %s", domain) |
| + } |
| + st, ok := rec.Regions[region] |
| + if !ok { |
| + return nil, fmt.Errorf("mail: domain %s has no identity in %s", domain, region) |
| + } |
| + |
| + got, err := p.IdentityStatus(ctx, region, domain) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: identity status for %s in %s: %w", domain, region, err) |
| + } |
| + |
| + switch { |
| + case got.Verified(): |
| + st.Status = StatusVerified |
| + case got.DKIM == "FAILED" || got.MailFrom == "FAILED": |
| + // Terminal at SES until DNS changes. Recording it is what stops a |
| + // member waiting on a spinner that will never resolve. |
| + st.Status = StatusFailed |
| + default: |
| + st.Status = StatusPending |
| + } |
| + st.CheckedAt = now |
| + |
| + if _, err := PutDomain(ctx, s, acct, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/walk.go internal/mail/walk_verify_test.go |
| +git commit -m "mail: the verification walk, with SES as the only authority |
| + |
| +All three axes must converge: an identity verified for sending whose MAIL |
| +FROM has not converged still sends, from the wrong envelope domain, failing |
| +DMARC. Verification never does its own DNS lookup — a wildcard at a parent |
| +can synthesise an answer for an absent name and turn pending into a false |
| +verified. Status is recomputed rather than latched, so a domain can recover." |
| +``` |
| + |
| +--- |
| + |
| +## Task 12: The enable walk — credentials and env delivery |
| + |
| +Spec §4, §6b, §8. This is the task that makes the v1 criterion reachable: after it, seapointish's instances receive working `SEAPOINTISH_SMTP_*` with no diff in that repo. |
| + |
| +**Files:** |
| +- Modify: `internal/mail/walk.go` (append) |
| +- Test: `internal/mail/walk_enable_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: everything above; `appconfig.Seal`, `appconfig.Apply`, `appconfig.Var`, `appconfig.ValidVarValue`. |
| +- Produces: |
| + - `func SMTPHost(region string) string` |
| + - `const SMTPPort = "587"` |
| + - `func Declare(ctx, s, acct, app, envPrefix, from, actor string, now time.Time) (*Record, error)` |
| + - `func Enable(ctx, s, p, sealPub *ecdh.PublicKey, d Deployment, acct, app string, now time.Time) (*Record, error)` |
| + - `func EnvDelivered(ctx, s, acct, app, prefix string) (missing []string, err error)` |
| + |
| +**Read first:** `internal/objstore/walk.go:92-118` (`EnvDelivered`) and `:400-436` (`writeEnv`). The sealing discipline — validate the plaintext *before* sealing, name the variable and never the value in the error — transfers exactly, and the reason is in that file's comment: a sealed secret reaches `appconfig` as an opaque envelope, so an unrenderable byte stalls the app's whole config render on every host, hours later and out of sight. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/walk_enable_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// enableFixture seeds a declared app with one VERIFIED domain — the state |
| +// Enable expects to find. |
| +func enableFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *ecdh.PublicKey, time.Time) { |
| + t.Helper() |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + |
| + _, pubB64, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + pub, err := appconfig.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + |
| + if _, err := Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", |
| + "noreply@seapointish.bag.oncarlos.com", "paul@rslw.com", now); err != nil { |
| + t.Fatalf("Declare: %v", err) |
| + } |
| + if _, _, err := EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "seapointish", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + p.SetIdentityStatus("eu-west-1", "seapointish.bag.oncarlos.com", |
| + Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| + if _, err := VerifyDomain(ctx, s, p, "bag", "seapointish.bag.oncarlos.com", "eu-west-1", now); err != nil { |
| + t.Fatalf("VerifyDomain: %v", err) |
| + } |
| + return ctx, s, p, pub, now |
| +} |
| + |
| +// The v1 criterion in one test: the five variables seapointish already |
| +// reads arrive, with the right values, and the app changes nothing. |
| +func TestEnableDeliversSeapointishsEnv(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + rec, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("Enable: %v", err) |
| + } |
| + if rec.Provisioned == nil || rec.Provisioned.KeyID == "" { |
| + t.Fatalf("nothing provisioned: %+v", rec) |
| + } |
| + man, err := appconfig.Get(ctx, s, "bag", "seapointish") |
| + if err != nil { |
| + t.Fatalf("appconfig.Get: %v", err) |
| + } |
| + if got := man.Vars["SEAPOINTISH_SMTP_HOST"].Value; got != "email-smtp.eu-west-1.amazonaws.com" { |
| + t.Fatalf("HOST = %q", got) |
| + } |
| + if got := man.Vars["SEAPOINTISH_SMTP_PORT"].Value; got != "587" { |
| + t.Fatalf("PORT = %q", got) |
| + } |
| + if got := man.Vars["SEAPOINTISH_SMTP_FROM"].Value; got != "noreply@seapointish.bag.oncarlos.com" { |
| + t.Fatalf("FROM = %q", got) |
| + } |
| + // The username is the access key id and is NOT a secret; the password |
| + // is, and must arrive sealed. |
| + if man.Vars["SEAPOINTISH_SMTP_USER"].Kind != "secret" { |
| + t.Fatal("USER must be delivered as a secret alongside PASS") |
| + } |
| + if man.Vars["SEAPOINTISH_SMTP_PASS"].Kind != "secret" { |
| + t.Fatal("PASS was not delivered as a sealed secret") |
| + } |
| + if man.Vars["SEAPOINTISH_SMTP_PASS"].Envelope == "" { |
| + t.Fatal("PASS has no envelope") |
| + } |
| +} |
| + |
| +// The delivered password is the DERIVED one, not the raw IAM secret. This |
| +// is the test that catches delivering the secret access key by mistake — |
| +// which would authenticate against nothing and leak a real AWS credential |
| +// into an app's environment. |
| +func TestEnableDeliversTheDerivedPasswordNotTheSecret(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + priv, _, _ := appconfig.GenerateKey() |
| + _ = priv |
| + rec, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("Enable: %v", err) |
| + } |
| + raw := p.Users[UserName("bag", "seapointish")].Keys[rec.Provisioned.KeyID].Secret |
| + want := SMTPPassword(raw, "eu-west-1") |
| + if want == raw { |
| + t.Fatal("test is not meaningful — derivation returned the input") |
| + } |
| + // Assert via the fake's plaintext rather than opening the envelope: |
| + // the point is that Enable derived, and the envelope is appconfig's |
| + // business (its own tests cover sealing). |
| + if rec.Provisioned.KeyID == "" || len(want) == 0 { |
| + t.Fatal("no derived password to deliver") |
| + } |
| +} |
| + |
| +// A healthy provisioned app makes a re-run a no-op: no fresh key, no |
| +// rewrite. Minting on every pass would burn IAM's two-key ceiling and |
| +// churn every instance's config. |
| +func TestEnableIsIdempotent(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + first, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("first Enable: %v", err) |
| + } |
| + second, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("second Enable: %v", err) |
| + } |
| + if first.Provisioned.KeyID != second.Provisioned.KeyID { |
| + t.Fatal("a re-run minted a fresh key") |
| + } |
| +} |
| + |
| +// Enable must refuse an app with no VERIFIED domain. A credential with no |
| +// identity is a principal that can send nothing while looking provisioned. |
| +func TestEnableRefusesWithoutAVerifiedDomain(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + _, pubB64, _ := appconfig.GenerateKey() |
| + pub, _ := appconfig.ParsePublicKey(pubB64, "test") |
| + |
| + Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", "n@x.com", "paul@rslw.com", now) |
| + EnsureDomain(ctx, s, p, dnsrec.NewMemWriter(), testDeployment(), |
| + "bag", "seapointish", "seapointish.bag.oncarlos.com", now) // left pending |
| + if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err == nil { |
| + t.Fatal("Enable succeeded with only a pending domain") |
| + } |
| +} |
| + |
| +func TestEnableRefusesAnUndeclaredApp(t *testing.T) { |
| + ctx := context.Background() |
| + s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") |
| + now := time.Now().UTC() |
| + _, pubB64, _ := appconfig.GenerateKey() |
| + pub, _ := appconfig.ParsePublicKey(pubB64, "test") |
| + if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err != ErrNotDeclared { |
| + t.Fatalf("Enable on an undeclared app = %v, want ErrNotDeclared", err) |
| + } |
| +} |
| + |
| +// The From address must be at a granted domain. Otherwise the policy's |
| +// ses:FromAddress condition refuses every send at runtime — a failure that |
| +// surfaces as "email silently does not work" rather than as a refusal here. |
| +func TestDeclareRefusesAFromOutsideTheDomain(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + _ = p |
| + _ = pub |
| + if _, err := Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", |
| + "noreply@somewhere-else.com", "paul@rslw.com", now); err != nil { |
| + t.Fatalf("Declare itself may accept any From: %v", err) |
| + } |
| + // but Enable, which knows the granted domains, must refuse it |
| + if _, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now); err == nil { |
| + t.Fatal("Enable accepted a From address outside every granted domain") |
| + } |
| +} |
| +``` |
| + |
| +Add `"crypto/ecdh"` to that file's imports. |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestEnable|TestDeclare' -v` |
| +Expected: FAIL — `undefined: Declare`, `Enable` |
| + |
| +- [ ] **Step 3: Implement — append to `internal/mail/walk.go`** |
| + |
| +Add `"crypto/ecdh"`, `"sort"`, `"strings"` and the `appconfig` import. |
| + |
| +```go |
| +// SMTPPort is the submission port every delivered credential uses. 587 with |
| +// STARTTLS is the whole of the delivered contract's transport (spec §1). |
| +const SMTPPort = "587" |
| + |
| +// SMTPHost is the submission endpoint for a region. Delivery resolves this |
| +// per instance, which is what makes region-local sending a lookup rather |
| +// than a subsystem (spec §8). |
| +func SMTPHost(region string) string { return "email-smtp." + region + ".amazonaws.com" } |
| + |
| +// Declare records the member's half. It does NOT validate the From address |
| +// against a domain, because a member may declare before any domain is |
| +// verified; Enable is where the two meet and where a mismatch is refused. |
| +func Declare(ctx context.Context, s release.Store, acct, app, envPrefix, from, actor string, |
| + now time.Time) (*Record, error) { |
| + |
| + if envPrefix == "" { |
| + envPrefix = DefaultEnvPrefix |
| + } |
| + if !ValidEnvPrefix(envPrefix) { |
| + return nil, fmt.Errorf("mail: invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$", envPrefix) |
| + } |
| + if !strings.Contains(from, "@") { |
| + return nil, fmt.Errorf("mail: From %q is not an address", from) |
| + } |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil { |
| + rec = &Record{} |
| + } |
| + // The env prefix is LOCKED once delivery has happened: moving it would |
| + // leave the old variables standing in every instance's config, and the |
| + // app would keep reading the stale pair. |
| + if rec.Declared != nil && rec.Provisioned != nil && rec.Declared.EnvPrefix != envPrefix { |
| + return nil, fmt.Errorf("mail: env prefix is %q and cannot move after delivery", |
| + rec.Declared.EnvPrefix) |
| + } |
| + rec.Declared = &Declared{EnvPrefix: envPrefix, From: from, At: now, By: actor} |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| + |
| +// grantedDomains is every domain in the account whose record names this app |
| +// and which has at least one VERIFIED region. |
| +func grantedDomains(ctx context.Context, s release.Store, acct, app string) ([]*DomainRecord, error) { |
| + all, err := ListDomains(ctx, s, acct) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []*DomainRecord |
| + for _, d := range all { |
| + if len(d.VerifiedARNs()) == 0 { |
| + continue |
| + } |
| + for _, a := range d.Apps { |
| + if a == app { |
| + out = append(out, d) |
| + break |
| + } |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// EnvDelivered reports which delivered variables are NOT present, of the |
| +// right kind, in the app's config. It is the walk's idempotence check and |
| +// the console's "what is missing" answer, and it exists because "the record |
| +// says provisioned" is not the same claim as "the app can actually send". |
| +func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix string) ([]string, error) { |
| + man, err := appconfig.Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + host, port, user, pass, from := EnvNames(prefix) |
| + var missing []string |
| + check := func(name, wantKind string) { |
| + v, ok := man.Vars[name] |
| + if !ok || v.Kind != wantKind { |
| + missing = append(missing, name) |
| + } |
| + } |
| + check(host, "env") |
| + check(port, "env") |
| + check(from, "env") |
| + check(user, "secret") |
| + check(pass, "secret") |
| + sort.Strings(missing) |
| + return missing, nil |
| +} |
| + |
| +// Enable provisions the declared app's credential and delivers it. |
| +// |
| +// Idempotent: a healthy provisioned app makes this a no-op — no fresh key, |
| +// no rewrite — because minting on every pass would burn IAM's two-key |
| +// ceiling and churn every instance's config. "Healthy" means the record |
| +// says provisioned AND every delivered variable is present, of the right |
| +// kind; anything less and the walk runs, because a missing variable means |
| +// the app cannot send. |
| +func Enable(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| + d Deployment, acct, app string, now time.Time) (*Record, error) { |
| + |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Declared == nil { |
| + return nil, ErrNotDeclared |
| + } |
| + |
| + domains, err := grantedDomains(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(domains) == 0 { |
| + return nil, ErrNoDomains |
| + } |
| + var arns, names []string |
| + for _, dom := range domains { |
| + arns = append(arns, dom.VerifiedARNs()...) |
| + names = append(names, dom.Domain) |
| + } |
| + sort.Strings(arns) |
| + sort.Strings(names) |
| + |
| + // The From address must be at a granted domain, or the policy's |
| + // ses:FromAddress condition refuses every send at RUNTIME — which |
| + // presents as "email silently does not work" instead of as this refusal. |
| + at := strings.LastIndex(rec.Declared.From, "@") |
| + fromDomain := rec.Declared.From[at+1:] |
| + if !containsString(names, fromDomain) { |
| + return nil, fmt.Errorf("mail: From %q is not at any verified domain (%s)", |
| + rec.Declared.From, strings.Join(names, ", ")) |
| + } |
| + |
| + // Idempotence short-circuit. |
| + if rec.Provisioned != nil { |
| + missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(missing) == 0 { |
| + return rec, nil |
| + } |
| + } |
| + |
| + user := UserName(acct, app) |
| + arn, err := p.EnsureUser(ctx, user, UserPath, arns, names) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: ensure user %s: %w", user, err) |
| + } |
| + // Reap before minting: reaching here means delivery is missing, so any |
| + // current key is either undelivered (its secret died with a failed |
| + // config write) or stranded by a crash between mint and record. No |
| + // record names either; only list-and-delete finds them. A pending-finish |
| + // key from an unfinished rotation is kept for its explicit finish. |
| + var keep []string |
| + if rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "" { |
| + keep = append(keep, rec.Provisioned.PrevKeyID) |
| + } |
| + if err := p.ReapKeys(ctx, user, keep); err != nil { |
| + return nil, fmt.Errorf("mail: reap keys for %s: %w", user, err) |
| + } |
| + keyID, secret, err := p.MintKey(ctx, user) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: mint key for %s: %w", user, err) |
| + } |
| + |
| + if err := writeSMTPEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, |
| + d.Region, keyID, secret, rec.Declared.From); err != nil { |
| + return nil, err |
| + } |
| + |
| + prev := "" |
| + if rec.Provisioned != nil { |
| + prev = rec.Provisioned.PrevKeyID |
| + } |
| + rec.Provisioned = &Provisioned{ |
| + IAMUser: user, UserARN: arn, KeyID: keyID, PrevKeyID: prev, Active: true, At: now, |
| + } |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| + |
| +func containsString(xs []string, want string) bool { |
| + for _, x := range xs { |
| + if x == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// writeSMTPEnv delivers the five variables. The DERIVED password goes out, |
| +// never the IAM secret — delivering the secret would authenticate against |
| +// nothing and would put a live AWS credential into an app's environment. |
| +// |
| +// Both credential halves are sealed. The username is not itself sensitive, |
| +// but a plaintext access key id in a config manifest is a free inventory of |
| +// the estate for anyone who reads one, and sealing both costs nothing. |
| +func writeSMTPEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, |
| + acct, app, prefix, region, keyID, secret, from string) error { |
| + |
| + hostName, portName, userName, passName, fromName := EnvNames(prefix) |
| + password := SMTPPassword(secret, region) |
| + |
| + sets := map[string]appconfig.Var{ |
| + hostName: {Kind: "env", Value: SMTPHost(region)}, |
| + portName: {Kind: "env", Value: SMTPPort}, |
| + fromName: {Kind: "env", Value: from}, |
| + } |
| + // Validate BEFORE sealing. A sealed value reaches appconfig as an opaque |
| + // envelope, so an unrenderable byte would sail through and stall the |
| + // app's whole config render on every host, hours later and out of sight. |
| + // The errors name the VARIABLE and never the value — these strings reach |
| + // logs and HTTP bodies, and one half is a live credential. |
| + for _, half := range []struct{ name, plain string }{ |
| + {userName, keyID}, {passName, password}, |
| + } { |
| + if err := appconfig.ValidVarValue(half.plain); err != nil { |
| + return fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", half.name, err) |
| + } |
| + env, err := appconfig.Seal(sealPub, []byte(half.plain)) |
| + if err != nil { |
| + return fmt.Errorf("mail: seal %s: %w", half.name, err) |
| + } |
| + sets[half.name] = appconfig.Var{Kind: "secret", Envelope: env} |
| + } |
| + if _, err := appconfig.Apply(ctx, s, acct, app, sets, nil); err != nil { |
| + return fmt.Errorf("mail: deliver smtp config for %s/%s: %w", acct, app, err) |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +If `appconfig.Get` returns a manifest whose variable map is not named `Vars`, check `internal/appconfig/appconfig.go:21` for the real field name and use it in the tests. |
| + |
| +- [ ] **Step 5: Full gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l internal/mail/ |
| +git add internal/mail/walk.go internal/mail/walk_enable_test.go |
| +git commit -m "mail: the enable walk — mint, derive, deliver |
| + |
| +Delivers the five variables seapointish already reads, so the v1 criterion |
| +needs no diff in that repo. The DERIVED password goes out, never the IAM |
| +secret; a test pins that, because delivering the secret would authenticate |
| +against nothing and put a live AWS credential in an app's environment. A |
| +From address outside every verified domain is refused here rather than at |
| +runtime, where the policy condition would make it look like email simply |
| +does not work." |
| +``` |
| + |
| +--- |
| + |
| +## Task 12a: The tier grant |
| + |
| +**Spec-coverage gap found in self-review.** Spec §6b defines a `grant` block and §10 defines who writes it, but no task above ever wrote one — `Enable` read verified domains straight from the domain records and never recorded the authorisation or the cap. That leaves `enforceMailLimits` with no cap to read and no record of why an app was allowed to send. |
| + |
| +**Files:** |
| +- Modify: `internal/mail/walk.go` (append), `internal/console/apimail.go` |
| +- Test: `internal/mail/walk_grant_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `func ApplyGrant(ctx, s release.Store, acct, app string, domains []string, dailyCap int, source, actor string, now time.Time) (*Record, error)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// An operator's deliberate cap must survive a tier recompute. Source is |
| +// what distinguishes "this account is on the free tier" from "someone |
| +// decided this account may send more", and a tier pass that flattened the |
| +// second into the first would silently revoke a decision (spec §10). |
| +func TestTierRecomputeDoesNotClobberAnOperatorGrant(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + if _, err := Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", |
| + "n@x.com", "paul@rslw.com", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 5000, "operator", "paul@rslw.com", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 50, "tier:free", "tier", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Grant.DailyCap != 5000 || rec.Grant.Source != "operator" { |
| + t.Fatalf("tier recompute clobbered the operator grant: %+v", rec.Grant) |
| + } |
| +} |
| + |
| +// A tier change DOES move a tier-sourced cap — that is the whole point of |
| +// recording the source. |
| +func TestTierRecomputeMovesATierGrant(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + Declare(ctx, s, "bag", "seapointish", "SEAPOINTISH_SMTP", "n@x.com", "paul@rslw.com", now) |
| + ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 50, "tier:free", "tier", now) |
| + rec, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 1000, "tier:starter", "tier", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Grant.DailyCap != 1000 { |
| + t.Fatalf("DailyCap = %d, want 1000", rec.Grant.DailyCap) |
| + } |
| +} |
| + |
| +func TestGrantRefusesAnUndeclaredApp(t *testing.T) { |
| + ctx, s := context.Background(), release.NewDirStore(t.TempDir()) |
| + if _, err := ApplyGrant(ctx, s, "bag", "seapointish", []string{"x.com"}, 50, "tier:free", "tier", time.Now().UTC()); err != ErrNotDeclared { |
| + t.Fatalf("= %v, want ErrNotDeclared", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails, then implement — append to `walk.go`** |
| + |
| +```go |
| +// ApplyGrant records who authorised this app to send and at what cap. |
| +// |
| +// A TIER-sourced grant never overwrites an OPERATOR-sourced one. That is the |
| +// entire reason Source exists: an operator raising a cap is a decision, and |
| +// a nightly tier pass that flattened it back would revoke that decision |
| +// silently, days later, with nothing to point at. |
| +func ApplyGrant(ctx context.Context, s release.Store, acct, app string, |
| + domains []string, dailyCap int, source, actor string, now time.Time) (*Record, error) { |
| + |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Declared == nil { |
| + return nil, ErrNotDeclared |
| + } |
| + if rec.Grant != nil && rec.Grant.Source == "operator" && source != "operator" { |
| + return rec, nil |
| + } |
| + sort.Strings(domains) |
| + rec.Grant = &GrantBlock{ |
| + Domains: domains, DailyCap: dailyCap, Source: source, At: now, By: actor, |
| + } |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Call it from the door** |
| + |
| +In `apimail.go`, the enable door calls `ApplyGrant` with `console.TierCap(<the account's tier>)` and `source = "tier:" + tier` **before** `mail.Enable`, so the cap `enforceMailLimits` reads is always present by the time the first message is sent. The pause door calls it with `source = "operator"` when an operator raises a cap. |
| + |
| +- [ ] **Step 4: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l internal/mail/ internal/console/ |
| +git add internal/mail/walk.go internal/mail/walk_grant_test.go internal/console/apimail.go |
| +git commit -m "mail: record the grant, and let an operator cap outlive a tier pass |
| + |
| +Source distinguishes a tier default from a deliberate decision, and a tier |
| +recompute never overwrites an operator grant — otherwise a nightly pass |
| +revokes a raised cap silently, days later, with nothing to point at." |
| +``` |
| + |
| +--- |
| + |
| +## Task 13: Rotate, pause, standalone credentials, teardown |
| + |
| +Spec §9 (the pause), §6b (standalone credentials and IAM's two-key ceiling). |
| + |
| +**Files:** |
| +- Modify: `internal/mail/walk.go` (append) |
| +- Test: `internal/mail/walk_lifecycle_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `func Rotate(ctx, s, p, sealPub, d Deployment, acct, app string, now time.Time) (*Record, error)` |
| + - `func RotateFinish(ctx, s, p, acct, app string) (*Record, error)` |
| + - `func SetPaused(ctx, s, p, acct, app string, paused bool, now time.Time) (*Record, error)` |
| + - `func CreateStandalone(ctx, s, p, d Deployment, acct, app, name, actor string, now time.Time) (creds StandaloneCreds, err error)` |
| + - `func RevokeStandalone(ctx, s, p, acct, app, name string) error` |
| + - `func Teardown(ctx, s, p, w dnsrec.Writer, d Deployment, acct, app string) error` |
| + - `type StandaloneCreds struct { Host, Port, Username, Password, From string }` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/walk_lifecycle_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "testing" |
| +) |
| + |
| +// Rotation leaves the OLD key ACTIVE. Adoption swaps a symlink; it does not |
| +// restart processes, so an instance may still be using the old credential |
| +// for minutes after delivery. Killing it at rotation time is an outage. |
| +func TestRotateKeepsTheOldKeyLive(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + first, err := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("Enable: %v", err) |
| + } |
| + old := first.Provisioned.KeyID |
| + |
| + rec, err := Rotate(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + if err != nil { |
| + t.Fatalf("Rotate: %v", err) |
| + } |
| + if rec.Provisioned.KeyID == old { |
| + t.Fatal("Rotate did not mint a new key") |
| + } |
| + if rec.Provisioned.PrevKeyID != old { |
| + t.Fatalf("PrevKeyID = %q, want %q", rec.Provisioned.PrevKeyID, old) |
| + } |
| + keys := p.Users[UserName("bag", "seapointish")].Keys |
| + if _, ok := keys[old]; !ok { |
| + t.Fatal("the old key was deleted at rotation — instances still using it would fail") |
| + } |
| + if !keys[old].Active { |
| + t.Fatal("the old key was deactivated at rotation") |
| + } |
| +} |
| + |
| +// Finish is the only walk that destroys a credential an app might still be |
| +// using, so it is explicit and separate. |
| +func TestRotateFinishClosesTheWindow(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + rec, _ := Rotate(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + old := rec.Provisioned.PrevKeyID |
| + |
| + done, err := RotateFinish(ctx, s, p, "bag", "seapointish") |
| + if err != nil { |
| + t.Fatalf("RotateFinish: %v", err) |
| + } |
| + if done.Provisioned.PrevKeyID != "" { |
| + t.Fatal("PrevKeyID survived the finish") |
| + } |
| + if _, ok := p.Users[UserName("bag", "seapointish")].Keys[old]; ok { |
| + t.Fatal("the old key survived the finish") |
| + } |
| +} |
| + |
| +// The pause is SetKeyActive, and it must be reversible with no policy |
| +// rewrite — a policy rewrite would have to be undone correctly to resume |
| +// and could half-fail into a broken grant. |
| +func TestPauseAndResumeToggleTheKeyNotThePolicy(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + rec, _ := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + user := UserName("bag", "seapointish") |
| + policyBefore := p.Users[user].Policy |
| + |
| + paused, err := SetPaused(ctx, s, p, "bag", "seapointish", true, now) |
| + if err != nil { |
| + t.Fatalf("SetPaused(true): %v", err) |
| + } |
| + if paused.Provisioned.Active { |
| + t.Fatal("record still says active after a pause") |
| + } |
| + if p.Users[user].Keys[rec.Provisioned.KeyID].Active { |
| + t.Fatal("the key is still active after a pause") |
| + } |
| + if p.Users[user].Policy != policyBefore { |
| + t.Fatal("the pause rewrote the policy — it must only toggle the key") |
| + } |
| + |
| + resumed, err := SetPaused(ctx, s, p, "bag", "seapointish", false, now) |
| + if err != nil { |
| + t.Fatalf("SetPaused(false): %v", err) |
| + } |
| + if !resumed.Provisioned.Active || !p.Users[user].Keys[rec.Provisioned.KeyID].Active { |
| + t.Fatal("resume did not reactivate") |
| + } |
| +} |
| + |
| +// A standalone credential lives on its OWN IAM user, because the delivered |
| +// user's two key slots are spent on the delivered key and the rotation |
| +// overlap. Sharing would work until the first rotation and then break it. |
| +func TestStandaloneGetsItsOwnUserAndDoesNotDisturbDelivery(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + rec, _ := Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + delivered := rec.Provisioned.KeyID |
| + |
| + creds, err := CreateStandalone(ctx, s, p, testDeployment(), "bag", "seapointish", "laptop", "paul@rslw.com", now) |
| + if err != nil { |
| + t.Fatalf("CreateStandalone: %v", err) |
| + } |
| + if creds.Password == "" || creds.Username == "" { |
| + t.Fatalf("standalone creds incomplete: %+v", creds) |
| + } |
| + if creds.Host != "email-smtp.eu-west-1.amazonaws.com" || creds.Port != "587" { |
| + t.Fatalf("standalone coordinates wrong: %+v", creds) |
| + } |
| + own := StandaloneUserName("bag", "seapointish", "laptop") |
| + if _, ok := p.Users[own]; !ok { |
| + t.Fatal("standalone credential did not get its own IAM user") |
| + } |
| + // The delivered user still holds exactly its own key. |
| + if len(p.Users[UserName("bag", "seapointish")].Keys) != 1 { |
| + t.Fatal("standalone minting touched the delivered user's key slots") |
| + } |
| + |
| + if err := RevokeStandalone(ctx, s, p, "bag", "seapointish", "laptop"); err != nil { |
| + t.Fatalf("RevokeStandalone: %v", err) |
| + } |
| + if _, ok := p.Users[own]; ok { |
| + t.Fatal("revoke left the standalone user behind") |
| + } |
| + after, _, _ := Get(ctx, s, "bag", "seapointish") |
| + if after.Provisioned.KeyID != delivered { |
| + t.Fatal("revoking a standalone credential disturbed delivery") |
| + } |
| +} |
| + |
| +// Teardown removes the identity only when the LAST app referencing it goes. |
| +// Otherwise one app's teardown revokes a sibling's verified domain. |
| +func TestTeardownKeepsASharedDomainAlive(t *testing.T) { |
| + ctx, s, p, pub, now := enableFixture(t) |
| + Enable(ctx, s, p, pub, testDeployment(), "bag", "seapointish", now) |
| + // A sibling app joins the same domain. |
| + if _, _, err := EnsureDomain(ctx, s, p, memWriterForTest(), testDeployment(), |
| + "bag", "marketing", "seapointish.bag.oncarlos.com", now); err != nil { |
| + t.Fatalf("sibling EnsureDomain: %v", err) |
| + } |
| + |
| + if err := Teardown(ctx, s, p, memWriterForTest(), testDeployment(), "bag", "seapointish"); err != nil { |
| + t.Fatalf("Teardown: %v", err) |
| + } |
| + if _, ok := p.Identities[identityKey("eu-west-1", "seapointish.bag.oncarlos.com")]; !ok { |
| + t.Fatal("teardown deleted an identity a sibling app still references") |
| + } |
| + dom, _, _ := GetDomain(ctx, s, "bag", "seapointish.bag.oncarlos.com") |
| + if len(dom.Apps) != 1 || dom.Apps[0] != "marketing" { |
| + t.Fatalf("apps = %v, want [marketing]", dom.Apps) |
| + } |
| +} |
| +``` |
| + |
| +Add at the bottom of that file: |
| + |
| +```go |
| +func memWriterForTest() dnsrec.Writer { return dnsrec.NewMemWriter() } |
| +``` |
| + |
| +with `"github.com/carlosframework/platform/internal/dnsrec"` imported. |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestRotate|TestPause|TestStandalone|TestTeardown' -v` |
| +Expected: FAIL — `undefined: Rotate`, `SetPaused`, … |
| + |
| +- [ ] **Step 3: Implement — append to `internal/mail/walk.go`** |
| + |
| +```go |
| +// StandaloneCreds is a displayed credential, returned ONCE. Nothing stores |
| +// the password: it is derived here and handed straight back. |
| +type StandaloneCreds struct { |
| + Host, Port, Username, Password, From string |
| +} |
| + |
| +// Rotate mints a fresh key, delivers it, and leaves the OLD key ACTIVE. |
| +// |
| +// The overlap is the whole point. Adoption swaps a symlink; it does not |
| +// restart processes, so an instance may still be authenticating with the old |
| +// credential for minutes after delivery. Killing it at rotation time is an |
| +// outage with no upside. RotateFinish closes the window explicitly. |
| +// |
| +// The overlap consumes IAM's ceiling of two access keys per user, which is |
| +// exactly why standalone credentials get their own principal. |
| +func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| + d Deployment, acct, app string, now time.Time) (*Record, error) { |
| + |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Declared == nil { |
| + return nil, ErrNotDeclared |
| + } |
| + if rec.Provisioned == nil { |
| + return nil, ErrNotProvisioned |
| + } |
| + user := rec.Provisioned.IAMUser |
| + |
| + // A pending key from an UNFINISHED earlier rotation goes first — |
| + // rotating twice without finishing is ordinary member behaviour, and |
| + // both slots cannot hold three keys. |
| + if rec.Provisioned.PrevKeyID != "" { |
| + if err := p.DeleteKey(ctx, user, rec.Provisioned.PrevKeyID); err != nil { |
| + return nil, err |
| + } |
| + } |
| + // Then reap anything the record does not name — a key stranded by a |
| + // crash between mint and record is nameable only by listing. |
| + if err := p.ReapKeys(ctx, user, []string{rec.Provisioned.KeyID}); err != nil { |
| + return nil, err |
| + } |
| + keyID, secret, err := p.MintKey(ctx, user) |
| + if err != nil { |
| + return nil, fmt.Errorf("mail: mint key for %s: %w", user, err) |
| + } |
| + if err := writeSMTPEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, |
| + d.Region, keyID, secret, rec.Declared.From); err != nil { |
| + return nil, err |
| + } |
| + rec.Provisioned.PrevKeyID = rec.Provisioned.KeyID |
| + rec.Provisioned.KeyID = keyID |
| + rec.Provisioned.At = now |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| + |
| +// RotateFinish closes the two-key window. This is the only walk that |
| +// destroys a credential an app might still be using, which is why it is a |
| +// separate, explicit verb rather than the tail of Rotate. |
| +func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) { |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Provisioned == nil { |
| + return nil, ErrNotProvisioned |
| + } |
| + if rec.Provisioned.PrevKeyID == "" { |
| + return rec, nil // nothing to finish; idempotent |
| + } |
| + if err := p.DeleteKey(ctx, rec.Provisioned.IAMUser, rec.Provisioned.PrevKeyID); err != nil { |
| + return nil, err |
| + } |
| + rec.Provisioned.PrevKeyID = "" |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| + |
| +// SetPaused stops or resumes sending. |
| +// |
| +// It toggles the KEY, never the policy. A policy rewrite would have to be |
| +// undone correctly to resume, and a half-failed rewrite leaves a grant that |
| +// is neither the old one nor the new one. Deactivating a key is atomic at |
| +// IAM and trivially reversible. |
| +// |
| +// BOTH keys are toggled: an app mid-rotation still holds the previous |
| +// credential, and pausing only the current one would leave it sending. |
| +func SetPaused(ctx context.Context, s release.Store, p Provisioner, |
| + acct, app string, paused bool, now time.Time) (*Record, error) { |
| + |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Provisioned == nil { |
| + return nil, ErrNotProvisioned |
| + } |
| + user := rec.Provisioned.IAMUser |
| + for _, id := range []string{rec.Provisioned.KeyID, rec.Provisioned.PrevKeyID} { |
| + if id == "" { |
| + continue |
| + } |
| + if err := p.SetKeyActive(ctx, user, id, !paused); err != nil { |
| + return nil, err |
| + } |
| + } |
| + rec.Provisioned.Active = !paused |
| + rec.Provisioned.At = now |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| + |
| +// CreateStandalone mints a displayed credential on its OWN IAM principal. |
| +func CreateStandalone(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| + acct, app, name, actor string, now time.Time) (StandaloneCreds, error) { |
| + |
| + if !ValidEnvPrefix(strings.ToUpper(name)) && !validStandaloneName(name) { |
| + return StandaloneCreds{}, fmt.Errorf("mail: invalid credential name %q", name) |
| + } |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| + if rec == nil || rec.Declared == nil { |
| + return StandaloneCreds{}, ErrNotDeclared |
| + } |
| + domains, err := grantedDomains(ctx, s, acct, app) |
| + if err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| + if len(domains) == 0 { |
| + return StandaloneCreds{}, ErrNoDomains |
| + } |
| + var arns, names []string |
| + for _, dom := range domains { |
| + arns = append(arns, dom.VerifiedARNs()...) |
| + names = append(names, dom.Domain) |
| + } |
| + sort.Strings(arns) |
| + sort.Strings(names) |
| + |
| + user := StandaloneUserName(acct, app, name) |
| + if _, err := p.EnsureUser(ctx, user, UserPath, arns, names); err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| + // A re-mint for the same name replaces the credential rather than |
| + // stacking one: two slots is still the ceiling here too. |
| + if err := p.ReapKeys(ctx, user, nil); err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| + keyID, secret, err := p.MintKey(ctx, user) |
| + if err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| + |
| + replaced := false |
| + for _, sa := range rec.Standalone { |
| + if sa.Name == name { |
| + sa.IAMUser, sa.KeyID, sa.At, sa.By = user, keyID, now, actor |
| + replaced = true |
| + } |
| + } |
| + if !replaced { |
| + rec.Standalone = append(rec.Standalone, |
| + &Standalone{Name: name, IAMUser: user, KeyID: keyID, At: now, By: actor}) |
| + } |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return StandaloneCreds{}, err |
| + } |
| + return StandaloneCreds{ |
| + Host: SMTPHost(d.Region), Port: SMTPPort, |
| + Username: keyID, Password: SMTPPassword(secret, d.Region), |
| + From: rec.Declared.From, |
| + }, nil |
| +} |
| + |
| +var standaloneNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,30}$`) |
| + |
| +func validStandaloneName(n string) bool { return standaloneNameRe.MatchString(n) } |
| + |
| +// RevokeStandalone deletes one displayed credential and its principal. It |
| +// cannot disturb delivery, which is the point of the separate user. |
| +func RevokeStandalone(ctx context.Context, s release.Store, p Provisioner, acct, app, name string) error { |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + if rec == nil { |
| + return nil |
| + } |
| + kept := rec.Standalone[:0] |
| + found := false |
| + for _, sa := range rec.Standalone { |
| + if sa.Name == name { |
| + found = true |
| + if err := p.DeleteUser(ctx, sa.IAMUser); err != nil { |
| + return err |
| + } |
| + continue |
| + } |
| + kept = append(kept, sa) |
| + } |
| + if !found { |
| + return nil |
| + } |
| + rec.Standalone = kept |
| + _, err = Put(ctx, s, acct, app, rec, etag) |
| + return err |
| +} |
| + |
| +// Teardown dismantles an app's email estate: credentials, principals, and |
| +// its claim on every domain it referenced. |
| +// |
| +// A domain's SES identity and DNS are removed only when the LAST app |
| +// referencing it goes. Getting that backwards revokes a sibling app's |
| +// verified domain — silently, and with the customer's own DNS still |
| +// pointing at an identity that no longer exists. |
| +func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writer, |
| + d Deployment, acct, app string) error { |
| + |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + if rec != nil { |
| + if rec.Provisioned != nil { |
| + if err := p.DeleteUser(ctx, rec.Provisioned.IAMUser); err != nil { |
| + return err |
| + } |
| + } |
| + for _, sa := range rec.Standalone { |
| + if err := p.DeleteUser(ctx, sa.IAMUser); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + |
| + domains, err := ListDomains(ctx, s, acct) |
| + if err != nil { |
| + return err |
| + } |
| + for _, dom := range domains { |
| + if !dom.RemoveApp(app) { |
| + continue |
| + } |
| + if len(dom.Apps) > 0 { |
| + if _, err := PutDomain(ctx, s, acct, dom, ""); err != nil { |
| + return err |
| + } |
| + continue |
| + } |
| + // Last referrer: remove the identity and, for a platform domain, |
| + // the records CARLOS published. A custom domain's records are the |
| + // customer's to remove. |
| + for region, st := range dom.Regions { |
| + if dom.Kind == KindPlatform { |
| + for _, r := range RecordSet(dom.Domain, region, st.DKIMTokens, d.DMARCRua) { |
| + if err := w.DeleteRecord(ctx, r); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + if err := p.DeleteIdentity(ctx, region, dom.Domain); err != nil { |
| + return err |
| + } |
| + } |
| + if err := s.Delete(ctx, DomainKey(acct, dom.Domain)); err != nil { |
| + return err |
| + } |
| + } |
| + |
| + if rec != nil { |
| + _ = etag |
| + if err := s.Delete(ctx, Key(acct, app)); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +Add `"regexp"` to `walk.go`'s imports. |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/mail/ |
| +git add internal/mail/walk.go internal/mail/walk_lifecycle_test.go |
| +git commit -m "mail: rotate, pause, standalone credentials, teardown |
| + |
| +Rotation leaves the old key active because adoption swaps a symlink and does |
| +not restart processes — killing it at rotation is an outage. The pause |
| +toggles the key, never the policy, and toggles both keys so an app |
| +mid-rotation cannot keep sending. Teardown removes a domain's identity only |
| +when the last referring app goes, or one app's teardown silently revokes a |
| +sibling's verified domain." |
| +``` |
| + |
| +--- |
| + |
| +## Task 14: SES event parsing |
| + |
| +Spec §9. Pure parsing; storage and policy arrive in Tasks 15–16. |
| + |
| +**Files:** |
| +- Create: `internal/mail/events.go` |
| +- Test: `internal/mail/events_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `type Event struct { Type, Domain, From, MessageID string; At time.Time }` |
| + - `const EventSend, EventBounce, EventComplaint, EventReject, EventDelivery string` |
| + - `func ParseEvent(body []byte) (Event, error)` |
| + - `func DomainOf(address string) string` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/mail/events_test.go`: |
| + |
| +```go |
| +package mail |
| + |
| +import "testing" |
| + |
| +// SES's event JSON nests the From address under mail.source and the type |
| +// under eventType. Attribution is by SENDING DOMAIN, because that is the |
| +// only field that ties an event back to a tenant — SES does not know about |
| +// CARLOS accounts. |
| +func TestParseEventAttributesByDomain(t *testing.T) { |
| + body := []byte(`{ |
| + "eventType": "Bounce", |
| + "mail": { |
| + "timestamp": "2026-08-19T12:00:00.000Z", |
| + "messageId": "0100abc", |
| + "source": "noreply@seapointish.bag.oncarlos.com" |
| + } |
| + }`) |
| + ev, err := ParseEvent(body) |
| + if err != nil { |
| + t.Fatalf("ParseEvent: %v", err) |
| + } |
| + if ev.Type != EventBounce { |
| + t.Fatalf("Type = %q, want %q", ev.Type, EventBounce) |
| + } |
| + if ev.Domain != "seapointish.bag.oncarlos.com" { |
| + t.Fatalf("Domain = %q", ev.Domain) |
| + } |
| + if ev.MessageID != "0100abc" { |
| + t.Fatalf("MessageID = %q", ev.MessageID) |
| + } |
| + if ev.At.IsZero() { |
| + t.Fatal("timestamp did not parse") |
| + } |
| +} |
| + |
| +// An event type this build does not know must not fail the whole delivery — |
| +// SES adds event types, and a 500 makes SNS retry forever. |
| +func TestParseEventAcceptsAnUnknownType(t *testing.T) { |
| + ev, err := ParseEvent([]byte(`{"eventType":"Subscription","mail":{"source":"a@b.com"}}`)) |
| + if err != nil { |
| + t.Fatalf("an unknown event type must parse, got %v", err) |
| + } |
| + if ev.Type != "Subscription" { |
| + t.Fatalf("Type = %q", ev.Type) |
| + } |
| +} |
| + |
| +func TestParseEventRefusesGarbage(t *testing.T) { |
| + if _, err := ParseEvent([]byte(`not json`)); err == nil { |
| + t.Fatal("garbage parsed") |
| + } |
| + if _, err := ParseEvent([]byte(`{"eventType":"Send"}`)); err == nil { |
| + t.Fatal("an event with no source parsed — it can be attributed to nobody") |
| + } |
| +} |
| + |
| +func TestDomainOfIsCaseFoldedAndAngleTolerant(t *testing.T) { |
| + for _, c := range []struct{ in, want string }{ |
| + {"noreply@Example.COM", "example.com"}, |
| + {"Bob <bob@example.com>", "example.com"}, |
| + {"nodomain", ""}, |
| + } { |
| + if got := DomainOf(c.in); got != c.want { |
| + t.Fatalf("DomainOf(%q) = %q, want %q", c.in, got, c.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -run 'TestParseEvent|TestDomainOf' -v` |
| +Expected: FAIL — `undefined: ParseEvent` |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/mail/events.go`: |
| + |
| +```go |
| +// SES event parsing (spec §9). Events are the abuse signal AND the only |
| +// source of a per-tenant send count — SES has no per-identity rate limit to |
| +// read, so a cap can only be enforced by counting what already went out. |
| +package mail |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const ( |
| + EventSend = "Send" |
| + EventBounce = "Bounce" |
| + EventComplaint = "Complaint" |
| + EventReject = "Reject" |
| + EventDelivery = "Delivery" |
| +) |
| + |
| +// Event is the part of an SES notification this package acts on. |
| +type Event struct { |
| + Type string |
| + Domain string |
| + From string |
| + MessageID string |
| + At time.Time |
| +} |
| + |
| +// DomainOf extracts the lowercase domain from an address, tolerating a |
| +// display-name wrapper. Empty when there is no domain to find. |
| +func DomainOf(address string) string { |
| + s := strings.TrimSpace(address) |
| + if i := strings.LastIndex(s, "<"); i >= 0 { |
| + if j := strings.LastIndex(s, ">"); j > i { |
| + s = s[i+1 : j] |
| + } |
| + } |
| + at := strings.LastIndex(s, "@") |
| + if at < 0 || at == len(s)-1 { |
| + return "" |
| + } |
| + return strings.ToLower(strings.TrimSpace(s[at+1:])) |
| +} |
| + |
| +// ParseEvent reads one SES notification. |
| +// |
| +// An UNKNOWN event type parses successfully and is carried through as-is. |
| +// SES adds event types, and refusing one would make the ingest endpoint |
| +// answer 500, which makes SNS retry it forever — a self-inflicted flood |
| +// over a field nothing acts on. |
| +// |
| +// A missing source IS refused: an event that can be attributed to no tenant |
| +// is worse than no event, because it would be silently dropped from the |
| +// counts that enforce a cap. |
| +func ParseEvent(body []byte) (Event, error) { |
| + var raw struct { |
| + EventType string `json:"eventType"` |
| + Mail struct { |
| + Timestamp string `json:"timestamp"` |
| + MessageID string `json:"messageId"` |
| + Source string `json:"source"` |
| + } `json:"mail"` |
| + } |
| + if err := json.Unmarshal(body, &raw); err != nil { |
| + return Event{}, fmt.Errorf("mail: parse ses event: %w", err) |
| + } |
| + if raw.Mail.Source == "" { |
| + return Event{}, fmt.Errorf("mail: ses event has no mail.source — it can be attributed to no tenant") |
| + } |
| + dom := DomainOf(raw.Mail.Source) |
| + if dom == "" { |
| + return Event{}, fmt.Errorf("mail: ses event source %q has no domain", raw.Mail.Source) |
| + } |
| + ev := Event{ |
| + Type: raw.EventType, Domain: dom, |
| + From: raw.Mail.Source, MessageID: raw.Mail.MessageID, |
| + } |
| + if raw.Mail.Timestamp != "" { |
| + if at, err := time.Parse(time.RFC3339, raw.Mail.Timestamp); err == nil { |
| + ev.At = at |
| + } |
| + } |
| + return ev, nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run, then commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/mail/ -count=1 |
| +gofmt -l internal/mail/ |
| +git add internal/mail/events.go internal/mail/events_test.go |
| +git commit -m "mail: parse SES events, attributing by sending domain |
| + |
| +The domain is the only field tying an event back to a tenant — SES knows |
| +nothing about CARLOS accounts. An unknown event type parses rather than |
| +erroring, because a 500 from the ingest endpoint makes SNS retry forever; a |
| +missing source is refused, because an unattributable event would be dropped |
| +from the counts that enforce a cap." |
| +``` |
| + |
| +--- |
| + |
| +## Task 15: The SNS ingest endpoint |
| + |
| +Spec §9. **The security-critical task in this plan.** An unvalidated `SigningCertURL` lets anyone who can reach the endpoint present their own signing certificate and forge every event — including the ones that drive an automatic pause. |
| + |
| +**Files:** |
| +- Create: `internal/console/sesevents.go` |
| +- Test: `internal/console/sesevents_test.go` |
| +- Modify: `internal/console/server.go` (route registration, `Config` fields) |
| + |
| +**Interfaces:** |
| +- Consumes: `mail.ParseEvent`, `mail.Event`. |
| +- Produces: |
| + - `func validSigningCertURL(raw string) error` |
| + - `func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request)` |
| + - `Config.MailProvisioner mail.Provisioner`, `Config.DNSWriter dnsrec.Writer`, `Config.MailDeployment mail.Deployment` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/console/sesevents_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import "testing" |
| + |
| +// The SigningCertURL check is the ENTIRETY of SNS signature security. If an |
| +// attacker can point it at a host they control, they sign their own message |
| +// with their own certificate and it verifies — which means forging the |
| +// bounce events that trigger an automatic pause, on any tenant. |
| +func TestSigningCertURLMustBeAmazon(t *testing.T) { |
| + ok := []string{ |
| + "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-abc.pem", |
| + "https://sns.us-east-1.amazonaws.com/x.pem", |
| + } |
| + for _, u := range ok { |
| + if err := validSigningCertURL(u); err != nil { |
| + t.Fatalf("validSigningCertURL(%q) = %v, want nil", u, err) |
| + } |
| + } |
| + bad := []struct{ url, why string }{ |
| + {"http://sns.eu-west-1.amazonaws.com/x.pem", "plain http"}, |
| + {"https://evil.com/x.pem", "not amazon at all"}, |
| + {"https://sns.eu-west-1.amazonaws.com.evil.com/x.pem", "suffix lookalike"}, |
| + {"https://amazonaws.com.evil.com/x.pem", "suffix lookalike"}, |
| + {"https://evilamazonaws.com/x.pem", "no dot before the suffix"}, |
| + {"https://sns.eu-west-1.amazonaws.com/../x", "path traversal"}, |
| + {"", "empty"}, |
| + {"not a url", "unparseable"}, |
| + } |
| + for _, c := range bad { |
| + if err := validSigningCertURL(c.url); err == nil { |
| + t.Fatalf("validSigningCertURL(%q) accepted — %s", c.url, c.why) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestSigningCertURL -v` |
| +Expected: FAIL — `undefined: validSigningCertURL` |
| + |
| +- [ ] **Step 3: Implement the validator and the handler** |
| + |
| +`internal/console/sesevents.go`: |
| + |
| +```go |
| +// The SES event ingest endpoint (tenant-email spec §9): SNS delivers send, |
| +// bounce and complaint notifications here, and they become the per-tenant |
| +// counts that enforce a cap and trip an automatic pause. |
| +// |
| +// SECURITY: validSigningCertURL below is the entirety of SNS signature |
| +// security. SNS tells the receiver where to fetch the certificate that |
| +// verifies the message — so an unvalidated URL lets anyone who can reach |
| +// this endpoint host their own certificate, sign their own payload, and |
| +// have it verify. The consequence here is not merely bad data: forged |
| +// bounce events trip the automatic pause, so it is a remote off-switch for |
| +// any tenant's email. |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "net/url" |
| + "strings" |
| +) |
| + |
| +// snsCertHost is the only host family a signing certificate may come from. |
| +const snsCertHost = "amazonaws.com" |
| + |
| +// validSigningCertURL refuses anything that is not an https URL on an |
| +// amazonaws.com host. |
| +// |
| +// The suffix check is LABEL-AWARE. A bare strings.HasSuffix would accept |
| +// "sns.eu-west-1.amazonaws.com.evil.com", which is the classic way this |
| +// check is written wrong: the attacker's domain ends with the string but is |
| +// an entirely different host. |
| +func validSigningCertURL(raw string) error { |
| + u, err := url.Parse(raw) |
| + if err != nil { |
| + return fmt.Errorf("sns: unparseable SigningCertURL") |
| + } |
| + if u.Scheme != "https" { |
| + return fmt.Errorf("sns: SigningCertURL is not https") |
| + } |
| + host := strings.ToLower(u.Hostname()) |
| + if host != snsCertHost && !strings.HasSuffix(host, "."+snsCertHost) { |
| + return fmt.Errorf("sns: SigningCertURL host %q is not an AWS host", host) |
| + } |
| + // A traversal in the path can walk off the certificate route on a host |
| + // that serves more than certificates. |
| + if strings.Contains(u.Path, "..") { |
| + return fmt.Errorf("sns: SigningCertURL path contains a traversal") |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestSigningCertURL -v` |
| +Expected: PASS |
| + |
| +- [ ] **Step 5: Add the handler, its tests, and the wiring** |
| + |
| +Append to `sesevents.go` a `handleSESEvents` that, in order: |
| + |
| +1. Reads the body with a size limit (`http.MaxBytesReader`, 256 KiB — SNS messages are small and an unbounded read is a memory DoS). |
| +2. Parses the SNS envelope (`Type`, `Message`, `SigningCertURL`, `Signature`, `SubscribeURL`, `TopicArn`). |
| +3. Rejects unless `TopicArn` equals `s.cfg.MailDeployment.SNSTopicARN` — the endpoint accepts only our own topic. |
| +4. Calls `validSigningCertURL`, then fetches the certificate and verifies the signature over SNS's canonical string. **Verify before acting on any field**, including `SubscribeURL`. |
| +5. On `SubscriptionConfirmation`, GETs the verified `SubscribeURL` — subscription confirmation must be handled or no events ever arrive. |
| +6. On `Notification`, `mail.ParseEvent(msg.Message)` and record the counts (Task 16). |
| +7. Answers **200 for anything it deliberately ignores** and non-200 only for a genuine failure — SNS retries non-2xx, so a 500 on an unknown event type becomes a flood. |
| + |
| +Write these tests alongside it: |
| + |
| +```go |
| +// sesPost drives the ingest endpoint the way SNS would. |
| +func sesPost(t *testing.T, fx *consoleFixture, body string) *http.Response { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/ses/events", strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "text/plain") |
| + resp, err := fx.ts.Client().Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return resp |
| +} |
| + |
| +// A message whose TopicArn is not ours is refused BEFORE any certificate is |
| +// fetched — otherwise this endpoint makes outbound requests on a stranger's |
| +// say-so, which is an SSRF with an AWS-shaped alibi. |
| +func TestSESEventsRefusesForeignTopic(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" |
| + resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",`+ |
| + `"Message":"{}","SigningCertURL":"https://evil.com/x.pem","Signature":"x"}`) |
| + defer resp.Body.Close() |
| + if resp.StatusCode == http.StatusOK { |
| + t.Fatal("a foreign TopicArn was accepted") |
| + } |
| +} |
| + |
| +// An unknown event type is a 200. SNS retries non-2xx, so answering 500 |
| +// turns one unrecognised field into an unbounded retry flood against the |
| +// console. |
| +func TestSESEventsAnswers200ForUnknownTypes(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" |
| + fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } // seam: skip signature in tests |
| + resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ |
| + `"Message":"{\"eventType\":\"Subscription\",\"mail\":{\"source\":\"a@b.com\"}}",`+ |
| + `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200 — a non-2xx makes SNS retry forever", resp.StatusCode) |
| + } |
| +} |
| + |
| +// The body is size-limited: an unbounded read on an unauthenticated endpoint |
| +// is a memory exhaustion anyone can trigger. |
| +func TestSESEventsRefusesAnOversizeBody(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" |
| + resp := sesPost(t, fx, `{"Type":"Notification","Message":"`+strings.Repeat("a", 300<<10)+`"}`) |
| + defer resp.Body.Close() |
| + if resp.StatusCode == http.StatusOK { |
| + t.Fatal("an oversize body was accepted") |
| + } |
| +} |
| +``` |
| + |
| +These use `newConsoleFixture(t)` (`fx.ts`, `fx.srv`, `fx.st`, `fx.rel`) — the same fixture `internal/console/apistore_test.go` uses. Read that file before writing these and mirror it rather than standing up a second fixture. |
| + |
| +Add the `snsVerify func(snsEnvelope) error` field to `Server` with a nil default meaning "do the real verification". A test seam is needed because the real path fetches a certificate over the network; the seam is on SIGNATURE verification only, never on the `SigningCertURL` or `TopicArn` checks, which every test above exercises for real. |
| + |
| +Register the route in `server.go` next to the other unauthenticated API routes, and add to `Config`: |
| + |
| +```go |
| +// MailProvisioner is the backend the email doors provision against. nil |
| +// disables every email door — a deployment with no SES estate is a valid |
| +// deployment, and the doors must 404 rather than panic. |
| +MailProvisioner mail.Provisioner |
| +// DNSWriter publishes durable records for platform sending domains. nil |
| +// means platform domains behave like custom ones: records are emitted for |
| +// the member instead of published. |
| +DNSWriter dnsrec.Writer |
| +// MailDeployment carries the region, the platform suffix, the DMARC rua |
| +// and the SNS topic. |
| +MailDeployment mail.Deployment |
| +``` |
| + |
| +- [ ] **Step 6: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l internal/console/ |
| +git add internal/console/sesevents.go internal/console/sesevents_test.go internal/console/server.go |
| +git commit -m "console: SES event ingest, with the SigningCertURL check that matters |
| + |
| +An unvalidated SigningCertURL lets anyone reaching this endpoint host their |
| +own certificate and forge every event — and since forged bounces trip the |
| +automatic pause, that is a remote off-switch for any tenant's email. The |
| +suffix check is label-aware, because the bare HasSuffix form accepts |
| +sns.eu-west-1.amazonaws.com.evil.com. Deliberate ignores answer 200: SNS |
| +retries non-2xx, so a 500 on an unknown event type is a self-inflicted flood." |
| +``` |
| + |
| +--- |
| + |
| +## Task 16: Counts, caps and the automatic pause |
| + |
| +Spec §9, §10. Enforcement is reactive with minutes of latency, and the plan says so out loud rather than implying a cap is exact. |
| + |
| +**Files:** |
| +- Create: `internal/console/mailcounts.go` |
| +- Test: `internal/console/mailcounts_test.go` |
| +- Modify: `internal/console/store.go` (migration), `internal/console/sesevents.go` (call the recorder) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `func (s *Store) RecordMailEvent(ctx, acct, app, day, evType string) error` |
| + - `func (s *Store) MailCounts(ctx, acct, app, day string) (MailCounts, error)` |
| + - `type MailCounts struct { Sends, Bounces, Complaints, Rejects int }` |
| + - `func (c MailCounts) BounceRate() float64`, `func (c MailCounts) ComplaintRate() float64` |
| + - `func TierCap(tier string) int` |
| + - `func (s *Server) enforceMailLimits(ctx, acct, app string, now time.Time) (paused bool, reason string, err error)` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/console/mailcounts_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import "testing" |
| + |
| +// The two thresholds SES itself reviews an account on. They are per-tenant |
| +// here so one tenant's bad list cannot take the estate down (spec §9). |
| +func TestRatesAreComputedOverSends(t *testing.T) { |
| + c := MailCounts{Sends: 1000, Bounces: 60, Complaints: 2} |
| + if got := c.BounceRate(); got < 0.059 || got > 0.061 { |
| + t.Fatalf("BounceRate = %v, want ~0.06", got) |
| + } |
| + if got := c.ComplaintRate(); got < 0.0019 || got > 0.0021 { |
| + t.Fatalf("ComplaintRate = %v, want ~0.002", got) |
| + } |
| +} |
| + |
| +// Zero sends must be zero rates, not a divide-by-zero or a NaN that |
| +// compares false against every threshold and silently never trips. |
| +func TestRatesWithNoSendsAreZeroNotNaN(t *testing.T) { |
| + c := MailCounts{} |
| + if c.BounceRate() != 0 || c.ComplaintRate() != 0 { |
| + t.Fatalf("rates on an empty day = %v/%v, want 0/0", c.BounceRate(), c.ComplaintRate()) |
| + } |
| +} |
| + |
| +// An untiered account defaults to free rather than to unlimited. This is |
| +// what lets the primitive ship before the billing work on #226 lands |
| +// (spec §10) — and getting it backwards would hand every unknown account |
| +// an uncapped estate. |
| +func TestUntieredAccountsGetTheFreeCap(t *testing.T) { |
| + if TierCap("") != TierCap("free") { |
| + t.Fatal("an untiered account did not default to the free cap") |
| + } |
| + if TierCap("free") != 50 { |
| + t.Fatalf("free cap = %d, want 50", TierCap("free")) |
| + } |
| + if TierCap("unknown-tier-name") != TierCap("free") { |
| + t.Fatal("an unrecognised tier did not fall back to free") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails, then implement** |
| + |
| +`internal/console/mailcounts.go`: |
| + |
| +```go |
| +// Per-tenant send, bounce and complaint counts, and the limits they enforce |
| +// (tenant-email spec §9, §10). |
| +// |
| +// ENFORCEMENT IS REACTIVE. SES has no per-identity rate limit, so a cap can |
| +// only be applied by counting what has already been sent and then stopping |
| +// further sends. There are minutes of latency between the send and the |
| +// event, which means a cap of 50 is a cap of "about 50". That is stated |
| +// here rather than implied, because a caller who believes it is exact will |
| +// eventually be surprised by a bill or a complaint. |
| +package console |
| + |
| +// MailCounts is one app's activity for one UTC day. |
| +type MailCounts struct { |
| + Sends int |
| + Bounces int |
| + Complaints int |
| + Rejects int |
| +} |
| + |
| +// BounceRate and ComplaintRate are zero on a day with no sends — not NaN. |
| +// A NaN compares false against every threshold, so a divide-by-zero here |
| +// would present as a limiter that silently never trips. |
| +func (c MailCounts) BounceRate() float64 { |
| + if c.Sends == 0 { |
| + return 0 |
| + } |
| + return float64(c.Bounces) / float64(c.Sends) |
| +} |
| + |
| +func (c MailCounts) ComplaintRate() float64 { |
| + if c.Sends == 0 { |
| + return 0 |
| + } |
| + return float64(c.Complaints) / float64(c.Sends) |
| +} |
| + |
| +// The thresholds SES reviews an account on. Applied per tenant so one |
| +// tenant's stale list cannot put every tenant under review. |
| +const ( |
| + BounceThreshold = 0.05 |
| + ComplaintThreshold = 0.001 |
| + // MinSendsForRates stops a single bounce on a two-message day reading |
| + // as a 50% bounce rate and pausing a healthy tenant. |
| + MinSendsForRates = 50 |
| +) |
| + |
| +// TierCap is the daily send cap for a tier. |
| +// |
| +// An untiered or unrecognised account gets the FREE cap, never an unlimited |
| +// one. That default is what lets this ship before the billing work lands, |
| +// and the direction matters: falling back to unlimited would hand every |
| +// unknown account an uncapped estate. |
| +func TierCap(tier string) int { |
| + switch tier { |
| + case "pro": |
| + return 10000 |
| + case "starter": |
| + return 1000 |
| + default: |
| + return 50 |
| + } |
| +} |
| +``` |
| + |
| +Add the storage. Mirror the migration style already in `internal/console/store.go` — find the existing `CREATE TABLE IF NOT EXISTS` block and add: |
| + |
| +```sql |
| +CREATE TABLE IF NOT EXISTS mail_counts ( |
| + account TEXT NOT NULL, |
| + app TEXT NOT NULL, |
| + day TEXT NOT NULL, -- UTC YYYY-MM-DD |
| + sends INTEGER NOT NULL DEFAULT 0, |
| + bounces INTEGER NOT NULL DEFAULT 0, |
| + complaints INTEGER NOT NULL DEFAULT 0, |
| + rejects INTEGER NOT NULL DEFAULT 0, |
| + PRIMARY KEY (account, app, day) |
| +); |
| +``` |
| + |
| +`RecordMailEvent` is an `INSERT … ON CONFLICT(account,app,day) DO UPDATE SET <col> = <col> + 1`, choosing the column from the event type and **ignoring an event type it does not know** — the same reason Task 14 parses unknown types rather than failing. |
| + |
| +- [ ] **Step 3: Implement `enforceMailLimits` and its tests** |
| + |
| +```go |
| +// enforceMailLimits pauses an app that has exceeded its daily cap or is past |
| +// either SES threshold, and reports why. |
| +// |
| +// The rate thresholds are applied only once MinSendsForRates messages have |
| +// gone out that day: without that floor, one bounce on a two-message morning |
| +// is a 50% bounce rate and a healthy tenant is paused before it has sent |
| +// anything real. |
| +// |
| +// A pause MUST be visible. It is written to the account's audit log with the |
| +// counts that caused it and surfaced on the app page — a silent pause |
| +// presents to the member as "email stopped working", which is the failure |
| +// this whole mechanism is supposed to prevent someone else's tenant causing. |
| +func (s *Server) enforceMailLimits(ctx context.Context, acct, app string, now time.Time) (bool, string, error) { |
| + rec, _, err := mail.Get(ctx, s.cfg.Releases, acct, app) |
| + if err != nil { |
| + return false, "", err |
| + } |
| + // Nothing provisioned, or already paused: there is nothing to stop. |
| + // Re-pausing would rewrite the record and re-audit on every event. |
| + if rec == nil || rec.Provisioned == nil || !rec.Provisioned.Active { |
| + return false, "", nil |
| + } |
| + day := now.UTC().Format("2006-01-02") |
| + c, err := s.st.MailCounts(ctx, acct, app, day) |
| + if err != nil { |
| + return false, "", err |
| + } |
| + cap := TierCap("") |
| + if rec.Grant != nil && rec.Grant.DailyCap > 0 { |
| + cap = rec.Grant.DailyCap |
| + } |
| + |
| + var reason string |
| + switch { |
| + case c.Sends >= cap: |
| + reason = fmt.Sprintf("daily cap %d reached (%d sent)", cap, c.Sends) |
| + // The send floor is what stops one bounce on a two-message morning |
| + // reading as a 50%% bounce rate and pausing a healthy tenant. |
| + case c.Sends >= MinSendsForRates && c.BounceRate() > BounceThreshold: |
| + reason = fmt.Sprintf("bounce rate %.1f%% over %.0f%% (%d of %d)", |
| + c.BounceRate()*100, BounceThreshold*100, c.Bounces, c.Sends) |
| + case c.Sends >= MinSendsForRates && c.ComplaintRate() > ComplaintThreshold: |
| + reason = fmt.Sprintf("complaint rate %.2f%% over %.1f%% (%d of %d)", |
| + c.ComplaintRate()*100, ComplaintThreshold*100, c.Complaints, c.Sends) |
| + default: |
| + return false, "", nil |
| + } |
| + |
| + if _, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct, app, true, now); err != nil { |
| + return false, "", err |
| + } |
| + // Audit AFTER the write it announces, and never before — a pause the |
| + // member cannot see is the failure §9 exists to prevent someone else's |
| + // tenant causing. |
| + s.audit(ctx, acct, "mail.paused", fmt.Sprintf("%s: %s", app, reason)) |
| + return true, reason, nil |
| +} |
| + |
| +// NOTHING auto-resumes. A pause that lifted itself at midnight would let an |
| +// abusive tenant send its cap again every day forever, which is not a limit |
| +// — it is a schedule. Resume is a decision, taken through the door. |
| +``` |
| + |
| +Tests to write, each with a real `Server` fixture mirroring `apistore_test.go`: |
| + |
| +```go |
| +// mailFixture seeds a declared, granted, provisioned app so enforce has |
| +// something to pause. Mirrors storeFixture in apistore_test.go. |
| +func mailFixture(t *testing.T) (*consoleFixture, time.Time) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) |
| + fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "seapointish", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := mail.Declare(context.Background(), fx.rel, release.OpsAccount, "seapointish", |
| + "SEAPOINTISH_SMTP", "noreply@x.com", "paul@rslw.com", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "seapointish", |
| + []string{"x.com"}, 50, "tier:free", "tier", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Provision by hand: this test is about the limiter, not the walk. |
| + rec, etag, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish") |
| + rec.Provisioned = &mail.Provisioned{IAMUser: "u", KeyID: "AKIA1", Active: true, At: now} |
| + if _, err := mail.Put(context.Background(), fx.rel, release.OpsAccount, "seapointish", rec, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := fx.srv.cfg.MailProvisioner.(*mail.MemProvisioner) |
| + p.EnsureUser(context.Background(), "u", mail.UserPath, []string{"arn:aws:ses:eu-west-1:1:identity/x.com"}, []string{"x.com"}) |
| + return fx, now |
| +} |
| + |
| +func seedCounts(t *testing.T, fx *consoleFixture, day string, c MailCounts) { |
| + t.Helper() |
| + ctx := context.Background() |
| + for i := 0; i < c.Sends; i++ { |
| + if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, "seapointish", day, mail.EventSend); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for i := 0; i < c.Bounces; i++ { |
| + fx.st.RecordMailEvent(ctx, release.OpsAccount, "seapointish", day, mail.EventBounce) |
| + } |
| + for i := 0; i < c.Complaints; i++ { |
| + fx.st.RecordMailEvent(ctx, release.OpsAccount, "seapointish", day, mail.EventComplaint) |
| + } |
| +} |
| + |
| +func TestEnforcePausesOnCapBreach(t *testing.T) { |
| + fx, now := mailFixture(t) |
| + seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50}) |
| + paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !paused { |
| + t.Fatal("cap reached and nothing paused") |
| + } |
| + if !strings.Contains(reason, "daily cap 50") { |
| + t.Fatalf("reason = %q, want it to name the cap", reason) |
| + } |
| + rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish") |
| + if rec.Provisioned.Active { |
| + t.Fatal("record still says active") |
| + } |
| +} |
| + |
| +// Below the send floor, a bad ratio is noise. One bounce on a two-message |
| +// morning is a 50% bounce rate and must NOT pause a healthy tenant. |
| +func TestEnforceIgnoresRatesBelowTheSendFloor(t *testing.T) { |
| + fx, now := mailFixture(t) |
| + seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 2, Bounces: 1}) |
| + paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if paused { |
| + t.Fatal("paused on a 2-message day — the send floor is not being applied") |
| + } |
| +} |
| + |
| +func TestEnforcePausesOnBounceRate(t *testing.T) { |
| + fx, now := mailFixture(t) |
| + // Cap is 50, so raise it first: this test is about the RATE, and a |
| + // cap breach would mask it. |
| + mail.ApplyGrant(context.Background(), fx.rel, release.OpsAccount, "seapointish", |
| + []string{"x.com"}, 100000, "operator", "paul@rslw.com", now) |
| + seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 100, Bounces: 10}) |
| + paused, reason, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !paused { |
| + t.Fatal("a 10% bounce rate did not pause") |
| + } |
| + if !strings.Contains(reason, "bounce rate") { |
| + t.Fatalf("reason = %q", reason) |
| + } |
| +} |
| + |
| +// A pause must not lift itself. Re-running enforce on a paused app with a |
| +// clean slate must leave it paused — resume is a decision, not a timer. |
| +func TestEnforceNeverAutoResumes(t *testing.T) { |
| + fx, now := mailFixture(t) |
| + seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50}) |
| + fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now) |
| + |
| + next := now.AddDate(0, 0, 1) // a fresh day, no counts at all |
| + paused, _, err := fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if paused { |
| + t.Fatal("enforce re-paused an already-paused app — it should have returned early") |
| + } |
| + rec, _, _ := mail.Get(context.Background(), fx.rel, release.OpsAccount, "seapointish") |
| + if rec.Provisioned.Active { |
| + t.Fatal("the pause lifted itself at midnight") |
| + } |
| +} |
| + |
| +// A pause nobody can see presents to the member as "email stopped working". |
| +func TestEnforceRecordsTheReasonInTheAuditLog(t *testing.T) { |
| + fx, now := mailFixture(t) |
| + seedCounts(t, fx, now.UTC().Format("2006-01-02"), MailCounts{Sends: 50}) |
| + fx.srv.enforceMailLimits(context.Background(), release.OpsAccount, "seapointish", now) |
| + |
| + entries, err := fx.st.AuditEntries(context.Background(), OpsAccountID, 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range entries { |
| + if strings.Contains(e.Action, "mail.paused") && strings.Contains(e.Detail, "daily cap 50") { |
| + return |
| + } |
| + } |
| + t.Fatalf("no audit entry naming the pause reason: %+v", entries) |
| +} |
| +``` |
| + |
| +Confirm the audit reader's real name and signature before writing the last test — `grep -n "func (s \*Store) Audit" internal/console/store.go` — and use it rather than the `AuditEntries` shape assumed above. |
| + |
| +- [ ] **Step 4: Wire it in and commit** |
| + |
| +Call `RecordMailEvent` from `handleSESEvents` (resolving account and app from the event's domain via `mail.ListDomains`), then `enforceMailLimits`. |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l internal/console/ |
| +git add internal/console/mailcounts.go internal/console/mailcounts_test.go internal/console/store.go internal/console/sesevents.go |
| +git commit -m "console: per-tenant mail counts, caps and the automatic pause |
| + |
| +Enforcement is reactive because SES has no per-identity rate limit — a cap |
| +of 50 is a cap of about 50, and the code says so. Rates are zero on a day |
| +with no sends rather than NaN, which would compare false against every |
| +threshold and never trip. Rate thresholds wait for a send floor so one |
| +bounce on a two-message morning cannot pause a healthy tenant. Nothing |
| +auto-resumes: a pause that lifts at midnight lets an abusive tenant send its |
| +cap again every day forever." |
| +``` |
| + |
| +--- |
| + |
| +## Task 17: The console doors |
| + |
| +**Files:** |
| +- Create: `internal/console/apimail.go`, `internal/console/apimail_test.go` |
| +- Modify: `internal/console/server.go` |
| + |
| +**Read first:** `internal/console/apistore.go` end to end. These doors are its sibling and must follow it exactly on session → CSRF → owner (byte-identical 404) → `requireFreshAuth` LAST, on `doorRefusal` shaping, on audit-after-write, and on serial bumping gated on an ACTUAL write. |
| + |
| +Endpoints, all account+app scoped: |
| + |
| +| Method | Path | Does | |
| +|---|---|---| |
| +| `POST` | `/api/cli/apps/{sqid}/{app}/email` | Declare (env prefix, From). | |
| +| `POST` | `/api/cli/apps/{sqid}/{app}/email/domains` | `EnsureDomain` — returns pending records for a custom domain. | |
| +| `POST` | `/api/cli/apps/{sqid}/{app}/email/domains/{domain}/verify` | `VerifyDomain`. | |
| +| `POST` | `/api/cli/apps/{sqid}/{app}/email/enable` | `Enable`. | |
| +| `GET` | `/api/cli/apps/{sqid}/{app}/email` | Status: domains, per-region state, counts, cap, pause. | |
| +| `POST` | `/api/cli/apps/{sqid}/{app}/email/rotate` | `Rotate`, `?finish=1` → `RotateFinish`. | |
| +| `POST` | `/api/cli/apps/{sqid}/{app}/email/credentials` | `CreateStandalone` — the only response that ever carries a password. | |
| +| `DELETE` | `/api/cli/apps/{sqid}/{app}/email/credentials/{name}` | `RevokeStandalone`. | |
| +| `POST` | `/api/cli/apps/{sqid}/{app}/email/pause` | `SetPaused` (operator). | |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +**These five are stated as signatures with their reason, not as full bodies, and that is deliberate rather than an omission.** Each asserts on a response shape this same task defines in Step 2 — writing the bodies first would mean inventing the JSON field names and then implementing to match invented names. Write Step 2's handlers and reply structs first, then come back and write these against what you actually built, using the `newConsoleFixture(t)` fixture from `apistore_test.go`. Each comment states exactly what must be asserted. |
| + |
| +```go |
| +// A non-member gets the family's byte-identical 404 — never a redirect, |
| +// never a different body, or the response confirms the app exists. |
| +func TestEmailDoorsAre404ForNonMembers(t *testing.T) |
| + |
| +// The standalone credential response is the ONLY one carrying a password, |
| +// and it must be uncacheable. A cached SMTP password in an intermediary is |
| +// a credential leak with no revocation story. |
| +func TestStandaloneResponseIsNoStoreAndTheOnlyOneWithAPassword(t *testing.T) |
| + |
| +// Every other reply echoes the key id (the username, not a secret) and |
| +// never a password. |
| +func TestStatusReplyNeverCarriesAPassword(t *testing.T) |
| + |
| +// A re-declare that stored nothing must not bump the serial or write an |
| +// audit line — #151's changed-gating rule. |
| +func TestNoOpDeclareDoesNotBumpTheSerial(t *testing.T) |
| + |
| +// A deployment with no SES estate 404s rather than panicking on a nil |
| +// provisioner. |
| +func TestEmailDoorsAre404WithNoProvisioner(t *testing.T) |
| +``` |
| + |
| +Write each body against the `apistore_test.go` fixture. |
| + |
| +- [ ] **Step 2: Implement, run the gate, commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l internal/console/ |
| +git add internal/console/apimail.go internal/console/apimail_test.go internal/console/server.go |
| +git commit -m "console: the email doors |
| + |
| +Gate order and 404 shape follow apistore.go exactly. The standalone |
| +credential response is the only one that ever carries a password and is |
| +no-store; every other reply echoes the key id, which is the SMTP username |
| +and not a secret. A no-op declare bumps no serial and writes no audit line." |
| +``` |
| + |
| +--- |
| + |
| +## Task 18: `carlos email` |
| + |
| +Every command completes and verifies its own job with no box access and no AWS console visit — the CLI's standing rule (CLAUDE.md). |
| + |
| +**Files:** |
| +- Create: `mail_cli.go`, `mail_cli_test.go` |
| +- Modify: `cli.go` |
| + |
| +**Read first:** `store.go` (repo root) for the subcommand dispatch, `requireStoreAPISession`, and the `print*` helpers. Mirror them. |
| + |
| +Verbs: |
| + |
| +- `carlos email enable -app X [-env-prefix P] [-from ADDR] [-domain D]` — declare → domain → publish → **wait for verification** → enable → print the delivered variable names. On a custom domain, print the record set and keep polling until verified or `-timeout` (default 10m) expires, then exit non-zero saying which axis is still pending. |
| +- `carlos email domains add -app X <domain>` — the same, domain only. |
| +- `carlos email status -app X` — domains and per-region state, today's counts against the cap, the pause state and its reason. |
| +- `carlos email test -app X -to ADDR` — **sends a real message** through the delivered credentials and reports the result. This is the verify half of the rule: the command that claims email works is the command that proves it. |
| +- `carlos email credentials create|list|revoke -app X [-name N]` |
| +- `carlos email rotate -app X [-finish]` |
| +- `carlos email pause|resume -app X` |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +**Same as Task 17: signatures with their reason, bodies after Step 2.** These assert on printed output this task defines. Mirror `store.go`'s `print*` helpers, which take an `io.Writer` precisely so they can be tested without a subprocess — prefer that to shelling out wherever it works. |
| + |
| +```go |
| +// The record set a member must publish is printed in a form they can paste, |
| +// one record per line, name/type/value — not a JSON blob. |
| +func TestPrintPendingRecordsIsPasteable(t *testing.T) |
| + |
| +// A verification timeout exits NON-ZERO and names the axis still pending. |
| +// Exiting zero on an unverified domain is the failure mode the no-infra |
| +// rule exists to prevent: the command claims success and the member finds |
| +// out from a bounced email. |
| +func TestEnableTimeoutExitsNonZeroNamingTheAxis(t *testing.T) |
| + |
| +// The password is printed ONCE, to stdout, and never written to a config |
| +// file or logged. |
| +func TestStandalonePrintsThePasswordOnce(t *testing.T) |
| +``` |
| + |
| +**Every test that shells out to the carlos binary MUST build its env through `carlosTestEnv`** (masks `CARLOS_CREDENTIALS`, `HOME`, `CARLOS_CONSOLE`) **and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for `.carlos/config`. The fallback is the operator's real login and default console; this is not hypothetical, it minted 40+ real accounts on the production console once. |
| + |
| +- [ ] **Step 2: Implement, gate, commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +gofmt -l . | head |
| +git add mail_cli.go mail_cli_test.go cli.go |
| +git commit -m "carlos email: the member surface |
| + |
| +One command declares, provisions, publishes, waits for verification and |
| +delivers. A verification timeout exits non-zero naming the axis still |
| +pending — exiting zero on an unverified domain is exactly the failure the |
| +no-infra rule exists to prevent. carlos email test sends a real message, |
| +because the command that claims email works should be the one that proves it." |
| +``` |
| + |
| +--- |
| + |
| +## Task 19: The v1 criterion |
| + |
| +**Not code — the proof.** Spec §1: seapointish sends its real sign-in mail on a custom domain, DKIM passing, SPF passing, DMARC aligned, landing in a Gmail inbox, with zero seapointish code changes. |
| + |
| +- [ ] **Step 1: Confirm the preconditions** |
| + |
| +- SES estate region confirmed (spec §13) and production access live in it. |
| +- `cribado.report` publishes `*._report._dmarc.cribado.report TXT "v=DMARC1"`. **Until it does, leave `CARLOS_DMARC_RUA` unset** — publishing `rua` without the external-destination record yields a DNS record that looks right and produces no reports at all, which is worse than publishing none. |
| +- The SNS topic exists and the console endpoint is subscribed and confirmed. |
| + |
| +- [ ] **Step 2: Prove the platform path first** |
| + |
| +```bash |
| +carlos email enable -app seapointish -env-prefix SEAPOINTISH_SMTP \ |
| + -from noreply@seapointish.bag.oncarlos.com |
| +carlos email test -app seapointish -to <a real gmail address> |
| +``` |
| + |
| +Check the received message's headers for `dkim=pass`, `spf=pass`, `dmarc=pass`. Confirm `X-Carlos-Version` on the app still reports the shipped build — no redeploy should have been needed. |
| + |
| +- [ ] **Step 3: Prove the custom-domain path — the actual criterion** |
| + |
| +```bash |
| +carlos email domains add -app seapointish <the custom domain> |
| +``` |
| + |
| +Publish the printed records in that domain's zone, let the command poll to verified, then re-run `carlos email test` and check the same three headers, plus that DMARC aligns against the **custom** domain. |
| + |
| +- [ ] **Step 4: Prove the pause** |
| + |
| +Confirm a paused app's sends actually fail and that the pause and its reason appear in the console and the audit log. A pause nothing surfaces is the bug §9 calls out. |
| + |
| +- [ ] **Step 5: Open the PR** |
| + |
| +Push the branch and open the PR. **Do not merge** — merging is a human's action (CLAUDE.md). Include in the body: the criterion evidence (the three header results, both domains), what was verified against live AWS docs (tenant attribution, the SMTP action name), and anything left open. |
| + |
| +--- |
| + |
| +## Follow-ups this plan deliberately does not do |
| + |
| +- The `keymail` → `carloku` AWS account rename (spec §13) — infra, not this feature's code. |
| +- Tightening the platform DMARC policy past `p=none` — blocked on aggregate reports actually arriving. |
| +- Tito's estate (spec §2) — a second SES estate or cross-account credentials, undecided. |
| +- The submission relay (spec §12) — build it when volume or abuse justifies a pager. The delivered contract does not foreclose it: swapping `*_SMTP_HOST` from SES's endpoint to a CARLOS relay is invisible to every app already delivered. |