AWS SES (Email)
SES v2 used for outbound email and domain identity management. Event notifications arrive via SNS.
Client
src/lib/providers/email.ts:
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
const ses = new SESv2Client({
region: process.env.AWS_REGION || "us-east-1",
credentials: { accessKeyId: ..., secretAccessKey: ... },
});
Same client singleton reused in src/lib/providers/domain.ts for identity management.
Sending
sendEmail() issues SendEmailCommand with:
FromEmailAddress: params.from,
Destination: { ToAddresses: params.to },
Content: { Simple: { Subject, Body: { Html?, Text? } } }
Returns { providerMessageId, providerResponse } with MessageId and SDK requestId.
Domain identity
src/lib/providers/domain.ts:
| Function | SES command | Purpose |
|---|---|---|
createDomainIdentity(domain) |
CreateEmailIdentityCommand |
Returns DKIM tokens |
checkDomainStatus(domain) |
GetEmailIdentityCommand |
Reads VerifiedForSendingStatus + DKIM status |
deleteDomainIdentity(domain) |
DeleteEmailIdentityCommand |
Removes identity |
The parent identity every sandbox domain rides
Nothing in this repo creates this, checks it at deploy time, or records that it exists. Nothing in this repo provisions it, so it has to be created by hand in the AWS console or CLI, once, and the product silently depends on it from the first signup onwards.
Both signup paths — POST /api/auth/register and the OAuth auto-signup in
findOrCreateUser (src/lib/auth/options.ts) — insert a domains row for
{org-slug}.sandbox.sendoka.com, already status: "verified", with
platform_managed = true. Neither calls SES. createDomainIdentity has exactly
two callers and both are customer add-a-domain routes
(v1/domains,
internal/domains), so a sandbox
domain has no SES identity of its own. It sends because SES authorises a
send against a verified identity for the exact name or for any parent of it,
so the parent — sendoka.com, or a narrower sandbox.sendoka.com — must be
verified on the account, in the region the sandbox rows name. Nothing in this
repo creates it, checks it, or records that it exists, so treat that as a
standing requirement to confirm rather than a fact this document establishes;
"Confirming it" below is the check.
That is not a shortcut we can withdraw, though it is a narrow one. Live sending
from the sandbox address is offered — the quickstart's first example sends from
you@{org-slug}.sandbox.sendoka.com, and platforms are told they can demo with a
sandbox key before DNS (platforms-walkthrough.md,
"skip DNS — offer the sandbox") — but only to the verified email addresses of the
sending org's own members, at most 100 recipients per org per UTC day, and never
as an audience broadcast (SANDBOX_RECIPIENT_NOT_ALLOWED / SANDBOX_DAILY_LIMIT,
enforced by the shared sender guard in
src/lib/api/sandbox-sender.ts). Every
send it does make still rides this parent identity, so it is a real operational
dependency for every trial account's first send.
Confirming it
Ask in the region the sandbox rows name. That is AWS_REGION for anything
created after the domain-region fix — ap-southeast-2 on this deployment — and
possibly the schema default us-east-1 on rows written before it (see
domains-region-repair.md). The region
is not cosmetic: the send client takes its region from the domains row
(resolveDomain), so an identity verified in the wrong region is exactly as
useless as no identity at all.
aws sesv2 get-email-identity \
--email-identity sendoka.com \
--region ap-southeast-2
A correct answer has all three of these:
{
"IdentityType": "DOMAIN",
"VerifiedForSendingStatus": true,
"DkimAttributes": { "SigningEnabled": true, "Status": "SUCCESS" }
}
VerifiedForSendingStatus: trueis the one that gates sending. Without it SES refuses the message outright.DkimAttributes.Status: "SUCCESS"does not gate sending — it gates inbox placement. Verified-but-unsigned mail still leaves, and then lands in spam under the Gmail/Yahoo bulk-sender rules the rest of domains.md is about.- The region flag matters more than the identity name. Re-run the command
once per distinct
regioninselect distinct region from domains where platform_managed = true.
If sendoka.com is not the verified name, try the narrower one before
concluding it is missing — either covers the sandbox subdomains:
aws sesv2 get-email-identity --email-identity sandbox.sendoka.com --region ap-southeast-2
The signal you already have
There is one piece of evidence for the parent identity that needs no AWS access
at all, and it is strong: every system email the app sends goes out from the
parent domain, in AWS_REGION.
Login codes (login-code.ts), password
resets, email verification, team invites, webhook alerts and the internal
signup notification all call sendEmail({ from: SYSTEM_FROM_EMAIL ?? "no-reply@sendoka.com", ... }) and none of them passes a region — so
clientFor(undefined) in providers/email.ts
falls through to process.env.AWS_REGION. SES accepts those sends only against
a verified identity for sendoka.com (or the exact address).
So: if password-reset mail is arriving in production, an identity covering
no-reply@sendoka.com is verified for sending in AWS_REGION right now. That
is not quite the whole requirement — it says nothing about DKIM status, and
nothing about a sandbox row that carries a different region than AWS_REGION
— but it rules out the loudest failure without opening a console.
The inverse holds too, and is worth knowing before you go looking elsewhere: if system mail has stopped arriving, the parent identity is a prime suspect, and every trial customer's sandbox sending is down with it.
What breaks when it is wrong
| State | Symptom |
|---|---|
| Identity absent in that region | SES rejects every live send from a sandbox address (MessageRejected — the identity failed the check in that region). The app surfaces a provider error per send. Every trial account's first send fails and nothing in the product says why. |
| Verified, but in a different region than the row names | Identical to absent. resolveDomain reads domains.region and builds the SES client there; the identity in the other region is never consulted. |
Verified for sending, DKIM short of SUCCESS |
Mail sends unsigned. It is not rejected, so nothing errors — it just stops reaching inboxes, which is the slowest failure of the three to notice. |
| Deleted later | Same as absent, retroactively, for every org on the platform at once. |
Do not create it through the customer API
Adding sendoka.com via POST /v1/domains would create the identity, but it
also creates a domains row owned by one org — and DELETE /v1/domains/{id}
calls DeleteEmailIdentityCommand. One customer deleting their domain would
take the parent identity out from under every sandbox row on the platform.
Create it out of band:
aws sesv2 create-email-identity --email-identity sendoka.com --region ap-southeast-2
then publish the three returned DKIM tokens as CNAMEs at
<token>._domainkey.sendoka.com → <token>.dkim.amazonses.com, the same records
domains.md describes for customer domains.
What watches it
/api/cron/probe-health reads one sample row per distinct region from
domains where platform_managed = true, walks up that sample's parent names,
and asks SES whether any of them is verified for sending in the row's own
region. The verdict rides in checks.sandbox_identity and in
sandbox_identity_unverified on the response, with a
cron.probe_health.sandbox_identity warn line — deliberately not in the
status column the public /status page renders, for the same reason schema
drift is kept out of it. It fails open on anything short of a definite answer.
See crons.md.
That probe is the only thing looking. Before platform_managed existed,
domain-verify-poll did touch SES for sandbox rows — it just misread the
exact-name miss as a deleted identity and flipped verified rows to pending.
Excluding those rows from the reconciler is correct, and it removes the last
incidental observer.
Inbound event notifications
SES publishes events (Delivery, Bounce, Complaint, Reject) to a configured SNS topic. Configure that SNS topic to HTTP-subscribe to https://<host>/api/webhooks/ses.
Handler: src/app/api/webhooks/ses/route.ts.
Flow:
- SNS sends
SubscriptionConfirmation— handler fetchesSubscribeURLto confirm. - SNS sends
NotificationwithMessage= JSON-stringified SES event. - Extract
mail.messageId— match tomessages.provider_message_id. - Update status accordingly, fan out to customer webhooks.
Setup (AWS side)
Topic creation, subscriptions, SignatureVersion, the IAM principals and the producer wiring all live in one place: aws-sns.md § Provisioning the topics. Note in particular that dedicated IP pools create their own configuration sets at runtime, so the event destination is needed on every set, not just the default.
Still done by hand outside this repo, and neither is optional:
- Move the SES account out of the SES sandbox for production (AWS request — see
scripts/aws-quota-requests.md). Until AWS lifts it, every send from the account — customer domains included — only reaches recipients verified in SES. That is AWS's account-wide restriction and is separate from Sendoka's own sandbox sender rule, which does not lift with it: live mail from an org's{org-slug}.sandbox.sendoka.comhost permanently reaches only that org's members' verified sign-in addresses, at most 100 recipients per org per UTC day, and never an audience broadcast. - Verify the parent identity the platform's own sandbox domains ride, in
AWS_REGION. See the section above — it has no code path at all, and its absence is invisible until a trial customer's first send.
Webhook verification
The SES webhook route verifies the SNS envelope signature via verifySnsSignature (src/lib/api/sns-verify.ts) — SignatureVersion 2 only, sns.*.amazonaws.com cert host, 5-minute replay window — and then pins the topic against SNS_TOPIC_ARN_ALLOWLIST, which fails closed in production. RawMessageDelivery must stay false, since raw delivery strips the envelope the signature covers.