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 updatingAWS_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/sesThe 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.devURL 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 theapi.vlozi.appgateway URL above.This requires the gateway to be deployed with the exemption (
commsProxy.all("/v1/webhooks/ses", …)). If you get a401/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.appDNS managed in Cloudflare (needed to publish DKIM records and to enable Email Routing).
The CLI commands below use
--region ap-south-1explicitly 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:
- Send —
dispatchSendcalls SES v2SendEmail. - Identities — the
/v1/sending-domainsroutes + theDomainVerifierDO callCreateEmailIdentity/GetEmailIdentity/DeleteEmailIdentitywhen 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-sesCopy 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):
- Go to SES → Configuration → Identities → Create identity (top-right region must read Asia Pacific (Mumbai) ap-south-1).
- Choose Domain, enter
vlozi.app. - Leave Assign a default configuration set unchecked for now, keep Easy DKIM with RSA_2048_BIT, DKIM signatures = Enabled.
- 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-1Then 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 textExample 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._domainkey — NOT 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:
- Dashboard → select the
vlozi.appzone → DNS → Records → Add record. - Type
CNAME, NameT._domainkey, TargetT.dkim.amazonses.com. - Click the orange cloud to turn it grey ("DNS only") — a proxied CNAME to Amazon breaks DKIM.
- 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 runningaws sesv2 put-email-identity-dkim-attributes --email-identity vlozi.app --signing-enabled --region ap-south-1.
3.4 (Recommended) DMARC + custom MAIL FROM
-
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=sDKIM 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_VALUEThen in Cloudflare DNS (both DNS only):
- MX — Name
bounce, Mail serverfeedback-smtp.ap-south-1.amazonses.com, Priority10. - TXT — Name
bounce, Contentv=spf1 include:amazonses.com ~all.
Requires
ses:PutEmailIdentityMailFromAttributes— add it to the policy in §2.1 if you do this. - MX — Name
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-1Copy 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 isPLATFORM_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/sesSNS 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).
- Cloudflare dashboard → Email → Email Routing, enable it on
vlozi.app(adds MX + SPF for routing automatically). - Add the subdomain
in.vlozi.appas a routing zone (or use a routing rule on it). - Create a catch-all rule → Send to a Worker →
logicspike-communication. That delivers everyreply+<sendId>@in.vlozi.appto the worker'semail()handler. - Set
INBOUND_DOMAIN=in.vlozi.app(§8). When set, tenant sends getReply-To: reply+<sendId>@in.vlozi.appso 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.appPush to prod (from apps/communication):
pnpm wrangler secret bulk .dev.varsWindows: always use
secret bulk, nevertype .dev.vars | wrangler secret put— a UTF-8 BOM corrupts base64 secret values. (Seeapps/internal-api/README.md.)Local dev: leave
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYunset 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:
- Send —
aws 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→ aDeliveryevent should hit/v1/webhooks/ses, writing acomms_message_eventsrow (source: "ses"). - Bounce — send to
bounce@simulator.amazonses.com→ within seconds:comms_message_logsflips tofailed, acomms_suppressionrow appears, the newsletter subscriber kill-switch fires, and acomms.email.bouncedFlows event is emitted. - Real send via the app — a newsletter test-send →
provider: "ses"incomms_list_logs/comms_get_analytics. - Custom domain —
POST /v1/sending-domains {domain}→ returns 3 CNAMEs; publish them → theDomainVerifierDO flips the row toverifiedwithin ~5 min; then a send from that domain passes the dispatch gate. - 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:
- Worker calls
ensureConfigurationSet("vlozi-tenant-<id>", SES_SNS_TOPIC_ARN)→ creates the set + an SNS event destination on the samevlozi-ses-eventstopic. - Worker calls
CreateEmailIdentity(domain)→ returns DKIM tokens → tenant publishes 3 CNAMEs. - The
DomainVerifierDO pollsGetEmailIdentity(backoff 1m→5m→15m→1h→6h→24h) untilSUCCESS/FAILED. - Once
verified,dispatchSendallows 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.