Skip to main content

Documentation

No results found.
Features Members

Unified Inbox

The Unified Inbox puts every customer conversation on one screen: support tickets, form submissions, live AI chat, and two-way SMS threads merge into a single list with needs-attention triage, and staff reply inline — ticket replies through...

The Unified Inbox puts every customer conversation on one screen: support tickets, form submissions, live AI chat, and two-way SMS threads merge into a single list with needs-attention triage, and staff reply inline — ticket replies through the Ticket System's normal write path, form replies by email, chat replies straight into the visitor's open widget, texts through the Twilio number already configured for Marketing. SMS was the net-new channel in v1 (the Twilio webhook previously handled only STOP/START keywords and discarded message bodies); v2 adds the form-submission channel and live chat takeover.


The problem

Customer conversations arrive through four doors — the support form, the site's own forms, the AI chat bubble, and text messages to the business number — and each lives on its own dashboard page (or, for SMS, nowhere at all: inbound texts were dropped after opt-out keyword handling). Nothing shows "what needs a human right now" across all of them. Two of those doors were also one-way: a form submission had no reply affordance anywhere in the CMS, and a chat the bot couldn't answer had no way for a person to step in.

The fix

A self-contained feature module under app/Features/Inbox/ that aggregates rather than duplicates: tickets stay in the Tickets module's tables, chat transcripts stay in the AI Chat module's tables, form submissions stay in core's form_submissions, and the inbox reads all of them. The module owns storage only for what it introduces — the SMS channel (inbox_sms_threads + inbox_sms_messages) and the record of form replies it sends (inbox_form_replies).

Like every addon, it's structured as a feature module gated by feature:inbox middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Toggling it on runs the module's migrations.

What the dashboard gets

Under Dashboard → Inbox (Conversations need Manager and up; Settings is Admin-only):

  • Conversations — one merged list across the four channels, newest activity first, each row showing a channel icon, the contact (requester name, "Visitor" for anonymous chats, the submitter for forms, phone or matched CRM contact name for SMS), a snippet of the latest message, relative time, a status badge, and an amber needs-attention dot. Tabs: All / Needs attention (tickets awaiting a staff reply + SMS threads with unread messages + submissions still marked new) / Tickets / Chats / Forms / SMS, plus search. Channels gate individually — the Tickets tab and rows appear only when the Ticket System is enabled, Chats only when AI Chat is enabled; Forms and SMS are core/inbox-owned and always present.
  • Ticket pane — the full thread (customer messages, staff replies, amber internal notes) with status quick-actions and an inline composer (reply or internal note). Replies go through the Ticket System's own write path, so the requester is emailed and the status flips to Awaiting reply exactly as if sent from the ticket page — which stays one click away via Open full ticket.
  • Chat pane — the live transcript with Take over / Hand back to the assistant and a reply composer. See Live chat takeover below.
  • Form pane — the submitter's answers as a label/value list, status quick-actions (new / read / replied / closed, shared with the Forms page), the replies already sent, and an email composer. See The form-submission channel below.
  • SMS pane — the message thread (inbound left, staff replies right, sender name on each) with a reply box. Opening a thread clears its unread counter. Replies are blocked with a clear message when the number has texted STOP (carriers block those sends anyway) and when no SMS provider is configured; sends that fail at the provider are surfaced as an error and never recorded, so the thread only ever shows texts that actually left.
  • Settings (admin only) — the inbound-capture toggle (on by default; turning it off keeps STOP/START compliance handling but stops storing message bodies), the Twilio webhook URL to paste into the number's "A message comes in" hook, a connection status check, and a pointer to Marketing → Settings where the Twilio credentials live (the inbox deliberately has no second credential store).

The dashboard also gets a Unified Inbox card (unread SMS + tickets awaiting reply) linking straight to the inbox, and the sidebar badge shows the same needs-attention count.

The SMS channel

  • Inbound — the existing signature-verified Twilio webhook (POST marketing/sms/webhook) gains a capture step: after STOP/START keyword processing, the message body is appended to the sender's thread (created on first contact, keyed by E.164 phone) and the unread counter bumps. STOP/START texts are recorded too, so the thread shows the full exchange. Capture is best-effort — a storage failure never breaks the webhook response Twilio expects.
  • Outbound — replies send through the Marketing module's SmsSender (Twilio) and are recorded only on send success, stamped with the staff sender.
  • Opt-outs — the Marketing module's SmsOptout suppression list is checked before every reply; opted-out numbers can't be texted from the inbox until they text START.
  • CRM matching — on thread creation the phone is matched against CRM contacts (digit-normalized, country-code tolerant) and the thread points at the contact; merged duplicate contacts repoint their threads to the survivor automatically.

Missed Call Text Back

Turns the Twilio number into a call-forwarder that never loses a lead: calls forward to the owner's real phone, and any call that doesn't connect gets an instant text so the conversation continues in the SMS thread.

Setting it up

  1. Connect Twilio first (skip if two-way SMS already works). On Dashboard → Marketing → Settings, choose Twilio as the SMS provider and enter the Account SID, auth token, and the From number (or Messaging Service SID). Missed Call Text Back sends through these same credentials — it has no credential store of its own, and the voice webhooks authenticate against this auth token.
  2. Configure the feature on Dashboard → Inbox → Settings → Missed Call Text Back:
    • Forward calls to — the owner's real phone, with country code (+1903…). This is where calls to the Twilio number ring.
    • Ring timeout — default 20 s. Must be shorter than the forward phone's carrier-voicemail pickup (usually ~25 s): if voicemail answers, Twilio sees the call as answered and no text is sent.
    • Wait between texts — default 4 h; the same caller isn't auto-texted twice inside the window (repeat calls still log a note in the thread).
    • Text message — the auto-text; :business inserts the business name from Settings → Business.
    • Flip Text back missed calls on and save.
  3. Point the number's voice hook at the site. In the Twilio console → Phone Numbers → the number → Voice Configuration, set "A call comes in" to Webhook, HTTP POST, with the URL shown at the bottom of the settings card (https://{site}/inbox/voice/incoming). The card has a copy button. (The "A message comes in" hook should already point at the SMS webhook from the two-way SMS setup.)
  4. Test it: call the Twilio number from a mobile, let it ring past the timeout without answering. You should hear "we just sent you a text," receive the text on the calling phone, and see a 📞 Missed call note plus the auto-text in a new inbox thread for that number. Calling again immediately texts nothing (throttle) but adds another note.

The number must be SMS-capable (and A2P-registered for US traffic) or the send fails — the call is still logged and noted, the caller just hears the apology instead of the text promise.

How it decides

The number's "A call comes in" hook points at POST inbox/voice/incoming.

  • Call flow — inbox/voice/incoming (signature-verified, like the SMS webhook) answers with <Dial timeout="N" action="inbox/voice/dial-status">forward-number</Dial>. The action callback runs the decision matrix in InboxMissedCallResponder — the ONE implementation shared by every entry point.
  • The decision matrix — a text goes out only when ALL of these hold: the dial ended busy / no-answer / failed / canceled (canceled = the caller hung up mid-ring — still worth texting; completed means somebody — possibly the owner's carrier voicemail — answered), the CallSid hasn't been handled before (Twilio retries the callback; inbox_missed_calls.call_sid is unique, so a retry can't text twice), the caller ID is a real E.164 number (anonymous/withheld markers are ignored outright), the number hasn't replied STOP, and it wasn't already texted inside the throttle window (default 4 h, sent texts only — a failed send doesn't block a later attempt).
  • Ring timeout beats voicemail — the classic failure is the owner's carrier voicemail answering the <Dial>, which reads as completed: no text, and the caller is stranded in personal voicemail. The timeout (default 20 s, capped at 55) must stay shorter than the voicemail pickup; the settings page says so.
  • Thread recording — every real-caller missed call drops a 📞 Missed call system note (message direction note) into the phone's SMS thread and bumps needs-attention; when the text sent, it's recorded as the outbound message too, so the caller's reply lands in a thread that already shows the whole story. The inbox_missed_calls table is the call log + throttle source.
  • Sender continuity — the text sends from the number the caller just dialed (the callback's To), overriding a Messaging Service SID's pool choice, so the reply thread matches the call.
  • Caller-facing TwiML — "we just sent you a text" is only said when a text actually sent; skips get a brief apology; answered/duplicate callbacks end silently.
  • Voice receptionist compat (dormant) — POST inbox/voice/missed-call-event accepts the signed mothership event the receptionist will relay in combined mode (HMAC-SHA256 over timestamp.body with the per-install inbox.voice_event_secret; fails closed while unprovisioned). Its ai_engaged flag skips the text when the AI took the call, unless inbox.missed_call_always_text is set. Same responder, same matrix.

v2 capabilities

  • Whisper screening (inbox.missed_call_whisper) — whoever answers the forwarded leg hears "Incoming call for {business}. Press any key to accept" before the bridge (<Number url> → inbox/voice/whisper, Gather → whisper-connect). No keypress falls through to <Hangup/>, so a carrier-voicemail answer ends the leg and the dial reads as missed — this replaces racing the ring timeout as the voicemail defense. (The exact DialCallStatus a screened-out leg produces still needs one real-call observation.)
  • Business hours + after-hours modes — an optional HH:MM window in the site timezone (inbox.missed_call_hours_start/_end; supports midnight wrap) with three after-hours behaviors (inbox.missed_call_after_hours_mode): forward (default — ring anyway), text_first (skip the dial, text immediately; logged under the pseudo-status after-hours), and hold_texts (ring, but a missed call's text queues until the next opening — the thread note still records immediately). Held texts drain via the inbox:send-queued-missed-call-texts LazyCron (5 min), which re-checks STOP opt-outs at send time.
  • Per-number routing (inbox.missed_call_number_routes) — for multi-location installs with several Twilio numbers: each dialed number forwards to its own phone; unlisted numbers use the default; texts always send from the number the caller dialed.
  • Voicemail fallback (inbox.missed_call_voicemail) — a missed call that got no "we texted you" (and a held one) is invited to leave a message; <Record transcribe> posts the transcription to inbox/voice/transcription, which lands it in the caller's thread as an inbound "🎙 Voicemail:" message (bumps needs-attention). Never offered when the text actually sent.
  • The log + ROI card — the settings page lists the recent inbox_missed_calls rows (result badges: Texted / Queued until X / Throttled / Opted out / Send failed), and a dashboard card (visible once the feature is enabled) shows the 30-day story: missed calls caught, texted back, and how many callers replied afterwards.

The form-submission channel

Every form on the site already collected submissions; nothing in the CMS could answer one. The inbox adds that.

  • What appears — every non-spam submission, newest first. Spam is excluded outright (the Forms page has its own review queue for it), and a submission counts as needing attention while its status is still new. Opening one advances new → read, but never walks a later status backwards, so a manual replied / closed survives a second look.
  • Who sent it — resolved from the form's own field definitions: the first email-type field is the address, name/full_name (or first_name + last_name) is the display name. FormSubmission::submitterEmail() / submitterName() / scalarAnswers() are the shared accessors; answers whose field was later removed from the form still render, so a redesign never hides what a visitor actually wrote.
  • Replying emails them and records the sent copy in inbox_form_replies, then stamps the submission replied. The send happens first and the record only on success, so the pane never shows a reply that never left — the same contract the SMS channel uses. Blocked with a clear message when the form has no email field, or when no mail provider is configured.
  • Their answer does NOT come back into the CMS. A form submission is a one-shot inbound message; a reply to it lands in the site's mailbox like any transactional email. The pane says so, and points at the escalation that fixes it.
  • Open ticket (when the Ticket System is on) turns the submission into a real two-way thread — the answers ride in as an internal note and the submitter's name and email arrive pre-filled. This is the path to take when the conversation needs to continue: ticket replies come back into the CMS by email, form replies don't.

Live chat takeover

In v1 an AI chat was a read-only transcript and the only handoff was opening a ticket. Now a person can step into the conversation while the visitor is still on the page.

  • Taking over — Take over claims the conversation, and sending a reply takes it over implicitly, so a staff message can never land while the bot is still answering. Either way ai_chat_conversations.taken_over_at is stamped with the staff member on taken_over_by.
  • The bot stands down for exactly as long as that timestamp is set. The widget stops dispatching ask() after a visitor message (the message still persists — it's waiting for a human, not lost), and ask() itself re-reads the flag from the database and refuses to run. ask() is a public Livewire action, so the stand-down can't rely on client state.
  • The visitor sees it live. Staff messages are stored as role = 'staff' with the author's user_id, render in their own bubble labelled with the staff member's name, and the widget's footer disclosure switches from "Answers are AI-generated" to "A member of our team is replying."
  • Polling is scoped to visitors who are actually in a conversation. The widget emits wire:poll only once the visitor has sent at least one message — someone who never opens the chat costs nothing — at 20s while the bot is handling it (just enough to notice a human joining) and 5s once one has. A poll landing mid-stream is skipped so it can't blank a partially streamed reply.
  • Handing back clears the flag and the next visitor message wakes the bot. The staff turns stay in the transcript and are handed to the model as assistant turns (providers accept only user/assistant, and a human reply is still our side of the exchange) — so the bot picks up with the full context of what the person said.
  • Transcripts name the speaker. A chat escalated to a ticket attaches "Visitor:", "Assistant:" and the staff member's own name, rather than flattening a human reply into "Assistant".

What it deliberately doesn't do

  • No Facebook / Instagram DM channels — planned for a future version.
  • No inbound threading on form replies — a reply is an email out, not a conversation. Escalate to a ticket when you need two-way.
  • No second SMS credential store — Twilio credentials live on the Marketing settings page only.
  • No push/WebSocket delivery for chat takeover — the widget polls. That keeps it working on shared hosting with no queue worker or socket server, which is the deployment floor this CMS targets.

Key files

Piece Path
Service provider app/Features/Inbox/InboxServiceProvider.php
SMS write paths (webhook capture + staff reply) app/Features/Inbox/Support/InboxSmsActions.php
Form-reply write path app/Features/Inbox/Support/InboxFormActions.php
Submitter resolution + answer list app/Models/FormSubmission.php (submitterEmail, submitterName, scalarAnswers)
Chat takeover write path app/Features/AiChatBot/Support/ChatTakeover.php
Chat widget (stand-down + staff polling) app/Features/AiChatBot/resources/views/widget/⚡chat.blade.php
Webhook capture hook app/Features/Marketing/Http/Controllers/MarketingPublicController.php smsWebhook()
Models app/Features/Inbox/Models/ (InboxSmsThread, InboxSmsMessage, InboxFormReply)
Dashboard app/Features/Inbox/resources/views/dashboard/ (⚡index, ⚡settings)
CRM merge repoint app/Features/Crm/Support/CrmMerge.php mergeContacts()