carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 276d0f22aa25b0945959b061bd251282c9bc0309 parent 5eb4c6e
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.