Inbox Compose-Message Modal — Acceptance Spec
Status: SHIPPED 2026-05-11/12. Pastors can reply to and compose new messages on any Inbox item via email, SMS, or internal note. Vertical-aware pastoral-voice templates auto-populate per item type. Privacy floor: prayer requests are internal-note-only.
Scope: The <ComposeMessageModal> reached from any Inbox item card, the channel/template/role gating, and the /api/inbox/compose route that ships the message.
This spec is the source of truth. Code that doesn't match it is wrong. Tests assert it.
Foundational decisions
- One item, one outbound message. Compose is per-Inbox-item, not bulk. A pastor composing a reply is responding to a specific visitor, caller, or submitter. Bulk pastoral messaging belongs in the Care Messaging product (separate surface).
- Prayer requests are internal-note-only — non-negotiable. Even admin cannot email or text a person who submitted a prayer request from the compose modal. Pastoral judgment about whether to reach out, and how, happens outside this system. The compose modal enforces the floor; emailing manually outside the system is the pastor's call.
- Pastoral voice is templated, not improvised. Templates per vertical × item-type encode the HEAR protocol — Hear → Engage → Acknowledge → Respond. Pastors can edit before sending, but the template is the starting point so the voice stays consistent across the team (and across volunteers with less training).
- No Cc to org address by default. Per
feedback_submitter_email_privacy.md: emails go to the submitter ONLY, at the personal email they entered. Cc'ingoffice@theirchurch.orgis invasive and opt-in (modal checkbox). - SMS requires per-church sender setup. SMS uses a per-church Twilio/Telnyx number (configured in Settings → SMS). Churches without an SMS sender see a disabled SMS button with an inline link to the setup flow. No shared sender pool — the From-number is the church's own.
- Send-now only. No scheduled sends, no drafts persisted across modal opens, no attachments, no HTML rich-text. Plaintext bodies, char-counted, sent immediately.
Tier visibility
| Tier | Inbox compose available? |
|---|---|
| Free | NO — Inbox tab not present on the free tier |
| Chat Starter / Pro / Suite | YES |
| Voice Starter / Pro | YES |
| Any Bundle | YES |
| Pro Website site-only | YES (contact-submission items still flow to the Inbox) |
| Pro Website bundled with Chat | YES |
| ITW Premium / SermonWise Pro / PewSearch Premium | N/A — different products |
The Inbox tab exists if the church has ANY of Chat/Voice/Pro-Website. Compose is universal across tiers — the differentiator is by role and item type, not by paid plan.
Per-role behavior
Channels are gated by role first, then by item type. The 3 RBAC caps shipped with FA-107: inbox:compose:email, inbox:compose:sms, inbox:compose:note.
| Role | Items visible (from ROLE_REQUEST_TYPES) | Channels available |
|---|---|---|
admin | prayer, visitor, callback, voice-call, contact-submission, safety | All 3 channels (gated by item type — see below) |
office_admin | prayer, visitor, callback, voice-call, contact-submission, safety | All 3 channels (gated by item type) |
prayer_team | prayer ONLY | note only on prayer items (privacy floor) |
care_team | prayer, visitor, callback | note on prayer; email/SMS/note on visitor + callback |
volunteer_coordinator | visitor, callback | email/SMS/note on visitor + callback |
treasurer | (none) | Inbox tab hidden — compose moot |
worship_leader | (none) | Inbox tab hidden — compose moot |
PASTORAL_ROLES = admin + office_admin. They see the full picture; everyone else sees a role-filtered slice.
Per-item-type behavior (the hard floor)
| Item type | SMS | Note | Default template | |
|---|---|---|---|---|
| Prayer request | NEVER (button hidden + server rejects) | NEVER | YES | None — free-form note |
| Visitor inquiry | YES | YES | YES | <vertical>.visitor_welcome |
| Callback request | YES | YES | YES | <vertical>.callback_response |
| Voice call summary | YES | YES | YES | <vertical>.pastoral_care |
| Pro Website contact submission | YES | YES | YES | <vertical>.family_inquiry_response (or church.pastoral_care for church vertical) |
| Safety item | YES | YES | YES (with extra confirmation prompt — see below) | <vertical>.safety_followup |
Prayer-request floor is enforced at 3 layers:
- Client: ComposeMessageModal hides email + SMS buttons when
item.type === 'prayer' - Server:
/api/inbox/composereturns400 PRAYER_NOTE_ONLYif channel ≠ note for a prayer item - RBAC catalog: prayer_team only has
inbox:compose:notecap, not the email/sms caps
Safety item extra confirmation: when composing on an item flagged is_safety_concern=true or with crisis keywords (crisis, abuse, dv, suicide), the modal shows an interstitial confirmation:
"This message is about a safety concern. Before sending, confirm: • You've reviewed the original submission • You've considered whether this needs an immediate phone call instead • You've consulted the safety protocol if relevant [Cancel] [I've considered this — continue]"
This is a soft gate — pastors can proceed. The point is the pause, not the block. (Crisis-protocol details are owned by the LIFE-SAFETY tier in the chatbot/voice agent spec, not this one.)
Per-channel behavior (church-level gates)
Email channel
- Always available for any item type that allows email
- Sends via
email-provider.ts → sendEmail()which callsresolveFromAddress(tenantId)to pick the From - If the church has verified a custom domain (see
acceptance/email-domain-verification.md): sends frompastor@theirchurch.org - Otherwise: sends from
pastor@churchwiseai.com - Body: plaintext, 1500-character soft cap (warning at 1400; hard limit not enforced — Resend accepts longer)
- Subject: auto-generated from template (
"Following up on your visit to <Tenant>") or editable
SMS channel
- Requires the church to have a verified SMS sender number on
church_voice_agents.sms_number(or wherever the per-church SMS sender is configured) - If not configured: SMS button is disabled with tooltip "Set up SMS in Settings → SMS first"
- Sends via
sms-provider.ts → sendSMS() - Body: plaintext, 320-character hard cap (2 SMS segments — refuses to send if over)
- Live char counter, turns amber at 280, red at 320
- Routes via Telnyx for new customers, Twilio for legacy numbers (provider selected by a sentinel — see
feedback/decision docs on Telnyx vs Twilio routing)
Internal note channel
- Always available for every item type and every role with note cap
- Doesn't send anything — writes a row to
inbox_outbox_messageswithchannel='note', visible to the team in the item's history - No character limit (it's a note; verbose pastoral context is fine)
- Body: plaintext
Per-vertical templates
Templates live in knowledge/data/inbox-message-templates.yaml (canonical) → derived to churchwiseai-web/src/lib/inbox-message-templates.generated.ts. 7 verticals × 10 intents = 70 templates.
| Vertical | Templates revised 2026-05-12 (founder pastoral-voice pass)? |
|---|---|
| Church (default) | YES — HEAR-protocol aligned, founder-approved |
| FuneralWiseAI | YES — sympathy-first, founder-approved |
| VetWiseAI | YES — clinical empathy |
| DentalWiseAI | Email-only at launch (SMS channel HIDDEN on dental items until HIPAA BAA chain signed — FA-108) |
| RestaurantWiseAI | Generic vertical template (pending vertical-specific revision) |
| LawWiseAI | Generic vertical template (pending vertical-specific revision) |
| RealEstateWiseAI | Generic vertical template (pending vertical-specific revision) |
Template variables: caller_first_name, caller_name, tenant_name, sender_name, plus vertical-specific (e.g., funeral: decedent_name; vet: pet_name).
Fallback: if the requested template doesn't exist for a vertical, falls back to church.pastoral_care. Logged but not surfaced to user.
"From scratch" is always an option in the template dropdown — empty body, user types from a blank canvas.
Flow (modal interaction)
- User clicks Compose / Reply button on an Inbox item card
- Modal opens. Header shows item type, submitter name, item summary (one line)
- Channel picker:
- Auto-selects if only 1 channel is available for this
item × role × church_setup - Multi-select disabled — pick exactly one channel per send
- For dental items: SMS hidden entirely (HIPAA gate)
- Auto-selects if only 1 channel is available for this
- Template dropdown: filtered by
vertical × item_type. Default is the canonical template for that combo. "From scratch" always at top. - Template variables auto-expand in the body when a template is selected. E.g.,
{{caller_first_name}}→ "Sarah";{{tenant_name}}→ "Grace Community Church". - Body editor: plaintext textarea, editable. Live char counter (channel-appropriate limits).
- Subject (email channel only): auto-filled from template, editable
- Cc me / Cc office (email channel only): two opt-in checkboxes, both off by default. Per
feedback_submitter_email_privacy.md— Cc to org address is NEVER default-on. - Send button: primary action. Disabled if body empty, or if channel + item-type combination is rejected.
- On click Send:
- Optimistic UI: "Sent ✓" pill appears on the item card immediately
composeOverridesMap records the optimistic state (mirrorsfollowupOverridesfrom PR #411)- POST
/api/inbox/composewith{item_id, item_type, channel, to_address, body, subject, template_id, cc_self, cc_org}
- On success: confirmation toast "Message sent"; modal closes; outbox row appears in item history at next refresh; the item's last-sent timestamp is visible inline on the card
- On error: mapped error code → toast with plain-English explanation; modal STAYS OPEN with the draft body intact (don't lose pastor's work); retry button enabled
- Error codes (all mapped in
src/lib/error-codes.tsor similar):INVALID_BODY→ "Please enter a message"CHANNEL_NOT_ALLOWED_FOR_ITEM→ "Email/SMS isn't available on this item type. Use an internal note instead."SMS_SENDER_NOT_CONFIGURED→ "Set up your SMS sender in Settings → SMS first"RECIPIENT_BLOCKED→ "This recipient has opted out / bounced previously"RATE_LIMIT_UPSTREAM(Resend/Telnyx) → "Sending was throttled. Try again in a moment."PRAYER_NOTE_ONLY→ "Prayer requests can only get an internal note from this system. Reach out to the submitter directly if you want to."UNKNOWN_ERROR(catch-all) → "Something went wrong. The team has been notified. Try again or save as a note."
DB writes per send
A successful compose writes one row to inbox_outbox_messages:
| Column | Value |
|---|---|
| id | uuid |
| tenant_id | the church's id |
| item_type | `prayer |
| item_id | the source item's id |
| channel | |
| status | state machine: `queued → sending → sent |
| to_address | recipient (email/phone for email/sms; NULL for note) |
| from_address | resolved at send time |
| subject | (email only) |
| body | plaintext |
| template_id | the template used (NULL if "from scratch") |
| sender_member_id | the team member who composed |
| sender_role | their role at compose time (audit) |
| cc_self | bool |
| cc_org | bool |
| created_at | timestamp |
| sent_at | timestamp on success |
| error_code | (set on failure) |
A partial index on (status) where status IN ('queued', 'failed') powers the cron worker that retries transient failures.
Non-goals (explicitly NOT in this spec)
- Multi-recipient compose (one item = one recipient, one outbound message)
- Scheduled sends
- Persistent drafts across modal closes
- Attachments (file uploads, photos)
- HTML rich-text editor
- AI-suggested-reply button — templates only at launch; AI-suggestion is a future feature
- Per-tier compose-volume rate limits (Resend/Telnyx upstream limits are the ceiling, not exposed to pastors)
- Compose threading — replying to a reply (the Inbox model is item-scoped, not thread-scoped). Reply-tracking happens on the email side (Resend handles
In-Reply-Toheaders); we don't surface threads in the UI. - Translation / multi-language UI for the compose modal (English only at launch)
- Bulk reply ("send the same message to all unhandled prayer requests") — belongs in Care Messaging, not Inbox compose
- Read receipts / open-tracking on outbound emails — pastoral correspondence shouldn't be surveilled
Test coverage
- Unit:
src/lib/inbox/__tests__/email-provider.test.ts(6 cases),sms-provider.test.ts(cases), template render tests (51 cases for the 7-vertical × 10-intent catalog). - Route:
src/app/api/inbox/compose/__tests__/route.test.ts(12 cases — channel + RBAC + prayer-floor + error paths). - E2E: 3 Playwright probes env-gated by
PLAYWRIGHT_INCLUDE_MUTATING=1(default off in CI; founder runs them manually before high-risk template changes). - All unit + route tests now wired into
.github/workflows/test.ymlvia--require ./src/lib/inbox/__tests__/setup-server-only.cjs(2026-05-12). - Critical-path registry: NOT currently registered. Compose is a customer-data-emitting flow but not customer-money. The prayer-note-only floor is the security-critical invariant — if a regression breaks it, an
ops_errorsP1 fires from the server-side rejection path.
What customers should see (the one-paragraph version a pastor can read)
Every item in your Inbox — a prayer request, a visitor card, a callback, a call summary — has a Reply or Compose button. Click it and you'll see a small form: pick how to reach them (email, text message, or just a note for your team), pick a template (or write from scratch), edit the message, and send. Templates are written in our HEAR pastoral voice — Hear, Engage, Acknowledge, Respond — so even when a volunteer replies, the tone matches what you'd write yourself. Prayer requests are note-only — those are sacred conversations and the system shouldn't be where you email someone about their grief. For everything else, pick the right channel for the person and send.