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": "tnt_..." }
Server:
- Calls
CreateEmailIdentityCommandon SESv2 (region fromdomains.region, defaultus-east-1) → returns DKIM tokens. - Stores
domainsrow withstatus: "pending"and the token list. - 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).
Re-check status
PATCH /api/internal/domains
{ "id": "dom_..." }
Owner-only. 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.
Warmup
POST /api/internal/domains/{id}/warmup
{ "total_days": 14 }
Owner-only. 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" }
Owner-only. 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": "tnt_..." | null }
Owner-only. 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.
Lifecycle webhook events
domain.verifieddomain.unverifieddomain.removeddomain.warmup_started