SMS System Plan — Text messaging for Pilot
Status: Draft (2026-04-17) — pre-implementation Goal: Let tenants send/receive SMS (and MMS) from their Pilot account the same way they send emails, scoped to a company-owned Twilio number, threaded into conversations, and reachable from projects + contacts.
What already exists (audit — verified 2026-04-17)
| Piece | Table/File | State | Notes |
|---|---|---|---|
| Inbound webhook capture | sms_inbound |
1 row, basic | from_number, to_number, message_body, raw_payload, processed — no threading, no contact link |
| Per-company Twilio number | company_phone_numbers |
1 row (Air4, +18882981641 toll-free, unverified) | twilio_sid, number_type, purpose, verification_status (pending/approved for 10DLC/toll-free) |
| Per-user Twilio creds | user_email_settings.sms_* |
fields exist | BYO Twilio account path — likely deprecate for platform-managed model |
| Outbound stub (unused) | outgoing_emails.message_type='sms' |
0 rows of 304 | Dead path. AWS SNS, one-way, no threading. |
| System SMS | config/core/mfa.php::sendSMS() |
in use (login MFA) | AWS SNS, no Twilio, direct send not queued |
| Planned (not built) | airchat-mobile-app.md | design only | sms_conversations + sms_messages in Phase 3 roadmap |
Conclusion: Infrastructure is seeded but incoherent. Outbound, threading, and project/contact linking don't exist.
Architecture decision: separate SMS pipeline (NOT bolted onto email)
Reject reusing outgoing_emails for SMS threads. Reasons:
- Email queue is entity-based (one email per
entity_id); SMS is thread-based (many messages per conversation). - Email backends are SES/Gmail/SMTP; SMS backend is Twilio (already in
company_phone_numbers). - Compliance (STOP/HELP/START keywords, quiet hours, 10DLC/toll-free verification gating, opt-in audit trail) has no home in email schema.
- MMS media handling, delivery receipts, Twilio SIDs have no columns.
Keep outgoing_emails SMS stub retired. Migrate sendSMS() MFA path to the new pipeline in Phase 2.
Schema
-- Conversations / threads
CREATE TABLE sms_threads (
id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
company_phone_id INT NOT NULL, -- FK company_phone_numbers.id (our number)
peer_phone VARCHAR(20) NOT NULL, -- E.164 customer number
contact_id INT DEFAULT NULL, -- FK contacts.id, nullable
project_id INT DEFAULT NULL, -- FK crm_projects.id, nullable (last linked project)
status ENUM('active','resolved','closed','blocked') DEFAULT 'active',
opt_in_status ENUM('unknown','opted_in','opted_out') DEFAULT 'unknown',
opt_in_source VARCHAR(50) DEFAULT NULL, -- 'web_form', 'verbal', 'reply_yes', etc.
opt_in_at DATETIME DEFAULT NULL,
opt_out_at DATETIME DEFAULT NULL,
last_message_at DATETIME DEFAULT NULL,
last_direction ENUM('inbound','outbound') DEFAULT NULL,
unread_count INT DEFAULT 0,
message_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_thread (company_phone_id, peer_phone),
INDEX idx_company (company_id, status, last_message_at),
INDEX idx_contact (contact_id),
INDEX idx_project (project_id)
);
-- Individual messages (inbound + outbound)
CREATE TABLE sms_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
company_id INT NOT NULL,
direction ENUM('inbound','outbound') NOT NULL,
body TEXT,
media_urls LONGTEXT, -- JSON array of Twilio media URLs (MMS)
twilio_sid VARCHAR(64) DEFAULT NULL, -- Twilio message SID (outbound or inbound)
status ENUM('queued','sending','sent','delivered','undelivered','failed','received') DEFAULT 'queued',
error_code VARCHAR(20) DEFAULT NULL,
error_message TEXT DEFAULT NULL,
segment_count TINYINT DEFAULT 1, -- Twilio reports num_segments
price_amount DECIMAL(10,6) DEFAULT NULL,
price_currency VARCHAR(8) DEFAULT NULL,
sender_user_id INT DEFAULT NULL, -- which user sent (outbound only)
entity_type VARCHAR(40) DEFAULT NULL,-- 'project','manual','campaign','mfa', etc.
entity_id INT DEFAULT NULL,
scheduled_at DATETIME DEFAULT NULL,
sent_at DATETIME DEFAULT NULL,
delivered_at DATETIME DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_thread (thread_id, created_at),
INDEX idx_status (status, scheduled_at),
INDEX idx_twilio (twilio_sid),
INDEX idx_entity (entity_type, entity_id)
);
-- Company-level Twilio account credentials (one row per company that owns a number)
CREATE TABLE company_twilio_credentials (
company_id INT PRIMARY KEY,
account_sid VARCHAR(64) NOT NULL,
auth_token_encrypted TEXT NOT NULL, -- encrypted at rest
account_type ENUM('subaccount','byo') DEFAULT 'subaccount',
messaging_service_sid VARCHAR(64) DEFAULT NULL, -- optional Twilio Messaging Service
webhook_secret VARCHAR(64) DEFAULT NULL, -- validate inbound webhooks
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Templates (mirrors email_templates, SMS-specific)
CREATE TABLE sms_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
company_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
category VARCHAR(50) DEFAULT NULL, -- 'project','onboarding','reminder'
body TEXT NOT NULL,
variables LONGTEXT, -- JSON array of {{vars}}
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_company (company_id, is_active)
);
-- Amend sms_inbound to link into the thread once processed
ALTER TABLE sms_inbound
ADD COLUMN thread_id INT DEFAULT NULL AFTER id,
ADD COLUMN message_id INT DEFAULT NULL AFTER thread_id,
ADD INDEX idx_thread (thread_id);
Design notes:
- Thread uniqueness is
(company_phone_id, peer_phone)— if the same customer texts two of our numbers, they're two threads (correct — different business contexts). contact_idandproject_idare denormalized last-link on the thread, messages carryentity_type/entity_idfor full history.unread_countsurfaces sidebar badge without a join (same pattern as AirChat).
Backend services & files
New services
-
storage/services/scripts/sms-queue-service.php— daemon (Service Manager ID TBD, e.g. 30)- Polls
sms_messages WHERE status='queued' AND (scheduled_at IS NULL OR scheduled_at <= NOW()) - Sends via Twilio REST, stores
twilio_sid, updates status →sending→ (webhook updates tosent/delivered/failed) - Retry with exponential backoff (mirror email daemon pattern)
- Respects 10DLC/toll-free verification: if
company_phone_numbers.verification_status != 'approved', queue stays held witherror_message='number_unverified'(avoid Twilio carrier rejection) - Quiet hours honored: US rule = no marketing texts 9pm–8am recipient local time (phone-to-timezone lookup via area code or
contacts.timezone)
- Polls
-
pilot/includes/handlers/twilio-webhook-handler.php— public endpoint (validate signature)- Routes:
inbound-sms,status-callback - Inbound: upsert
sms_threads, insertsms_messages(direction='inbound'), incrementunread_count, match contact by phone, trigger in-app notification - Auto-handles
STOP/START/HELPkeywords → setsopt_in_status, sends compliance auto-reply - Status callback: update
sms_messages.status+delivered_at/error_*
- Routes:
-
pilot/includes/services/twilio-service.php—TwilioServiceclass- Wraps Twilio REST (no SDK — use curl, like the rest of the codebase)
sendMessage($companyId, $fromNumber, $to, $body, $mediaUrls=[])purchaseNumber($companyId, $areaCode, $tollFree=false)submitTollFreeVerification($companyPhoneId, $businessData)→ storesverification_sidcheckVerificationStatus($companyPhoneId)→ polls + updatesverification_status
-
pilot/includes/handlers/sms-handler.php— page/AJAX handler for SMS Management- Actions:
list_threads,get_thread,send_message,mark_read,link_contact,link_project,opt_out
- Actions:
Modified
config/core/mfa.php::sendSMS()→ route throughTwilioService::sendMessage(MFA becomes just anotherentity_type='mfa'outbound message). Retire AWS SNS.outgoing_emails.message_type='sms'→ mark deprecated; optional cleanup migration later.- Service Manager registry → add
sms-queue-service(systemd daemon). - AirChat mobile unified inbox → query
sms_threadsalongside airchat_conversations.
UI / touchpoints
Phase 1 surfaces (minimum viable)
- Project detail page — "Send Text" button next to "Send Email"
- Opens modal: pre-fills recipient from
project.primary_contact.phone, template picker, variable substitution, send - Message stored as
entity_type='project', entity_id=<project_id>, thread getsproject_idupdated
- Opens modal: pre-fills recipient from
- Contact detail page — "Send Text" button
- Same modal, recipient =
contact.phone,entity_type='contact'
- Same modal, recipient =
- SMS Management page (new, mirrors Email Management)
- Tabs: Threads (default), Sent, Templates, Numbers, Compose
- Threads tab: Air4List of
sms_threads— columns: peer, contact name, last message preview, unread badge, status, last_message_at - Row click → thread detail modal: full message history + reply input (like AirChat conversation view)
- Sent tab: Air4List of
sms_messageswhere direction=outbound (with same eye-icon view action pattern we just added to email) - Numbers tab: manage
company_phone_numbers, purchase new, submit verification
- Sidebar badge — red pill on SMS menu item when
SUM(unread_count) > 0(reusenotification-badge-handler.phppattern from AirChat)
Phase 2 surfaces
- Contact import/edit — validate + normalize phone to E.164, track opt-in source
- Airmail campaigns — add SMS channel (reuse templates, target
contactswith opted-in phones) - Quiet hours & timezone — admin settings panel
Phases / milestones
| Phase | Scope | Outcome |
|---|---|---|
| 1 | Schema migration + TwilioService + sms-queue daemon + webhook handler + STOP/HELP keywords | Can send/receive SMS end-to-end (via API only, no UI) |
| 2 | SMS Management page (Threads, Sent, Compose tabs) + sidebar badge | Can use SMS in browser |
| 3 | "Send Text" from project + contact pages + templates | Project/contact integration |
| 4 | Number management UI (purchase + toll-free verification submission) | Tenants self-serve 800 numbers |
| 5 | Airmail SMS campaigns + quiet hours + opt-in audit trail | Marketing-grade SMS |
| 6 | Retire outgoing_emails SMS stub + migrate MFA to sms_messages |
Cleanup |
Phase 1-3 is the MVP for "send texts from projects" requested in this conversation.
Compliance checklist (must-haves before Phase 2 ships)
- [ ] STOP keyword auto-handled →
opt_in_status='opted_out', auto-reply "You're unsubscribed..." - [ ] START / UNSTOP re-opt-in
- [ ] HELP keyword → configurable auto-reply per company
- [ ] Block outbound when
opt_in_status='opted_out'(hard gate, raise error) - [ ] Block outbound when
company_phone_numbers.verification_status != 'approved'for toll-free (Twilio rejects anyway) - [ ] Audit trail: every outbound has
sender_user_id, every opt-in change has source + timestamp - [ ] Twilio webhook signature validation (prevent spoofed inbound)
Open decisions (need input)
- Twilio account model — Air4 master account with per-tenant subaccounts (cleaner billing, we collect + mark up) vs BYO (tenants bring their own Twilio)?
user_email_settings.sms_*suggests BYO path was started,company_phone_numbers.twilio_sidsuggests platform-managed. Recommend platform-managed subaccounts — simpler tenant UX, enables us to resell. - Per-user vs per-company phone numbers — one shared company number (all users send from it) vs per-user numbers? Recommend per-company for v1, per-user later if needed.
- Service ID for sms-queue daemon — next available (check service-registry.md).
- Credits/storage model — should SMS consume AI credits? Or dedicated "SMS credits" counter on
companies? Twilio costs ~$0.008/SMS + ~$0.02/MMS + verification fees. - AirChat mobile integration — fold SMS threads into unified inbox from day 1 (yes, already planned).
First implementation step
Once Open Decisions #1 + #4 are answered:
- Write migration:
storage/migrations/2026-04-17-sms-system.sql(create tables above) - Scaffold
TwilioServicewithsendMessage()+ auth wiring - Scaffold
sms-queue-service.phpdaemon - Scaffold
twilio-webhook-handler.phpwith signature validation + STOP/HELP - Manual end-to-end test: queue → send → webhook status callback → verify row updates
- Then UI (Phase 2+)