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:

  1. Calls CreateEmailIdentityCommand on SESv2 (region from domains.region, default us-east-1) → returns DKIM tokens.
  2. Stores domains row with status: "pending" and the token list.
  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).

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 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.

Lifecycle webhook events

  • domain.verified
  • domain.unverified
  • domain.removed
  • domain.warmup_started