# PRD — E-07: Communications — SendGrid Email & Twilio SMS Integration

## Context

HubWallet CRM needs a full communications platform covering transactional email via SendGrid, two-way SMS via Twilio, A2P 10DLC regulatory compliance, and a VoIP click-to-call power dialer. This PRD documents what already exists, what is a stub, and exactly what must be built — grounded in codebase exploration before any code is written.

---

## Current State (Codebase Audit)

### Production-Ready (Do Not Break)
- `app/Models/Masters/SiteTemplate.php` — `sendMail()` static method routes all outbound email via `Mail::send()` with template system. **All new email must preserve this call-site.**
- `app/Http/Controllers/Manages/EmailTemplateController.php` — full CRUD for email/SMS templates in `site_templates` table (template_type: 1=Email, 2=SMS).
- `app/Models/Masters/MasterLeadField.php` — Phone field type (18) already declares `dialer` and `sms` in `$fieldTypeOptions`. Email type (12) already configured.
- `app/Models/Users/UserActivityLog.php` — generic activity logger (user_id, company_id, entity_id, activity_type, activity_description). All communication events must log here.
- `app/Models/User.php` — Notification types 27 (Phone Call Missed), 29 (SMS Received), 33 (Voicemail Received), 34 (Call in Queue) already declared. Do not change IDs.
- `app/Http/Middleware/VerifyThirdParty.php` + `routes/api.php` — established webhook auth pattern using `Secret-Key` header.

### Skeleton / Stubs (To Be Implemented)
- `app/Http/Controllers/Communications/CommunicationsController.php` — class exists, all methods return empty responses.
- `resources/views/admin/communications/email-metrics.blade.php` — empty placeholder (~400 bytes).
- `resources/views/admin/communications/sms-metrics.blade.php` — empty placeholder.
- `resources/views/admin/communications/email-history.blade.php` — empty placeholder.
- `resources/views/admin/communications/email-validation.blade.php` — empty placeholder.
- Route stubs in `routes/web.php` for all four communications URLs already exist.

### Does Not Exist (Must Build)
- No SendGrid SDK, API key config, or `email_events` table.
- No Twilio SDK, credentials, `sms_conversations`, `sms_messages`, or A2P tables.
- No `call_logs` or `power_dialer_sessions` tables.
- No `email_invalid`, `email_unsubscribed`, `sms_opted_out` columns on `leads`.
- No webhook controllers for SendGrid or Twilio.
- No SMS send implementation (templates exist, no provider).
- No VoIP/dialer implementation.

---

## Database Schema

### New Tables

#### `email_events`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | |
| sg_message_id | VARCHAR(255) INDEX | X-Message-Id from SendGrid |
| entity_type | VARCHAR(50) NULL | 'lead', 'merchant' |
| entity_id | BIGINT UNSIGNED NULL | |
| template_id | BIGINT UNSIGNED NULL | FK site_templates.id |
| to_email | VARCHAR(255) NOT NULL | |
| from_email | VARCHAR(255) NULL | |
| event_type | VARCHAR(50) NOT NULL | delivered, bounce, open, click, unsubscribe |
| event_payload | JSON NULL | Full SendGrid event object |
| sg_timestamp | BIGINT NULL | Unix timestamp from SendGrid |
| opened_at / clicked_at / bounced_at / unsubscribed_at | TIMESTAMP NULL | |
| timestamps + deleted_at | | SoftDeletes |

#### `sms_conversations`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | |
| company_id | BIGINT UNSIGNED NOT NULL | FK companies.id |
| entity_type | VARCHAR(50) NULL | |
| entity_id | BIGINT UNSIGNED NULL | |
| twilio_number | VARCHAR(20) NOT NULL | E.164 |
| external_number | VARCHAR(20) NOT NULL | E.164 |
| last_message_at | TIMESTAMP NULL | |
| last_message_direction | ENUM('inbound','outbound') NULL | |
| unread_count | TINYINT UNSIGNED DEFAULT 0 | |
| status | TINYINT UNSIGNED DEFAULT 1 | 1=active, 2=archived |
| timestamps + deleted_at | | SoftDeletes |

#### `sms_messages`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | |
| conversation_id | BIGINT UNSIGNED NOT NULL | FK sms_conversations.id |
| twilio_sid | VARCHAR(100) NULL UNIQUE | |
| direction | ENUM('inbound','outbound') NOT NULL | |
| from_number / to_number | VARCHAR(20) NOT NULL | |
| body | TEXT NOT NULL | |
| status | VARCHAR(30) NULL | queued, sent, delivered, failed |
| error_code | VARCHAR(10) NULL | |
| sent_by_user_id | BIGINT UNSIGNED NULL | FK users.id |
| template_id | BIGINT UNSIGNED NULL | FK site_templates.id |
| sent_at / delivered_at / failed_at | TIMESTAMP NULL | |
| timestamps + deleted_at | | SoftDeletes |

#### `a2p_brands`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | |
| company_id | BIGINT UNSIGNED NOT NULL | |
| brand_name / ein / brand_type / vertical | VARCHAR | |
| twilio_brand_sid | VARCHAR(100) NULL UNIQUE | |
| registration_status | VARCHAR(50) NULL | PENDING, APPROVED, FAILED |
| error_message | TEXT NULL | |
| registered_at | TIMESTAMP NULL | |
| timestamps + deleted_at | | SoftDeletes |

#### `a2p_campaigns`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | |
| brand_id | BIGINT UNSIGNED NOT NULL | FK a2p_brands.id |
| company_id | BIGINT UNSIGNED NOT NULL | |
| campaign_name / use_case / description | VARCHAR/TEXT | |
| message_sample_1 / message_sample_2 | TEXT NULL | |
| twilio_campaign_sid | VARCHAR(100) NULL UNIQUE | |
| registration_status | VARCHAR(50) NULL | |
| approved_at | TIMESTAMP NULL | |
| timestamps + deleted_at | | SoftDeletes |

#### `call_logs`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | |
| company_id | BIGINT UNSIGNED NOT NULL | |
| entity_type / entity_id | VARCHAR/BIGINT NULL | |
| twilio_call_sid | VARCHAR(100) NULL UNIQUE | |
| agent_user_id | BIGINT UNSIGNED NULL | FK users.id |
| from_number / to_number | VARCHAR(20) NOT NULL | |
| direction | ENUM('inbound','outbound') NOT NULL | |
| status | VARCHAR(30) NULL | initiated, completed, failed, busy, no-answer |
| duration_seconds | INT UNSIGNED NULL | |
| recording_sid / recording_url | VARCHAR NULL | |
| disposition | VARCHAR(100) NULL | answered, voicemail, busy, no-answer, wrong-number |
| voicemail_url | VARCHAR(500) NULL | |
| started_at / answered_at / ended_at | TIMESTAMP NULL | |
| timestamps + deleted_at | | SoftDeletes |

#### `power_dialer_sessions`
| Column | Type | Notes |
|--------|------|-------|
| id | BIGINT UNSIGNED PK | |
| company_id / agent_user_id | BIGINT UNSIGNED NOT NULL | |
| name | VARCHAR(255) NULL | |
| lead_ids | JSON NOT NULL | Ordered array of lead IDs |
| current_index / total_leads / called_count | INT UNSIGNED DEFAULT 0 | |
| status | TINYINT UNSIGNED DEFAULT 1 | 1=active, 2=paused, 3=completed |
| started_at / completed_at | TIMESTAMP NULL | |
| timestamps + deleted_at | | SoftDeletes |

### Migrations on Existing Tables
```sql
-- leads: add three communication-flag columns
ALTER TABLE leads
  ADD COLUMN email_invalid      TINYINT(1) NOT NULL DEFAULT 0,
  ADD COLUMN email_unsubscribed TINYINT(1) NOT NULL DEFAULT 0,
  ADD COLUMN sms_opted_out      TINYINT(1) NOT NULL DEFAULT 0;

-- site_templates: add SendGrid dynamic template mapping
ALTER TABLE site_templates
  ADD COLUMN sg_template_id VARCHAR(100) NULL;
```

---

## Files to Create

### E-07-S01 — SendGrid Email
```
app/Services/Email/SendGridService.php
app/Http/Controllers/Communications/SendGridWebhookController.php
app/Models/Communications/EmailEvent.php
database/migrations/..._create_email_events_table.php
database/migrations/..._add_email_flags_to_leads_table.php
database/migrations/..._add_sg_template_id_to_site_templates.php
```

### E-07-S02 — Email Metrics Dashboard
```
app/Http/Controllers/Communications/EmailMetricsController.php
```
_(The four blade stubs under `resources/views/admin/communications/` are replaced in-place.)_

### E-07-S03 — Twilio SMS
```
app/Services/Sms/TwilioSmsService.php
app/Http/Controllers/Communications/TwilioSmsWebhookController.php
app/Http/Controllers/Communications/SmsConversationController.php
app/Models/Communications/SmsConversation.php
app/Models/Communications/SmsMessage.php
database/migrations/..._create_sms_conversations_table.php
database/migrations/..._create_sms_messages_table.php
resources/views/admin/communications/sms-inbox.blade.php
resources/views/admin/communications/partials/sms-thread.blade.php
```

### E-07-S04 — A2P 10DLC
```
app/Http/Controllers/Communications/A2pRegistrationController.php
app/Models/Communications/A2pBrand.php
app/Models/Communications/A2pCampaign.php
database/migrations/..._create_a2p_brands_table.php
database/migrations/..._create_a2p_campaigns_table.php
resources/views/admin/communications/a2p-registration.blade.php
```

### E-07-S05 — VoIP / Power Dialer
```
app/Services/Voip/TwilioVoipService.php
app/Http/Controllers/Communications/VoipController.php
app/Http/Controllers/Communications/TwilioVoiceWebhookController.php
app/Http/Controllers/Communications/PowerDialerController.php
app/Models/Communications/CallLog.php
app/Models/Communications/PowerDialerSession.php
database/migrations/..._create_call_logs_table.php
database/migrations/..._create_power_dialer_sessions_table.php
resources/views/admin/communications/dialer.blade.php
resources/views/admin/communications/partials/dialer-widget.blade.php
resources/views/admin/communications/call-history.blade.php
```

---

## Files to Modify

| File | Change |
|------|--------|
| `config/services.php` | Add `sendgrid` block (api_key, webhook_secret, from_email, from_name) and `twilio` block (account_sid, auth_token, from_number, twiml_app_sid) |
| `config/mail.php` | Add `sendgrid` mailer entry (SMTP to `smtp.sendgrid.net:587`, user=`apikey`, password=SENDGRID_API_KEY). Change default to `env('MAIL_MAILER', 'sendgrid')` |
| `.env.example` | Add SENDGRID_API_KEY, SENDGRID_WEBHOOK_SECRET, SENDGRID_FROM_EMAIL, SENDGRID_FROM_NAME, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER, TWILIO_TWIML_APP_SID |
| `app/Models/Masters/SiteTemplate.php` | Extend `sendMail()`: if SENDGRID_API_KEY set, delegate to `SendGridService`; otherwise fall through to existing `Mail::send()` path. Log `EmailEvent` on dispatch. |
| `app/Models/Leads/Lead.php` | Add `email_invalid`, `email_unsubscribed`, `sms_opted_out` to `$fillable`. Add `scopeEmailValid()`, `scopeSmsOptedIn()` scopes. Add `markEmailBounced()`, `markEmailUnsubscribed()`, `markSmsOptOut()` helpers (update flag + log to `UserActivityLog`). |
| `routes/web.php` | Replace four stub closures with `EmailMetricsController`. Add new routes for SMS inbox, A2P, dialer, call history, power dialer under `communications` prefix. |
| `routes/api.php` | Add webhook routes group (CSRF-exempt, no session auth): 6 Twilio + 1 SendGrid + 1 A2P status callback endpoints. |
| `resources/views/admin/layouts/layout.blade.php` | Include `dialer-widget.blade.php` partial at bottom of authenticated layout (hidden by default). |

---

## Routes

### New Web Routes (prefix: `communications`, `permission` middleware)

| Method | URI | Controller@Method | Route Name |
|--------|-----|-------------------|------------|
| GET | `/communications/email-metrics` | `EmailMetricsController@index` | `communications.email-metrics` |
| GET | `/communications/email-history` | `EmailMetricsController@history` | `communications.email-history` |
| GET | `/communications/email-validation` | `EmailMetricsController@validation` | `communications.email-validation` |
| PATCH | `/communications/email-validation/{lead}/clear` | `EmailMetricsController@clearFlag` | `communications.email-validation.clear` |
| GET | `/communications/sms-inbox` | `SmsConversationController@inbox` | `communications.sms-inbox` |
| GET | `/communications/sms/{conversation}` | `SmsConversationController@thread` | `communications.sms-thread` |
| POST | `/communications/sms/send` | `SmsConversationController@send` | `communications.sms-send` |
| GET | `/communications/sms-metrics` | `SmsConversationController@metrics` | `communications.sms-metrics` |
| GET | `/communications/a2p` | `A2pRegistrationController@index` | `communications.a2p` |
| POST | `/communications/a2p/brand` | `A2pRegistrationController@storeBrand` | `communications.a2p.brand.store` |
| POST | `/communications/a2p/campaign` | `A2pRegistrationController@storeCampaign` | `communications.a2p.campaign.store` |
| GET | `/communications/dialer` | `VoipController@index` | `communications.dialer` |
| POST | `/communications/dialer/token` | `VoipController@generateToken` | `communications.dialer.token` |
| GET | `/communications/call-history` | `VoipController@callHistory` | `communications.call-history` |
| GET | `/communications/power-dialer` | `PowerDialerController@index` | `communications.power-dialer` |
| POST | `/communications/power-dialer/session` | `PowerDialerController@startSession` | `communications.power-dialer.session` |
| PUT | `/communications/power-dialer/session/{session}` | `PowerDialerController@updateSession` | `communications.power-dialer.session.update` |

### New API Webhook Routes (no CSRF, no session auth)

| Method | URI | Controller@Method |
|--------|-----|-------------------|
| POST | `/api/webhooks/sendgrid` | `SendGridWebhookController@handle` |
| POST | `/api/webhooks/twilio/sms` | `TwilioSmsWebhookController@handle` |
| POST | `/api/webhooks/twilio/sms-status` | `TwilioSmsWebhookController@statusCallback` |
| POST | `/api/webhooks/twilio/voice` | `TwilioVoiceWebhookController@handle` |
| POST | `/api/webhooks/twilio/voice-status` | `TwilioVoiceWebhookController@statusCallback` |
| POST | `/api/webhooks/twilio/recording` | `TwilioVoiceWebhookController@recordingCallback` |
| POST | `/api/webhooks/twilio/a2p-status` | `A2pRegistrationController@brandStatusCallback` |

Webhook authentication: create `VerifySendGridWebhook` middleware (ECDSA signature check) and `VerifyTwilioWebhook` middleware (HMAC-SHA1 using `Twilio\Security\RequestValidator`) — register both in `app/Http/Kernel.php`.

---

## Story-by-Story Implementation Details

### E-07-S01-T01: Install SendGrid SDK + configure SENDGRID_API_KEY
1. `composer require sendgrid/sendgrid`
2. Add `sendgrid` block to `config/services.php`
3. Add `sendgrid` mailer to `config/mail.php` (SMTP to `smtp.sendgrid.net:587`)
4. Add all keys to `.env.example`

### E-07-S01-T02: Route all CRM outbound emails through SendGrid
1. Set `MAIL_MAILER=sendgrid` in `.env`
2. Update `SiteTemplate::sendMail()` to use `SendGridService` when `SENDGRID_API_KEY` is set; fall back to `Mail::send()` otherwise
3. `SendGridService::send()` generates a UUID `sg_message_id`, sends via SDK, creates `EmailEvent` row with `event_type = 'dispatched'`

### E-07-S01-T03: Build SendGrid delivery webhook
1. Migration: `email_events` table
2. `EmailEvent` model with `recordFromSgPayload(array $payload)` static method (upsert by sg_message_id + event_type)
3. `SendGridWebhookController@handle`: verify ECDSA signature, iterate payload array, call `EmailEvent::recordFromSgPayload()` per event
4. Register `POST /api/webhooks/sendgrid` in `routes/api.php`

### E-07-S01-T04: Handle hard bounces
1. Migration: add `email_invalid` to `leads`
2. In `SendGridWebhookController`, on `bounce` event with `bounce_classification = 'Invalid address'` (hard bounce): call `Lead::markEmailBounced($email)`
3. `Lead::markEmailBounced()`: find lead by email → set `email_invalid = 1` → log to `UserActivityLog`
4. Show red "Invalid Email" badge in lead email field (Blade: check `$lead->email_invalid`)

### E-07-S01-T05: Handle unsubscribes
1. Migration: add `email_unsubscribed` to `leads`
2. In `SendGridWebhookController`, on `unsubscribe` event: call `Lead::markEmailUnsubscribed($email)`
3. `SiteTemplate::sendMail()`: before sending, check `email_unsubscribed` on the resolved lead; skip if true for non-transactional templates

### E-07-S02-T01: Email Metrics KPI cards
- `EmailMetricsController@index()`: aggregate `email_events` by `event_type`; compute Open Rate, Click Rate, Bounce Rate, Unsubscribe Rate as percentages
- `email-metrics.blade.php`: use `.project-box` pattern (match `resources/views/admin/leads/sales-metrics.blade.php`). Cards: Sent, Delivered, Opened, Clicked, Bounced, Unsubscribed. Add Chart.js line chart for sends-per-day.

### E-07-S02-T02: Email performance by template table
- Group `email_events` by `template_id`; join `site_templates.template_name`
- Table columns: Template Name, Sent, Open Rate, Click Rate, Bounce Rate

### E-07-S02-T03: Hard vs soft bounce chart
- Query `email_events WHERE event_type = 'bounce'`; distinguish hard (classification: invalid) vs soft (other)
- Chart.js bar chart grouped by date period; trend lines using two datasets

### E-07-S03-T01: Install Twilio SDK + configure credentials
1. `composer require twilio/sdk`
2. Add `twilio` block to `config/services.php`
3. Add keys to `.env.example`

### E-07-S03-T02: Build SMS send function
1. Migrations: `sms_conversations`, then `sms_messages`
2. `TwilioSmsService::send()`: instantiates `Twilio\Rest\Client`, calls `messages->create()`, creates `SmsMessage` record (direction=outbound), updates `SmsConversation` `last_message_at`
3. `SmsConversation::findOrCreateForNumbers()` static helper
4. `Lead::getSmsPhoneNumberAttribute()`: query `lead_field_values` for phone field flagged `sms=true` in `MasterLeadField::$fieldTypeOptions`

### E-07-S03-T03: Build Twilio inbound SMS webhook
1. `TwilioSmsWebhookController@handle()`: validate `X-Twilio-Signature` via `Twilio\Security\RequestValidator`; find/create conversation; create inbound `SmsMessage`; fire notification type 29; respond with empty `<Response/>` TwiML
2. `statusCallback()`: update `SmsMessage` status, `delivered_at`, `failed_at` by `twilio_sid`

### E-07-S03-T04: Two-way SMS conversation view
- `SmsConversationController@inbox()`: conversations ordered by `last_message_at DESC` with unread badges
- `SmsConversationController@thread()`: load messages; render `partials/sms-thread.blade.php` (chat bubble layout, direction-dependent alignment, timestamp, delivery status icon)
- Real-time updates via Pusher channel `sms.{company_id}`

### E-07-S03-T05: SMS opt-out (STOP keyword)
- In `TwilioSmsWebhookController@handle()`: detect STOP/UNSUBSCRIBE/CANCEL/END/QUIT (case-insensitive); call `Lead::markSmsOptOut($number)`; respond with Twilio-required confirmation TwiML
- `TwilioSmsService::send()`: check `sms_opted_out` before sending; block and log if opted out

### E-07-S03-T06: SMS Metrics page
- `SmsConversationController@metrics()`: aggregate from `sms_messages` (sent, delivered, failed, inbound, opted-out count)
- `sms-metrics.blade.php`: KPI cards using `.project-box` pattern

### E-07-S04-T01: A2P Brand Registration form
1. `A2pRegistrationController@storeBrand()`: validate → create `a2p_brands` row (status=PENDING) → call Twilio TrustHub API → store `twilio_brand_sid`
2. `a2p-registration.blade.php` step 1: brand form fields (legal name, EIN, brand type, vertical); show status badge

### E-07-S04-T02: A2P Campaign Registration form
1. `A2pRegistrationController@storeCampaign()`: validate → create `a2p_campaigns` row → call `Messaging.a2p_brands.campaigns.create()` → store `twilio_campaign_sid`
2. Form available only when brand `registration_status = APPROVED`

### E-07-S04-T03: A2P status tracking UI — block SMS on non-Approved
1. `brandStatusCallback()` in `A2pRegistrationController`: receive Twilio async status webhook → update `a2p_brands.registration_status`
2. In `TwilioSmsService::send()`: check `a2p_campaigns` for company; if no APPROVED campaign, throw exception and return error
3. UI: show status badge; disable SMS send button with tooltip when campaign not APPROVED

### E-07-S05-T01: Twilio Voice click-to-call
1. `TwilioVoipService::generateToken()`: `Twilio\Jwt\AccessToken` with `VoiceGrant` (TwiML App SID); TTL 3600s
2. `VoipController@generateToken()`: return JWT as JSON
3. `dialer.blade.php`: include Twilio Client JS SDK; fetch token on load; wire `Twilio.Device.connect()`, `disconnect`, `error` events; dial pad UI; show lead info in power-dialer mode
4. Add call button to lead/merchant detail pages that opens the dialer widget

### E-07-S05-T02: Call logging
1. `TwilioVoiceWebhookController@handle()`: validate signature; return TwiML `<Dial>` to connect agent to called number
2. `statusCallback()`: update `CallLog` status, `duration_seconds`, `answered_at`, `ended_at`; log to `UserActivityLog` with lead reference
3. `call-history.blade.php`: paginated table (Date, Agent, Lead, Direction, Status, Duration, Recording link)

### E-07-S05-T03: Call disposition modal
- After `Twilio.Device` fires `disconnect`, show modal: Connected / Voicemail / No Answer / Busy / Wrong Number
- POST to `VoipController@saveDisposition()` (new route): update `CallLog.disposition`; if Voicemail, trigger voicemail drop if selected

### E-07-S05-T04: Power dialer mode
1. `PowerDialerController@startSession()`: accept lead ID array → create `PowerDialerSession` → redirect to dialer with `session_id`
2. `PowerDialerController@updateSession()`: advance `current_index`, update `called_count`; return next lead data as JSON
3. Dialer UI: when `session_id` present, show current lead info panel; auto-dial next lead after disposition saved

### E-07-S05-T05: Voicemail drop
1. Agents can upload pre-recorded voicemail files (new simple upload endpoint on `VoipController`)
2. On disposition = "Voicemail": if agent selected a recording, call Twilio API to play the recording to the voicemail system; store `voicemail_url` on `CallLog`

---

## Integration Points

| New Component | Integrates With | How |
|---------------|-----------------|-----|
| `SendGridService` | `SiteTemplate::sendMail()` | Conditional delegation — preserves all existing call-sites |
| `TwilioSmsService` | `Lead::getSmsPhoneNumberAttribute()` | Uses dynamic field system to find the SMS-enabled phone field |
| Webhook controllers | `VerifyTwilioWebhook` / `VerifySendGridWebhook` middleware | New middleware classes following `VerifyThirdParty` pattern |
| All write ops | `UserActivityLog` | Every flag change, call end, SMS send logs via existing logger |
| VoIP notifications | `User.php` notification type IDs 27, 29, 33, 34 | Already declared — dispatch using existing notification helper |
| Dialer widget | Master layout `layout.blade.php` | Injected as hidden partial, visible on demand |
| SMS opt-out / bounce | `Lead` model | `markEmailBounced()`, `markEmailUnsubscribed()`, `markSmsOptOut()` helpers |

---

## Packages Required

```bash
composer require sendgrid/sendgrid
composer require twilio/sdk
```

---

## Verification Steps

### S01 — SendGrid Email
1. Set `SENDGRID_API_KEY` → send an existing template email → confirm `email_events` row with `event_type=dispatched`
2. POST bounce webhook to `/api/webhooks/sendgrid` → confirm `email_invalid=1` on lead
3. POST unsubscribe webhook → confirm `email_unsubscribed=1` on lead
4. Remove `SENDGRID_API_KEY` → confirm emails still send via SMTP fallback

### S02 — Email Metrics
1. `/communications/email-metrics` — KPI cards show correct counts
2. `/communications/email-history` — pagination works; rows link to leads
3. `/communications/email-validation` — invalid leads listed; clear flag removes row

### S03 — Twilio SMS
1. Send SMS from UI → `sms_messages` row created → visible in Twilio dashboard
2. Replay inbound webhook → inbound `SmsMessage` created; `unread_count` increments
3. Send `STOP` inbound → `sms_opted_out=1` on lead; TwiML response confirms opt-out
4. `/communications/sms-metrics` — aggregates match `sms_messages` data

### S04 — A2P 10DLC
1. Submit brand form → `a2p_brands` row with `PENDING` status
2. POST A2P status webhook with `APPROVED` → local record updates
3. Campaign form unavailable until brand `APPROVED`
4. SMS send blocked (error returned) when no `APPROVED` campaign exists

### S05 — VoIP / Power Dialer
1. `/communications/dialer/token` → valid JWT with `grants.voice` (verify at jwt.io)
2. Initiate outbound call → `call_logs` row with `status=initiated` → status callback updates `duration_seconds` and `status=completed`
3. Power dialer: create session with 3 leads → `current_index` advances after each disposition → session `status=completed` after last call
4. Missed call status callback → notification type 27 dispatched to agent

---

## Critical Files

- `app/Models/Masters/SiteTemplate.php` — single integration point for all outbound email; extend `sendMail()` here
- `app/Models/Leads/Lead.php` — add three flag columns and helper methods
- `config/services.php` — all third-party credentials configured here
- `routes/api.php` — all webhook routes (CSRF-exempt) registered here
- `app/Http/Controllers/Communications/CommunicationsController.php` — skeleton to be replaced by dedicated controllers
