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:

  1. Calls CreateEmailIdentityCommand on SESv2 → returns DKIM tokens and the region they were created in. The region comes from AWS_REGION, read once into DEFAULT_SES_REGION (src/lib/providers/domain.ts) — not from domains.region, because at creation there is no row yet.
  2. Stores domains row with status: "pending", the token list, and region taken from step 1's result.
  3. 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. GetEmailIdentity is an exact-name lookup, so it misses on a covered subdomain. Once its NotFoundException was 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 to pending, cleared verified_at, and fanned out a customer-facing domain.unverified webhook carrying dkim_status: "IDENTITY_NOT_FOUND" — a value we synthesise, not one SES returned. A flipped row also stops passing the send guard, which reads status = '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; createDomainIdentity has 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 nameresolveDomain takes 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-health watches it as an operator signal.

  • It holds no plan slot, and it does not tick the onboarding checklist. checkResourceLimit(org, "domains") counts platform_managed = false rows 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 answered 402 PLAN_RESOURCE_LIMIT. The dashboard's "Verify your domain" step likewise wants a verified non-platform row; the sandbox row is verified from 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" until scripts/backfill-platform-managed-domains.ts --apply has 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, cc and bcc) that are the verified email (users.email_verified set) 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 in src/lib/api/sandbox-sender.ts. Test keys are unaffected. It is the one consumer that also treats a name under sendoka.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 read false.

  • No customer can add a name in the zone. POST /v1/domains and the dashboard add route refuse sendoka.com and every name under it — another org's sandbox host included — with 422 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.devchaparly.devsendoka.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 DeleteEmailIdentityCommand on SES — failure is logged via logError (operator can reconcile orphan against AWS console) but does not block local cleanup.
  • Deletes domains row.
  • Fires domain.removed webhook + 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.verified
  • domain.unverified
  • domain.removed
  • domain.warmup_started