tenant email: design — SES identities, SMTP credentials, region-keyed from day one
An app declares "I send email"; the platform 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 as env. Follows objstore's shape almost verb for verb, because it is the same problem with a scarcer resource: a bucket spends money, a sending identity spends shared reputation. v1 criterion: seapointish sends its real sign-in mail on a custom domain with DKIM/SPF passing and DMARC aligned, and zero seapointish code changes. Rulings worth the commit message: - Identity defaults to the app's own host, so the signing domain is the same domain as the links inside the mail. - Isolation is the IAM policy (identity ARNs AND a ses:FromAddress condition), not the SES tenant — the tenant layer is a seam. - adkim=s is the tenant boundary on a shared registrable domain: relaxed DMARC alignment cannot tell two oncarlos.com tenants apart. - The MAIL FROM subdomain carries its region from the first domain ever verified; for a custom domain it lives in the customer's zone, so it is the one thing we cannot fix for them later. - Bounce/complaint ingestion is in v1: SES reviews the whole account at ~5% bounces, so one tenant's stale list would suspend every tenant. - Tier auto-grants with a cap standing in for trust; an operator grant is needed only to exceed it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+533
−0
docs/superpowers/specs/2026-08-19-tenant-email-design.md+533 −0
diff --git a/docs/superpowers/specs/2026-08-19-tenant-email-design.md b/docs/superpowers/specs/2026-08-19-tenant-email-design.md| new file mode 100644 |
| index 000000000..268134014 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-19-tenant-email-design.md |
| @@ -0,0 +1,533 @@ |
| +# Tenant email — design |
| + |
| +CARLOS tenants need to send mail, and today every one of them solves it by |
| +hand: verify a domain in SES, publish DKIM by hand, mint an IAM user, derive |
| +an SMTP password, paste four values into env. The primitive: an app declares |
| +"I send email", the platform provisions an SES identity on a domain it can |
| +prove, mints SMTP credentials scoped to exactly that identity, publishes |
| +every DNS record it is able to publish itself, and delivers the coordinates |
| +to the app's instances as env — plus caps, bounce handling, pause, rotation |
| +and teardown as platform verbs. |
| + |
| +Decided with Paul, 2026-08-19. Section-by-section decisions and the |
| +alternatives they beat are inline. The shape follows `objstore`'s |
| +(`docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md`) |
| +almost verb for verb, because it is the same problem — a shared, |
| +operator-paid resource provisioned per app and delivered as env — with one |
| +difference that changes the risk calculus: a bucket spends money, and a |
| +sending identity spends **reputation**, which is shared across every tenant |
| +in the estate and cannot be bought back. |
| + |
| +## 1. Scope and v1 success criterion |
| + |
| +**v1 is done when `seapointish` sends its real sign-in mail through the |
| +primitive on a custom domain**, with DKIM passing, SPF passing, DMARC |
| +aligned, and the message landing in a Gmail inbox — with **zero seapointish |
| +code changes**. |
| + |
| +That app is the criterion because it already fits the contract without |
| +being asked to. `internal/app/signinflow.go` reads |
| +`SEAPOINTISH_SMTP_HOST/PORT/USER/PASS/FROM` and its README says "SES SMTP |
| +credentials work". It also carries a real forcing function: with SMTP |
| +unset it writes the live sign-in link to the log, which its own README |
| +calls "a development convenience [that] must never be a deployment". Today |
| +seapointish cannot be deployed without hand-provisioned SES credentials. |
| +That is the gap this closes, and `env_prefix: "SEAPOINTISH_SMTP"` closes it |
| +with no diff in that repo at all. |
| + |
| +**Control plane only, never the data plane.** Apps keep their own SMTP |
| +client and speak to SES directly. A CARLOS submission relay in the byte |
| +path is a SPOF and a pager, and it is out of scope for v1 for the same |
| +reason `objstore` permanently excluded a byte-proxy. §12 records the one |
| +argument for building it later and why the contract here does not foreclose |
| +it. |
| + |
| +**The delivered contract** — everything an app may assume: an SMTP |
| +submission endpoint reachable on port 587 with STARTTLS, PLAIN auth with |
| +the delivered username and password, and the right to send mail whose |
| +`From:` is an address at one of the delivered domains. Nothing more: no |
| +inbound mail, no per-message API, no bounce webhook to the app, no promise |
| +about which region the endpoint is in beyond what the delivered |
| +`*_SMTP_HOST` says. Worded per-**credential**, not per-**app**, so |
| +per-instance credentials can arrive later without re-wording. |
| + |
| +Out of scope for v1, deliberately: inbound/receiving mail, per-message |
| +tracking pixels or link rewriting, template storage, a sending API that is |
| +not SMTP, and any second SES region (§8 makes the second region a config |
| +entry, but does not add one). |
| + |
| +## 2. Where it runs |
| + |
| +The SES estate is the flagship AWS account — **the account presently |
| +aliased `keymail`, which is to be renamed `carloku`** (§13). It has SES |
| +production access. One AWS account, N regions; v1 provisions in |
| +**`eu-west-1`** only. |
| + |
| +The tito deployment is self-hosted CARLOS in the titogo AWS account and is |
| +**not** in scope for v1. SES identities and tenants are per-account, so |
| +tito's apps sending mail is a separate decision — a second estate in the |
| +titogo account, or tito's boxes holding credentials minted in the carloku |
| +account — and this design deliberately does not make it. Nothing here |
| +prevents either; the record shapes are account-agnostic. |
| + |
| +## 3. The layering: tenant, identity, credential |
| + |
| +Three levels, three owners, three lifetimes. |
| + |
| +**SES tenant ↔ CARLOS account.** One tenant per account, named by sqid, |
| +created on first use. This is what makes one account's reputation stop at |
| +its own boundary and what makes a per-account pause meaningful to SES |
| +itself rather than only to us. |
| + |
| +The tenant layer is **additive and seam-shaped**, not load-bearing. SES |
| +Tenant Management is recent enough that the exact attribution mechanism |
| +for an SMTP send (believed to be an `X-SES-TENANT-NAME` header) **MUST be |
| +verified against live AWS documentation at implementation time, not taken |
| +from this document**. It is designed as one method on the seam in §5 so |
| +that a wrong guess costs one function rather than the model. If tenant |
| +attribution over SMTP turns out to be unavailable, v1 ships without the |
| +tenant layer and loses nothing in §4's isolation. |
| + |
| +**Identity ↔ a domain, per region.** The default identity for an app is |
| +**the app's own host** — `seapointish.bag.oncarlos.com` — not the account |
| +parent. Two reasons, and the first is the one that matters: |
| + |
| +- The signing domain becomes the same domain as the links inside the mail. |
| + A sign-in message from `noreply@seapointish.bag.oncarlos.com` whose link |
| + points at `https://seapointish.bag.oncarlos.com/…` is coherent to a |
| + receiver in a way an account-level signature over an app-level link is |
| + not. |
| +- The custom-domain story becomes a straight 1:1 swap: the app's domain |
| + *is* its sending domain, before and after. |
| + |
| +**Credential ↔ an IAM user and access key.** The SMTP username **is** the |
| +access key id; the SMTP password is a pure local derivation from the IAM |
| +secret (HMAC-SHA256 over the literal `SendRawEmail`, region-tagged version |
| +byte, base64) — no API call, and no sending credential is ever written to |
| +the bucket (§4). One IAM user per app. |
| + |
| +## 4. Isolation — what actually stops account A sending as account B |
| + |
| +Not the tenant. The **inline IAM policy**, which names two independent |
| +rails and needs both: |
| + |
| +- `Action: ses:SendRawEmail`, `Resource:` exactly this app's identity ARNs, |
| + one per verified `(domain, region)` pair. (SMTP submission authorises |
| + against `ses:SendRawEmail` rather than the v2 API's `ses:SendEmail`; |
| + confirm against live docs alongside §3's tenant check, and do **not** |
| + widen to both — the contract in §1 is SMTP-only, and the API action is |
| + not part of it.) |
| +- `Condition: StringLike { ses:FromAddress: ["*@<domain>", …] }` over the |
| + same domain set. |
| + |
| +`Resource` alone is insufficient: it authorises sending *as that identity*, |
| +and a subdomain identity covers `From:` addresses the app should not be |
| +able to claim. The condition alone is insufficient: it does not bind the |
| +identity used to sign. Both, always. |
| + |
| +The policy is rewritten **wholesale** on every domain change — |
| +`PutUserPolicy` overwrites, exactly as `objstore` already depends on, so |
| +there is nothing to merge and no versioning. This is what gives §7 its best |
| +property: attaching a custom domain adds an ARN and rewrites the policy |
| +without rotating a credential. |
| + |
| +**No sending credential in the bucket.** `email.json` records the access |
| +key **id** (which is the SMTP username, and is not a secret) and never the |
| +secret or the derived password. The password is derived at delivery time |
| +from the IAM secret held by the provisioning door. A bucket leak therefore |
| +exposes usernames, never the ability to send. |
| + |
| +## 5. The provisioner seam |
| + |
| +One interface, mirroring `objstore.Provisioner` in both shape and contract: |
| +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. |
| + |
| +```go |
| +type Provisioner interface { |
| + EnsureTenant(ctx, name string) error |
| + EnsureConfigSet(ctx, region, name, snsTopicARN string) error |
| + |
| + EnsureIdentity(ctx, region, domain, mailFrom string) (Identity, error) |
| + IdentityStatus(ctx, region, domain string) (Status, error) |
| + DeleteIdentity(ctx, region, domain string) error |
| + |
| + EnsureUser(ctx, name, path string, identityARNs []string, domains []string) (arn string, err error) |
| + MintKey(ctx, user string) (id, secret string, err error) |
| + SetKeyActive(ctx, user, keyID string, active bool) error |
| + DeleteKey(ctx, user, keyID string) error |
| + ReapKeys(ctx, user string, keep []string) error |
| + DeleteUser(ctx, user string) error |
| +} |
| +``` |
| + |
| +`EnsureIdentity` covers create + Easy DKIM + custom MAIL FROM in one |
| +idempotent call and answers the DKIM tokens the caller must publish. |
| +`ReapKeys` is here for the same crash-safety reason it exists in |
| +`objstore`: a key minted before a failed delivery is recorded nowhere, so |
| +only list-and-delete can find it. |
| + |
| +Ships with the AWS implementation and an in-memory fake, so every walk in |
| +§6 and §7 is testable with no credentials and no network. |
| + |
| +## 6. Records |
| + |
| +Two families, because the two things have genuinely different scopes. |
| + |
| +### 6a. `accounts/<sqid>/email/domains/<domain>.json` — the identity |
| + |
| +Account-scoped, because 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 ever looks at it. |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "domain": "seapointish.bag.oncarlos.com", |
| + "kind": "platform", |
| + "apps": ["seapointish"], |
| + "regions": { |
| + "eu-west-1": { |
| + "identity_arn": "arn:aws:ses:eu-west-1:…:identity/seapointish.bag.oncarlos.com", |
| + "dkim_tokens": ["abc…", "def…", "ghi…"], |
| + "mail_from": "mail-eu-west-1.seapointish.bag.oncarlos.com", |
| + "config_set": "carlos-bag", |
| + "status": "verified", |
| + "checked_at": "2026-08-19T12:00:07Z" |
| + } |
| + }, |
| + "created_at": "2026-08-19T12:00:00Z" |
| +} |
| +``` |
| + |
| +`kind` is `platform` (CARLOS owns the zone) or `custom` (the customer |
| +does) — the one bit that decides who publishes DNS (§7). |
| + |
| +`status` is exactly one of `pending` (identity created, DNS not yet green), |
| +`verified` (DKIM, MAIL FROM and sending status all green at SES) or |
| +`failed` (SES reports the identity's DKIM or MAIL FROM permanently |
| +failed). Only `verified` puts the identity's ARN into a credential's |
| +policy (§4), so a pending domain grants nothing. |
| + |
| +`apps` is the referencing set, and it is what makes the account-scoping |
| +worth its cost: `carlos email domains add` appends, teardown of an app |
| +removes, and **the SES identity and its DNS are deleted only when the set |
| +empties**. Without it, one app's teardown would silently revoke a sibling |
| +app's verified domain. |
| + |
| +**`regions` is a map, not a scalar field.** Every SES dimension that |
| +matters is regional — identities, DKIM tokens, configuration sets, tenants, |
| +sending quota, and production access — so a scalar region would have to be |
| +migrated the first time a second region appears. Region-keying costs |
| +nothing today and is the difference between adding a region and migrating |
| +every record in the fleet. |
| + |
| +### 6b. `apps/<sqid>/<app>/email.json` — the credential and the delivery |
| + |
| +Same three blocks and the same CAS read-modify-write If-Match discipline as |
| +`store.json`, for the same reason: three actors, three halves of one record. |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "app": "seapointish", |
| + "declared": { |
| + "env_prefix": "SEAPOINTISH_SMTP", |
| + "from": "noreply@seapointish.bag.oncarlos.com", |
| + "at": "2026-08-19T12:00:00Z", "by": "paul@rslw.com" |
| + }, |
| + "grant": { |
| + "domains": ["seapointish.bag.oncarlos.com"], |
| + "daily_cap": 50, |
| + "source": "tier:free", |
| + "at": "2026-08-19T12:00:01Z", "by": "tier" |
| + }, |
| + "provisioned": { |
| + "iam_user": "carlos-mail-bag-seapointish", |
| + "user_arn": "arn:aws:iam::…:user/carlos-mail/carlos-mail-bag-seapointish", |
| + "key_id": "AKIA…", |
| + "prev_key_id": "", |
| + "active": true, |
| + "at": "2026-08-19T12:00:09Z" |
| + } |
| +} |
| +``` |
| + |
| +- `declared` — the member's half. `env_prefix` is the one delivery knob, |
| + default `CARLOS_SMTP`, charset `^[A-Z][A-Z0-9_]{0,31}$` — the same rail |
| + `objstore` already enforces, and `SEAPOINTISH_SMTP` (16 chars) fits it |
| + unchanged. `from` must be an address at one of the granted domains; the |
| + door refuses otherwise. |
| +- `grant` — who authorised, and the cap. `source` records **why** the |
| + grant exists (`tier:free`, `tier:pro`, `operator`) so a cap can be |
| + recomputed when a tier changes without losing an operator's deliberate |
| + override. |
| +- `provisioned` — the walk's half. `active` is the pause state (§9); |
| + `prev_key_id` is the rotation overlap. |
| + |
| +**Standalone credentials get their own IAM user, and this is forced, not |
| +stylistic.** IAM allows **two access keys per user**, and both slots are |
| +already spoken for: the delivered key plus `prev_key_id` during a rotation |
| +overlap. A standalone credential minted on the same user would therefore |
| +either be impossible or would silently make rotation impossible later — |
| +the worst kind of coupling, since it appears to work until the first |
| +rotation. So `carlos email credentials create` mints |
| +`carlos-mail-<sqid>-<app>-<n>` with the same §4 policy, recorded in a |
| +`standalone` list alongside `provisioned`. Revoking one deletes its user |
| +and cannot disturb delivery. |
| + |
| +**No `region` in `provisioned`.** IAM users and access keys are global; the |
| +regional facts live in 6a, and the SMTP host is resolved at delivery time |
| +(§8). |
| + |
| +## 7. DNS — the asymmetry that shapes the flow |
| + |
| +The two domain kinds differ in exactly one respect: **who can write.** |
| + |
| +**Platform domain** (`<app>.<sqid>.oncarlos.com`) — CARLOS owns the zone |
| +and publishes everything itself. Verified in about a minute, zero customer |
| +action, which is what makes `carlos email enable` a one-command story: |
| + |
| +| Name | Type | Value | |
| +|---|---|---| |
| +| `<token>._domainkey.<host>` ×3 | CNAME | `<token>.dkim.amazonses.com` | |
| +| `mail-eu-west-1.<host>` | MX | `10 feedback-smtp.eu-west-1.amazonses.com` | |
| +| `mail-eu-west-1.<host>` | TXT | `v=spf1 include:amazonses.com ~all` | |
| +| `_dmarc.<host>` | TXT | `v=DMARC1; p=none; adkim=s; aspf=s` | |
| + |
| +**Custom domain** — CARLOS creates the identity and *emits* the same record |
| +set; the customer publishes it; CARLOS polls until green. |
| + |
| +Four rulings inside that table: |
| + |
| +**Nothing is ever written at the app host itself.** Every name above is a |
| +distinct label below the host. The edge already owns an A record at |
| +`<app>.<sqid>.oncarlos.com`, and email records that shared that name could |
| +fight it. This is a permanent invariant, not an implementation detail: it |
| +is what lets the email pass and the provisioning reconciler write into the |
| +same zone without knowing about each other. |
| + |
| +**`adkim=s; aspf=s` is mandatory on the platform domain.** DMARC's default |
| +relaxed alignment compares *organizational* domains, so under `p=none` |
| +relaxed, tenant `bag`'s DKIM signature would align for a `From:` at |
| +`bae.oncarlos.com`. Strict alignment is what makes one tenant's signature |
| +unable to authenticate another tenant's From address on a shared |
| +registrable domain. On a custom domain it is merely good hygiene; on |
| +`oncarlos.com` it is the tenant boundary. |
| + |
| +**The MAIL FROM subdomain carries the region, from the first domain ever |
| +verified.** The MX target is region-specific, and one subdomain cannot hold |
| +two regions' MX records without ambiguity. `mail-eu-west-1.<host>` — the |
| +full region name, so there is no abbreviation table to maintain and no |
| +collision to reason about. This is the single hardest thing in the design |
| +to change later: for a custom domain it lives in the **customer's** zone, |
| +so getting it wrong means asking every customer to republish DNS. It is |
| +region-qualified on day one for that reason alone. |
| + |
| +**`p=none` and no `rua`.** `p=none` because a tenant whose first send is |
| +rejected by its own DMARC record learns nothing useful. No `rua` because |
| +aggregate reports need a mailbox to arrive at, and inbound mail is out of |
| +scope (§1) — publishing a reporting address CARLOS cannot receive at would |
| +be a lie in DNS, and a lie every receiver on earth would act on. The |
| +consequence is real and worth naming: **without `rua` there are no |
| +aggregate reports, so nothing tells us whether tightening the policy is |
| +safe.** Arranging a reporting destination is therefore a precondition of |
| +the tightening follow-up, not an afterthought to it (§13). A custom |
| +domain's policy is the customer's to set; CARLOS proposes and does not |
| +impose. |
| + |
| +### The seam |
| + |
| +`internal/edge/dns.go` writes **transient** TXT records for ACME |
| +challenges. Durable CNAME/MX/TXT is a different job with a different |
| +lifetime, so v1 adds `internal/dnsrec` — a `Writer` seam |
| +(`EnsureRecord`/`DeleteRecord` over CNAME, MX, TXT), a Route53 |
| +implementation, and a fake — and **leaves the ACME provider untouched**. |
| +Overloading `dnsProvider` would put durable records behind an interface |
| +whose contract is "present this, then call cleanup". |
| + |
| +Provisioning and DNS writes run **console-side**, with the console's role, |
| +exactly as store grants already do. |
| + |
| +**Implementation check, not an assumption:** a wildcard record at a parent |
| +of these names can synthesise answers for names we expect to be absent, and |
| +verification polling must not read that as success. The poller's authority |
| +is SES's own `IdentityStatus`, never a direct DNS lookup — that is the |
| +design answer, and the check exists to confirm no code path shortcuts it. |
| + |
| +## 8. Multi-region — capable, not deployed |
| + |
| +v1 provisions in `eu-west-1` only. Everything is region-keyed anyway. |
| + |
| +What being regional actually costs: identities, DKIM tokens, configuration |
| +sets, tenants, sending quota **and production access** are all per-region, |
| +so adding a region is an AWS access request with a lead time, not merely |
| +code. A second region is therefore a config entry plus an AWS request — no |
| +schema change, no migration, no customer DNS churn, because §6a is a map |
| +and §7 named the MAIL FROM subdomain for its region. |
| + |
| +A domain verified in two regions publishes 6 DKIM CNAMEs. The token names |
| +differ, so they coexist with no collision. DMARC is region-independent — |
| +one record, always. |
| + |
| +**Region-local sending is a delivery-time lookup, not a subsystem.** The |
| +`*_SMTP_HOST` an instance receives should name the endpoint local to that |
| +instance, and route-scoped config delivery (#206, merged) already delivers |
| +per-instance values. So delivery resolves the instance's region against |
| +the domain record's `regions` map and hands over |
| +`email-smtp.<region>.amazonaws.com`. With one region provisioned this is a |
| +map with one entry; the code path is the same on the day there are three. |
| + |
| +One credential works across regions: IAM users and keys are global, and |
| +§4's policy simply lists the ARNs for every `(domain, region)` pair. |
| + |
| +## 9. Bounces, complaints, caps and the pause |
| + |
| +**This is in v1, not a follow-up.** SES puts the whole AWS account under |
| +review at roughly 5% bounces or 0.1% complaints. On a shared estate that |
| +means one tenant's stale list suspends every tenant's mail, including the |
| +platform's own. Nothing else in this design protects against that. |
| + |
| +**Ingestion.** One configuration set per account per region, with an SNS |
| +event destination onto a console endpoint (`POST /api/ses/events`). |
| +Two security requirements, both non-negotiable: |
| + |
| +- SNS **subscription confirmation** must be handled, or no events arrive. |
| +- The `SigningCertURL` **must be validated as an `amazonaws.com` host |
| + before it is fetched**. That check is the entirety of SNS signature |
| + security and it is the classic place to get it wrong: an unvalidated |
| + URL lets anyone who can reach the endpoint present their own signing |
| + certificate and forge every event below. |
| + |
| +**Counts.** Events give per-app send, bounce, complaint and delivery |
| +counts. Those counts are simultaneously the abuse signal and the **only** |
| +way to enforce a daily cap — approach A has no preventive rate limit, |
| +because SES has none per identity. Enforcement is therefore reactive, with |
| +minutes of latency. That is stated as a limitation, not hidden: a cap of 50 |
| +is a cap of "about 50". |
| + |
| +**The pause is `SetKeyActive(false)`.** Instant, reversible, needs no |
| +policy rewrite, works identically whatever the provider, and leaves an |
| +unambiguous audit trail. It fires automatically on cap breach or on a |
| +bounce/complaint rate past threshold, and manually via `carlos email |
| +pause`. `resume` flips it back. |
| + |
| +**A paused tenant fails loudly.** Its SMTP auth starts failing, which for |
| +seapointish means sign-in links stop arriving — the cap working as |
| +designed, and exactly the kind of failure that is invisible until someone |
| +complains. So a pause MUST surface in the console's app page and in the |
| +account's audit log at the moment it happens, with the reason and the |
| +counts that caused it. A silent pause is a bug. |
| + |
| +**Suppression.** SES's account-level suppression list does the enforcing. |
| +CARLOS ingests suppression events so a tenant can see and clear its own |
| +suppressed destinations; it does not run a second suppression list. |
| + |
| +## 10. Authorisation — tier auto-grants, operator raises |
| + |
| +`objstore`'s gate is an operator grant, because a bucket spends the |
| +operator's money. Email spends the estate's reputation, which is scarcer — |
| +but an operator gate would mean every carloku customer waits on a human, |
| +which is not the product. |
| + |
| +The resolution is that **the cap stands in for trust**: |
| + |
| +- **Free tier** self-serves the platform domain at a hard low cap (50/day). |
| + No human in the loop. |
| +- **Paid tiers** self-serve custom domains at their tier's cap. |
| +- **An operator grant is needed only to exceed a tier's cap**, and is |
| + recorded as `source: "operator"` so a later tier change does not silently |
| + revoke it. |
| + |
| +This couples to the tier work on #226 as a **dependency, not a blocker**: |
| +an account with no tier defaults to free, so this ships whether or not |
| +billing has landed. |
| + |
| +## 11. The member surface |
| + |
| +Every command completes and verifies its own job, with no box access and no |
| +AWS console visit — the CLI's standing rule. |
| + |
| +- `carlos email enable -app X [-env-prefix P] [-from ADDR]` — declares, |
| + auto-grants against the tier, provisions the identity, publishes DNS, |
| + waits for verification, delivers env. Reports verified, or fails saying |
| + which of DKIM / MAIL FROM / sending is not yet green. |
| +- `carlos email domains add -app X <domain>` — the custom-domain path. |
| + Prints the exact record set to publish, then polls (with `-wait`, on by |
| + default) until SES reports all three green. |
| +- `carlos email status -app X` — domains and their per-region state, the |
| + cap, today's counts, the pause state. |
| +- `carlos email test -app X -to ADDR` — **sends a real message** and |
| + reports what SES said. This is the verify half of the rule taken |
| + seriously: the command that claims email works is the command that proves |
| + it. |
| +- `carlos email credentials create|rotate|revoke -app X` — the standalone |
| + displayed credential, for a laptop, a cron box, or an app hosted |
| + elsewhere. Shown once. |
| +- `carlos email pause|resume -app X` — operator. |
| + |
| +**Console:** a section on the app page showing per-domain verification |
| +state, the record set still to publish (with a copy control) for a pending |
| +custom domain, today's send/bounce/complaint counts against the cap, and |
| +the pause state with its reason. |
| + |
| +## 12. Why not a relay |
| + |
| +A CARLOS SMTP submission relay — tenants authenticate to us, we enforce |
| +policy and forward to SES — would give **preventive** rate limits rather |
| +than §9's reactive ones, remove IAM users entirely, and make the provider |
| +swappable. It loses on being a byte-path service: a SPOF and a pager, the |
| +shape `objstore` ruled permanently out for storage. |
| + |
| +It is not built in v1, and the reason it can wait is that **this design |
| +does not foreclose it**. An app only ever sees host, port, username and |
| +password. Swapping that host from SES's endpoint to a CARLOS relay is |
| +invisible to every app already delivered — a config change, not a |
| +migration. Build the relay when volume or abuse justifies a pager, not |
| +before. |
| + |
| +## 13. Follow-ups recorded |
| + |
| +- **Rename the `keymail` AWS account alias to `carloku`.** Touches infra |
| + and the pinfra scripts, not this feature's code. Filed separately; §2 |
| + names the account by its role so this spec does not go stale on the day |
| + it lands. |
| +- **Confirm the SES region for the estate** before writing the AWS |
| + implementation. `eu-west-1` is assumed throughout, matching `objstore`. |
| +- **Verify SES tenant attribution over SMTP** against live AWS docs (§3). |
| +- **A DMARC reporting destination, then a tighter policy.** v1 publishes |
| + no `rua` (§7), so there is no evidence on which to tighten `p=none`. |
| + Arranging somewhere aggregate reports can land is the first half of this |
| + follow-up; moving the platform domain to `quarantine` is the second, and |
| + it must not happen first. |
| +- **Tito's estate.** Whether the titogo account gets its own SES estate or |
| + reaches carloku's (§2). |
| +- **Per-instance credentials.** §1's contract is worded per-credential so |
| + this needs no re-wording when it arrives. |
| + |
| +## 14. Testing |
| + |
| +- **Fake provisioner** (`objstore.memprovisioner`'s mirror) behind §5, so |
| + every walk — provision, verify, rotate, pause, teardown — is tested with |
| + no credentials and no network, including after a half-failure at each |
| + step. |
| +- **SMTP password derivation** against AWS's published test vectors. A pure |
| + function with a published answer; there is no excuse for deriving it |
| + wrong. |
| +- **Golden test on the IAM policy document**, covering both rails from §4. |
| + A change that drops the `ses:FromAddress` condition must fail a test, not |
| + a review. |
| +- **DNS record set golden test** per domain kind, pinning the |
| + region-qualified MAIL FROM name and `adkim=s; aspf=s` — the two things in |
| + §7 that are expensive to change later. |
| +- **SNS handler**: subscription confirmation, valid signature, and hostile |
| + `SigningCertURL` (non-AWS host, lookalike host, redirect) — each must be |
| + refused. |
| +- **Cap and pause walk**: counts accumulate, threshold trips, |
| + `SetKeyActive(false)` fires, audit and console surface record it, resume |
| + restores. |
| +- **e2e — the criterion**: seapointish sends a real sign-in link from a |
| + custom domain that lands with DKIM and SPF passing and DMARC aligned. |