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). One exception: an identity Sendoka itself left behind — a domain removed here whose SES delete failed, so no row in any account names it and the audit trail records its domain.removed — is deleted and created afresh, with new DKIM tokens, so the domain can be added again. Platform names (sendoka.com and below, the system sender's domain) are never reclaimed. The add routes also stop making such orphans themselves: the identity is created before the domains row, so when that insert throws, the route deletes the identity it just created (releaseUnrecordedIdentity, in the region it was created in) before the error surfaces as it did before. It is kept if any row in any org names the domain, since the insert may have committed before the error.

Another account's domain, or a name beneath it (SEC-1)

Both add routes refuse a name that a domains row in another org holds (any status), or any name beneath one of its verified domains on a label boundary: with a verified acme.com in org A, org B's acme.com, x.acme.com and a.b.acme.com are all 409 DOMAIN_CLAIMED_BY_ANOTHER_ACCOUNT (v1, code on the dashboard's 409 too), before any SES call. notacme.com is not under acme.com. The same org adding a subdomain of its own domain, in any project, is fine. The check is domainClaimedByAnotherOrg (src/lib/api/domain-claim.ts), and the orphan reclaim above runs it as well, so it never deletes an identity beneath another org's domain.

Why: every customer identity lives in one shared SES account, and SES reports a verified parent identity as verifying every name beneath it. A subdomain added under another tenant's verified domain came up verified with no DNS change — and a verified row is what SSO email_domain binding, SCIM's child→parent rule and the live sender guard all trust. That allowed SAML login as the other tenant's users through a self-signed IdP.

Another org's pending parent does not block names beneath it: a pending row proves nothing, and counting it let any Free org add bigcorp.com (or a public suffix like co.uk, which the name pattern accepts) and lock the real owner out of the whole tree. A child added under a still-pending parent cannot verify off it later — own DKIM is required. A parent added above another org's subdomain is not refused: that org still has to publish the parent's DKIM records, which only the parent's owner can do (see "Own DKIM" below).

Rows written before this landed are listed, with the SSO connections, SCIM directories and captured-domain members of the orgs holding them, by scripts/audit-cross-org-subdomains.mjs. It is read-only (one READ ONLY transaction) but reads the shared dev/prod database and prints email addresses. What to do with a finding is an operator decision taken through the app.

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

  • It cannot be removed or re-checked. DELETE /v1/domains/{id}, POST /v1/domains/{id}/verify and their dashboard twins answer 409 DOMAIN_PLATFORM_MANAGED for it, before any write or SES call, and the Domains page shows it with a Sandbox badge and neither button. A removal could never be undone (nothing re-creates the row, and the add routes refuse its name), and a re-check is the exact-name lookup that misses on it. The rule is isPlatformSandboxRow: the flag, or a name in a sandbox zone for rows the backfill has not reached.

  • 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.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 and the identity's own DkimAttributes.Status is SUCCESS. If a previously verified domain flips to unverified (DKIM revoked / DNS pulled), status returns to pending and domain.unverified audit + webhook fire.

Own DKIM (SEC-1). VerifiedForSendingStatus alone is not proof: SES also reports it for a subdomain whose parent identity is verified, whoever owns that parent. The verify route, this recheck and the poll below all decide through isOwnDkimVerified (src/lib/providers/domain.ts). TEMPORARY_FAILURE keeps a row that is already verified, so a DNS blip does not fire domain.unverified, but never verifies one for the first time. A subdomain that was only ever verified through its parent (no DKIM records of its own published) is demoted to pending on its next recheck, and its owner has to publish its own CNAMEs.

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 (own DKIM SUCCESS, as above)
  • verified rows last checked > 24h ago → flip back to pending if SES reports unverified, or the identity's own DKIM is no longer SUCCESS/TEMPORARY_FAILURE

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: a 5xx or throttle from SES, or a connection that never opened (refused, unresolvable or unroutable host, or the 3 s connect timeout). A reset or timeout after the connection opened never fails over, because SES may already have accepted the message. region and fallback_region must differ.

The route only re-points routing. It never creates an SES identity, because identities are regional and a new one would need new DKIM records. So any region the request newly names, as primary or fallback, must already hold a verified identity for the domain, checked with GetEmailIdentity in that region. Otherwise the request answers 409 REGION_IDENTITY_NOT_VERIFIED and changes nothing. If SES cannot be asked, it answers 502. Before this check, pointing a domain at a region with no identity made every live send fail with MessageRejected, and that error does not trigger the fallback. The next daily recheck then demoted the domain to pending.

A domain assigned to a dedicated IP pool keeps its primary region in the pool's region: a new region that differs from it answers 409 POOL_REGION_MISMATCH before SES is asked, and changes nothing. The pool's SES configuration set exists only in its own region, so the move used to make every live send fail with ConfigurationSetDoesNotExist. Unassign the domain first. The pool assignment route refuses the same pairing from the other side. fallback_region is not checked against the pool.

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.
  • Removes the domain's inbound receipt rule, if inbound was on (best-effort, logged on failure).
  • Fires domain.removed webhook + audit.

Inbound email

GET|POST|DELETE /api/internal/domains/{id}/inbound
GET|POST|DELETE /api/v1/domains/{id}/inbound

POST creates the domain's SES receipt rule, DELETE removes it, GET reads it; each answers the MX record the customer publishes (inbound-smtp.<SES_INBOUND_REGION>.amazonaws.com, priority 10). Reads are any member / read:domains; changes are developer+ (requireDeveloperSession) / write:domains, audited as domain.inbound_enabled / domain.inbound_disabled. Logic in src/lib/api/domain-inbound.ts, provider calls in src/lib/providers/inbound-email.ts. A domain that leaves verified (verify-poll cron, or a recheck) has its rule removed, so SES bounces its mail rather than accepting it for a row the webhook will not route to. There is no column: the rule at SES is the state. A test key is simulated (simulated: true, no provider call, no audit). Customer guide: inbound-messages.md; operator setup: aws-ses.md § Inbound email receiving.

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