Тема
ТЗ — Email-маркетинг (cms/email-marketing)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Триггерные email-цепочки (приветствие, брошенная корзина) и автоворонки на основе сегментов пользователей/заказов. Все отправки строго через очереди, без синхронной отправки в потоке запроса.
- Триггерные цепочки: welcome-серия при регистрации, брошенная корзина (слушает
cms/commerce-abandoned) - Сегменты пользователей по данным профиля и истории заказов
- Автоворонки: последовательность писем с задержками и условиями продолжения/остановки
- Отправка строго через очереди с ретраями и троттлингом
- Шаблоны писем цепочек — на базе
cms/email-templates - Отмена/пауза цепочки при конверсии (заказ оформлен — брошенная корзина останавливается)
- Снимок состава сегмента фиксируется один раз в момент запуска отправки (материализуется в строки
cms_email_marketing_runs) — изменения базы подписчиков во время долгой отправки на уже стартовавшую волну не влияют - Постоянный список подавления (suppression) по bounce/жалобам на спам — блокирует дальнейшую отправку по адресу во всех цепочках, не только в текущей
Зависимости и выключение
requires: cms/newsletter (подписная база и статус отписки), cms/email-templates (шаблоны шагов) · suggests: cms/commerce-abandoned (без него не стартует цепочка брошенной корзины, остальные цепочки работают) · слушает: cms/commerce-abandoned, события ядра UserRegistered, LeadStatusChanged(lead, from, to) (ревизия 14.07.2026, п. 16 — модуль явно назван потребителем наравне с integration-crm/analytics: терминальный статус лида останавливает активные цепочки, где лид является субъектом рассылки)
Поведение при выключении: триггерные цепочки не запускаются, накопленные сегменты и история runs сохраняются — оформление заказов и регистрация пользователей не затрагиваются, деградация маркетинга, не функциональности. Если во время работы выключается required-модуль cms/newsletter — health-чек модуля переходит в failed: новые runs не стартуют (нечем сверить статус отписки), уже идущие приостанавливаются с понятной ошибкой в админке, а не 500 (см. «Крайние случаи»).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_email_marketing_chains | id, code, trigger_event, is_active, lock_version, external_id | цепочка триггерных писем |
cms_email_marketing_steps | id, chain_id, order, template_id, delay_hours | шаг цепочки с задержкой и шаблоном |
cms_email_marketing_runs | id, chain_id, subject_type, subject_id, current_step, status, external_id | состояние цепочки для конкретного получателя — снимок сегмента на момент запуска |
cms_email_marketing_segments | id, name, rules (json), lock_version | сегмент по правилам профиля/заказов — вычисляется заново при каждом запуске |
cms_email_marketing_suppressions | id, subject_type, subject_id (nullable), email_hash, reason, occurred_at | постоянный список подавления (bounce/complaint) |
FK chain_id, template_id — constrained() + index(); status/reason — PHP Enum; rules — JSONB → GIN; уникальный индекс (chain_id, subject_id, current_step) в cms_email_marketing_runs — идемпотентность отправки шага; полиморфный индекс (subject_type, subject_id) под горячий путь обработки тика цепочек; lock_version на chains/segments — optimistic lock конкурентного редактирования (409 при конфликте).
Снимок сегмента. Правила rules сегмента вычисляются один раз в момент запуска отправки (команда/API) — на основании них создаются строки cms_email_marketing_runs для всех подпадающих на тот момент субъектов; сам факт создания строки runs и есть снимок. Повторного пересчёта состава по ходу отправки нет — новые подписки/отписки, случившиеся после старта, не добавляют и не убирают получателей из уже запущенной волны (иначе на миллионе адресов пришлось бы гонять полный пересчёт на каждом тике).
ПДн-паспорт. Модуль не хранит e-mail в открытом виде: runs/segments держат только полиморфную ссылку на субъекта (subject_type/subject_id — обычно пользователь или подписчик cms/newsletter), сам адрес и статус подписки берутся у cms/newsletter на момент отправки (requires-вызов). Единственное исключение — email_hash в cms_email_marketing_suppressions (SHA-256 адреса) для блокировки повторной отправки, когда исходный субъект уже мог быть удалён. Ретеншн: runs — 24 месяца (история для аналитики), затем анонимизация/удаление плановой джобой; suppressions — бессрочно (это технический антиспам-список, не персональные данные о поведении). По запросу «выгрузить всё»/«забыть по субъекту» (152-ФЗ) модуль реализует стандартный хук ядра: находит и анонимизирует строки runs по subject_id; запись в suppressions с этим subject_id обезличивается (subject_id обнуляется), сам email_hash сохраняется — иначе «забытый» адрес снова получит письма после повторной подписки, что нарушает цель suppression-листа.
Входные и выходные данные
Входы — whitelist-принцип (§11 стандарта): всё, что не перечислено, модуль отвергает.
| Источник | Поля | Чем валидируется |
|---|---|---|
| Filament: конструктор цепочки | code, trigger_event, is_active, steps[].order/template_id/delay_hours | FormRequest, trigger_event — whitelist по Enum, template_id — существующий шаблон cms/email-templates |
| Filament / API: редактор сегмента | name, rules (json: поле/оператор/значение) | FormRequest + rules движка полей ядра; пользовательские regex-условия — ReDoS-валидатор ядра |
API POST /admin/email-marketing/chains | те же поля цепочки | FormRequest, email-marketing.manage |
| API: запуск отправки на сегмент | segment_id, chain_id, confirm=true | FormRequest, обязательное явное подтверждение (см. «UX-требования»), email-marketing.launch |
Событие cms/commerce-abandoned (брошена/восстановлена корзина) | subject_type, subject_id, cart_id | схема события проверяется тонким слушателем, не доверяется как есть |
Событие ядра UserRegistered | user_id | стандартное событие ядра |
Событие ядра LeadStatusChanged | lead_id, from, to | стандартное событие ядра; интересен только терминальный to (won/lost), остальные переходы игнорируются |
| Вебхук bounce/complaint провайдера | email, reason, event_id | только через cms/webhooks-in (подпись, идемпотентность по event_id), модуль вебхуки напрямую не принимает |
Команда process-runs (cron) | нет пользовательского ввода | ScheduleRegistrar, системный вызов |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Filament (админка) | список цепочек/сегментов/статусов, прогресс отправки | Blade/Livewire через сервис модуля, не прямой SQL |
API /admin/email-marketing/* | цепочки, шаги, сегменты, статистика | конверт {data, meta}, keyset-пагинация |
mail-transport (provides-контракт) | получатель, шаблон, переменные письма | вызов контракта из джобы очереди |
События EmailChainStarted/StepSent/Stopped/Bounced/Complained | id цепочки/шага/субъекта, причина | шина событий (канал 1) |
cms/health | отставание очереди, доля failed runs, расход квоты транспорта | health-агрегат |
Настройки (группа email-marketing)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
email-marketing.enabled | bool | true | нет | Включение триггерных цепочек |
email-marketing.abandoned_cart_delay_hours | int | 2 | нет | Задержка первого письма брошенной корзины |
email-marketing.max_retries | int | 3 | нет | Число повторных попыток отправки при сбое |
email-marketing.max_segment_size | int | 1000000 | нет | Максимальный размер сегмента для одного запуска рассылки; превышение — 422 с понятной ошибкой, не тихое обрезание |
email-marketing.send_rate_per_minute | int | 500 | нет | Троттлинг отправки; не должен превышать фактический лимит mail-transport (сверяется при сохранении настроек) |
email-marketing.kill_switch | bool | false | нет | Kill-switch. Немедленно прекращает постановку новых писем в очередь на отправку (тик process-runs пропускает шаг отправки), не выключая модуль целиком — история и сегменты остаются доступны |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/email-marketing/chains | admin (email-marketing.view) | Список цепочек со статусами |
| POST | /api/v1/admin/email-marketing/chains | admin (email-marketing.manage) | Создание/правка цепочки и шагов |
| GET | /api/v1/admin/email-marketing/segments | admin (email-marketing.view) | Список сегментов и их размер |
Компоненты
Filament: конструктор цепочки (шаги, задержки, шаблоны), редактор правил сегмента, экран запуска отправки на сегмент с превью размера и прогрессом. Команды (все с --json): cms:email-marketing:process-runs (тик очередной обработки цепочек), cms:email-marketing:recount (пересчёт размера сегментов и кеша, идемпотентна), cms:email-marketing:import-legacy --source=<профиль> (legacy-импорт, см. «Донорский код»).
Фронтенд-бюджет и a11y: не применимо, кроме админ-UI — у модуля нет публичных блоков/виджетов (только Filament-экраны на стандартном бандле темы админки).
Демо-контент: сидер EmailMarketingDemoSeeder — одна демо-цепочка (welcome, 3 шага) и один демо-сегмент («заказ за последние 90 дней») для галереи/playground; сидер не создаёт реальных runs и не отправляет писем.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
EmailChainStarted | цепочка запущена для получателя | chain_id, subject_type, subject_id |
EmailChainStepSent | отправлен шаг цепочки | chain_id, step_id, subject_id |
EmailChainStopped | цепочка остановлена (конверсия/отписка/bounce) | chain_id, subject_id, reason |
EmailBounced / EmailComplained | вебхук провайдера сообщил жёсткий отказ/жалобу | email_hash, subject_id, reason |
Слушает: cms/commerce-abandoned (брошенная/восстановленная корзина), события ядра UserRegistered и LeadStatusChanged (терминальный переход останавливает активные runs, где лид — субъект рассылки: EmailChainStopped reason=lead_closed). Требует cms/newsletter как источник подписной базы и статуса отписки (requires) и cms/email-templates — шаблоны шагов (requires).
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
ядро: UserRegistered | событие (канал 1) | ядро → email-marketing | запускает welcome-цепочку |
ядро: LeadStatusChanged | событие (канал 1), ревизия 14.07.2026, п. 16 | ядро → email-marketing | терминальный статус лида (won/lost) останавливает активные runs, где лид — субъект (EmailChainStopped reason=lead_closed) |
cms/commerce-abandoned | событие (канал 1), suggests | commerce-abandoned → email-marketing | брошена корзина запускает цепочку; восстановление/оформление заказа — останавливает (EmailChainStopped) |
cms/newsletter | requires-сервис (канал 4) | email-marketing → newsletter | сверка статуса подписки/отписки перед каждой отправкой шага |
cms/newsletter | событие NewsletterUnsubscribed (канал 1) | newsletter → email-marketing | останавливает активные runs подписчика |
cms/email-templates | requires-сервис (канал 4) | email-marketing → email-templates | получение и рендер шаблона письма шага |
mail-transport | provides-контракт (канал 3) | email-marketing → реализация DI | фактическая отправка письма с учётом rate-limit провайдера |
cms/webhooks-in | очередь + событие (каналы 5→1) | провайдер → webhooks-in → email-marketing | приём bounce/complaint, публикация EmailBounced/EmailComplained |
cms/health | health/метрики | email-marketing → health | отставание очереди, доля failed runs, расход квоты транспорта |
Фоновая работа
Очередь email-marketing: process-runs — плановый тик обработки цепочек (проверка задержек, отправка очередного шага) через ScheduleRegistrar; сами отправки писем идут через очередь с ретраями (max_retries) и троттлингом. Джобы идемпотентны — повторный тик не отправляет уже отправленный шаг дважды (уникальный индекс (chain_id, subject_id, current_step)). Запуск на большой сегмент создаёт и ставит в очередь runs чанками через Bus::batch (не одним bulk-insert/одной транзакцией на миллион строк) с учётом rate-limit конкретной реализации mail-transport; прогресс (создано/поставлено/ отправлено) виден в Filament в реальном времени.
Эксплуатация (ранбук). Метрики: глубина и возраст самой старой job в очереди email-marketing, доля failed среди runs за 24ч, bounce-rate/complaint-rate по кампаниям цепочек, расход rate-limit mail-transport в %. Алерты: очередь отстаёт дольше порога, bounce-rate выше порога (риск блокировки IP/домена у провайдера), доля failed runs выше порога.
| Симптом | Что проверить | Чем чинить |
|---|---|---|
Очередь email-marketing растёт, process-runs не успевает | глубину очереди и число воркеров через cms/health | временно снизить send_rate_per_minute, добавить воркеров, process-runs --json вручную для диагностики |
| Всплеск bounce/complaint | статистику последних кампаний/цепочек, лог EmailBounced | email-marketing.kill_switch=true немедленно, разобрать причину (устаревшая база/плохой сегмент), затем снять kill-switch |
Сбой mail-transport (5xx/таймауты) | health-чек контракта, лимиты провайдера | дождаться восстановления (ретраи с backoff); при длительном сбое — переключить реализацию контракта в настройках |
Runs зависли в processing после рестарта воркеров | process-runs --json --dry-run по конкретному chain_id | ре-энкью зависших runs (идемпотентно, повтор не дублирует отправку) |
| Размер сегмента в UI разошёлся с фактом | кеш-тег email-marketing:segment:{id}:size | cms:email-marketing:recount --json пересчитывает и инвалидирует кеш |
Бэкап/рестор: в бэкап попадают все таблицы модуля (chains, steps, runs, segments, suppressions) — история отправок и suppression-лист критичны для комплаенса (потеря suppression-листа означает риск повторной отправки заблокированным адресам). После рестора пересоздаётся: кеш тегов размера сегментов (recount); очередь email-marketing не бэкапится — незавершённые runs подхватывает очередной тик process-runs.
Производительность и кеш
Ожидаемые объёмы: сегменты — от сотен до ~1 млн подписчиков; отправка кампании/волны цепочки на весь сегмент — до 1 млн писем.
Горячие пути и бюджет запросов: тик process-runs — keyset-выборка due runs по составному индексу (status, chain_id), не полное сканирование таблицы; создание runs при запуске на сегмент — bulk insert чанками (например по 1000 строк за запрос) внутри Bus::batch, не по одной строке; сверка статуса отписки у cms/newsletter — на уровне шага, с кешем ответа на короткий TTL, не отдельный запрос на каждое письмо при массовой отправке.
Критичные индексы: (subject_type, subject_id) и (status, chain_id) в runs, уникальный (chain_id, subject_id, current_step) — идемпотентность, GIN на rules в segments, индекс email_hash в suppressions (проверка на каждую отправку).
Теги кеша: email-marketing:chain:{id}, email-marketing:segment:{id}:size (кешированный размер сегмента для превью запуска, короткий TTL); инвалидация — afterSave() цепочки/ сегмента в Filament и событиями EmailChainStarted/Stopped. Модуль не участвует в page-cache (нет публичного рендера, кроме административных вьюх).
Безопасность
Все отправки — асинхронные через очереди, без синхронного внешнего вызова в веб-потоке; конструктор цепочек — только под email-marketing.manage. Перед отправкой каждый шаг проверяет статус отписки и bounce/suppression — отписавшийся или заблокированный получатель не должен получать письма (двойная сверка: cms/newsletter + собственный cms_email_marketing_suppressions).
Границы входа: все формы конструктора цепочки/сегмента — FormRequest whitelist (§11 стандарта); пользовательские regex-условия сегмента — только через ReDoS-валидатор ядра, самодельные проверки в контроллере — незачёт; вебхуки bounce/complaint принимаются исключительно через cms/webhooks-in (подпись, идемпотентность), модуль не открывает собственный публичный вебхук-эндпоинт. У модуля нет публичных API — только /admin/* под rate-limit ядра; запуск отправки на сегмент дополнительно ограничен email-marketing.max_segment_size.
Матрица ролей
| Permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
email-marketing.view — просмотр цепочек/сегментов/статистики | ✓ | ✓ | ✓ | ✓ |
email-marketing.manage — создание/правка цепочек, шагов, сегментов | ✓ | ✓ | — | ✓ |
email-marketing.launch — запуск отправки на сегмент (массовая, необратимая по факту) | ✓ | ✓ | — | ✓ |
email-marketing.kill-switch — аварийная остановка отправки | ✓ | — | — | ✓ |
ПДн-паспорт — см. «Модель данных» (адреса не хранятся в открытом виде, ретеншн runs 24 месяца, suppressions бессрочно, хуки «выгрузить всё»/«забыть по субъекту» описаны там). В логи и метрики модуля попадают только subject_id/email_hash, не сырые адреса.
Права: email-marketing.view, email-marketing.manage, email-marketing.launch, email-marketing.kill-switch.
UX-требования
Админ. Пустой список цепочек/сегментов — состояние с подсказкой «создайте первую цепочку» и ссылкой на конструктор, не голая таблица. Массовые действия на списке runs: пауза/остановка нескольких цепочек разом, повторная постановка зависших runs. Ошибки — человеческим языком («сегмент пуст, отправлять некому», а не 500/generic exception). Запуск отправки на сегмент — необратимая по факту операция (письма уже разосланным не отозвать): экран подтверждения обязан показать посчитанный размер сегмента («будет отправлено писем: 842 000») и явное действие подтверждения, кнопка недоступна, пока превью размера не посчитано. Прогресс долгой отправки виден в реальном времени (создано/поставлено/отправлено, ETA). Kill-switch — заметная кнопка на дашборде модуля с собственным подтверждением.
Посетитель (получатель письма). Ссылки в письме корректны и не протухают раньше заявленного (отписка обязана работать сколь угодно долго после отправки — см. «Крайние случаи»); отписка доступна в один клик без авторизации; письмо не дублируется получателю из-за ретраев/двойного запуска (идемпотентность на уровне runs).
Крайние случаи и типовые баги
- Двойной клик «Запустить отправку» на форме сегмента → идемпотентность запуска: повторный клик до ответа сервера заблокирован на UI и на сервере проверкой «уже есть активный запуск для этой пары chain+segment» — вторая волна runs не создаётся.
- Ретрай джобы отправки шага (воркер упал после фактической отправки письма, до фиксации статуса) → идемпотентность по уникальному индексу
(chain_id, subject_id, current_step): повторный прогон джобы не отправляет письмо дважды, только дописывает статус run. - Выключение модуля посреди активной рассылки → уже поставленные в очередь джобы шага долетают и завершаются, новые тики
process-runsне запускаются;cms:doctorпоказывает «модуль выключен, N runs в processing» с ссылкой на команду возобновления при повторном включении. - Отсутствует/выключен suggests-модуль
cms/commerce-abandoned→ welcome-цепочка и сегментные рассылки работают штатно, цепочка брошенной корзины просто не имеет триггера старта — деградация конкретной цепочки, не всего модуля. - Ссылка отписки в уже отправленных письмах. Unsubscribe-роут принадлежит
cms/newsletter; если модуль выключат после рассылки миллиона писем, ссылка отписки не имеет права отдавать 404 (RFC 8058). Решено ревизией ядра 14.07.2026 (п. 14): роут регистрируется какpersistent— жив при выключенном модуле, запросы обрабатываются в очередь/лог до повторного включения. - Сбой внешнего транспорта (
mail-transport) — таймаут/5xx у провайдера на кванте батча → троттлинг снижается, ретраи с backoff в пределахmax_retries; после исчерпания шаг помечаетсяfailedи виден в админке с причиной — не блокирует отправку остальным получателям батча. - Лид, являющийся субъектом рассылки, закрыт в CRM (
LeadStatusChanged→won/lost) → активныеrunsэтого субъекта останавливаются (EmailChainStopped reason=lead_closed), новые шаги не отправляются — дальнейший маркетинг закрытому лиду не имеет смысла и создаёт риск жалоб/bounce. - Bounce/complaint от провайдера — вебхук через
cms/webhooks-in→ событиеEmailBounced/EmailComplained→ адрес попадает вcms_email_marketing_suppressionsбессрочно → все активные runs субъекта останавливаются (EmailChainStopped reason=bounced), новые шаги и будущие кампании его пропускают. - Пустой сегмент на старте отправки → человеческая ошибка «сегмент пуст, 0 получателей», runs не создаются, кнопка запуска недоступна до положительного превью размера.
- Огромный сегмент (900 000+) → создание и постановка runs — чанками
Bus::batch(см. «Фоновая работа»), сам запуск асинхронный (не блокирует HTTP-поток формы), превышениеemail-marketing.max_segment_size— 422 с понятной ошибкой. - Ловушка измерений locale/city/site — правила сегмента не учитывают
site_id/localeсубъекта при мультисайте/мультиязычности → письмо на «русском» шаблоне может уйти подписчику другого сайта/языка. Правило: rules сегмента обязаны фильтровать по измерениям текущегоRequestContext(сайта, из которого запущена рассылка) либо явно включать выбор охвата всех сайтов в UI запуска — не implicit-поведение. - Противоречивые настройки —
email-marketing.max_retries=0при периодически падающем транспорте означает, что часть писем никогда не долетит без явного сигнала;abandoned_cart_delay_hoursбольше типичного времени жизни корзины делает цепочку бессмысленной. Валидация группы настроек при сохранении предупреждает о таких комбинациях (не блокирует явно валидные, но нетипичные значения). - Конкурентное редактирование цепочки — два админа одновременно правят шаги одной цепочки →
lock_versionнаcms_email_marketing_chains, конфликт отдаёт 409 и сообщение «цепочку изменили, обновите и перенесите свои правки», не «последний победил» молча.
Донорский код
Донор: — (новая разработка).
Legacy-импорт. cms:email-marketing:import-legacy --source=<профиль> [--dry-run] — на случай переезда клиента с внешнего ESP (Mailchimp/UniSender и т.п.): маппинг экспортированных цепочек/сегментов/истории отправок на схему модуля по external_id (есть в chains и runs); повторный прогон обновляет существующие записи, не дублирует. --dry-run печатает отчёт расхождений без записи: сколько прочитано/создано/обновлено/ пропущено и почему, построчные ошибки скачиваемы, молчаливый пропуск запрещён. Прогон на копии данных — часть приёмки при наличии у клиента реального донора (внешнего ESP).
Тесты и приёмка
- [ ] Контрактный тест: брошенная корзина запускает цепочку и останавливается при оформлении заказа
- [ ] Все отправки писем идут через очереди, ни одной синхронной отправки в веб-потоке
- [ ] Сегменты корректно фильтруют пользователей по правилам (без N+1 на больших выборках)
- [ ] При выключении модуля новые цепочки не стартуют, уже отправленные письма не переотправляются
- [ ] Права
email-marketing.view/manage/launch/kill-switchразграничивают доступ по матрице ролей - [ ] Отписавшийся пользователь не получает писем цепочки (сверка с
cms/newsletter) - [ ]
LeadStatusChangedс терминальным статусом (won/lost) останавливает активные runs субъекта-лида (EmailChainStopped reason=lead_closed) - [ ] Входы вне whitelist (лишние поля формы цепочки/сегмента, неизвестный вебхук вне
cms/webhooks-in) отклоняются, не игнорируются - [ ] Крайние случаи из ТЗ покрыты тестами: двойной запуск, ретрай джобы не дублирует отправку, bounce/complaint останавливает runs, пустой и огромный сегмент, конкурентное редактирование → 409
- [ ] ПДн-хуки «выгрузить всё»/«забыть по субъекту» отрабатывают на
runs/suppressions;email_hashв suppressions переживает «забыть» (задокументировано как осознанное исключение) - [ ]
email-marketing.kill_switch=trueнемедленно останавливает постановку новых писем в очередь, не выключая модуль - [ ]
cms:email-marketing:import-legacy --source=<профиль> --dry-runидемпотентен, отчёт расхождений корректен - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API; тестовая БД только
email-marketing_test,migrate:freshзапрещён