Skip to content

ТЗ — 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_chainsid, code, trigger_event, is_active, lock_version, external_idцепочка триггерных писем
cms_email_marketing_stepsid, chain_id, order, template_id, delay_hoursшаг цепочки с задержкой и шаблоном
cms_email_marketing_runsid, chain_id, subject_type, subject_id, current_step, status, external_idсостояние цепочки для конкретного получателя — снимок сегмента на момент запуска
cms_email_marketing_segmentsid, name, rules (json), lock_versionсегмент по правилам профиля/заказов — вычисляется заново при каждом запуске
cms_email_marketing_suppressionsid, subject_type, subject_id (nullable), email_hash, reason, occurred_atпостоянный список подавления (bounce/complaint)

FK chain_id, template_idconstrained() + 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_hoursFormRequest, 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=trueFormRequest, обязательное явное подтверждение (см. «UX-требования»), email-marketing.launch
Событие cms/commerce-abandoned (брошена/восстановлена корзина)subject_type, subject_id, cart_idсхема события проверяется тонким слушателем, не доверяется как есть
Событие ядра UserRegistereduser_idстандартное событие ядра
Событие ядра LeadStatusChangedlead_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/Complainedid цепочки/шага/субъекта, причинашина событий (канал 1)
cms/healthотставание очереди, доля failed runs, расход квоты транспортаhealth-агрегат

Настройки (группа email-marketing)

КлючТипДефолтaffectsPageCacheОписание
email-marketing.enabledbooltrueнетВключение триггерных цепочек
email-marketing.abandoned_cart_delay_hoursint2нетЗадержка первого письма брошенной корзины
email-marketing.max_retriesint3нетЧисло повторных попыток отправки при сбое
email-marketing.max_segment_sizeint1000000нетМаксимальный размер сегмента для одного запуска рассылки; превышение — 422 с понятной ошибкой, не тихое обрезание
email-marketing.send_rate_per_minuteint500нетТроттлинг отправки; не должен превышать фактический лимит mail-transport (сверяется при сохранении настроек)
email-marketing.kill_switchboolfalseнетKill-switch. Немедленно прекращает постановку новых писем в очередь на отправку (тик process-runs пропускает шаг отправки), не выключая модуль целиком — история и сегменты остаются доступны

API

МетодПутьДоступНазначение
GET/api/v1/admin/email-marketing/chainsadmin (email-marketing.view)Список цепочек со статусами
POST/api/v1/admin/email-marketing/chainsadmin (email-marketing.manage)Создание/правка цепочки и шагов
GET/api/v1/admin/email-marketing/segmentsadmin (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), suggestscommerce-abandoned → email-marketingброшена корзина запускает цепочку; восстановление/оформление заказа — останавливает (EmailChainStopped)
cms/newsletterrequires-сервис (канал 4)email-marketing → newsletterсверка статуса подписки/отписки перед каждой отправкой шага
cms/newsletterсобытие NewsletterUnsubscribed (канал 1)newsletter → email-marketingостанавливает активные runs подписчика
cms/email-templatesrequires-сервис (канал 4)email-marketing → email-templatesполучение и рендер шаблона письма шага
mail-transportprovides-контракт (канал 3)email-marketing → реализация DIфактическая отправка письма с учётом rate-limit провайдера
cms/webhooks-inочередь + событие (каналы 5→1)провайдер → webhooks-in → email-marketingприём bounce/complaint, публикация EmailBounced/EmailComplained
cms/healthhealth/метрики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статистику последних кампаний/цепочек, лог EmailBouncedemail-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}:sizecms: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 (LeadStatusChangedwon/ 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 запрещён

Внутренняя база знаний студии