carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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:
{"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.