Тема
ТЗ — Рассылки (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-provider — cms/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_subscribers | id, 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_lists | id, name | список подписки |
cms_newsletter_campaigns | id, list_id, template_id, subject, status, sent_at, lock_version | кампания рассылки; lock_version — optimistic lock черновика |
cms_newsletter_events | id, campaign_id, subscriber_id, type (open|click|unsubscribe), occurred_at | статистика по кампании |
FK list_id, template_id, campaign_id, subscriber_id — constrained() + 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/subscribe | email, list_id, consent (bool), honeypot | FormRequest whitelist, email-валидатор, captcha-provider (если есть включённая реализация), rate-limit |
Ссылка подтверждения → GET /newsletter/confirm/{token} | token (path) | Подписанный одноразовый токен, TTL confirm_link_ttl_hours |
Форма/письмо отписки → POST /newsletter/unsubscribe | token (RFC 8058 List-Unsubscribe-Post) | Подписанный токен письма, без пароля/сессии |
Трекинг письма → GET /newsletter/track/{open|click}/{token} | token (path) | Подписанный многоразовый токен, редирект — только на опубликованный контент |
Админка → POST /admin/newsletter/campaigns | list_id, template_id, subject, scheduled_at | FormRequest (newsletter.manage), whitelist полей шаблона |
Легаси-импорт → cms:newsletter:import-legacy | email, 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.enabled | bool | true | да | Включение формы подписки на сайте |
newsletter.double_opt_in | bool | true | нет | Требовать подтверждение email перед активацией (см. «Безопасность») |
newsletter.send_rate_per_minute | int | 500 | нет | Троттлинг отправки писем в минуту |
newsletter.confirm_link_ttl_hours | int | 48 | нет | Срок жизни ссылки подтверждения подписки |
newsletter.max_subscribers_per_list | int | 200000 | нет | Лимит подписчиков на список; достижение — понятная ошибка формы + метрика, не 500 |
newsletter.subscribe_rate_limit_per_hour | int | 5 | нет | Попыток подписки с одного IP в час |
newsletter.unsubscribed_retention_days | int | 365 | нет | Через сколько дней после отписки обезличивать запись (ПДн-ретеншн) |
newsletter.legacy_import_requires_reconfirm | bool | true | нет | Импортированные без consent_at требуют re-permission перед первой кампанией |
newsletter.kill_switch_pause_sending | bool | false | нет | Kill-switch: аварийная пауза отправки текущей и новых кампаний без выключения модуля |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/newsletter/subscribe | public (rate-limit) | Подписка с фиксацией согласия |
| GET | /api/v1/newsletter/confirm/{token} | public | Подтверждение double opt-in |
| POST | /api/v1/newsletter/unsubscribe | public | One-click отписка (RFC 8058) |
| GET | /api/v1/newsletter/track/open/{token}.gif | public | Пиксель открытия (1×1 gif, событие open) |
| GET | /api/v1/newsletter/track/click/{token} | public | Редирект по ссылке кампании (событие click) |
| POST | /api/v1/admin/newsletter/campaigns | admin (newsletter.manage) | Создание/отправка кампании |
| GET | /api/v1/admin/newsletter/campaigns/{id}/stats | admin (newsletter.view) | Статистика открытий/кликов кампании |
| GET | /api/v1/admin/newsletter/subscribers | admin (newsletter.view) | Список подписчиков (keyset, фильтр по статусу/списку) |
| POST | /api/v1/admin/newsletter/subscribers/bulk | admin (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 | подписчик подтвердил email | subscriber_id, list_id |
NewsletterCampaignSent | кампания отправлена | campaign_id, recipients_count |
NewsletterUnsubscribed | подписчик отписался | subscriber_id, campaign_id |
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
| Ядро: очереди | requires-сервис-вызов (4) | newsletter → ядро | Отправка писем и кампаний только из очереди, не в веб-потоке |
Ядро: cms/email-templates | requires-сервис-вызов (4) | newsletter → email-templates | Кампания/письмо-подтверждение рендерятся на шаблоне |
Ядро: RequestContext | requires-сервис-вызов (4) | newsletter → ядро | locale/site_id/city_id формы берутся из контекста, не парсятся вручную |
Ядро: CacheTags | requires-сервис-вызов (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-marketing | requires-сервис-вызов (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 |
| Кампания зависла в очереди | Отставание/живость воркера очереди newsletter | cms: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), ответ идемпотентен — «уже подписан» / «письмо подтверждения выслано», без повторной отправки письма, если оно уже уходило недавно. - Повторная подписка уже
confirmedemail на тот же список → не создаёт вторую запись, не шлёт повторное письмо подтверждения, отвечает понятным{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вsubscribersnullable, кампания без city-таргетинга уходит всем независимо от города; контрактный тест гоняется в обоих режимах (§4 стандарта).
Донорский код
Донор: — (новая разработка).
Легаси-импорт для клиентов, переезжающих с внешних сервисов рассылок:
cms:newsletter:import-legacy --source=<профиль> [--dry-run] [--json]
- профиль маппинга (
config/newsletter.php→import_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запрещён