Skip to main content

Inbox Email Sender Domain

Goal

Each church sends Inbox compose emails from their own verified domain (e.g., pastor@gracecommunity.church) — recipients see a real church email address, not a generic ChurchWiseAI sender. The shared pastor@churchwiseai.com remains as a graceful fallback until a church completes domain verification.

State machine

A church's email domain config sits in one of four states:

  • unverifiedpremium_churches.email_send_status = 'unverified' (default). No domain has been registered with Resend. All Inbox compose sends use the shared CWA sender (INBOX_COMPOSE_FROM env, currently pastor@churchwiseai.com).
  • pending — Domain registered + Resend has issued DNS records, but email_send_status = 'pending'. DNS propagation has not completed or the church has not yet clicked "Check verification." Sends STILL use the shared CWA fallback. Settings UI shows the DNS records table + "Check verification" button.
  • verifiedemail_send_status = 'verified'. Resend confirmed all DNS records. Sends use email_send_from_address. UI shows green confirmation + "Remove" button.
  • failedemail_send_status = 'failed'. Resend's verification check failed — DNS records are misconfigured or not yet propagated. Sends use the shared CWA fallback. Settings UI shows DNS records + "Check verification" button (same as pending) plus a red error banner.

State transitions:

unverified ──POST /api/admin/email-domain──► pending
pending ──POST /api/admin/email-domain/verify (status=verified)──► verified
pending ──POST /api/admin/email-domain/verify (status=failed)──► failed
failed ──POST /api/admin/email-domain/verify (re-check)──► pending
failed ──DELETE /api/admin/email-domain──► unverified
verified ──DELETE /api/admin/email-domain──► unverified
pending ──DELETE /api/admin/email-domain──► unverified

Database columns (premium_churches)

Five new columns added in migrations/2026-05-12-per-church-email-domain.sql:

ColumnTypeDefaultPurpose
email_send_domainTEXTNULLCustom sending domain registered with Resend (e.g., gracecommunity.church)
email_send_from_addressTEXTNULLFull From address (e.g., pastor@gracecommunity.church) — populated after verification
email_send_resend_idTEXTNULLResend domain object ID — required for verify + delete API calls
email_send_statusTEXT (enum, CHECK constraint)'unverified'Verification state — one of unverified | pending | verified | failed
email_send_dns_recordsJSONBNULLCached DNS records from Resend for Settings panel rendering

All five columns are additive with safe defaults — no migration of existing rows needed. Existing churches start in unverified state automatically (the CHECK constraint default is 'unverified'). The church's chosen local-part is stored as part of email_send_from_address (e.g., pastor@gracecommunity.church) rather than as a separate local-part column.

Code paths

Send path (every Inbox compose)

  1. src/app/api/inbox/compose/route.ts — resolves premiumId from the admin token, passes it as tenantId to sendEmail().
  2. src/lib/inbox/email-provider.tssendEmail({ tenantId }) calls loadFromContext(tenantId) to fetch email_send_status and email_send_from_address, then delegates to resolveFromAddress().
  3. src/lib/inbox/resolve-from-address.ts — returns { fromAddress, usingCustomDomain }. Decision logic:
    • If email_send_status === 'verified' AND email_send_from_address is non-null → use email_send_from_address, usingCustomDomain = true.
    • Otherwise → fromAddress = process.env.INBOX_COMPOSE_FROM ?? 'pastor@churchwiseai.com', usingCustomDomain = false.
    • Defensive: any DB error in loadFromContext returns null → falls back to shared sender. Never throws; never sends from a broken address.

Admin Settings path (church configures their domain)

  1. Create domainPOST /api/admin/email-domain

    • Validates domain format + fromLocalPart (the local-part of the From address).
    • Calls resend-domain.createDomain(domain) — Resend issues DNS records.
    • Writes email_send_domain, email_send_resend_id, email_send_dns_records, email_send_from_address, and email_send_status = 'pending' to premium_churches.
    • Returns EmailDomainConfig in pending state.
  2. Check verificationPOST /api/admin/email-domain/verify

    • Calls resend-domain.verifyDomain(resendId) → fetches updated status from Resend.
    • If Resend status is verified: sets email_send_status = 'verified'.
    • If Resend status is failed: sets email_send_status = 'failed'.
    • Returns updated EmailDomainConfig with per-record DNS status.
  3. Disconnect domainDELETE /api/admin/email-domain

    • Calls resend-domain.deleteDomain(resendId).
    • Clears all five email_send_* columns on premium_churches; resets email_send_status to 'unverified'.
    • Church reverts to shared CWA sender.
  4. Get current statusGET /api/admin/email-domain

    • Returns EmailDomainConfig (reads cached email_send_dns_records; no external Resend call on status-only fetch).

Resend integration

All Resend calls are wrapped in src/lib/inbox/resend-domain.ts (server-only). Authentication via RESEND_API_KEY env var. The wrapper normalizes Resend HTTP errors into a typed ResendDomainError with one of: CONFIG_MISSING | NETWORK | NOT_FOUND | INVALID_DOMAIN | ALREADY_EXISTS | UNKNOWN.

Resend domain status enum

Resend's GET /domains/:id response includes a status field:

not_started | pending | verified | partially_verified | partially_failed | failed

Only status === 'verified' causes our app to set email_send_status = 'verified' and enter the verified state. partially_verified is explicitly NOT treated as verified — we require full SPF + DKIM confirmation before switching the From address on customer-facing email.

DNS records issued by Resend

Resend issues the following record types when a domain is registered:

RecordTypeNameNotes
SPFTXT@ (root)v=spf1 include:amazonses.com ~all
DKIM (×3)CNAMEresend._domainkey, etc.Three CNAMEs required for full DKIM signing
Return-pathMXbounces.{domain}Optional but recommended

v1 scope: Tracking subdomain (CNAME for click/open tracking) is NOT enabled in v1. We pass { region: 'us-east-1' } only. This keeps the DNS record list shorter and avoids click/open tracking concerns.

Webhook domain.updated is available from Resend but out of scope for v1. The Settings UI uses polling (user clicks "Check verification") rather than a push webhook.

API request/response field naming

Note: API request/response shapes use camelCase (fromLocalPart, fromAddress, dnsRecords, resendId) for TypeScript ergonomics, as defined in email-domain-types.ts (RegisterDomainRequest, GetDomainResponse, VerifyDomainResponse, DeleteDomainResponse). DB column names are snake_case (email_send_*). Mapping between the two conventions happens inside the API route handlers.

Failure modes

FailureBehavior
Resend API down during createDomainRoute returns 500. No DB write — church stays in unverified.
DB write fails after Resend createDomainRoute attempts compensating deleteDomain at Resend, then returns 500.
Tenant lookup fails during sendEmailDefensive: loadFromContext returns null → falls back to shared CWA sender. No broken send.
INBOX_COMPOSE_FROM env unset AND no verified domainFalls back to hardcoded 'pastor@churchwiseai.com' (?? 'pastor@churchwiseai.com' in resolveFromAddress).
Resend verifyDomain returns partially_verifiedTreated as still-pending. email_send_status stays 'pending'. Church sees "still pending" UI.
Resend verifyDomain returns failedDB writes email_send_status = 'failed'. UI shows red error banner + DNS records + Verify button. Sends use shared CWA fallback.
Church deletes domain at Resend console directlyverifyDomain will return NOT_FOUND → route returns 404. UI should prompt the church to disconnect and re-register.

Auth + RBAC

All four API routes (POST, GET, DELETE /api/admin/email-domain and POST /api/admin/email-domain/verify) require:

  • A valid admin token resolving to a premium_churches row.
  • The token must be the primary admin token (not a team-member token) — resolved.memberId === null. Team members cannot configure the send domain.
  • CSRF validation on all mutating methods.

Non-goals (v1)

  • Multi-domain per church (one domain only)
  • Custom local-part beyond a single configurable field (no +aliasing)
  • DMARC enforcement / reporting (DNS records advertise it but we don't track it)
  • Auto-detection of TLD from church website
  • Email click/open tracking (tracking subdomain disabled)
  • Webhook-based auto-verification (Resend domain.updated event — poll on user click instead)

Migration history

  • migrations/2026-05-12-per-church-email-domain.sql — added 5 columns to premium_churches (email_send_domain, email_send_from_address, email_send_resend_id, email_send_status with CHECK constraint + default 'unverified', email_send_dns_records). Additive only; all columns nullable with safe defaults. Existing rows default to email_send_status = 'unverified'.