logicspike/docs

Communication

AWS SES + SNS Setup Guide (Communication Service)

Audience: Whoever provisions the cloud infrastructure for the email stack. Applies to: apps/communication (outbound send + event webhook + per-tenant domains). Region: ap-south-1 (Mumbai) — do not change without updating AWS_REGION. Related: aws-ses-migration.md (design), this doc (runbook).

This is the one-time setup that makes the SES-based email stack live. The application code is already deployed and expects the exact resource names below — rename anything and the worker stops matching. Every value the worker reads is listed in §9 Secret reference.


0. How the pieces fit

                         ┌──────────── AWS (ap-south-1) ────────────┐
apps/communication ──────┼─▶ SES v2  SendEmail (SigV4, aws4fetch)   │
  dispatchSend           │      │                                   │
                         │      ▼  (Configuration Set routes events)│
                         │   Config Set  vlozi-platform  ──▶ SNS topic vlozi-ses-events
                         └──────────────────────────────────────────┼──────┘
                                                                     │ HTTPS POST (RSA-signed)
   /v1/webhooks/ses  ◀───────────────────────────────────────────────┘
        │  (verifies SNS signature, dedups on MessageId)
        ├─▶ comms_message_events (delivered / open / click / bounce / complaint)
        ├─▶ suppression + newsletter kill-switch (hard bounce / complaint)
        └─▶ Flows events
 
recipient reply ─▶ Cloudflare Email Routing (in.vlozi.app catch-all) ─▶ worker email() handler
                     reply+<sendId>@in.vlozi.app        ├─▶ contact-intelligence timeline
                                                         └─▶ forward to tenant inbox (best-effort)

Two clouds, one job each: AWS SES does outbound (cheap, high ceiling, open/click tracking, any tenant domain). Cloudflare Email Routing does inbound (replies). This guide is mostly AWS; the Cloudflare half is §7.


1. Prerequisites & values to collect

Before you start, have these ready — you'll substitute them into the commands:

Placeholder What it is Where to get it
ACCOUNT_ID 12-digit AWS account number aws sts get-caller-identity --query Account --output text
WEBHOOK_URL Public URL SNS posts events to https://api.vlozi.app/comms/v1/webhooks/ses — see §1.1

1.1 Which URL does SNS post to?

The communication worker has no public route of its own — all traffic goes through the gateway (api.vlozi.app, service binding COMMS_SERVICE). So SNS posts to the gateway, and the gateway forwards to comms:

https://api.vlozi.app/comms/v1/webhooks/ses

The gateway normally requires a JWT on every /comms/* route, but this one path is explicitly exempted (apps/gateway/src/routes/comms.proxy.ts) because SNS is a server-to-server caller with no login. It is not unauthenticated end-to-end: the comms worker verifies the SNS RSA signature against Amazon's cert before trusting the payload, and rejects any topic other than SES_SNS_TOPIC_ARN.

Do not use the comms worker's *.workers.dev URL for this. It may be disabled at the account level, bypasses the gateway's rate-limiting/observability, and isn't the stable first-party surface. Always use the api.vlozi.app gateway URL above.

This requires the gateway to be deployed with the exemption (commsProxy.all("/v1/webhooks/ses", …)). If you get a 401/JWT error when SNS confirms, the gateway is running an older build — redeploy it.

Requirements:

  • An AWS account with billing enabled (SES is not free-tier-limited but is cheap: $0.10 / 1,000 emails).
  • AWS CLI v2 installed and configured with an admin/root profile for this setup only (the runtime uses a scoped IAM user created in §2).
  • vlozi.app DNS managed in Cloudflare (needed to publish DKIM records and to enable Email Routing).

The CLI commands below use --region ap-south-1 explicitly on every call so a misconfigured default profile can't silently create resources in the wrong region. The AWS Console path is given alongside where it's materially easier.


2. IAM user + policy (produces your two runtime secrets)

The worker authenticates to SES with a single, least-privilege IAM user. Its access key becomes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.

2.1 Create the policy

Console: IAM → Policies → Create policy → JSON, paste the below, name it vlozi-ses-policy.

CLI:

aws iam create-policy --policy-name vlozi-ses-policy --policy-document '{
  "Version": "2012-10-17",
  "Statement": [
    { "Sid": "Send", "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"], "Resource": "*" },
    { "Sid": "Identities", "Effect": "Allow",
      "Action": ["ses:CreateEmailIdentity","ses:GetEmailIdentity","ses:DeleteEmailIdentity","ses:ListEmailIdentities"],
      "Resource": "*" },
    { "Sid": "ConfigSets", "Effect": "Allow",
      "Action": ["ses:CreateConfigurationSet","ses:GetConfigurationSet","ses:CreateConfigurationSetEventDestination"],
      "Resource": "*" }
  ]
}'

Why each block:

  • SenddispatchSend calls SES v2 SendEmail.
  • Identities — the /v1/sending-domains routes + the DomainVerifier DO call CreateEmailIdentity / GetEmailIdentity / DeleteEmailIdentity when tenants add their own domains.
  • ConfigSets — the worker lazily creates a vlozi-tenant-<tenantId> Configuration Set (with its own SNS event destination) the first time each tenant adds a domain. Without these actions, custom-domain onboarding fails.

SES has almost no resource-level ARN support for these actions, so Resource: "*" is expected. The account is already the isolation boundary.

2.2 Create the user + access key

Console: IAM → Users → Create user vlozi-ses → attach vlozi-ses-policy → after creation, Security credentials → Create access key → “Application running outside AWS”.

CLI:

aws iam create-user --user-name vlozi-ses
aws iam attach-user-policy --user-name vlozi-ses \
  --policy-arn arn:aws:iam::ACCOUNT_ID:policy/vlozi-ses-policy
aws iam create-access-key --user-name vlozi-ses

Copy AccessKeyId + SecretAccessKey from the last command's output straight into your secret manager (or .dev.vars for local) — you set them in Cloudflare in §6. The secret is shown once. Do not paste it into chat or commit it.


3. Verify the platform domain vlozi.app

Goal: prove to SES that you own vlozi.app by publishing 3 DKIM keys in DNS. Once verified, the platform can send from hello@vlozi.app and every <alias>@vlozi.app, DKIM-signed and DMARC-aligned. This is the step people get wrong most often, so it's spelled out fully.

You do this once for vlozi.app. (Per-tenant customer domains follow the same shape later, but that flow is automated by the app — see §11.)

3.1 Create the SES identity

Option A — SES Console (easiest, gives you copy-buttons for the records):

  1. Go to SES → Configuration → Identities → Create identity (top-right region must read Asia Pacific (Mumbai) ap-south-1).
  2. Choose Domain, enter vlozi.app.
  3. Leave Assign a default configuration set unchecked for now, keep Easy DKIM with RSA_2048_BIT, DKIM signatures = Enabled.
  4. Click Create identity. SES now shows the identity page with a "Publish DNS records" section listing 3 CNAME records (Name + Value, each with a copy button). Keep this tab open — you'll copy from it in 3.2.

Option B — CLI:

aws sesv2 create-email-identity --email-identity vlozi.app --region ap-south-1

Then print just the 3 DKIM tokens (tab-separated):

aws sesv2 get-email-identity --email-identity vlozi.app --region ap-south-1 \
  --query "DkimAttributes.Tokens" --output text

Example output:

nqf6dv... 7hk2p9... x4m8ab...

Each token becomes one CNAME (built in 3.2).

3.2 Publish the 3 DKIM CNAMEs in Cloudflare — carefully

Each of the 3 tokens T maps to one CNAME record:

Field Value
Type CNAME
Name T._domainkey
Target (content) T.dkim.amazonses.com
Proxy status DNS only (grey cloud — NOT orange)
TTL Auto

Worked example — if your first token is nqf6dvbkg7abc:

Type Name (type this in Cloudflare) Target
CNAME nqf6dvbkg7abc._domainkey nqf6dvbkg7abc.dkim.amazonses.com

Do this three times, once per token.

IMPORTANT

The #1 mistake: Cloudflare auto-appends the zone name to whatever you type in the Name field. So type only T._domainkeyNOT T._domainkey.vlozi.app. If you paste the full name, Cloudflare stores it as T._domainkey.vlozi.app.vlozi.app and verification never completes.

If you copied the record name from the SES console (which shows the full T._domainkey.vlozi.app), delete the trailing .vlozi.app before saving in Cloudflare — or paste the full name and let Cloudflare strip it (it shows a preview of the final hostname; confirm it ends in exactly one .vlozi.app).

Steps in Cloudflare:

  1. Dashboard → select the vlozi.app zone → DNS → Records → Add record.
  2. Type CNAME, Name T._domainkey, Target T.dkim.amazonses.com.
  3. Click the orange cloud to turn it grey ("DNS only") — a proxied CNAME to Amazon breaks DKIM.
  4. Save. Repeat for tokens 2 and 3.

3.3 Confirm verification

DNS on Cloudflare usually propagates in 1–5 minutes (SES can take up to ~15). Check:

aws sesv2 get-email-identity --email-identity vlozi.app --region ap-south-1 \
  --query "{ Dkim: DkimAttributes.Status, VerifiedForSending: VerifiedForSendingStatus }"

You're done when you see:

{ "Dkim": "SUCCESS", "VerifiedForSending": true }

(Console equivalent: the identity's Identity status flips to Verified and DKIM shows Successful.)

  • Dkim: "PENDING" → records not visible yet. Re-check the names in Cloudflare for the double-suffix mistake, confirm proxy is grey, wait a few minutes, and query again.
  • Dkim: "FAILED" → a record is wrong/missing. Fix it, then re-trigger by running aws sesv2 put-email-identity-dkim-attributes --email-identity vlozi.app --signing-enabled --region ap-south-1.
  • DMARC — add a TXT record so mailbox providers trust the domain and you get reports. In Cloudflare: Add record → Type TXT, Name _dmarc, Content:

    v=DMARC1; p=quarantine; rua=mailto:dmarc@vlozi.app; adkim=s; aspf=s

    DKIM alignment alone already passes DMARC; this publishes the policy + gets you reports.

  • Custom MAIL FROM (optional, better SPF alignment + branded bounces): makes bounces come from your domain instead of amazonses.com.

    aws sesv2 put-email-identity-mail-from-attributes --region ap-south-1 \
      --email-identity vlozi.app --mail-from-domain bounce.vlozi.app \
      --behavior-on-mx-failure USE_DEFAULT_VALUE

    Then in Cloudflare DNS (both DNS only):

    • MX — Name bounce, Mail server feedback-smtp.ap-south-1.amazonses.com, Priority 10.
    • TXT — Name bounce, Content v=spf1 include:amazonses.com ~all.

    Requires ses:PutEmailIdentityMailFromAttributes — add it to the policy in §2.1 if you do this.


4. SNS topic (the event pipe) + publish permission

SES delivers events (delivered / bounce / complaint / open / click) to an SNS topic, which POSTs them to the worker. One topic serves both the platform and every per-tenant config set.

4.1 Create the topic

aws sns create-topic --name vlozi-ses-events --region ap-south-1

Copy the returned TopicArn — this is SES_SNS_TOPIC_ARN (arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events).

4.2 Allow SES to publish to it

Without this, SES silently can't emit events. Set the topic access policy:

aws sns set-topic-attributes --region ap-south-1 \
  --topic-arn arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events \
  --attribute-name Policy --attribute-value '{
    "Version":"2012-10-17",
    "Statement":[{
      "Sid":"AllowSESPublish",
      "Effect":"Allow",
      "Principal":{"Service":"ses.amazonaws.com"},
      "Action":"sns:Publish",
      "Resource":"arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events",
      "Condition":{"StringEquals":{"AWS:SourceAccount":"ACCOUNT_ID"}}
    }]
  }'

5. Configuration Set vlozi-platform + event destination

The Configuration Set is what tags each send with an event stream. The worker sends platform

  • default-domain mail under vlozi-platform (this is PLATFORM_CONFIGURATION_SET).
# 5.1 Create the set
aws sesv2 create-configuration-set --region ap-south-1 \
  --configuration-set-name vlozi-platform
 
# 5.2 Point its events at the SNS topic. The event types MUST be this exact set —
# they map 1:1 to the worker's statusMap (Send→sent, Delivery→delivered, Bounce→bounced,
# Complaint→complained, Reject→failed, Open→opened, Click→clicked).
aws sesv2 create-configuration-set-event-destination --region ap-south-1 \
  --configuration-set-name vlozi-platform \
  --event-destination-name sns-events \
  --event-destination "Enabled=true,MatchingEventTypes=[SEND,DELIVERY,BOUNCE,COMPLAINT,REJECT,OPEN,CLICK],SnsDestination={TopicArn=arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events}"

Notes:

  • Open/click tracking works out of the box on SES's default tracking domain (*.awstrack.me). A branded tracking domain is optional and not required.
  • The per-tenant sets (vlozi-tenant-<id>) the worker creates later reuse this same SNS topic — so you never touch AWS again when a tenant onboards a domain.

6. Subscribe the webhook to the topic

aws sns subscribe --region ap-south-1 \
  --topic-arn arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events \
  --protocol https \
  --notification-endpoint https://api.vlozi.app/comms/v1/webhooks/ses

SNS immediately POSTs a SubscriptionConfirmation to that URL. The worker auto-confirms it (it verifies the SNS signature, then GETs the SubscribeURL) — you do nothing. Verify it moved from PendingConfirmation to a real ARN:

aws sns list-subscriptions-by-topic --region ap-south-1 \
  --topic-arn arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events \
  --query "Subscriptions[].SubscriptionArn"

If it stays PendingConfirmation: the gateway/worker isn't deployed, the URL is wrong, or the gateway is running a build without the /comms/v1/webhooks/ses exemption (returns 401 → SNS can't confirm). Redeploy the gateway and re-subscribe.


7. Cloudflare Email Routing (inbound / replies)

This is the receive half — required only if you want two-way email (replies → contact timeline).

  1. Cloudflare dashboard → Email → Email Routing, enable it on vlozi.app (adds MX + SPF for routing automatically).
  2. Add the subdomain in.vlozi.app as a routing zone (or use a routing rule on it).
  3. Create a catch-all rule → Send to a Worker → logicspike-communication. That delivers every reply+<sendId>@in.vlozi.app to the worker's email() handler.
  4. Set INBOUND_DOMAIN=in.vlozi.app (§8). When set, tenant sends get Reply-To: reply+<sendId>@in.vlozi.app so replies can be correlated back to the send.

Tenant-inbox forwarding caveat: message.forward() only delivers to a verified Cloudflare destination address. Replies are always captured on the contact timeline; direct forwarding to an arbitrary tenant inbox is best-effort until that address is verified.


8. Set the Cloudflare secrets

Put these in apps/communication/.dev.vars for local dev, and push to production with wrangler secret bulk.

AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=ap-south-1
PLATFORM_CONFIGURATION_SET=vlozi-platform
SES_SNS_TOPIC_ARN=arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events
INBOUND_DOMAIN=in.vlozi.app

Push to prod (from apps/communication):

pnpm wrangler secret bulk .dev.vars

Windows: always use secret bulk, never type .dev.vars | wrangler secret put — a UTF-8 BOM corrupts base64 secret values. (See apps/internal-api/README.md.)

Local dev: leave AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY unset to fall back to the console-log email provider — no real sends, no AWS account needed.


9. Production access (leave the SES sandbox)

New SES accounts are sandboxed: you can only send to verified addresses, max 200/day at 1 msg/sec. Request production access on day one (≈24h SLA).

Console: SES → Account dashboard → Request production access.

CLI:

aws sesv2 put-account-details --region ap-south-1 \
  --production-access-enabled \
  --mail-type TRANSACTIONAL \
  --website-url https://vlozi.app \
  --contact-language EN \
  --use-case-description "Transactional + newsletter email for Vlozi SaaS tenants sending from platform (vlozi.app) and their own DKIM-verified domains. Bounces/complaints handled via SNS with automatic suppression + subscriber kill-switch."

After approval, request a sending-rate increase if you expect large campaigns (default is often 14 msg/sec / 50k/day; newsletters of 100k need a bump).


10. Verify end-to-end

Use SES's built-in mailbox simulator (these don't affect your reputation):

Simulator address Triggers
success@simulator.amazonses.com normal delivery
bounce@simulator.amazonses.com hard bounce
complaint@simulator.amazonses.com complaint
suppressionlist@simulator.amazonses.com account-level suppression bounce

Checks:

  1. Sendaws sesv2 send-email --region ap-south-1 --from-email-address hello@vlozi.app --destination "ToAddresses=success@simulator.amazonses.com" --content "Simple={Subject={Data=test},Body={Text={Data=hi}}}" --configuration-set-name vlozi-platform → a Delivery event should hit /v1/webhooks/ses, writing a comms_message_events row (source: "ses").
  2. Bounce — send to bounce@simulator.amazonses.com → within seconds: comms_message_logs flips to failed, a comms_suppression row appears, the newsletter subscriber kill-switch fires, and a comms.email.bounced Flows event is emitted.
  3. Real send via the app — a newsletter test-send → provider: "ses" in comms_list_logs / comms_get_analytics.
  4. Custom domainPOST /v1/sending-domains {domain} → returns 3 CNAMEs; publish them → the DomainVerifier DO flips the row to verified within ~5 min; then a send from that domain passes the dispatch gate.
  5. Reply — reply to a real send → the contact's timeline in contact-intelligence shows the inbound message.

11. Per-tenant custom domains — what happens on AWS

No manual AWS work per tenant. When a tenant adds a domain in the dashboard:

  1. Worker calls ensureConfigurationSet("vlozi-tenant-<id>", SES_SNS_TOPIC_ARN) → creates the set + an SNS event destination on the same vlozi-ses-events topic.
  2. Worker calls CreateEmailIdentity(domain) → returns DKIM tokens → tenant publishes 3 CNAMEs.
  3. The DomainVerifier DO polls GetEmailIdentity (backoff 1m→5m→15m→1h→6h→24h) until SUCCESS / FAILED.
  4. Once verified, dispatchSend allows sending from that domain and tags the send with the tenant's config set (so its events flow through the same webhook).

Delete flow calls DeleteEmailIdentity and removes the row (hard-blocked if the domain is bound to the tenant's sender settings).


12. Operations & troubleshooting

Symptom Likely cause Fix
Send returns MessageRejected: Email address is not verified Still in sandbox, recipient not verified Complete §9 production access
Send 400 ConfigurationSetDoesNotExist PLATFORM_CONFIGURATION_SET name mismatch Must equal the set created in §5 (vlozi-platform)
No events at the webhook SNS not confirmed, or topic policy missing Re-check §4.2 + §6; subscription must show a real ARN
Webhook returns 401 SNS signature failed Ensure the worker is deployed with the current code; SNS certs are on sns.<region>.amazonaws.com (allow-listed)
Webhook returns 403 Unexpected topic SES_SNS_TOPIC_ARN mismatch Set it to the exact ARN from §4.1
DKIM stuck PENDING CNAMEs missing/proxied In Cloudflare, records must be DNS-only (grey cloud), exact names from §3
Account sending paused Bounce rate >5% or complaint rate >0.1% SES auto-pauses; investigate list hygiene, then appeal

Deliverability guardrails: SES pauses the whole account at ~10% bounce or ~0.5% complaint. The app already writes per-tenant suppression + a subscriber kill-switch on hard bounce/complaint to keep these low — don't disable them.


13. Secret reference

Secret Set where Notes
AWS_ACCESS_KEY_ID Cloudflare secret IAM user vlozi-ses (§2)
AWS_SECRET_ACCESS_KEY Cloudflare secret shown once at key creation
AWS_REGION Cloudflare secret ap-south-1
PLATFORM_CONFIGURATION_SET Cloudflare secret vlozi-platform (§5)
SES_SNS_TOPIC_ARN Cloudflare secret topic ARN (§4.1); webhook rejects other topics
INBOUND_DOMAIN Cloudflare secret in.vlozi.app (§7); enables reply routing

Unset AWS creds locally → console fallback (no real send).


14. Cost

  • SES: $0.10 per 1,000 emails + $0.12/GB attachments. ~$10/mo at 100k emails.
  • SNS: first 1M HTTP deliveries/mo free, then ~$0.60/1M — effectively free for email events.
  • Cloudflare Email Routing: free, unlimited inbound.

At Vlozi's credit pricing (1 credit = 5 emails ≈ ₹0.166/email charged), SES cost (~$0.0001/email) is a ~20× margin.

Communication