Skip to main content

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

  1. 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).
  2. 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.
  3. 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).
  4. 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'ing office@theirchurch.org is invasive and opt-in (modal checkbox).
  5. 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.
  6. 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

TierInbox compose available?
FreeNO — Inbox tab not present on the free tier
Chat Starter / Pro / SuiteYES
Voice Starter / ProYES
Any BundleYES
Pro Website site-onlyYES (contact-submission items still flow to the Inbox)
Pro Website bundled with ChatYES
ITW Premium / SermonWise Pro / PewSearch PremiumN/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.

RoleItems visible (from ROLE_REQUEST_TYPES)Channels available
adminprayer, visitor, callback, voice-call, contact-submission, safetyAll 3 channels (gated by item type — see below)
office_adminprayer, visitor, callback, voice-call, contact-submission, safetyAll 3 channels (gated by item type)
prayer_teamprayer ONLYnote only on prayer items (privacy floor)
care_teamprayer, visitor, callbacknote on prayer; email/SMS/note on visitor + callback
volunteer_coordinatorvisitor, callbackemail/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 typeEmailSMSNoteDefault template
Prayer requestNEVER (button hidden + server rejects)NEVERYESNone — free-form note
Visitor inquiryYESYESYES<vertical>.visitor_welcome
Callback requestYESYESYES<vertical>.callback_response
Voice call summaryYESYESYES<vertical>.pastoral_care
Pro Website contact submissionYESYESYES<vertical>.family_inquiry_response (or church.pastoral_care for church vertical)
Safety itemYESYESYES (with extra confirmation prompt — see below)<vertical>.safety_followup

Prayer-request floor is enforced at 3 layers:

  1. Client: ComposeMessageModal hides email + SMS buttons when item.type === 'prayer'
  2. Server: /api/inbox/compose returns 400 PRAYER_NOTE_ONLY if channel ≠ note for a prayer item
  3. RBAC catalog: prayer_team only has inbox:compose:note cap, 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 calls resolveFromAddress(tenantId) to pick the From
  • If the church has verified a custom domain (see acceptance/email-domain-verification.md): sends from pastor@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_messages with channel='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.

VerticalTemplates revised 2026-05-12 (founder pastoral-voice pass)?
Church (default)YES — HEAR-protocol aligned, founder-approved
FuneralWiseAIYES — sympathy-first, founder-approved
VetWiseAIYES — clinical empathy
DentalWiseAIEmail-only at launch (SMS channel HIDDEN on dental items until HIPAA BAA chain signed — FA-108)
RestaurantWiseAIGeneric vertical template (pending vertical-specific revision)
LawWiseAIGeneric vertical template (pending vertical-specific revision)
RealEstateWiseAIGeneric 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)

  1. User clicks Compose / Reply button on an Inbox item card
  2. Modal opens. Header shows item type, submitter name, item summary (one line)
  3. 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)
  4. Template dropdown: filtered by vertical × item_type. Default is the canonical template for that combo. "From scratch" always at top.
  5. Template variables auto-expand in the body when a template is selected. E.g., {{caller_first_name}} → "Sarah"; {{tenant_name}} → "Grace Community Church".
  6. Body editor: plaintext textarea, editable. Live char counter (channel-appropriate limits).
  7. Subject (email channel only): auto-filled from template, editable
  8. 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.
  9. Send button: primary action. Disabled if body empty, or if channel + item-type combination is rejected.
  10. On click Send:
    • Optimistic UI: "Sent ✓" pill appears on the item card immediately
    • composeOverrides Map records the optimistic state (mirrors followupOverrides from PR #411)
    • POST /api/inbox/compose with {item_id, item_type, channel, to_address, body, subject, template_id, cc_self, cc_org}
  11. 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
  12. 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
  13. Error codes (all mapped in src/lib/error-codes.ts or 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:

ColumnValue
iduuid
tenant_idthe church's id
item_type`prayer
item_idthe source item's id
channel`email
statusstate machine: `queued → sending → sent
to_addressrecipient (email/phone for email/sms; NULL for note)
from_addressresolved at send time
subject(email only)
bodyplaintext
template_idthe template used (NULL if "from scratch")
sender_member_idthe team member who composed
sender_roletheir role at compose time (audit)
cc_selfbool
cc_orgbool
created_attimestamp
sent_attimestamp 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-To headers); 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.yml via --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_errors P1 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.