Skip to content

ТЗ — Рассылки (newsletter) (cms/newsletter)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Списки подписки с двойным подтверждением (double opt-in), редактор и отправка кампаний на шаблонах, статистика открытий/кликов и корректная one-click отписка. Отправка — только через очереди с троттлингом, согласие на обработку ПДн обязательно на подписке.

  • Списки подписки с сегментацией по источнику подписки
  • Double opt-in: подтверждение email перед активацией подписки
  • Кампании на шаблонах cms/email-templates, предпросмотр перед отправкой
  • One-click отписка (RFC 8058: List-Unsubscribe + List-Unsubscribe-Post)
  • Статистика открытий и кликов по кампании (пиксель открытия, трекинг ссылок)
  • Троттлинг отправки через очереди (лимит писем/минуту на почтовый провайдер)
  • Явное согласие на обработку ПДн фиксируется при подписке (текст, дата, IP)

Зависимости и выключение

requires: ядро (очереди, шаблоны писем) · suggests: реализация captcha-providercms/integration-yandex (SmartCaptcha) или cms/integration-google (reCAPTCHA) — защита формы подписки от ботов (канал 3) · потребитель: cms/email-marketing объявляет requires: cms/newsletter в своём манифесте и вызывает публичный NewsletterService (канал 4 — жёсткая зависимость), а не абстрактный provides-контракт (см. «⚠️ Противоречие» в «Крайние случаи»).

Поведение при выключении: форма подписки скрывается, активные списки сохраняются без отправки новых кампаний — ранее собранные подписчики не теряются, деградация без поломки. Уже запущенная в очереди отправка кампании доканчивает текущий батч и останавливается (см. «Крайние случаи»), а не обрывается посреди письма.

Persistent-роут отписки (ревизия ядра 14.07.2026, п. 14): POST /api/v1/newsletter/unsubscribe регистрируется как persistent — работает и при выключенном модуле (факт отписки пишется в очередь/журнал и применяется при включении): ссылки RFC 8058 в уже отправленных письмах не имеют права отдавать 404.

Модель данных

ТаблицаКлючевые поляПримечание
cms_newsletter_subscribersid, email, list_id, status (pending|confirmed|unsubscribed|imported), confirmed_at, consent_at, consent_ip, external_id, imported_at, locale, city_idподписчик с журналом согласия; external_id — ключ идемпотентности легаси-импорта
cms_newsletter_listsid, nameсписок подписки
cms_newsletter_campaignsid, list_id, template_id, subject, status, sent_at, lock_versionкампания рассылки; lock_version — optimistic lock черновика
cms_newsletter_eventsid, campaign_id, subscriber_id, type (open|click|unsubscribe), occurred_atстатистика по кампании

FK list_id, template_id, campaign_id, subscriber_idconstrained() + index(); status/type — PHP Enum; уникальный индекс email в cms_newsletter_subscribers, уникальный nullable external_id; locale/city_id — nullable-измерения (мультисайт без городов/локалей работает без отдельной ветки кода, §4 стандарта); cms_newsletter_events — журнальная append-only таблица, кандидат на BRIN по occurred_at.

ПДн-паспорт. Хранит email, consent_at, consent_ip (у импортированных — часто отсутствует, см. «Крайние случаи»). Срок хранения — пока подписка активна; после unsubscribed запись обезличивается джобой ретеншна (unsubscribed_retention_days, см. «Настройки») — email/consent_ip затираются, id остаётся для целостности статистики. Модуль реализует хуки ядра «выгрузить всё по субъекту» и «забыть по запросу» (152-ФЗ) для cms_newsletter_subscribers (полное удаление/обезличивание по email) и cms_newsletter_events (обезличивание subscriber_id, не удаление строк — иначе ломается агрегированная статистика кампании). Каскад в cms/email-marketing: отдельного вызова не требуется — модуль только читает статус через сервис newsletter, обезличенная/удалённая запись перестаёт находиться и цепочка останавливается естественно.

Входные и выходные данные

ИсточникПоляЧем валидируется
Форма подписки (блок) → POST /newsletter/subscribeemail, list_id, consent (bool), honeypotFormRequest whitelist, email-валидатор, captcha-provider (если есть включённая реализация), rate-limit
Ссылка подтверждения → GET /newsletter/confirm/{token}token (path)Подписанный одноразовый токен, TTL confirm_link_ttl_hours
Форма/письмо отписки → POST /newsletter/unsubscribetoken (RFC 8058 List-Unsubscribe-Post)Подписанный токен письма, без пароля/сессии
Трекинг письма → GET /newsletter/track/{open|click}/{token}token (path)Подписанный многоразовый токен, редирект — только на опубликованный контент
Админка → POST /admin/newsletter/campaignslist_id, template_id, subject, scheduled_atFormRequest (newsletter.manage), whitelist полей шаблона
Легаси-импорт → cms:newsletter:import-legacyemail, list, external_id, consent_at?, consent_ip?Профиль маппинга + email-валидатор; без consent_at → статус imported

Всё, что не перечислено выше, модуль обязан отвергать (whitelist-принцип §11 стандарта).

ПотребительДанныеФормат
Посетитель (форма подписки)Статус подписки / текст ошибки{data,meta} (успех) / {message,errors} 422 (ошибка)
Получатель письмаКампания на шаблоне email-templates + List-Unsubscribe-заголовкиHTML/text email (RFC 8058)
cms/email-marketingСтатус подписки/отписки emailОтвет публичного сервиса NewsletterService
Filament (админ)Список подписчиков, статистика открытий/кликовТаблица/дашборд
EventBus (все подписчики)NewsletterSubscribed/CampaignSent/UnsubscribedСобытие после коммита
Легаси-импорт → отчётПрочитано/создано/обновлено/пропущено + причиныJSON-отчёт, --dry-run без записи в БД

Настройки (группа newsletter)

КлючТипДефолтaffectsPageCacheОписание
newsletter.enabledbooltrueдаВключение формы подписки на сайте
newsletter.double_opt_inbooltrueнетТребовать подтверждение email перед активацией (см. «Безопасность»)
newsletter.send_rate_per_minuteint500нетТроттлинг отправки писем в минуту
newsletter.confirm_link_ttl_hoursint48нетСрок жизни ссылки подтверждения подписки
newsletter.max_subscribers_per_listint200000нетЛимит подписчиков на список; достижение — понятная ошибка формы + метрика, не 500
newsletter.subscribe_rate_limit_per_hourint5нетПопыток подписки с одного IP в час
newsletter.unsubscribed_retention_daysint365нетЧерез сколько дней после отписки обезличивать запись (ПДн-ретеншн)
newsletter.legacy_import_requires_reconfirmbooltrueнетИмпортированные без consent_at требуют re-permission перед первой кампанией
newsletter.kill_switch_pause_sendingboolfalseнетKill-switch: аварийная пауза отправки текущей и новых кампаний без выключения модуля

API

МетодПутьДоступНазначение
POST/api/v1/newsletter/subscribepublic (rate-limit)Подписка с фиксацией согласия
GET/api/v1/newsletter/confirm/{token}publicПодтверждение double opt-in
POST/api/v1/newsletter/unsubscribepublicOne-click отписка (RFC 8058)
GET/api/v1/newsletter/track/open/{token}.gifpublicПиксель открытия (1×1 gif, событие open)
GET/api/v1/newsletter/track/click/{token}publicРедирект по ссылке кампании (событие click)
POST/api/v1/admin/newsletter/campaignsadmin (newsletter.manage)Создание/отправка кампании
GET/api/v1/admin/newsletter/campaigns/{id}/statsadmin (newsletter.view)Статистика открытий/кликов кампании
GET/api/v1/admin/newsletter/subscribersadmin (newsletter.view)Список подписчиков (keyset, фильтр по статусу/списку)
POST/api/v1/admin/newsletter/subscribers/bulkadmin (newsletter.manage)Массовые действия (отписать/удалить выбранных)

Компоненты

Блоки (BlockRegistry): форма подписки (demo-props, fallback при выключении модуля). Filament: редактор кампаний с предпросмотром, статистика открытий/кликов, список подписчиков по статусу с массовыми действиями. Команды: cms:newsletter:send-campaign --json, cms:newsletter:purge-unconfirmed --json, cms:newsletter:anonymize-unsubscribed --json, cms:newsletter:recount --json, cms:newsletter:import-legacy --source=<профиль> --json.

Фронтенд-бюджет: блок формы подписки — ≤8 KB JS (клиентская валидация email, честный чекбокс согласия) + ≤2 KB CSS, без CLS (резерв высоты под сообщение об ошибке/успехе), поля с <label>, чекбокс и кнопка — с клавиатуры.

Демо-контент: сидер списка demo с ~20 подписчиками (статусы pending/confirmed/ unsubscribed/imported) и одной демо-кампанией — для галереи блока и playground.

События и обмен

СобытиеКогдаPayload
NewsletterSubscribedподписчик подтвердил emailsubscriber_id, list_id
NewsletterCampaignSentкампания отправленаcampaign_id, recipients_count
NewsletterUnsubscribedподписчик отписалсяsubscriber_id, campaign_id
Сущность/модульКаналНаправлениеЧто происходит
Ядро: очередиrequires-сервис-вызов (4)newsletter → ядроОтправка писем и кампаний только из очереди, не в веб-потоке
Ядро: cms/email-templatesrequires-сервис-вызов (4)newsletter → email-templatesКампания/письмо-подтверждение рендерятся на шаблоне
Ядро: RequestContextrequires-сервис-вызов (4)newsletter → ядроlocale/site_id/city_id формы берутся из контекста, не парсятся вручную
Ядро: CacheTagsrequires-сервис-вызов (4)newsletter → ядроТег newsletter инвалидируется по SettingChanged
Ядро: EventBusсобытие (1)newsletter → все подписчикиИздаёт NewsletterSubscribed/CampaignSent/Unsubscribed после коммита
Реализации captcha-provider (cms/integration-yandex/-google)provides-контракт (3), если включеныnewsletter → captcha-providerФорма подписки резолвит капчу; нет провайдера → honeypot + rate-limit
cms/email-marketingrequires-сервис-вызов (4), не provides — см. ⚠️email-marketing → newsletterПеред каждым шагом цепочки сверяет статус отписки через NewsletterService

Фоновая работа

Очередь newsletter: send-campaign — отправка кампании батчами с троттлингом (send_rate_per_minute), перед каждым батчем проверяет kill_switch_pause_sending и статус модуля (пауза/disable — доканчивает текущий батч и останавливается, см. «Крайние случаи»); purge-unconfirmed — очистка pending старше confirm_link_ttl_hours; anonymize-unsubscribed — обезличивание unsubscribed старше unsubscribed_retention_days (ПДн-ретеншн). Всё — через ScheduleRegistrar, джобы идемпотентны (ключ campaign_id+subscriber_id не даёт письму уйти дважды).

Метрики и алерты: отправлено/отклонено писем в минуту, доля отписок за кампанию (алерт при аномальном всплеске), отставание очереди newsletter — видны в Pulse/cms/health.

Ранбук:

СимптомЧто проверитьКоманда/действие
Всплеск отписок за кампаниюТема/контент, попадание в спам, дубли в спискеcms:newsletter:campaign-stats --json, при необходимости kill_switch_pause_sending
Подписчики застряли в pendingДоставляемость писем-подтверждений (транспорт)Логи mail-transport, cms:newsletter:purge-unconfirmed --dry-run
Кампания зависла в очередиОтставание/живость воркера очереди newslettercms:health, рестарт воркера
Импорт даёт много skippedОтчёт import-legacy --dry-run, профиль маппингаПострочный отчёт → правка профиля → повтор --dry-run

Бэкап/рестор: в бэкап — все таблицы модуля и вложения шаблонов (владеет email-templates). После рестора events восстанавливаются как есть; денормализованные агрегаты статистики в Filament при расхождении пересчитываются cms:newsletter:recount --json — рестор без этого шага не считается завершённым.

Производительность и кеш

Объёмы: десятки–сотни тысяч подписчиков на список, кампания рассылается на весь объём батчами через очередь; cms_newsletter_events растёт быстрее всего (клик+открытие на каждого получателя каждой кампании) — партиционирование/BRIN по occurred_at обязательны при росте.

Горячие пути: форма подписки — page-cache + 0 запросов настроек (чтение из кеша группы); пиксель открытия и клик — публичные, без сессии, кладут событие в очередь, не синхронный INSERT на каждый open; тик отправки кампании — выборка батча подписчиков курсором по (list_id, status), не весь список в память джобы.

Индексы: уникальный email; составной (list_id, status) под выборку батча; unique nullable external_id под идемпотентность импорта; (campaign_id, subscriber_id, type) в events под подсчёт уникальных открытий; BRIN occurred_at в events.

Кеш: тег newsletter (форма подписки, enabled), инвалидация — SettingChanged группы newsletter. Собственных тегов на статистику/списки нет — админ-данные читаются живыми, кешируется только публичная форма через page-cache ядра.

Безопасность

Подписка, подтверждение, отписка и трекинг — публичные под rate-limit ядра и honeypot; email и consent — обязательные поля FormRequest, всё вне таблицы входов отвергается (§11 стандарта). Капча — через captcha-provider (канал 3), если включена реализация контракта (suggests); иначе единственная защита — honeypot + rate-limit. Токены подтверждения и отписки — подписанные одноразовые, с TTL; токен трекинга — подписанный многоразовый (клик по одной и той же ссылке считается повторно).

Double opt-in — дефолт true, обязателен к сохранению. Отправка на неподтверждённые адреса — прямой спам- и репутационный риск для mail-transport (блокировка транспорта провайдером бьёт по всем модулям, использующим его же канал). Попытка выключить double_opt_in: модуль не блокирует технически (решение клиента/студии), но обязан показать явное предупреждение в Filament («подписка без подтверждения — риск блокировки почтового транспорта и жалоб на спам») и требовать роль studio для сохранения значения false — рядовой админ/менеджер выключить не может.

ПДн: см. «Модель данных» (паспорт, ретеншн, каскад в email-marketing). В логи и Pulse не попадают email/IP — только обезличенные id.

Матрица ролей:

Permission / действиеАдминМенеджерРедакторStudio
newsletter.view (подписчики, статистика)
newsletter.manage (создание/отправка кампании)
Отправка на весь список (с подтверждением)
Выключение double_opt_in
Kill-switch kill_switch_pause_sending
Легаси-импорт (import-legacy)

Отправка кампании на весь список — необратимое по влиянию действие: Filament требует подтверждения с числом получателей перед постановкой в очередь.

UX-требования

Админ. Пустой список подписчиков/кампаний — подсказка «добавьте форму на сайт» / «создайте первую кампанию» со ссылкой на блок. Массовые действия на списке подписчиков — отписать/удалить выбранных, экспорт сегмента. Ошибки на человеческом языке («email уже подписан на этот список», а не код валидации). Подтверждение необратимых операций: отправка кампании — модалка с числом получателей; включение kill-switch — подтверждение с указанием кампании, которая сейчас отправляется; выключение double_opt_in — отдельное предупреждение (см. «Безопасность»).

Посетитель. Форма подписки: при ошибке (email занят/невалиден/rate-limit) — сообщение у поля, введённый email не теряется; при успехе — честный текст «проверьте почту для подтверждения» (double opt-in), без ложного «вы подписаны» до подтверждения. Текст согласия — конкретика (какая рассылка, кто оператор, ссылка на политику), не общая фраза. Отклик — без перезагрузки страницы, ощутимо быстро (обратная связь до ~300 мс, отправка — асинхронно). Доступность: <label> на поле email, чекбокс согласия и кнопка — с клавиатуры, ошибка объявляется скринридеру (aria-live).

Крайние случаи и типовые баги

  • Двойной сабмит формы подписки (два клика/два таба) → второй запрос с тем же email не создаёт вторую запись (уникальный email), ответ идемпотентен — «уже подписан» / «письмо подтверждения выслано», без повторной отправки письма, если оно уже уходило недавно.
  • Повторная подписка уже confirmed email на тот же список → не создаёт вторую запись, не шлёт повторное письмо подтверждения, отвечает понятным {data:{status: confirmed}}.
  • Ретрай джобы send-campaign (сбой воркера) → идемпотентность по campaign_id+subscriber_id (журнал отправленных перед фактической отправкой) — письмо конкретному подписчику этой кампании не уходит дважды.
  • Модуль выключен/поставлен на паузу посреди отправки кампанииsend-campaign проверяет статус перед каждым батчем: доканчивает текущий батч и останавливается, статус кампании — paused_module_disabled, не обрыв на письме и не тихое «отправлено всем». ⚠️ Противоречие: стандарт (§3) фиксирует «выключение = деградация, данные не удаляются», но не описывает явно поведение уже запущенной джобы очереди при disable модуля-владельца. Разрешение принято здесь и годится как образец для других модулей с батчевой рассылкой/пересчётом.
  • captcha-provider не включён (нет ни одной реализации контракта) → форма подписки работает на honeypot + rate-limit; Filament показывает предупреждение «капча не подключена, риск спам-подписок».
  • Сбой mail-transport (недоступен/лимит провайдера) → джоба не падает целиком: письмо помечается failed, батч продолжается, кампания уходит в статус с процентом неотправленных, алерт в cms/health, ретрай — по стандартной политике очереди.
  • Пустой список получателей на момент отправки (все отписались/список пуст) → кампания не уходит «отправлена 0 получателям» молча — Filament блокирует отправку с сообщением «в списке нет подтверждённых подписчиков».
  • Огромный список (сотни тысяч) → выборка батча курсором (keyset) по (list_id, status, id), не OFFSET; фиксированный размер батча, не весь список в память джобы.
  • Импорт легаси-базы без доказанного согласия → импортированные без consent_at/consent_ip не попадают в confirmed — статус imported, кампании на них не отправляются, пока не пройден отдельный re-permission (письмо-подтверждение подписки, не обычная рассылка); legacy_import_requires_reconfirm=true не обходится настройкой double_opt_in — импорт всегда отдельный путь входа.
  • Ссылка в письме кампании ведёт на удалённый со сайта товар/страницу → не голый 404-сюрприз получателю: переход резолвится стандартной деградацией ядра (редирект на замену, если задан, иначе страница «контент недоступен») — newsletter не подменяет и не проверяет URL самостоятельно, поведением владеет модуль-владелец контента.
  • Конкурентное редактирование кампании (два админа правят один черновик) → lock_version на cms_newsletter_campaigns, конфликт сохранения — 409 с сообщением «кампанию только что изменили, обновите и повторите», не «последний победил».
  • Сайт без городов/локалей (nullable измерение) → форма подписки и отправка кампании обязаны работать без ошибки: locale/city_id в subscribers nullable, кампания без city-таргетинга уходит всем независимо от города; контрактный тест гоняется в обоих режимах (§4 стандарта).

Донорский код

Донор: — (новая разработка).

Легаси-импорт для клиентов, переезжающих с внешних сервисов рассылок:

cms:newsletter:import-legacy --source=<профиль> [--dry-run] [--json]

  • профиль маппинга (config/newsletter.phpimport_profiles) переводит поля донора в email, list, external_id, consent_at?, consent_ip?;
  • идемпотентность — по external_id (unique nullable): повторный прогон обновляет, не дублирует;
  • --dry-run — отчёт расхождений без записи: сколько будет создано/обновлено/пропущено и почему (невалидный email, дубль внутри файла импорта);
  • отсутствие consent_at/consent_ip → статус imported, не confirmed (см. «Крайние случаи»);
  • прогон на копии данных — часть приёмки модуля при наличии у клиента боевой базы донора (§16 стандарта).

Тесты и приёмка

  • [ ] Контрактный тест: подписка без подтверждения не получает кампании (status = pending)
  • [ ] One-click отписка работает по одному клику без авторизации (RFC 8058)
  • [ ] Отправка кампании идёт через очереди с троттлингом, не превышает send_rate_per_minute
  • [ ] Согласие на обработку ПДн фиксируется (текст, дата, IP) на каждой подписке
  • [ ] При выключении модуля форма подписки скрыта, существующие подписчики не удаляются
  • [ ] Права newsletter.view/newsletter.manage разграничивают статистику и отправку кампаний
  • [ ] Входы вне whitelist (лишнее поле формы, неизвестный статус импорта) отвергаются 422
  • [ ] Крайние случаи покрыты: двойной сабмит, ретрай send-campaign, пауза посреди отправки, конкурентное редактирование кампании (409)
  • [ ] ПДн-хуки «выгрузить всё»/«забыть по запросу» отрабатывают на subscribers, обезличивают events
  • [ ] Матрица ролей: редактор не видит отправку кампании, double_opt_in=false сохраняет только studio
  • [ ] cms:newsletter:import-legacy --dry-run даёт отчёт расхождений и не пишет в БД; повтор без --dry-run не дублирует по external_id; импортированные без согласия получают статус imported
  • [ ] kill_switch_pause_sending останавливает отправку между батчами без выключения модуля
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут API; тестовая БД только newsletter_test, migrate:fresh запрещён

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