| 1 | # Tenant email SES spike |
| 2 | |
| 3 | > **For agentic workers:** this is a spike, not a build. Its output is |
| 4 | > four answers written back into the spec, and throwaway AWS resources |
| 5 | > deleted at the end. Do not keep the code. |
| 6 | |
| 7 | **Goal:** Answer the four AWS questions the sender design depends on, |
| 8 | against the Tito deployment's real SES account, before any plan is |
| 9 | written for steps 2 and 3. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-09-15-tenant-email-senders-design.md` §4 |
| 12 | |
| 13 | **Tech stack:** `aws` CLI (SES v2, IAM), a throwaway domain the Tito |
| 14 | deployment already controls, Python `smtplib` for the SMTP sends. |
| 15 | |
| 16 | ## Global constraints |
| 17 | |
| 18 | - Region `eu-west-1`, AWS account 665106592142 (the Tito deployment). |
| 19 | - **Never touch `tito.io`, `mail.tito.io`, or any identity Classic |
| 20 | owns.** The spike's identity is a fresh subdomain created for it. |
| 21 | - `aws --profile` for assumed roles needs `~/.aws/cli/cache`, which the |
| 22 | sandbox blocks — run those commands with the sandbox off. |
| 23 | - The SES control plane is throttled to one request per second; space |
| 24 | the calls. |
| 25 | - Everything created here is deleted in step 5, including the IAM user |
| 26 | and its access key. |
| 27 | |
| 28 | ## Setup |
| 29 | |
| 30 | - [ ] **Create the throwaway identity.** A subdomain of a domain the |
| 31 | deployment controls, e.g. `spike.<a domain you own>`. Verify DKIM the |
| 32 | normal way and wait for `GetEmailIdentity` to report |
| 33 | `DkimAttributes.Status = SUCCESS`. Do **not** set a custom MAIL FROM: |
| 34 | the spike needs an identity shaped like `tito.io`, which has none. |
| 35 | - [ ] **Create two configuration sets**, `spike-owned` and `spike-other`, |
| 36 | each with an SNS event destination on a throwaway topic you can read. |
| 37 | - [ ] **Bind `spike-other` as the identity's default configuration set** |
| 38 | with `PutEmailIdentityConfigurationSetAttributes`. This models |
| 39 | Classic's set: one CARLOS is not allowed to change. |
| 40 | - [ ] **Create a tenant** `spike-tenant` with `CreateTenant`; record |
| 41 | `TenantArn`. Associate the identity and `spike-owned` with it using |
| 42 | `CreateTenantResourceAssociation`. Do **not** associate `spike-other`. |
| 43 | - [ ] **Create an IAM user** with one inline policy, one access key, and |
| 44 | derive its SMTP password (the repo's `SMTPPassword` is |
| 45 | `internal/mail/smtppass.go:37`; a small Go program calling it is the |
| 46 | quickest route). Policy: |
| 47 | |
| 48 | ```json |
| 49 | {"Version":"2012-10-17","Statement":[{"Effect":"Allow", |
| 50 | "Action":"ses:SendRawEmail", |
| 51 | "Resource":["<identity ARN>","<spike-owned config set ARN>"], |
| 52 | "Condition":{"StringEquals":{"ses:TenantName":"spike-tenant"}}}]} |
| 53 | ``` |
| 54 | |
| 55 | ## Question 1: is `ses:TenantName` enforced over SMTP? |
| 56 | |
| 57 | - [ ] **Send over SMTP with both headers** — `X-SES-TENANT: spike-tenant` |
| 58 | and `X-SES-CONFIGURATION-SET: spike-owned`. Record accepted or |
| 59 | refused, and the exact SMTP error if refused. |
| 60 | - [ ] **Send over SMTP with neither header.** Record the same. |
| 61 | - [ ] **Write down which of the three outcomes happened:** |
| 62 | 1. Both accepted — the key is not populated for SMTP; the tenant |
| 63 | condition buys nothing and comes out of the policy. |
| 64 | 2. Tagged accepted, untagged refused — the design works as written. |
| 65 | 3. Both refused — SES never populates the key; the condition denies |
| 66 | everything and must come out of the policy. |
| 67 | |
| 68 | ## Question 2: does a tenant apply its configuration set on its own? |
| 69 | |
| 70 | - [ ] **Send with `X-SES-TENANT` but no `X-SES-CONFIGURATION-SET`.** |
| 71 | The identity's default is `spike-other`, which is not associated with |
| 72 | the tenant. Record whether the send is refused (the documented |
| 73 | behaviour) or accepted. |
| 74 | - [ ] **If accepted, check which topic the events arrived on.** That |
| 75 | tells you whether events would reach CARLOS or the identity owner. |
| 76 | - [ ] **Send with both headers and confirm the event arrives on |
| 77 | `spike-owned`'s topic**, carrying `ses:caller-identity` with the IAM |
| 78 | user's name — §8's attribution depends on that tag being present. |
| 79 | |
| 80 | ## Question 3: is an associated identity left alone, and are its other senders? |
| 81 | |
| 82 | - [ ] **Record `GetEmailIdentity` before and after the association** and |
| 83 | diff: DKIM tokens, `MailFromAttributes`, and |
| 84 | `ConfigurationSetName`. Nothing should change. |
| 85 | - [ ] **Send on the identity with no tenant header at all**, using a |
| 86 | second IAM user whose policy has no tenant condition. This is |
| 87 | Classic's traffic. Confirm it still sends, and that its events go to |
| 88 | `spike-other` as before. |
| 89 | - [ ] **Confirm `DeleteEmailIdentity` is refused** while the association |
| 90 | exists, then re-associate. This is the cross-team contract §7 records: |
| 91 | Classic loses the ability to delete the identity. |
| 92 | |
| 93 | ## Question 4: can a tenant be paused directly? |
| 94 | |
| 95 | - [ ] **Pause it** with `UpdateReputationEntityCustomerManagedStatus` |
| 96 | (`ReputationEntityType=RESOURCE`, the tenant ARN, |
| 97 | `SendingStatus=DISABLED`). |
| 98 | - [ ] **Send with both headers** and confirm the send is refused. |
| 99 | - [ ] **Read `GetTenant`** and record where the paused state appears, so |
| 100 | §8 knows what status to display. |
| 101 | - [ ] **Re-enable** with `SendingStatus=ENABLED` and confirm sending |
| 102 | resumes. |
| 103 | - [ ] **Record the tenant's reputation policy** as created — the default |
| 104 | is Standard, which auto-pauses — so §8 can decide between adopting |
| 105 | SES's pause and setting the policy to `None`. |
| 106 | |
| 107 | ## Finish |
| 108 | |
| 109 | - [ ] **Write the four answers into the spec** as a new "§4 results" |
| 110 | subsection: outcome number for question 1, refused-or-accepted for |
| 111 | question 2, the before/after diff for question 3, and the pause verb |
| 112 | and status field for question 4. Say plainly what each one changes. |
| 113 | - [ ] **Delete everything**: both IAM users and their keys, the tenant |
| 114 | and its associations, both configuration sets, the SNS topic, and the |
| 115 | identity. `ListTenantResources` before deleting the tenant, since an |
| 116 | identity cannot be deleted while associated. |
| 117 | - [ ] **Commit the spec change** on this branch. No other code lands. |
| 118 | |