Plan: the SES spike that gates the sender design
Four questions against a throwaway identity shaped like tito.io, with the outcomes written back into the spec and every resource deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 file changed,
+117
−0
docs/superpowers/plans/2026-09-15-tenant-email-ses-spike.md+117 −0
diff --git a/docs/superpowers/plans/2026-09-15-tenant-email-ses-spike.md b/docs/superpowers/plans/2026-09-15-tenant-email-ses-spike.md| new file mode 100644 |
| index 000000000..4b36fd803 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-15-tenant-email-ses-spike.md |
| @@ -0,0 +1,117 @@ |
| +# Tenant email SES spike |
| + |
| +> **For agentic workers:** this is a spike, not a build. Its output is |
| +> four answers written back into the spec, and throwaway AWS resources |
| +> deleted at the end. Do not keep the code. |
| + |
| +**Goal:** Answer the four AWS questions the sender design depends on, |
| +against the Tito deployment's real SES account, before any plan is |
| +written for steps 2 and 3. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md` §4 |
| + |
| +**Tech stack:** `aws` CLI (SES v2, IAM), a throwaway domain the Tito |
| +deployment already controls, Python `smtplib` for the SMTP sends. |
| + |
| +## Global constraints |
| + |
| +- Region `eu-west-1`, AWS account 665106592142 (the Tito deployment). |
| +- **Never touch `tito.io`, `mail.tito.io`, or any identity Classic |
| + owns.** The spike's identity is a fresh subdomain created for it. |
| +- `aws --profile` for assumed roles needs `~/.aws/cli/cache`, which the |
| + sandbox blocks — run those commands with the sandbox off. |
| +- The SES control plane is throttled to one request per second; space |
| + the calls. |
| +- Everything created here is deleted in step 5, including the IAM user |
| + and its access key. |
| + |
| +## Setup |
| + |
| +- [ ] **Create the throwaway identity.** A subdomain of a domain the |
| + deployment controls, e.g. `spike.<a domain you own>`. Verify DKIM the |
| + normal way and wait for `GetEmailIdentity` to report |
| + `DkimAttributes.Status = SUCCESS`. Do **not** set a custom MAIL FROM: |
| + the spike needs an identity shaped like `tito.io`, which has none. |
| +- [ ] **Create two configuration sets**, `spike-owned` and `spike-other`, |
| + each with an SNS event destination on a throwaway topic you can read. |
| +- [ ] **Bind `spike-other` as the identity's default configuration set** |
| + with `PutEmailIdentityConfigurationSetAttributes`. This models |
| + Classic's set: one CARLOS is not allowed to change. |
| +- [ ] **Create a tenant** `spike-tenant` with `CreateTenant`; record |
| + `TenantArn`. Associate the identity and `spike-owned` with it using |
| + `CreateTenantResourceAssociation`. Do **not** associate `spike-other`. |
| +- [ ] **Create an IAM user** with one inline policy, one access key, and |
| + derive its SMTP password (the repo's `SMTPPassword` is |
| + `internal/mail/smtppass.go:37`; a small Go program calling it is the |
| + quickest route). Policy: |
| + |
| +```json |
| +{"Version":"2012-10-17","Statement":[{"Effect":"Allow", |
| + "Action":"ses:SendRawEmail", |
| + "Resource":["<identity ARN>","<spike-owned config set ARN>"], |
| + "Condition":{"StringEquals":{"ses:TenantName":"spike-tenant"}}}]} |
| +``` |
| + |
| +## Question 1: is `ses:TenantName` enforced over SMTP? |
| + |
| +- [ ] **Send over SMTP with both headers** — `X-SES-TENANT: spike-tenant` |
| + and `X-SES-CONFIGURATION-SET: spike-owned`. Record accepted or |
| + refused, and the exact SMTP error if refused. |
| +- [ ] **Send over SMTP with neither header.** Record the same. |
| +- [ ] **Write down which of the three outcomes happened:** |
| + 1. Both accepted — the key is not populated for SMTP; the tenant |
| + condition buys nothing and comes out of the policy. |
| + 2. Tagged accepted, untagged refused — the design works as written. |
| + 3. Both refused — SES never populates the key; the condition denies |
| + everything and must come out of the policy. |
| + |
| +## Question 2: does a tenant apply its configuration set on its own? |
| + |
| +- [ ] **Send with `X-SES-TENANT` but no `X-SES-CONFIGURATION-SET`.** |
| + The identity's default is `spike-other`, which is not associated with |
| + the tenant. Record whether the send is refused (the documented |
| + behaviour) or accepted. |
| +- [ ] **If accepted, check which topic the events arrived on.** That |
| + tells you whether events would reach CARLOS or the identity owner. |
| +- [ ] **Send with both headers and confirm the event arrives on |
| + `spike-owned`'s topic**, carrying `ses:caller-identity` with the IAM |
| + user's name — §8's attribution depends on that tag being present. |
| + |
| +## Question 3: is an associated identity left alone, and are its other senders? |
| + |
| +- [ ] **Record `GetEmailIdentity` before and after the association** and |
| + diff: DKIM tokens, `MailFromAttributes`, and |
| + `ConfigurationSetName`. Nothing should change. |
| +- [ ] **Send on the identity with no tenant header at all**, using a |
| + second IAM user whose policy has no tenant condition. This is |
| + Classic's traffic. Confirm it still sends, and that its events go to |
| + `spike-other` as before. |
| +- [ ] **Confirm `DeleteEmailIdentity` is refused** while the association |
| + exists, then re-associate. This is the cross-team contract §7 records: |
| + Classic loses the ability to delete the identity. |
| + |
| +## Question 4: can a tenant be paused directly? |
| + |
| +- [ ] **Pause it** with `UpdateReputationEntityCustomerManagedStatus` |
| + (`ReputationEntityType=RESOURCE`, the tenant ARN, |
| + `SendingStatus=DISABLED`). |
| +- [ ] **Send with both headers** and confirm the send is refused. |
| +- [ ] **Read `GetTenant`** and record where the paused state appears, so |
| + §8 knows what status to display. |
| +- [ ] **Re-enable** with `SendingStatus=ENABLED` and confirm sending |
| + resumes. |
| +- [ ] **Record the tenant's reputation policy** as created — the default |
| + is Standard, which auto-pauses — so §8 can decide between adopting |
| + SES's pause and setting the policy to `None`. |
| + |
| +## Finish |
| + |
| +- [ ] **Write the four answers into the spec** as a new "§4 results" |
| + subsection: outcome number for question 1, refused-or-accepted for |
| + question 2, the before/after diff for question 3, and the pause verb |
| + and status field for question 4. Say plainly what each one changes. |
| +- [ ] **Delete everything**: both IAM users and their keys, the tenant |
| + and its associations, both configuration sets, the SNS topic, and the |
| + identity. `ListTenantResources` before deleting the tenant, since an |
| + identity cannot be deleted while associated. |
| +- [ ] **Commit the spec change** on this branch. No other code lands. |