# Events · Funnel Automation — email, SMS, voice calls & Zoom emails (Manage)

**Portal:** Manage · **Home:** the funnel hub's **Automation** tab (`/manage/events/funnels/{uuid}` → *Automation*, `?tab=automation`) · **Routes:** `manage.events.funnel-automation.*` · **Command:** `funnel:run-automation-reminders`

## What it does

The funnel hub's **Automation** tab (formerly *WhatsApp*) is the one place for **everything that happens around a funnel's events**, laid out per slot as the registrant's **five-step journey timeline**:

> **① When they register → ② Before each session → ③ After it starts → ④ During the webinar (the offer) → ⑤ After it ends.**

Steps ①②③⑤ hold **message rules** across all channels (below); step ④ holds the slot's **offers** — which payable the webinar pitches, riding which WhatsApp CTA link:

- **Offers (`event_series_offers`, [EventSeriesOffer](/src/Event/EventSeriesOffer.php))** — slot × **payable** × [`WhatsappCtaLink`](/src/Whatsapp/WhatsappCtaLink.php). The payable is polymorphic (`offerable_type`/`offerable_id`) over the payment module's trio — an ACTIVE [Membership tier](/docs/modules_handbook/manage/membership/memberships/readMe.md), an ACTIVE Project (fee) or a sellable-visibility [LMS Course](/docs/modules_handbook/manage/lms/readMe.md) — the same three things [`purchase_histories`](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md) records money against, so what a webinar pitches is always something a payment can land on. (It briefly targeted the retired Product wrapper — re-pointed when `2026_07_28_200003` dropped it.) The CTA link is the whole delivery mechanism (it already existed globally under Messages → CTA Links): its **short URL is pasted into the Zoom chat** during the pitch, a tap opens WhatsApp pre-filled, the inbound match records a **capture attributed to the live session**, and the link's **flow is the offer's automation**. The offer card surfaces the full pipeline — payable (kind badge + price) → link (copy button) → flow (amber `No automation flow` when missing) → captures count across the slot's sessions — so broken wiring is visible before a webinar runs. CRUD: `manage.events.funnel-offers.*` ([FunnelOffersController](/app/Http/Controllers/Manage/Events/FunnelOffersController.php) + [EventSeriesOfferRepository](/src/Event/Repositories/EventSeriesOfferRepository.php)); one live offer per (slot, payable); editing only swaps the CTA link (payable change = delete + re-add, keeping capture history honest).
  - **The Slots tab MIRRORS this, read-only** (2026-08-10). Product decision: the offer belongs conceptually to the slot — it is keyed on `event_series_id`, and "what does this slot sell?" is a question asked while looking at the slot — but it stays **authored here**, because it is step ④ of a five-step journey whose step ⑤ chases the same pitch, and a second create/edit path would fork the writer. So `FunnelsController::show()`'s existing slot-uuid-keyed `slotOffers` prop is now ALSO handed to `SlotsTab.vue`, which renders chips in the Slot cell and a block in the expand row, with a *Manage offers* link to **`?tab=automation&scope={slot uuid}`**. `AutomationTab.vue` therefore seeds `activeScope` from **`?scope=`** and mirrors it back with `history.replaceState` (no navigation), the same way `ShowTabs` owns `?tab=` — otherwise the deep link would land the reader on the funnel scope to hunt for the slot again.
  - **The offer names a PAYMENT LINK, and that is what makes the money countable** (`payment_link_id`, migration `2026_08_10_200001`). Naming the payable says what is *pitched*; naming the link is what lets the money be *recognised* — `purchase_histories.payment_link_id` is already recorded on 326 of the 338 rows in production, so a purchase through that link is an exact join back to the offer. The payable alone would only ever be a guess, since two links can sell the same tier at different prices. Nullable, and the modal warns when it is empty: without it a sale can still be credited to a session by attendance, but it cannot be told apart from a **cross-sell**, so "did the pitch work?" stays unanswerable.
  - **A session freezes its own copy** in **`event_offers`** (migration `2026_08_10_200002`, model [EventOffer](/src/Event/EventOffer.php), written by [EventOfferRepository::syncFor()](/src/Event/Repositories/EventOfferRepository.php)). The slot offer stays editable forever; reading it to explain a session that already ran would let July's conversion rate rewrite itself every time somebody edits today's. The snapshot **tracks the live offer until the session is PAST** — an admin genuinely can attach the link minutes before going on air — and is immutable from then on. Same lesson `event_registrations` learned when it started snapshotting the Meta ad ids at join time. The freeze is taken by **[`SnapshotSessionOffers`](/app/Jobs/Event/SnapshotSessionOffers.php)**, dispatched by the `webinar.ended` webhook beside the call that marks the session Completed — the same authoritative "this is over" signal. ⚠️ It was originally taken lazily by the Ads tab, which made a **GET endpoint write on every view and every grain switch** and tied recording a historical fact to the unrelated moment a human opened a page; the read path is now `EventOfferRepository::resolveFor()`, which returns the frozen rows or — for a session that has not ended, or one predating the feature — derives the display from the live slot offer and flags `frozen: false`. Pinned by `test_the_ad_insights_endpoint_never_writes` and `test_the_snapshot_job_is_what_freezes_the_offer`.
  - ⚠️ **An offer is still not DELIVERED by anything** — it is read (the Ads tab's session-revenue figures, since 2026-08-10) but never acted on: pasting the CTA short URL into the Zoom chat is a human action, and the payment link does not yet ride the wrap-up message as a `{{payment_link}}` token. Until one of those exists, an offer only measures a pitch somebody made by hand. The table is also still empty on the production snapshot, so every number built on it is untested against real data.
- **Step ⑤ (`TRIGGER_AFTER_ENDED = 4`, on BOTH rule tables)** — timed from when the webinar **ACTUALLY ends**: `zoom_webinars.ended_at`, stamped once by the `webinar.ended` webhook (`ZoomWebinarRepository::markEnded`), never the scheduled `end_time` a live session can run far past. The scanners HOLD while the webinar is still LIVE (or never started, unless the session was marked Completed); a physical session falls back to its scheduled end once passed. Timing math in `SessionDueWindow::candidateEndedSessions()` (calendar window widened by `OVERRUN_SLACK_MINUTES` = 12h) + `endedAt()`.

The message channels, mixed as rows in every rule step:

1. **WhatsApp** (to each registrant, or one post into the funnel's group) — the original machinery, **unchanged**: own tables, routes (`manage.events.funnel-whatsapp.*`), scanner (`whatsapp:run-funnel-reminders`) and modal. Full docs in [Funnel WhatsApp automation](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md).
2. **Email** at admin-defined times — subject + free-form body with the same `{{token}}`s, delivered through the provider-agnostic `EmailSender` (SMTP today; a deliverability provider such as GetResponse slots in by swapping the binding — `FUNNEL_EMAIL_DRIVER`).
3. **SMS** at admin-defined times (default preset: **15 min before** a session) via the shared [`SmsSender`](/docs/modules_handbook/shared/sms/readMe.md) (SMS360). **Every send costs money** — the composer shows a live billed-segment counter, the body is capped at 3 GSM segments (459 chars), and test sends are confirmed + rate-limited.
4. **Voice calls (`MEDIUM_VOICE = 3`, added 2026-08-07)** — an **AI-voiced phone call**: the rule's `body` is the SCRIPT (same `{{token}}`s, rendered per send), synthesised by Gemini TTS in the rule's catalogue voice (`voice_id`, `config('video.voices')` — 中文/EN voices shared with AI Video) and played down a Twilio call via the shared [`VoiceCaller`](/docs/modules_handbook/shared/voice/readMe.md). Key facts: **audio is cached per (rule, session, rendered script)** — a token-less script costs ONE synthesis per session; **quiet hours 09:00–21:00** (`Skipped(quiet_hours)` outside — the scanner runs 24h, the guard is what keeps a 3am catch-up from ringing anyone); **voicemail is hung up on** (answering-machine detection → `FAILED(voicemail)`, never a billed playback to a mailbox); the **status webhook** rewrites the ledger honestly (`no_answer` / `busy` / `call_failed`; a human keeps SENT + `call_duration` in `meta`). Script capped at 700 chars (~90s speech); the natural home is the **no-show nudge** and the high-value follow-up — WhatsApp does the bulk reminders for free. Ledger columns added by `2026_08_07_100001`: `voice_id` on rules, `provider_call_id` + `meta` on sends. Voice also slots into the same welcome / backfill / test machinery (test = a real rate-limited call to the admin's typed number, confirmed first).
5. **AI calls (`MEDIUM_AI_CALL = 4`, added 2026-08-19)** — a **real-time two-way AI conversation** with the lead, taken by an [AI call profile](/docs/modules_handbook/shared/voice-agent/readMe.md) (Phone Call → AI Profiles). The rule holds NO script — the profile carries the prompt + knowledge; the rule decides **when** (trigger/offset) and **with which profile** (`ai_call_profile_id`). Three behaviours differ from the one-way voice medium, all deliberate: (a) a WELCOME's `offset_minutes` is the **delay** — "call X minutes after registering" (0 = immediate, the speed-to-lead play; the only welcome medium where the offset means anything); (b) **quiet hours DEFER instead of skip** — a call due outside 09:00–21:00 re-queues itself for the next 09:00 (`SendFunnelAiCall` self-dispatches with a delay; the ledger row stays QUEUED), because an AI follow-up is not time-coupled the way a "class starts now" playback is; (c) **test + backfill are refused** for this medium — the test is a live conversation (it lives on the profiles page, throttled), and a backfill would robo-dial every registrant in one burst. The funnel tokens ride into the call as Retell dynamic variables (`{{name}}`, `{{event_title}}`, … + `lead_name` alias). Outcomes: ledger SENT/FAILED with `meta.ai_voice_call_uuid` linking to the [ai_voice_calls](/docs/modules_handbook/shared/voice-agent/readMe.md) record (transcript, recording, analysis on Phone Call → AI Calls). Billed per minute by Retell AND Twilio (~RM1/min all-in) — the `UNIQUE(message, lead, event)` claim is the cost ceiling per rule.
6. **Zoom's own registrant emails — ALWAYS ON (2026-08-07)** — every app-created webinar is created with reminder **1 day + 1 hour** before (Zoom's additive `type` 3) and follow-ups **1 day after** to **attendees + absentees**, hardcoded in `CreateSessionWebinarAction::emailSettings()` (now a constant payload). The old four per-slot toggles are **gone** (endpoint, `ZoomEmailsRequest`, repo method, columns — migration `2026_08_07_200002`): with all four off — the default — each new webinar was explicitly created with `enable: false`, silently switching OFF reminders the Zoom account itself would have sent. The Automation tab's `ZoomEmailCard` is now purely **informational** ("Always on"). These are Zoom's own English emails from Zoom's own address — the free **backup layer** under the rules above, not a replacement (fixed times only, content not editable, adopted webinars keep whatever is configured at Zoom, and a `…@notfound.com` placeholder-email lead receives nothing useful). **`zoom:push-email-settings`** (one-off artisan) queues `PushWebinarEmailSettings` per Zoom slot to catch up webinars minted before the change — run it once after deploy.

**VSL video-engagement triggers (2026-08-19, VSL funnels only).** Two more triggers pivot on the WATCH RECORD instead of sessions: `TRIGGER_VIDEO_NOT_WATCHED = 6` (opted in `offset_minutes` ago, never reached `video_percent` — including never opening the video) and `TRIGGER_VIDEO_WATCHED = 7` (reached `video_percent`, then went QUIET for `offset_minutes` — anchored on `funnel_video_views.last_seen_at` so nobody is called mid-video). Scanner: [`RunFunnelVslTriggers`](/app/Console/Commands/RunFunnelVslTriggers.php) (`funnel:run-vsl-triggers`, every 5 min). Two structural guards: **one send per (rule, lead), ever** — via `claimVideoTriggerSend()`'s firstOrCreate (the unique index can't enforce it: standalone sends have `event_id` NULL and MySQL lets NULLs repeat) — and **no backfill by design**: only opt-ins/activity AFTER the rule was created count, so adding a rule over an old funnel never burst-messages (or burst-CALLS) history. The Automation tab shows these in a VSL-only **Video engagement** group; v1 UI wires the AI-call medium (backend supports all mediums).

Email/SMS rules share the WhatsApp rules' trigger model exactly: **welcome** (on registration; funnel-level or slot-level, exactly ONE scope fires per registration **per medium** — a slot with its own email welcome but only a funnel SMS welcome gets the slot email + the funnel SMS) and **session-timed** rules (before/after, slot-scoped, unsigned `offset_minutes`, direction on the trigger).

## How it works

### Data model (2 tables)

- **`funnel_automation_messages`** ([FunnelAutomationMessage](/src/Event/FunnelAutomationMessage.php), key model: uuid + blame + soft delete) — one email/SMS/voice rule: `event_funnel_id`, `event_series_id` (slot scope, same semantics as WhatsApp), **`medium`** (`MEDIUM_EMAIL = 1` / `MEDIUM_SMS = 2` / `MEDIUM_VOICE = 3` / `MEDIUM_AI_CALL = 4`), `ai_call_profile_id` (AI-call only), `trigger` (same values as the WhatsApp model), `offset_minutes`, `subject` (email only), `body` (a voice rule's body is its SCRIPT), `voice_id` (voice only), `is_active`. WhatsApp rules deliberately stay on their own table — their template / steps / group machinery doesn't generalise, and a merged table would grow medium branches through every WhatsApp code path.
- **`funnel_automation_sends`** ([FunnelAutomationSend](/src/Event/FunnelAutomationSend.php), append-only ledger, no uuid/blame) — one row per delivery: rule / lead / `event_id` (null = welcome), **`to`** (snapshot of the address/phone actually used), `status` (Queued/Sent/Skipped/Failed) + `skip_reason`, `provider_call_id` + `meta` (voice call bookkeeping), `sent_at`. Same **`UNIQUE(message, lead, event)`** de-dupe trick as the WhatsApp ledger. Unlike WhatsApp there is **no delivery-receipt feed** — SMTP reports "accepted", SMS360 an accepted bool — so the ledger status IS the outcome ("reached" in the UI = accepted by the transport; there is no read count); voice is the exception in the failure direction, since its status webhook rewrites a no-answer/busy/voicemail call to FAILED.
  - `SKIP_*`: `no_email` · **`placeholder_email`** (a manufactured `…@notfound.com` address from `LeadLinker` — sending would only bounce and hurt the domain) · `no_phone` · `missing_config` · `empty_content` · `opted_out` (**reserved** — see *Deferred* below) · voice: `quiet_hours` / `tts_failed`, and FAILED reasons `no_answer` / `busy` / `voicemail` / `call_failed`.

*(The four `event_series` zoom-toggle booleans that used to sit here were dropped by `2026_08_07_200002` — Zoom's registrant emails are always-on, see bullet 5 above.)*

### Sending

- **Welcome (inline):** [`DispatchFunnelWelcomeAction`](/app/Actions/DispatchFunnelWelcomeAction.php) now fires all three mediums from the one `RegisterLeadAction` call-site — the shared `chooseScope()` picks slot-beats-funnel **per medium**, ledger row → [`SendFunnelEmailMessage`](/app/Jobs/Automation/SendFunnelEmailMessage.php) / [`SendFunnelSmsMessage`](/app/Jobs/Automation/SendFunnelSmsMessage.php) on the **default** lane.
- **Session-timed (scanner):** [`RunFunnelAutomationReminders`](/app/Console/Commands/RunFunnelAutomationReminders.php) (`funnel:run-automation-reminders`, every 5 min, `withoutOverlapping`) mirrors the WhatsApp scanner's loop via the extracted [`SessionDueWindow`](/src/Event/Support/SessionDueWindow.php) (candidate sessions, exact start instant, the 60-min catch-up band — extracted so the two scanners can never drift; `RunFunnelReminders` still carries its own copy, an optional later cleanup). Sends ride **`redis-broadcast`** (paced, low priority) so a big session never bursts SMTP or the billed SMS gateway. A BEFORE rule targets `STATUS_REGISTERED` only; an AFTER rule ignores registration status (attendance reconciliation rewrites every row shortly after a session ends).
- **Tokens:** ONE engine — [`FunnelWhatsappComposer`](/src/Event/Support/FunnelWhatsappComposer.php) gained public `variablesForAutomationSend()` + `render()`, so `{{name}}` / `{{event_title}}` / `{{event_location}}` / `{{login_link}}` / `{{ticket_link}}` … resolve identically across all mediums. The email body is wrapped in [`emails/funnel/automation.blade.php`](/resources/views/emails/funnel/automation.blade.php) (logo + card + linkified URLs; subject rendered separately). **SMS caveat:** the length cap validates the *raw template* — tokens can push the rendered message past a segment boundary.
- **Email transport:** [`EmailSender`](/src/Common/Email/EmailSender.php) interface (the email twin of `SmsSender`) → [`SmtpEmailSender`](/src/Common/Email/SmtpEmailSender.php) via `Mail::html()` on the app's default mailer (the `failover` chain — primary SMTP, then the `.env` backup; see the [Email](/docs/modules_handbook/shared/email/readMe.md) handbook. `MAIL_MAILER=log` = free local no-send). Binding in `AppServiceProvider` keyed on `config('services.funnel_email.driver')`; `services.getresponse.*` is reserved for the future driver.

### Zoom's own emails — always on

- [`CreateSessionWebinarAction::emailSettings()`](/app/Actions/CreateSessionWebinarAction.php) is a **constant payload** since 2026-08-07: reminders `type` 3 (additive — 1 = 1 hour + 2 = 1 day) and both follow-ups `type` 1 (days-after), all `enable: true`. Applied at webinar **creation** unconditionally; **adopted/discovered webinars are never mutated at Zoom**.
- There is **no endpoint / repo method / columns any more** (removed with migration `2026_08_07_200002` — see bullet 5 above for the why). [`PushWebinarEmailSettings`](/app/Jobs/Zoom/PushWebinarEmailSettings.php) survives as the **catch-up job**, dispatched per Zoom slot by the one-off **`zoom:push-email-settings`** command — run once after deploy so webinars minted under the old force-off settings turn their reminders on.
- **ADOPTED webinars are covered too — a deliberate, narrow exception** to the module's "never mutate a webinar we did not create" rule. It is the common case in practice (a session linked to a webinar somebody scheduled in Zoom's own UI), and three things make it safe: we **already** write to adopted webinars (`SyncWebinarRegistrants` adds our leads as registrants, with no source guard), the change is **additive and reversible** at Zoom (nothing deleted / rescheduled / renamed — teardown still skips discovered webinars), and a linked webinar is one an admin deliberately pulled into a funnel. Skipping it would leave exactly the registrants we just pushed with **no reminder at all**. Mechanics: the job takes `includeAdopted` (default **false**, so the rule stays the default and every exception is visible at its call site); `zoom:push-email-settings` passes **true** and offers `--app-only` to opt back out; and **[`LinkSessionWebinarAction`](/app/Actions/LinkSessionWebinarAction.php) now dispatches it on every link**, so a newly adopted webinar gets the settings without anyone remembering the command.

### HTTP layer

`FunnelAutomationController` (uses `ResolvesFunnel` — the funnel is posted, `{id}` is the rule uuid) + Form Requests in `app/Http/Requests/Manage/Events/FunnelAutomation/`: `store`/`update` (per-medium content rules: email → subject + body; SMS → body ≤ 459 chars, **no** subject; voice → script ≤ 700 chars + catalogue `voice_id`, no subject), `toggle`, `destroy`, **`test`** (`preview=true` renders subject/body only; a real email test defaults to the admin's own address; a real SMS **or voice** test needs a typed phone and is rate-limited **5 per 10 min per admin** on top of the route throttle — each one is billed), **`backfill`** ([BackfillFunnelAutomationWelcomeAction](/app/Actions/BackfillFunnelAutomationWelcomeAction.php) — the WhatsApp back-fill's twin, "already had it" judged **within the rule's medium**; same **recipient picker** contract — `preview` returns `recipients[]` capped at `RECIPIENT_LIST_CAP`, and a posted `recipients` list is INTERSECTED with the resolved audience, never trusted as it, see the [WhatsApp handbook](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md) → *Pick WHO*). `FunnelsController::show()` merges the WhatsApp + automation rules into ONE **`automationRules`** prop (each row tagged `medium`) + a **`zoomEmailSettings`** map (now just *which slots are Zoom-managed* — the card is informational).

### Frontend

`Funnels/Show.vue` tab `{ key: 'automation', label: 'Automation', icon: Zap }` → [`Partials/Tabs/AutomationTab.vue`](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/AutomationTab.vue) (renamed from `WhatsappTab.vue`). The old five copies of the rule-row markup are extracted into [`Partials/Automation/RuleRow.vue`](/resources/js/Pages/Manage/Events/Funnels/Partials/Automation/RuleRow.vue) (medium badge — Email indigo / SMS orange / WhatsApp emerald — + context badge + preview + stat line + actions). Adding a rule goes through [`AddRuleMenu.vue`](/resources/js/Pages/Manage/Events/Funnels/Partials/Automation/AddRuleMenu.vue) (Email / SMS / WhatsApp picker — the WhatsApp item disables with a "connect a number first" hint, so the tab itself is no longer gated on a channel). Per-medium modals: the untouched `WhatsappRuleFormModal`, [`EmailRuleFormModal`](/resources/js/Pages/Manage/Events/Funnels/Partials/EmailRuleFormModal.vue) (live inbox-style preview) and [`SmsRuleFormModal`](/resources/js/Pages/Manage/Events/Funnels/Partials/SmsRuleFormModal.vue) (segment counter + cost note), sharing [`RuleForm/ScheduleFields.vue`](/resources/js/Pages/Manage/Events/Funnels/Partials/RuleForm/ScheduleFields.vue) + [`RuleForm/TokenPicker.vue`](/resources/js/Pages/Manage/Events/Funnels/Partials/RuleForm/TokenPicker.vue). [`ZoomEmailCard.vue`](/resources/js/Pages/Manage/Events/Funnels/Partials/Automation/ZoomEmailCard.vue) sits above group ① on each Zoom-managed slot's pane — since 2026-08-07 a purely **informational "Always on" card** (no toggles, no requests). `TestSendModal.vue` is medium-aware (recipient input + preview variant switch per medium; **SMS test confirms via `ConfirmModal`** before sending). `WhatsappBackfillModal.vue` serves all mediums (endpoint switches on `rule.medium`; an SMS run shows a billed-count warning).

Old `?tab=whatsapp` links fall back to the first tab (ShowTabs ignores unknown keys) — accepted.

## Deferred (documented, not built)

- **Email unsubscribe** — a `List-Unsubscribe` header + an opt-out flag honoured as `SKIP_OPTED_OUT` (the constant is already in the ledger vocabulary). Funnel sends are registration-scoped lifecycle messages, so v1 ships without; revisit before any marketing-flavoured use.
- **SMS STOP** — SMS360 has no inbound path today; genuinely blocked, not skipped.
- **GetResponse driver** — needs an account + API key; implement `EmailSender` and add it to the `AppServiceProvider` match. The key + driver switch are already admin-configurable on **Messages → Settings → Delivery APIs** (below), so no .env change will be needed.

## Delivery credentials (admin-configurable)

The SMS gateway account (SMS360 user/pass/url), the outgoing SMTP transport + from identity, the funnel email driver and the GetResponse API key are managed on **Messages → Settings → Delivery APIs** (`/manage/integrations/messaging`, `view-integrations` / `manage-integrations`). Stored **encrypted** in the single-row `messaging_credentials` table ([MessagingCredential](/src/Common/MessagingCredential.php) + [repository](/src/Common/Repositories/MessagingCredentialRepository.php), mirroring `zoom_server_credentials`); [MessagingCredentialProvider](/src/Common/Services/MessagingCredentialProvider.php)`::applyOverrides()` pushes saved values into the runtime config at boot (`services.sms360.*`, `mail.*`, `services.funnel_email.driver`, `services.getresponse.api_key`) — DB over .env, blank falls back to .env — so `Sms360Sender`, the Mail facade and the `EmailSender` binding needed no changes. Secrets are write-only (blank keeps; masked last4 shown); the page has an "Email me a test" button (no SMS twin — billed). **Caveat:** long-running Horizon workers apply overrides at process boot, so a credential change reaches queued sends after the next Horizon restart/deploy (same as the WhatsApp settings).

## Related files

**Backend** — `src/Event/FunnelAutomationMessage.php` · `src/Event/FunnelAutomationSend.php` · `src/Event/Repositories/FunnelAutomationMessageRepository.php` · `src/Event/Support/SessionDueWindow.php` · `src/Event/Support/FunnelWhatsappComposer.php` (shared token engine) · `src/Common/Email/{EmailSender,SmtpEmailSender}.php` · `src/Common/Voice/{VoiceCaller,TwilioVoiceCaller,LogVoiceCaller}.php` (see the [Voice handbook](/docs/modules_handbook/shared/voice/readMe.md)) · `app/Jobs/Automation/{SendFunnelEmailMessage,SendFunnelSmsMessage,SendFunnelVoiceCall}.php` · `app/Http/Controllers/Webhooks/TwilioVoiceController.php` · `app/Jobs/Zoom/PushWebinarEmailSettings.php` · `app/Console/Commands/RunFunnelAutomationReminders.php` · `app/Actions/{DispatchFunnelWelcomeAction,BackfillFunnelAutomationWelcomeAction,CreateSessionWebinarAction}.php` · `app/Http/Controllers/Manage/Events/{FunnelAutomationController,FunnelsController}.php` · `app/Http/Requests/Manage/Events/FunnelAutomation/*` · `src/Event/Repositories/EventSeriesRepository.php` · `resources/views/emails/funnel/automation.blade.php` · `config/services.php` (`funnel_email`, `getresponse`, `twilio`) · `app/Providers/AppServiceProvider.php` · `app/Console/Kernel.php`

**Frontend** — `resources/js/Pages/Manage/Events/Funnels/Show.vue` · `Partials/Tabs/AutomationTab.vue` · `Partials/Automation/{RuleRow,AddRuleMenu,ZoomEmailCard}.vue` · `Partials/RuleForm/{ScheduleFields,TokenPicker}.vue` · `Partials/{EmailRuleFormModal,SmsRuleFormModal,VoiceRuleFormModal,TestSendModal,WhatsappBackfillModal}.vue`

**Migrations** — `2026_07_29_100001_create_funnel_automation_tables.php` · `2026_07_29_100002_add_zoom_email_settings_to_event_series.php` · `2026_08_07_100001_add_voice_medium_to_funnel_automation.php` (voice: `voice_id` on rules, `provider_call_id` + `meta` on sends) · `2026_08_07_200002_drop_zoom_email_toggles_from_event_series.php` (Zoom emails always-on — the four toggle columns dropped)

**Routes** — `routes/web.php` → `manage.events.funnel-automation.*` (store · update · toggle · test · backfill · destroy), all `permission:manage-events` (the old `zoom-emails` toggle route is gone — Zoom emails are always-on)

**Tests** — `tests/Feature/Event/{FunnelAutomationTest,FunnelAutomationReminderTest,FunnelVoiceTest,ZoomEmailSettingsTest}.php`
