Email Domain Verification — Acceptance Spec
Status: SHIPPED 2026-05-12 — FA-107 v2. Pastors can verify their own sending domain so Inbox compose emails (and any other system-originated email) ship from pastor@theirchurch.org. Until verified, the system sends from pastor@churchwiseai.com.
Scope: The Settings → Email Sender (Custom Domain) panel, its 4-state machine, and the email-provider-side resolver that picks the From address at send time.
This spec is the source of truth. Code that doesn't match it is wrong. Tests assert it.
Foundational decisions
- Brand alignment, not premium differentiator. Custom sending domain is available to every paid tier. Pastors emailing parishioners about pastoral matters from a third-party domain reads as phishing; the system should let any paying church fix that without paywalling it as a "Pro feature." The Resend domain-API cost is fixed per church regardless of plan.
- Admin-only write, office-admin read. Changing the sending domain is an identity-level change. The pastor (admin) authorizes it. The office admin needs to know what's going on (to answer "why did our emails just start coming from
pastor@churchwiseai.comagain" type questions) but doesn't push the buttons. - One verified domain per church. No multi-domain support. A church has one canonical From address. (Subdomains like
mail.theirchurch.orgare out of scope at launch — root domain only.) - Webmaster delegation is a first-class flow. Pastors are not IT people. The "Email this to my webmaster" button — with the DNS records pre-formatted for someone who knows where to paste them — is the realistic path for most churches, not "go log into your Porkbun account."
- No auto-fallback on post-verification breakage. If DNS gets broken upstream after verification (church's IT team rotates records), the system keeps sending from the verified address; bounces are surfaced to the founder for support intervention. Auto-falling back to
pastor@churchwiseai.comwould mask a real problem the church needs to know about. (Reviewed 2026-05-13: confirmed acceptable.)
Tier visibility
| Tier | Settings panel visible? |
|---|---|
| Free | NO — panel hidden entirely. Free tier doesn't compose outbound email via the Inbox. |
| Chat Starter / Pro / Suite | YES |
| Voice Starter / Pro | YES |
| Any Bundle (chat+voice / chat+website / suite_both / etc.) | YES |
| Pro Website site-only ($14.95) | YES |
| Pro Website bundled with Chat ($19.95) | YES |
| ITW Premium ($9.95) | N/A — different product, separate admin |
| SermonWise Pro ($19.95) | N/A — different product, separate admin |
| PewSearch Premium ($9.95) | N/A — different product, separate admin |
The panel is rendered by EmailSenderSettings.tsx, mounted under the Settings tab. Tier gate is enforced server-side in the loader for /admin/[token]/settings.
Per-role behavior (within a paying church)
| Role | Sees panel? | Can add domain? | Can remove? | Can resend webmaster email? |
|---|---|---|---|---|
admin | YES | YES | YES (confirm prompt) | YES |
office_admin | YES (view-only) | NO — disabled button with "Ask your admin to add a sending domain" tooltip | NO | YES — can re-send the DNS-records email to the webmaster without changing the domain |
prayer_team | NO — panel hidden | — | — | — |
care_team | NO | — | — | — |
treasurer | NO | — | — | — |
volunteer_coordinator | NO | — | — | — |
worship_leader | NO | — | — | — |
Server-side: the 4 API routes (POST /api/admin/email-domain, POST .../verify, DELETE /api/admin/email-domain, POST .../send-to-webmaster) reject non-admin requests with 403 INSUFFICIENT_PERMISSIONS. Office-admin gets the read view via the loader returning the current state but no edit affordance. Webmaster-resend is the only mutation an office-admin is allowed to trigger (it doesn't change state, it just re-emails the existing DNS records).
State machine (4 states)
The state is stored in premium_churches.email_send_status (EmailDomainStatus enum: unverified | pending | verified | failed).
State: unverified (default for all new churches)
Panel shows:
- Eyebrow: "Email Sender (Custom Domain)"
- Heading: "Send Inbox emails from your church's domain"
- Rationale block (2-3 sentences): "Right now, when you send an email from your Inbox, it comes from
pastor@churchwiseai.com. That can look like spam to your parishioners. Verify your church's domain and emails will come frompastor@yourchurch.orginstead." - Domain entry form: single text input (placeholder
yourchurch.org), submit button "Add Domain" - Validation (client-side + server-side):
- Not empty
- Looks like a domain (regex
^[a-z0-9-]+(\.[a-z0-9-]+)+$, case-insensitive, lowercased on submit) - Not a free email provider (
gmail.com,yahoo.com, etc.) — friendly error: "That's an email-provider domain, not your church's. Use the domain your church owns (e.g.firstbaptist.org)." - Not already-verified by another tenant (Resend API will reject; surface as "That domain is already verified by another ChurchWiseAI customer. Contact support if you believe this is in error.")
Action: Submit domain →
- POST to
/api/admin/email-domainwith{ domain } - Server calls Resend
createDomain(), stores returned DNS records, writesemail_send_domain+email_send_status='pending'+ DNS records (as JSON) topremium_churches - UI transitions to
pending(optimistic; reverts on error)
State: pending
Panel shows:
- Status banner: "Pending DNS verification" (amber, with a small spinner icon)
- The domain being verified:
yourchurch.org(read-only display, with a "Remove and start over" link) - DNS records as cards — one card per record, rendered by
EmailSenderDnsRecordsTable.tsx. Each card has:- Plain-English description of what the record does (from
dns-record-descriptions.ts): e.g. "This MX record tells email servers where to deliver bounce notifications." - Record type (TXT / CNAME / MX) — labeled clearly, not as a cryptic abbreviation
- Name field with copy button
- Value field with copy button
- Priority (for MX records)
- Plain-English description of what the record does (from
- "Email these records to my webmaster" button — opens a modal:
- Input: webmaster's email address (validated)
- Optional checkbox: "Cc me" (sends a copy to the admin's email on the record)
- Submit → POST
/api/admin/email-domain/send-to-webmaster→ friendly templated email with the DNS records + plain-English context + a "thanks for helping" close. Toast on success.
- "Check verification" button — POST to
/api/admin/email-domain/verify. Server polls Resend's verify endpoint. If verified → state flips toverified. If still pending → toast "Still propagating. DNS changes can take up to 48 hours. Try again later." If hard-failed → state flips tofailed. - "Remove and start over" link (bottom, subtle, red text) — DELETE the domain, return to
unverified. Confirms first ("Remove the pending domainyourchurch.org? You can re-add it any time.").
Resend webhook can also auto-transition pending → verified or pending → failed (handled in /api/admin/email-domain/route.ts or a dedicated webhook endpoint per the architecture doc). The UI doesn't need to know — next page load picks up the new state.
State: verified
Panel shows:
- Status banner: ✅ green "Verified — sending from
pastor@yourchurch.org" - The verified address (read-only):
email_send_from_address(resolved byresolveFromAddress()— typicallypastor@<domain>but can be configured) - Verification timestamp ("Verified 3 days ago")
- DNS records table — collapsed by default ("Show DNS records"). Useful for diagnosing post-verification breakage. Has a "Re-send to webmaster" button (useful if records get accidentally removed by IT).
- "Remove domain" button — red, bottom, confirms with a clear warning: "Are you sure? Inbox emails will go back to coming from
pastor@churchwiseai.com. Your DNS records will still be on file at Resend — you can re-verify the same domain later without redoing them."- DELETE → state flips back to
unverified. Records are kept at Resend (Resend handles cleanup on its side); ourpremium_churches.email_send_domainis set to NULL butemail_send_status='unverified'.
- DELETE → state flips back to
Send-time behavior: sendEmail() calls resolveFromAddress(tenantId) which reads premium_churches.email_send_* for the tenant. If status='verified', returns the verified From address. Otherwise returns the default pastor@churchwiseai.com. The resolver is pure and tested (resolve-from-address.test.ts).
State: failed
Panel shows:
- Status banner: ⚠️ amber "Verification failed —
<reason>" - The failure reason in plain English (mapped from Resend's failure code via
dns-record-descriptions.ts):dns-records-missing: "DNS records aren't visible yet. Try the recheck button in a few minutes (changes can take up to 48 hours to propagate)."dns-records-incorrect: "Some DNS records don't match what we expect. Compare your DNS to the records below."domain-not-found: "We can't find that domain. Did you spell it correctly?"domain-blocked: "This domain has been blocked by our email provider for past abuse. Contact support."unknown(catch-all): "Something went wrong on our end. The team has been notified. You can try removing and re-adding, or contact support."
- The DNS records table (so the pastor or webmaster can compare)
- "Retry verification" button — re-polls Resend
- "Remove and start over" button — DELETE, returns to
unverified - On hard-failure (
domain-blocked, repeateddns-records-incorrectfor >7 days), anops_errorsrow is inserted withseverity='P1'so the founder gets pinged via WatchTower.
API contracts
| Route | Method | Auth | Behavior |
|---|---|---|---|
/api/admin/email-domain | GET | admin token; loader-style | Returns current state + DNS records + verified address |
/api/admin/email-domain | POST | admin only | Adds a domain (calls Resend createDomain); rejects if office_admin |
/api/admin/email-domain | DELETE | admin only | Removes the domain |
/api/admin/email-domain/verify | POST | admin only | Polls Resend verify endpoint; transitions state |
/api/admin/email-domain/send-to-webmaster | POST | admin OR office_admin | Sends the DNS-delegation email; doesn't change state |
All routes use the standard admin-token-resolves-to-role pattern. Office-admin write rejections return 403 with code OFFICE_ADMIN_READONLY and a body explaining the restriction.
Non-goals (explicitly NOT in this spec)
- Multi-domain per church (one domain, period)
- Subdomain verification (
mail.yourchurch.org) — root domain only at launch - Per-team-member From addresses (no
<member_first>@yourchurch.orgrewriting) - DKIM rotation UI — Resend handles key rotation transparently
- Custom email templates per church (templates live in
data/inbox-message-templates.yaml, not editable in this panel) - Auto-fallback to
pastor@churchwiseai.comon post-verification breakage — bounces are surfaced via WatchTower for support intervention, not silently re-routed - Bulk-domain CSV import (no use case)
- Shared-tenant domains (HIPAA-bound verticals: the tenant ID MUST be the church's own ID, never a shared parent like a denomination office — enforced by the
tenantIdparameter insendEmail()already)
HIPAA-bound verticals (dental at launch)
When DentalWiseAI launches:
- Same panel, same flow
- The verified domain MUST be one the dental practice controls (enforced via the existing
tenantIdparameter — there is no "shared parent" mode) - The 4-state machine and DNS records are identical
- Dental adds an extra acknowledgment line in the panel: "Emails sent from this domain may contain PHI. Make sure your domain's email-hosting setup is HIPAA-compliant on your side (the BAA covers Resend's role only)."
Tracked under FA-108 (BAA chain) — Resend + Telnyx + Supabase BAAs must be signed before first dental customer onboarded.
Test coverage
- Unit:
src/lib/inbox/__tests__/resend-domain.test.ts(10 cases),resolve-from-address.test.ts(8 cases),email-provider.test.ts(6 cases),dns-record-descriptions.test.ts(7 cases),send-to-webmaster/__tests__/route.test.ts(12 cases). All 43 cases wired into.github/workflows/test.ymlvia--require ./src/lib/inbox/__tests__/setup-server-only.cjs(2026-05-12). - E2E:
e2e/email-domain-verification.spec.ts— 6 scenarios with mocked Resend, covering all 4 state transitions (7.3s green on preview). - Critical-path registry: NOT currently registered (low-risk surface — not a customer-money/data flow). Add to
registry.yamlif the failure mode escalates (e.g., if a verified-domain regression caused a real customer to look like spam to their congregation).
What customers should see (the one-paragraph version a pastor can read)
Under Settings, you'll find an "Email Sender (Custom Domain)" section. Enter your church's domain (
firstbaptist.org), and we'll show you the DNS records to set up. You can email those records straight to your IT person or webmaster from the panel. Once DNS is set up and verifies, every email the Inbox sends comes frompastor@firstbaptist.orginstead ofpastor@churchwiseai.com. If you want to undo it, hit Remove — you'll go back to sending frompastor@churchwiseai.com.