Skip to main content

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

  1. 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.
  2. 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.com again" type questions) but doesn't push the buttons.
  3. One verified domain per church. No multi-domain support. A church has one canonical From address. (Subdomains like mail.theirchurch.org are out of scope at launch — root domain only.)
  4. 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."
  5. 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.com would mask a real problem the church needs to know about. (Reviewed 2026-05-13: confirmed acceptable.)

Tier visibility

TierSettings panel visible?
FreeNO — panel hidden entirely. Free tier doesn't compose outbound email via the Inbox.
Chat Starter / Pro / SuiteYES
Voice Starter / ProYES
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)

RoleSees panel?Can add domain?Can remove?Can resend webmaster email?
adminYESYESYES (confirm prompt)YES
office_adminYES (view-only)NO — disabled button with "Ask your admin to add a sending domain" tooltipNOYES — can re-send the DNS-records email to the webmaster without changing the domain
prayer_teamNO — panel hidden
care_teamNO
treasurerNO
volunteer_coordinatorNO
worship_leaderNO

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 from pastor@yourchurch.org instead."
  • 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-domain with { domain }
  • Server calls Resend createDomain(), stores returned DNS records, writes email_send_domain + email_send_status='pending' + DNS records (as JSON) to premium_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)
  • "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 to verified. If still pending → toast "Still propagating. DNS changes can take up to 48 hours. Try again later." If hard-failed → state flips to failed.
  • "Remove and start over" link (bottom, subtle, red text) — DELETE the domain, return to unverified. Confirms first ("Remove the pending domain yourchurch.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 by resolveFromAddress() — typically pastor@<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); our premium_churches.email_send_domain is set to NULL but email_send_status='unverified'.

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, repeated dns-records-incorrect for >7 days), an ops_errors row is inserted with severity='P1' so the founder gets pinged via WatchTower.

API contracts

RouteMethodAuthBehavior
/api/admin/email-domainGETadmin token; loader-styleReturns current state + DNS records + verified address
/api/admin/email-domainPOSTadmin onlyAdds a domain (calls Resend createDomain); rejects if office_admin
/api/admin/email-domainDELETEadmin onlyRemoves the domain
/api/admin/email-domain/verifyPOSTadmin onlyPolls Resend verify endpoint; transitions state
/api/admin/email-domain/send-to-webmasterPOSTadmin OR office_adminSends 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.org rewriting)
  • 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.com on 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 tenantId parameter in sendEmail() 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 tenantId parameter — 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.yml via --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.yaml if 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 from pastor@firstbaptist.org instead of pastor@churchwiseai.com. If you want to undo it, hit Remove — you'll go back to sending from pastor@churchwiseai.com.