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:
- unverified —
premium_churches.email_send_status = 'unverified'(default). No domain has been registered with Resend. All Inbox compose sends use the shared CWA sender (INBOX_COMPOSE_FROMenv, currentlypastor@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. - verified —
email_send_status = 'verified'. Resend confirmed all DNS records. Sends useemail_send_from_address. UI shows green confirmation + "Remove" button. - failed —
email_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:
| Column | Type | Default | Purpose |
|---|---|---|---|
email_send_domain | TEXT | NULL | Custom sending domain registered with Resend (e.g., gracecommunity.church) |
email_send_from_address | TEXT | NULL | Full From address (e.g., pastor@gracecommunity.church) — populated after verification |
email_send_resend_id | TEXT | NULL | Resend domain object ID — required for verify + delete API calls |
email_send_status | TEXT (enum, CHECK constraint) | 'unverified' | Verification state — one of unverified | pending | verified | failed |
email_send_dns_records | JSONB | NULL | Cached 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)
src/app/api/inbox/compose/route.ts— resolvespremiumIdfrom the admin token, passes it astenantIdtosendEmail().src/lib/inbox/email-provider.ts—sendEmail({ tenantId })callsloadFromContext(tenantId)to fetchemail_send_statusandemail_send_from_address, then delegates toresolveFromAddress().src/lib/inbox/resolve-from-address.ts— returns{ fromAddress, usingCustomDomain }. Decision logic:- If
email_send_status === 'verified'ANDemail_send_from_addressis non-null → useemail_send_from_address,usingCustomDomain = true. - Otherwise →
fromAddress = process.env.INBOX_COMPOSE_FROM ?? 'pastor@churchwiseai.com',usingCustomDomain = false. - Defensive: any DB error in
loadFromContextreturnsnull→ falls back to shared sender. Never throws; never sends from a broken address.
- If
Admin Settings path (church configures their domain)
-
Create domain —
POST /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, andemail_send_status = 'pending'topremium_churches. - Returns
EmailDomainConfiginpendingstate.
- Validates domain format +
-
Check verification —
POST /api/admin/email-domain/verify- Calls
resend-domain.verifyDomain(resendId)→ fetches updated status from Resend. - If Resend status is
verified: setsemail_send_status = 'verified'. - If Resend status is
failed: setsemail_send_status = 'failed'. - Returns updated
EmailDomainConfigwith per-record DNS status.
- Calls
-
Disconnect domain —
DELETE /api/admin/email-domain- Calls
resend-domain.deleteDomain(resendId). - Clears all five
email_send_*columns onpremium_churches; resetsemail_send_statusto'unverified'. - Church reverts to shared CWA sender.
- Calls
-
Get current status —
GET /api/admin/email-domain- Returns
EmailDomainConfig(reads cachedemail_send_dns_records; no external Resend call on status-only fetch).
- Returns
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:
| Record | Type | Name | Notes |
|---|---|---|---|
| SPF | TXT | @ (root) | v=spf1 include:amazonses.com ~all |
| DKIM (×3) | CNAME | resend._domainkey, etc. | Three CNAMEs required for full DKIM signing |
| Return-path | MX | bounces.{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 inemail-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
| Failure | Behavior |
|---|---|
Resend API down during createDomain | Route returns 500. No DB write — church stays in unverified. |
DB write fails after Resend createDomain | Route attempts compensating deleteDomain at Resend, then returns 500. |
Tenant lookup fails during sendEmail | Defensive: loadFromContext returns null → falls back to shared CWA sender. No broken send. |
INBOX_COMPOSE_FROM env unset AND no verified domain | Falls back to hardcoded 'pastor@churchwiseai.com' (?? 'pastor@churchwiseai.com' in resolveFromAddress). |
Resend verifyDomain returns partially_verified | Treated as still-pending. email_send_status stays 'pending'. Church sees "still pending" UI. |
Resend verifyDomain returns failed | DB 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 directly | verifyDomain 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_churchesrow. - 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.updatedevent — poll on user click instead)
Migration history
migrations/2026-05-12-per-church-email-domain.sql— added 5 columns topremium_churches(email_send_domain,email_send_from_address,email_send_resend_id,email_send_statuswith CHECK constraint + default'unverified',email_send_dns_records). Additive only; all columns nullable with safe defaults. Existing rows default toemail_send_status = 'unverified'.