logicspike/docs

Communication

SES production access: runbook

Status (2026-10-07). SES in ap-south-1 is in the sandbox. Two earlier requests were denied (on 2026-07-11 and 2026-07-19) with only "for security reasons we cannot provide specific details". A third draft was written and never sent. Nothing has been submitted since.

  • The code changes (declared-use guard, invite and sign-up limits, owner alerts, credentials kept out of logs, role mailboxes) were deployed and pushed to main on 2026-10-06.
  • The ten role mailboxes exist. Only platform-sent tests have run; the outside-account test is pending (R4). hello@ and founder@ stay on their Cloudflare rules by decision (gap-log G55, G56).
  • The operator is decided (2026-10-07) and the four new policy pages are live (deployed by the owner on 2026-10-07; the live text matches the repository). They promise a monitored abuse@ and no unconsented lists, so the outside-account test (R4) and revoke-unconsented-import.mjs are now overdue.

What is left is in gap-log.md ("Open at a glance") and the gates in readiness-checklist.md. Submit only when every gate is green.

Files here: contact-addresses.md · readiness-checklist.md · case-message.md · reply-templates.md · evidence-checklist.md · case-log.md · gap-log.md · deploy-runbook.md · phase-b.md. The older draft, ../aws-ses-production-access-case.md, is superseded.

1. The decision

Ask for the narrow thing first. This request is only for platform system mail: login and verification codes, password resets, workspace invitations, account messages (welcome, extra workspace created), billing and credit notices, and alerts to workspace owners. The platform sends all of it, to people who signed up, were invited, or (alerts) own the workspace. It is declared TRANSACTIONAL. The list comes from what the code sends (the manager's email templates and comms' owner alerts); if a new system email is ever added, update the case text first.

Not in this request: newsletters, mailbox user-to-user mail, form notifications and replies, flows and automation mail, chat mail, tenants' custom sending domains, the mail.vlozi.app shared lane, any imported list. That is a separate, later request (phase-b.md).

Why the earlier requests probably failed

AWS never confirmed a reason, so this is our assessment:

  1. They described a multi-tenant platform that sends other people's mail (newsletters, business mailboxes) from a new account with no sending history. That profile gets the hardest review, because it looks like a possible spam relay.
  2. The draft claimed controls that were not true (abuse@ monitored, DMARC quarantine, auto-pause, Turnstile on sign-up). A reviewer who checks one and finds it false rejects all of it.
  3. The public site gave no identity or abuse contact: no operator name, no address, a one-line acceptable-use rule, and a home page selling a free newsletter tool.
  4. The declared mail type did not match the story: TRANSACTIONAL declared, marketing described.

A narrow first request does not unblock a launch by itself: Resend already delivers vlozi.app system mail through the sandbox failover. Its value is a foothold and a clean sending record for the tenant-mail request that follows. If SES keeps refusing, see "If SES keeps refusing" in phase-b.md.

2. What is true today (what the case may and may not say)

Topic Reality
abuse@, postmaster@ Mailboxes on vlozi.app in Vlozi's own workspace since 2026-10-06. Platform-sent tests arrived; the outside-account test is pending (R4). Mail to an address nobody owns is accepted and dropped with no bounce.
DMARC p=none with Cloudflare's report address; mail.vlozi.app has no record of its own. Tightening is optional (R3).
Custom MAIL FROM None on the platform domains, so SPF is not aligned. bounce.vlozi.app does not exist yet (R3).
Bounce and complaint pipeline SNS topic confirmed, signature check strict, suppression written. Needs a simulator proof (R5).
Reputation auto-pause Built, but REPUTATION_ENFORCE is not set in production (checked 2026-10-07), so nothing pauses automatically; a pause is a manual staff action (gap-log G68). System mail is exempt. Do not claim automatic pausing.
Unsubscribe Per workspace, not platform-wide. Irrelevant to Phase A (no marketing mail), but do not claim otherwise.
Public site Operator identity and the four new policy pages are live since 2026-10-07 (R1, R8). The Abuse and Anti-spam pages promise things that are not yet tested or done (R4, gap-log G61).
Vlozi's own newsletter list About 503 contacts who never opted in. Never send to them through SES; consent is revoked by a script you run (gap-log G17).
Support plan Our notes disagree (Developer or Business vs Basic). Confirm in the console (R2).

3. Go / no-go gates

Nothing is submitted until all ten are green. Evidence for each goes in readiness-checklist.md.

# Gate Who
R1 Operator identity published: name, address, contact on the site and in the policies you
R2 AWS account in good standing: payment method, account contact = operator, root MFA, support plan known, root email receives mail you
R3 Domain authentication: DKIM SUCCESS; custom MAIL FROM bounce.vlozi.app passing; DMARC published and the digest read (tightening optional) you (DNS); verify read-only
R4 The role addresses (hello@, privacy@, abuse@, postmaster@, security@, legal@, plus private ops@) tested from outside; see contact-addresses.md you
R5 Bounce and complaint pipeline proven with the SES mailbox simulator both
R6 Declared-use guard: SES carries platform mail only deployed 2026-10-06; verify
R7 Platform-mail abuse bounds (sign-up, resend and invitation limits) deployed 2026-10-06; verify
R8 Public site accurate; policy pages published live 2026-10-07; the outside test and the revoke run are overdue
R9 CloudWatch alarms on SES bounce and complaint rate, with an email target you
R10 Case log kept: every AWS message verbatim file exists; open until the case closes

4. Step by step

Roughly two to three weeks, most of it in parallel.

Phase 0: facts and decisions (you)

  1. Paste every previous AWS message you still have, even partial, into case-log.md. If none remain, say so; the log starts now. Record every later AWS message verbatim, with the case ID and date.
  2. In the AWS console confirm the account's age, payment method, root MFA, that the account contact matches the operator, and the Support plan (the plan decides whether you can open a technical case and reach a human). Send a test mail to the root address (founder@vlozi.app) from an outside account, because AWS replies there. Check you are in the right account: the SES account is the one signed in as founder@vlozi.app, with vlozi.app under Verified identities in Mumbai. The AWS CLI on the development machine points at a different, empty account (gap-log G77), so run aws sts get-caller-identity before any CLI command in this runbook, or use the console.
  3. The address spelling is confirmed (2026-10-07). Make sure the AWS account contact carries the identical name and address.
  4. Put the docs site (handbook.vlozi.app) behind a login. It is internal-only by intent, but it answers without one today (checked 2026-10-07, gap-log G70).

Phase 1: code changes (done 2026-10-06)

  • The declared-use guard (SES_TENANT_SENDING) is live. Tenant mail on the SES account is refused with 503 TENANT_SENDING_NOT_ENABLED unless Resend serves the sending domain. So today, workspaces on mail.vlozi.app cannot send campaigns, mailbox replies or form notifications, while mail from vlozi.app goes through Resend (gap-log G22, G23). It stays off SES until the separate tenant-mail request.
  • The invite, sign-up and verification-resend limits are live in the manager.
  • The runbook for that deploy, with versions and rollback targets, is deploy-runbook.md.

Phase 2: DNS and AWS configuration (you; verify with read-only lookups)

  1. Custom MAIL FROM (R3). SES console → Verified identities → vlozi.app → Authentication → Custom MAIL FROM domain → bounce.vlozi.app, "Use default value" on MX failure. In Cloudflare DNS (both DNS only) add MX bounce → feedback-smtp.ap-south-1.amazonses.com priority 10, and TXT bounce → v=spf1 include:amazonses.com ~all. Wait until the console shows Successful.
  2. DMARC (R3). Keep Cloudflare's rua. Leave p=none for a week and read the digest; confirm SES and Resend both pass. Tightening to p=quarantine; pct=25, then pct=100, with relaxed alignment (adkim=r; aspf=r), is optional; never tighten before the digest is clean.
  3. Role addresses (R4). The mailboxes exist. Send a test from an outside address to each and confirm it appears, then reply once and confirm it arrives from the same address (contact-addresses.md §4). Repeat monthly: a failure here is silent.
  4. IAM. The vlozi-ses-policy policy already exists, so aws iam create-policy fails. Edit it in the IAM console (Policies → vlozi-ses-policy → Edit → JSON) to match ../aws-setup.md §2.1, which now includes ses:GetAccount. From the command line, use aws iam create-policy-version --policy-arn <arn> --policy-document file://policy.json --set-as-default, with policy.json saved as UTF-8 without a BOM (PowerShell 5.1 Set-Content -Encoding utf8 adds one and the call fails). Then run aws sesv2 get-account --region ap-south-1 with a profile that has ses:GetAccount and record ProductionAccessEnabled, the real send quota and the suppression attributes.
  5. Account-level suppression. Leave the SES default for now. The planned bounce-only change (ses-account-suppression.mjs) belongs to the tenant-mail phase; do not run it.
  6. CloudWatch alarms (R9). Reputation.BounceRate warn 2% / alarm 4%, Reputation.ComplaintRate warn 0.05% / alarm 0.08%, to an email you read that does not depend on this platform being up.
  7. Optional: register vlozi.app in Google Postmaster Tools.

Phase 3: prove it and collect evidence

  1. The sandbox only sends to verified recipients. For each Gmail, Outlook or Yahoo address you want to test with, create it as an SES identity (SES → Verified identities → Create identity → Email address, then click the link) and delete the identity afterwards. Without that, SES refuses and the mail fails over to Resend, and the headers will show Resend, not SES. Send a login code and an invitation to each; in "Show original" confirm SPF pass (aligned via bounce.vlozi.app), DKIM pass, DMARC pass, and check that the comms log shows provider: ses for the send.
  2. Run the simulator proof in R5 (invitations to the simulator addresses from the Vlozi workspace).
  3. File everything under evidence/ per evidence-checklist.md.

Phase 4: publish

Done on 2026-10-07 (the pages are live). What is left is the overdue pre-flight in R8: the outside-account test and the revoke script. Re-read each page against the case text before submitting.

Phase 5: submit (you, in the console)

SES → Account dashboard → Request production access. Region ap-south-1, mail type Transactional, website https://vlozi.app. Paste the text from case-message.md after every bracketed tag is resolved or deleted. Submit, then copy the case ID and the submitted text into case-log.md the same day.

Phase 6: handling the reply (within hours, not days)

  • Approved: go to Phase 7.
  • AWS asks questions: answer in the same case the same day, using reply-templates.md, with evidence.
  • Denied with generic text: do not resubmit. Open a Support case (technical, SES) citing the case ID and ask which requirement is unmet; use phone or chat if the plan allows. Fix anything named, wait at least a week, then submit a new case.
  • Denied with specifics: answer each point; fix the product or the site, not the wording.

Phase 7: approval day

  1. Read the granted quota and rate in the console and record them in case-log.md.
  2. Set the comms secret SES_SANDBOX to false. From apps\communication, with $env:CLOUDFLARE_ACCOUNT_ID set, write {"SES_SANDBOX":"false"} to a file without a BOM (the Write tool, or [IO.File]::WriteAllText with UTF8Encoding($false)), run npx wrangler secret bulk <file>, and delete the file. Never pipe into secret put on Windows.
  3. Check that EMAIL_PROVIDER is not resend (if it is, SES is never tried), and that a platform send now shows provider: ses in the comms log.
  4. Leave SES_TENANT_SENDING unset so tenant mail stays off SES.
  5. Keep Resend only as a transient-error fallback (RESEND_DOMAINS).
  6. Watch the SES reputation dashboard and the CloudWatch alarms daily for two weeks; stay under 2% bounce and 0.05% complaint.
  7. Update the website Status page and ../aws-setup.md §9 with the outcome.

5. Ground rules

  • Say only what is true that day, and be able to show it. If a claim is not live, delete the sentence. The list of claims that must not appear is in case-message.md.
  • No resubmitting after a denial without a Support case first.
  • Never send to the ~503 un-consented contacts, from any provider. A "please confirm" email to people who never opted in is itself unsolicited mail.
  • Keep the policy text, the website and the case consistent with each other; reviewers compare them.

6. Risks

  • AWS may deny again even when everything is true: new accounts with no spend history are screened heavily, and the reply text is generic.
  • DMARC tightened too early blocks your own mail.
  • Publishing policies that nobody qualified has read: the drafts in ../../legal/ are not legal advice.
  • An unconsented list is the easiest way to lose the account after approval.
Communication