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 §13 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",
"ses:PutEmailIdentityMailFromAttributes"],
"Resource": "*" },
{ "Sid": "ConfigSets", "Effect": "Allow",
"Action": ["ses:CreateConfigurationSet","ses:GetConfigurationSet","ses:CreateConfigurationSetEventDestination",
"ses:DeleteConfigurationSet","ses:ListConfigurationSets",
"ses:GetConfigurationSetEventDestinations","ses:UpdateConfigurationSetEventDestination"],
"Resource": "*" },
{ "Sid": "SuppressionList", "Effect": "Allow",
"Action": ["ses:GetAccount","ses:GetSuppressedDestination","ses:ListSuppressedDestinations",
"ses:DeleteSuppressedDestination","ses:PutAccountSuppressionAttributes"],
"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, and deletes it again a WEEK after the tenant's last domain is removed (DeleteConfigurationSet, run by a Durable Object alarm, and only if no domain row uses the set by then; SES publishes events to the set a message was sent with, so deleting it sooner would lose the bounces and complaints for mail still in flight). It never deletesvlozi-platform. Without the create actions, custom-domain onboarding fails. TheList/Get…EventDestinations/Update…EventDestinationactions are used only by the one-offscripts/update-config-set-events.mjs. PutEmailIdentityMailFromAttributes— each new sending domain gets a custom MAIL FROM (bounce.<domain>,USE_DEFAULT_VALUE) so SPF aligns for the tenant's own domain. Optional at runtime: if this action is denied the domain is still created and works, and the Domains page simply shows no return-path records (mailFromStatus: "unavailable").scripts/backfill-mail-from.mjsneeds it to retrofit existing domains.- SuppressionList — bounce classification looks up SES's own account-level suppression
entry (
GetSuppressedDestination) before deciding whether a bounce is the address or the sender; ops clears an address from the ops dashboard (DeleteSuppressedDestination); andscripts/ses-account-suppression.mjs(one-off) sets the account list to BOUNCE-only and migrates existing COMPLAINT entries (GetAccountto read the current reasons,List…,PutAccountSuppressionAttributes;ses:GetAccountwas missing from this policy until the first production run of the script was denied on it). Every runtime call degrades if denied (the DTO shows "unavailable"), it never fails a send; the one-off script just stops.
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 — publish a policy so mailbox providers trust the domain, and tighten it in stages. Do not jump straight to
quarantine/reject: a policy stricter than what your real senders pass will drop your own mail.- Start at
p=noneand keep the existing report address. Read the reports until every legitimate sender (SES, and Resend while it is the failover) passes. - Then
p=quarantine; pct=25, a few clean days laterpct=100. Use relaxed alignment (adkim=r; aspf=r). The old advice here (rua=mailto:dmarc@vlozi.app; adkim=s; aspf=s) had two problems: strict SPF alignment can never pass until the custom MAIL FROM below is in place, and aruaaddress on another domain needs an extra authorisation record to receive reports. Cloudflare's managed report address avoids both.
v=DMARC1; p=quarantine; pct=100; adkim=r; aspf=r; rua=<keep the existing Cloudflare rua>(Only publish that final form once the staged steps above have been clean.) DKIM alignment alone already passes DMARC; this publishes the policy and gets you reports.
- Start at
-
Custom MAIL FROM (better SPF alignment + branded bounces): makes bounces come from your domain instead of
amazonses.com. Customer domains get this automatically (bounce.<their domain>, records shown on the Domains page); the steps below are only for the platform's ownvlozi.app, whose DNS ops manages by hand.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(now in the §2.1 policy). - 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 are the ones the worker maps (Send→sent, Delivery→delivered, Bounce→bounced,
# Complaint→complained, Reject→failed, DeliveryDelay, RenderingFailure). OPEN and CLICK are
# deliberately NOT subscribed: since Phase 4 Vlozi owns open/click tracking itself, and SES's
# own link wrapping would rewrite the unsubscribe link and double-count engagement.
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,DELIVERY_DELAY,RENDERING_FAILURE],SnsDestination={TopicArn=arn:aws:sns:ap-south-1:ACCOUNT_ID:vlozi-ses-events}"A set created earlier with the old OPEN/CLICK list is corrected by
apps/communication/scripts/update-config-set-events.mjs (dry run by default).
Notes:
- Open/click tracking is first-party (Newsletter's own pixel and link redirector), not
SES's. Do not add
OPEN/CLICKback to the event destination. - 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. Leaving the sandbox is a manual review by AWS, not an automatic step.
Status (2026-10-07): still sandboxed in ap-south-1. Two 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 but never submitted, because several statements in it were not true (see
aws-ses-production-access-case.md, now superseded). Until approval, system mail that SES refuses (recipient not verified) is delivered by Resend, which is why login codes and invitations still work.
Do not use the old one-line CLI use-case ("transactional + newsletter email for Vlozi SaaS tenants ..."). Describing a multi-tenant platform that sends other people's mail, from a new account with no sending history and no tenants yet, is the profile AWS screens hardest, and it was the shape of the earlier requests.
The plan is now deliberately narrow: ask first for platform system mail only (login codes,
invitations, billing notices and owner alerts to the platform's own users), declared
TRANSACTIONAL, and ask separately for tenant newsletters/mailbox later, after the controls
that kind of sending needs exist and there is a clean sending record.
Everything needed is in ses-production-access/: the
step-by-step runbook, the go/no-go gates that must be green before submitting, the case text,
reply templates for AWS follow-ups, and the evidence to collect.
Two switches keep the account honest once access is granted (both comms secrets, set with
wrangler secret bulk):
SES_TENANT_SENDING— leave unset. Unset keeps tenant mail (newsletters, mailbox, forms, flows, API) off the SES account, because the declared use is platform mail only.SES_SANDBOX=false— set on approval day so a sandbox rejection stops failing over to Resend.
After approval, check the granted quota and rate in the console and record them; do not assume the defaults.
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 or complaint rate over SES's limits (see below) | Investigate list hygiene, fix the cause, then appeal in the console |
Tenant mail refused TENANT_SENDING_NOT_ENABLED (HTTP 503) |
SES_TENANT_SENDING is unset, by design |
Expected until the separate tenant-mail request is approved; do not set it just to clear the error |
Deliverability guardrails. SES puts the account under review at about 5% bounce or 0.1%
complaint and can pause the whole account at about 10% bounce or 0.5% complaint. Keep
well inside the first pair: treat 2% bounce and 0.05% complaint as the warning line. The app
writes suppression on a hard bounce or complaint to keep these low — don't disable it. Set
CloudWatch alarms on Reputation.BounceRate and Reputation.ComplaintRate (see the readiness
checklist) so you hear about it before SES does.
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.