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 §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/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",
                 "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 — dispatchSend 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, 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 deletes vlozi-platform. Without the create actions, custom-domain onboarding fails. The List/Get…EventDestinations/Update…EventDestination actions are used only by the one-off scripts/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.mjs needs 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); and scripts/ses-account-suppression.mjs (one-off) sets the account list to BOUNCE-only and migrates existing COMPLAINT entries (GetAccount to read the current reasons, List…, PutAccountSuppressionAttributes; ses:GetAccount was 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-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._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:

  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 — 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.

    1. Start at p=none and keep the existing report address. Read the reports until every legitimate sender (SES, and Resend while it is the failover) passes.
    2. Then p=quarantine; pct=25, a few clean days later pct=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 a rua address 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.

  • 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 own vlozi.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_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 (now in the §2.1 policy).


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 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/CLICK back 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/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. 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:

  1. 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 → 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 domain — POST /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 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.

Communication