Domain Verification
Required for sending email from a custom From address. SES verifies ownership via DKIM.
Dashboard: /overview/domains. Internal API: /api/internal/domains. Public API: /api/v1/domains.
SES wrapper: src/lib/providers/domain.ts.
Add domain
POST /api/internal/domains
{ "domain": "mail.acme.com", "tenantId": "ten_..." }
Server:
- Calls
CreateEmailIdentityCommandon SESv2 → returns DKIM tokens and the region they were created in. The region comes fromAWS_REGION, read once intoDEFAULT_SES_REGION(src/lib/providers/domain.ts) — not fromdomains.region, because at creation there is no row yet. - Stores
domainsrow withstatus: "pending", the token list, andregiontaken from step 1's result. - Returns tokens to caller — customer must add CNAME records at their DNS provider:
<token1>._domainkey.mail.acme.com → <token1>.dkim.amazonses.com <token2>._domainkey.mail.acme.com → <token2>.dkim.amazonses.com <token3>._domainkey.mail.acme.com → <token3>.dkim.amazonses.com
If the same identity already exists at SES under a different account, the route returns 409 DOMAIN_TAKEN_AT_PROVIDER (v1) or a friendly 409 (internal).
Where the region comes from
The direction of that dependency matters, and it is the opposite of what it
looks like. createDomainIdentity returns the region it built in rather than
leaving the caller to work it out, because the caller's only other source is a
second read of process.env.AWS_REGION — and two independent reads of one env
var is exactly how this broke: both add-domain routes created the identity in
AWS_REGION and then inserted a row with no region, so the column default
us-east-1 recorded a region the identity was never in. Every later operation
keys off the row — the PATCH recheck's checkDomainStatus, the verify-poll
cron's configuration-set backfill, deleteDomainIdentity — so on any deployment
whose AWS_REGION is not us-east-1 (this one is ap-southeast-2) the domain
got a NotFoundException on every recheck (surfaced as
DKIM_STATUS_IDENTITY_MISSING, so it could never verify), never received its
event configuration set, and outlived its own deletion at SES.
Returning the region makes "where we created it" and "what we persisted" the
same value by construction, which no amount of care at two call sites can
guarantee. DEFAULT_SES_REGION is the one place AWS_REGION is turned into a
region name; POST /api/internal/ip-pools defaults its pool region from the
same const for the same reason.
Platform-managed sandbox domains
Not every domains row is a customer's. Both signup paths insert one for
{org-slug}.sandbox.sendoka.com, already status: "verified" with
verified_at set, flagged platform_managed = true — and no SES call is
made. There is no identity for that name, and there is not meant to be: SES
authorises a send against a verified identity for the exact name or for any
parent of it, so the row rides the platform's sendoka.com identity. That is
what lets a trial account send on day one with no DNS, which is a promise the
product makes in writing.
Two consequences worth stating plainly, because both have already cost something:
Anything that reconciles a row against SES must exclude these.
GetEmailIdentityis an exact-name lookup, so it misses on a covered subdomain. Once itsNotFoundExceptionwas correctly reclassified from a transient error to a definitive answer, the verified-cohort recheck below read that miss as a deleted identity: it flipped sandbox rows topending, clearedverified_at, and fanned out a customer-facingdomain.unverifiedwebhook carryingdkim_status: "IDENTITY_NOT_FOUND"— a value we synthesise, not one SES returned. A flipped row also stops passing the send guard, which readsstatus = 'verified', so the flip breaks the very sends it was reporting on.The parent identity is a real operational dependency with no code path. Nothing in this repo creates, verifies or records it;
createDomainIdentityhas exactly two callers and both are the customer add-domain routes above. It is provisioned by hand in AWS, and it has to exist in the region the sandbox rows name —resolveDomaintakes the send client's region from the row. What to run to confirm it, what a correct answer looks like, and what breaks when it is wrong: integrations/aws-ses.md § The parent identity every sandbox domain rides./api/cron/probe-healthwatches it as an operator signal.It holds no plan slot, and it does not tick the onboarding checklist.
checkResourceLimit(org, "domains")countsplatform_managed = falserows only — Free carries exactly one domain, so counting the sandbox row put every Free org at its cap before it had added anything, and the first own-domain add answered402 PLAN_RESOURCE_LIMIT. The dashboard's "Verify your domain" step likewise wants a verified non-platform row; the sandbox row isverifiedfrom birth and was ticking it on every fresh org.Both of those read the flag, so both are inert until the backfill has run. Migration 0054 added the column
DEFAULT false NOT NULL, so every sandbox row that predates it reads as the customer's own: those orgs stay at 1/1 on Free and keep the self-ticked "Verify your domain" untilscripts/backfill-platform-managed-domains.ts --applyhas run against the database. The check, which must return zero:select count(*) from domains where domain like '%.sandbox.sendoka.com' and platform_managed = false;Live sends from it only reach the org's own team. Every live email surface —
/v1/emails(immediate and scheduled),/v1/emails/batch(per item), both audience senders, the dashboard resend and template test-send, email/v1/verifications, and the scheduled-send cron at fire time — runs the shared sender guard, which for a sandbox sender allows only recipients (to,ccandbcc) that are the verified email (users.email_verifiedset) of a member of the sending org, refuses audience broadcasts outright, and caps the org at 100 recipients per UTC day:403 SANDBOX_RECIPIENT_NOT_ALLOWED/429 SANDBOX_DAILY_LIMIT(the cron cancels instead). The rule lives insrc/lib/api/sandbox-sender.ts. Test keys are unaffected. It is the one consumer that also treats a name undersendoka.com(or a legacy sandbox zone) as a sandbox sender whatever the column says — the error runs the other way here: a false positive is a loud 403 that names the fix, a false negative is an open relay on Sendoka's identity, and rows the backfill has not reached still readfalse.No customer can add a name in the zone.
POST /v1/domainsand the dashboard add route refusesendoka.comand every name under it — another org's sandbox host included — with422 DOMAIN_RESERVED, before the plan cap and before any SES call. SES reports a verified parent as verifying every subdomain, so such a row would be an unrestricted sender on our identity.
The flag is a column and not a hostname suffix match on purpose. The sandbox
domain has been renamed twice — mrsendo.dev → chaparly.dev → sendoka.com
— and a string that carries a semantic stops carrying it the next time someone
renames it, silently, and only inside the reconciler.
Re-check status
PATCH /api/internal/domains
{ "id": "dom_..." }
Developer or owner (requireDeveloperSession). Calls GetEmailIdentityCommand. Updates status to verified and sets verified_at when SES reports VerifiedForSendingStatus = true. If a previously verified domain flips to unverified (DKIM revoked / DNS pulled), status returns to pending and domain.unverified audit + webhook fire.
Public counterpart: POST /api/v1/domains/{id}/verify.
Background poll
Cron /api/cron/domain-verify-poll (every 5 min) processes both cohorts:
- pending rows newer than 7 days → flip to verified on success
- verified rows last checked > 24h ago → flip back to pending if SES reports unverified
DNS diagnose
POST /api/internal/domains/{id}/diagnose
POST /api/v1/domains/{id}/diagnose
DoH lookups against Cloudflare 1.1.1.1 for each DKIM CNAME plus an apex A lookup for Cloudflare-proxy detection. Returns per-record outcome and an actionable hint (cloudflare_proxy, wrong_target, wrong_type, partial, etc.). Used by the dashboard's pending-domain card.
Alignment (SPF + DMARC)
The same call also returns an alignment array — SPF at the apex, DMARC at _dmarc.<domain>:
| status | meaning |
|---|---|
pass |
configured, nothing to do |
warn |
works, but a receiver will hold something against it — p=none, ?all, SES not in SPF, over the 10-lookup limit |
fail |
absent, duplicated, or actively harmful (+all, no p= tag) |
unknown |
the DNS lookup itself failed — not a finding, retry |
overallReady reflects DKIM only. That is what SES requires before it will send, so it is what gates verification. SPF and DMARC govern inbox placement, which is a different question — a domain can be verified here and still land in spam. Alignment never blocks a domain from verifying, and the dashboard keeps showing it after a domain goes green, because that is when it starts mattering.
Since February 2024 Gmail and Yahoo require DMARC from bulk senders (>5k/day). p=none is reported as a warning, not a pass: it satisfies the letter of that requirement while instructing receivers to do nothing, so treating it as done leaves a sender believing they are protected when a spoofer is not blocked at all.
Two SPF records, or two DMARC records, are reported as fail — receivers permerror on duplicate SPF and ignore the policy entirely on duplicate DMARC, so both are strictly worse than having none.
Warmup
POST /api/internal/domains/{id}/warmup
{ "total_days": 14 }
Developer or owner (requireDeveloperSession). Sets warmup_started_at to now. Each send checks checkWarmupLimit(orgId, fromAddress); daily cap is 50 * 2^daysIn, capped at total_days (default 14). After completion the domain is "warm" and the helper returns no cap.
Region
PATCH /api/internal/domains/{id}/region
{ "region": "eu-west-1", "fallback_region": "us-east-1" }
Developer or owner (requireDeveloperSession). Provider client is created per-region and cached. fallback_region is consulted on retryable region failures during send. region and fallback_region must differ.
Tenant rebind
PATCH /api/internal/domains/{id}/tenant
{ "tenant_id": "ten_..." | null }
Developer or owner (requireDeveloperSession). Reassigns or unbinds the domain's tenant. Resolver cache is invalidated so subsequent sends pick up the new binding.
Delete
DELETE /api/internal/domains?id=dom_...
DELETE /api/v1/domains/{id}
- Calls
DeleteEmailIdentityCommandon SES — failure is logged vialogError(operator can reconcile orphan against AWS console) but does not block local cleanup. - Deletes
domainsrow. - Fires
domain.removedwebhook + audit.
Validation
Domain format regex shared via domainNameField in src/lib/api/validation.ts. Allowed regions enumerated in ALLOWED_REGIONS in src/lib/providers/domain.ts.
Case
Domain names are case-insensitive, and domainNameField stores the canonical form: trimmed, lowercased, one trailing root dot dropped. Mail.Acme.com. is added — and handed to SES, stored and echoed back — as mail.acme.com. Before that, the name was stored as typed while every send-path reader lowercased the from-domain and matched it case-sensitively, so a domain added with capitals verified and then refused every live send with SENDER_DOMAIN_NOT_VERIFIED.
Rows written before the fix still carry their capitals, and so does nearly every sandbox row (its label ends in six characters of the org id). They work as they are: every lookup by name — the sender guard, resolveDomain, the warmup cap, inbound routing, and the duplicate check on both add routes — matches with domainNameEquals (lower() on both sides, src/lib/api/domain-name.ts). Where a lookup takes one row, the row stored under exactly the lowercase name wins over a mixed-case twin, so an org that re-added its domain in lowercase keeps sending from the row it already used. The duplicate check means mail.acme.com beside an existing Mail.Acme.com is 409 DOMAIN_ALREADY_ADDED.
scripts/lowercase-domains.ts lowercases the legacy rows. Dry run by default, --apply to write. It refuses a row whose lowercase name another row in the same org already has, and a customer row unless SES answers to the lowercase name in the row's region (and answers verified for a verified row) — the verify-poll recheck and the delete route address SES by the stored name, so renaming a row away from the name SES knows would demote it. Nothing depends on it running.
Lifecycle webhook events
domain.verifieddomain.unverifieddomain.removeddomain.warmup_started