Тема
ТЗ — Шаблоны писем (cms/email-templates)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Редактируемые из админки шаблоны транзакционных писем с безопасной подстановкой переменных: без исполнения произвольного кода, только whitelist-переменные на шаблон. При отсутствии кастомного шаблона используется blade-шаблон, поставляемый самим пакетом.
- Редактор шаблона письма (тема + тело) из Filament, без выхода в код;
- переменные строго по whitelist на конкретный тип письма (без
eval/произвольных выражений); - preview с тестовыми данными прямо в админке;
- тестовая отправка письма на указанный адрес перед сохранением;
- версионирование шаблона (история изменений, откат на предыдущую версию);
- fallback на blade-шаблон пакета, если кастомный шаблон не задан или удалён.
Зависимости и выключение
requires: — · suggests: cms/transport-providers
Манифест не объявляет provides: — email-templates не входит в канонический реестр provides-контрактов ядра (core.md) и не является взаимозаменяемой способностью (как платёжный шлюз с несколькими реализациями), а конкретным модулем-владельцем рендера писем. Модули-потребители объявляют requires: cms/email-templates и вызывают его публичный сервис напрямую (канал 4, см. «Таблица взаимодействий»).
При выключении все письма отправляются по blade-шаблонам пакетов (дефолтным), без возможности редактирования текста из админки — рассылка не прерывается.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_email_templates | key, locale, subject, body, variables (json), is_active | Активная версия шаблона на пару ключ+локаль |
cms_email_template_versions | template_id, subject, body, created_by, created_at | История версий для отката |
variables — JSON-столбец с cast array; is_active — bool cast. template_id — constrained('cms_email_templates')->index(). Уникальный индекс (key, locale) на cms_email_templates — одна активная версия на пару ключ+локаль.
ПДн-паспорт: ПДн не храню — cms_email_templates/cms_email_template_versions хранят только структуру шаблона (тема/тело/whitelist переменных-заглушек), не реальные данные получателей; payload с фактическими данными подставляется на лету при рендере и не персистится в таблицах модуля. Тестовая отправка (test-send) пишет в лог факт отправки (кто, когда, какой ключ), не адрес получателя в открытом виде за пределами штатного email-лога транспорта.
Входные и выходные данные
Whitelist-принцип: переменная, не объявленная в variables конкретного ключа, не подставляется в шаблон и не проходит валидацию на сохранении/preview/рендере — отвергается, а не молча игнорируется.
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Filament: редактор шаблона | key, locale, subject, body (HTML), is_active | FormRequest, whitelist переменных ключа (движок полей), санитайзер rich-text (двойной барьер), {!! !!} только под studio-ролью |
API PUT /admin/email-templates/{key} | locale, subject, body, variables (подмножество whitelist) | FormRequest, whitelist переменных, If-Match/версия для optimistic lock (409 при конфликте) |
API POST .../{key}/preview | locale, тестовые значения переменных (sample_data) | Whitelist переменных ключа — неизвестная переменная → 422, а не тихий пропуск |
API POST .../{key}/test-send | email получателя (опционально — по умолчанию e-mail текущего авторизованного пользователя), locale | FormRequest (формат email), rate-limit test_send_rate_limit; произвольный (не свой) адрес получателя принимается только под studio-ролью |
API POST .../{key}/rollback/{version} | version (id версии) | Версия существует и принадлежит тому же template_id |
Команда cms:email-templates:sync (регистрация ключей модулями-потребителями) | key, whitelist переменных, путь fallback blade-шаблона пакета | Схема реестра ключей (внутренний контракт модуля); fallback-шаблон обязателен |
Сервис-вызов от notifications-bus / модулей-отправителей (канал 4, requires) | key, locale, payload (данные для подстановки) | payload фильтруется до объявленных переменных ключа перед рендером |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
notifications-bus и модули-отправители (requires: cms/email-templates) | Скомпилированные subject + body + template_version_id на пару key+locale+payload | Строка (тема) + HTML (тело) + int/null template_version_id (id использованной версии; null при рендере по fallback blade-шаблону пакета) |
| Filament: панель предпросмотра | HTML предпросмотра с тестовыми данными | HTML в iframe |
API GET /admin/email-templates | Список шаблонов (ключ, локаль, активность, дата изменения) | JSON {data, meta} |
API GET /admin/email-templates/{key}/revisions | История версий с diff | JSON |
| Шина событий | EmailTemplateUpdated, EmailTemplateRolledBack | Payload события (канал 1) |
cms:email-templates:sync --json | Реестр зарегистрированных ключей и их whitelist-переменных | JSON |
Модуль-отправитель сохраняет template_version_id в своём собственном журнале доставки (email-templates журнала отправки не ведёт — только рендерит и отдаёт ссылку на использованную версию); это позволяет точно установить, каким текстом ушло конкретное письмо, даже после последующего отката шаблона (см. «Крайние случаи»).
Настройки (группа email-templates)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
email-templates.fallback_to_package | bool | true | — | Использовать blade-шаблон пакета при отсутствии кастомного |
email-templates.fallback_locale | string | дефолтная локаль сайта (locale-группа ядра) | — | Локаль рендера, если нет перевода ни на нужную, ни на дефолтную (используется в fallback-цепочке ядра) |
email-templates.test_send_rate_limit | int | 10 | — | Лимит тестовых отправок в час на пользователя |
email-templates.versions_retention | int | 20 | — | Сколько последних версий шаблона хранить |
email-templates.max_body_size_kb | int | 256 | — | Лимит размера тела письма (HTML+инлайн-стили); превышение отклоняется при сохранении |
email-templates.max_attachments_size_mb | int | 10 | — | Лимит суммарного размера вложений/изображений письма (через MediaService) |
email-templates.preview_enabled_for_studio_only | bool | false | — | Kill-switch: ограничить предпросмотр и тестовую отправку studio-ролью |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/email-templates | email-templates.manage | Список шаблонов (ключ, локаль, активность) |
| PUT | /api/v1/admin/email-templates/{key} | email-templates.manage | Сохранение шаблона (whitelist-валидация переменных) |
| POST | /api/v1/admin/email-templates/{key}/preview | email-templates.manage | Рендер preview с тестовыми данными |
| POST | /api/v1/admin/email-templates/{key}/test-send | email-templates.manage | Тестовая отправка на указанный адрес |
| POST | /api/v1/admin/email-templates/{key}/rollback/{version} | email-templates.manage | Откат на предыдущую версию |
Компоненты
Filament: редактор шаблонов с preview-панелью и списком доступных переменных для текущего ключа, история версий с diff. Команды: cms:email-templates:sync --json (регистрация новых ключей шаблонов от модулей), cms:email-templates:doctor --json.
Мини-ранбук (§15):
- письма уходят с дефолтным (не кастомным) текстом → health-чек не находит fallback blade-шаблон для ключа →
cms:email-templates:sync --json(сверить реестр ключей и путь fallback-шаблона, зарегистрировать/поправить путь у модуля-владельца ключа); - шаблон не рендерится (ошибка/пустое письмо) → сверить whitelist переменных ключа →
cms:email-templates:doctor --json(диагностика конкретного ключа/локали, покажет расхождение whitelist иpayload).
Бэкап/рестор: в бэкап — cms_email_templates, cms_email_template_versions (история для отката) и вложения шаблонов в медиатеке. После рестора пересоздаётся: скомпилированный кеш рендера (лениво — из события EmailTemplateUpdated или первого промаха на паре key+locale, отдельной команды не требует) и реестр зарегистрированных ключей — восстанавливается прогоном cms:email-templates:sync от модулей-потребителей.
Демо-контент: сидер демо-шаблона demo.welcome (тема + тело + набор тестовых значений переменных) для галереи блоков /_gallery и playground — показывает редактор и preview без реальной интеграции с notifications-bus; сидер не регистрируется как боевой ключ и не участвует в cms:email-templates:sync.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
EmailTemplateUpdated | Сохранена новая версия шаблона | key, locale, user_id |
EmailTemplateRolledBack | Откат на предыдущую версию | key, to_version |
Потребляется модулями-отправителями писем через прямой сервис-вызов (канал 4, requires: cms/email-templates) — в первую очередь notifications-bus и cms/transport-providers как транспорт-слой. Регистрация ключей шаблонов от модулей-потребителей — через cms:email-templates:sync (модуль объявляет свой ключ и whitelist переменных). FilterBus не используется. Слушает собственные команды синхронизации ключей шаблонов.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
| notifications-bus | Сервис-вызов (канал 4, requires) | in | Запрашивает рендер письма: передаёт key+locale+payload, получает subject+body |
Модули-отправители (владельцы key, например заявки/заказы) | Сервис-вызов (регистрация через cms:email-templates:sync) | in | Объявляют ключ шаблона, whitelist переменных и путь fallback blade-шаблона своего пакета |
Ядро: i18n строк (core-contracts) | Сервис-вызов (прямое использование контракта ядра) | out | Резолв fallback-цепочки локалей, если нет перевода на нужную locale |
Ядро: MediaService | Сервис-вызов (core-contracts) | out | Подстановка изображений/вложений в HTML письма по id медиа (не по произвольному URL) |
Ядро: SettingsStore | Сервис-вызов | out | Чтение группы настроек email-templates из кеша (0 запросов на горячем пути) |
Ядро: EventBus | Событие (канал 1) | out | Издаёт EmailTemplateUpdated / EmailTemplateRolledBack |
cms/transport-providers (suggests) | Сервис-вызов (канал 4, опционально) | in | Транспортный слой физической отправки — не участвует в рендере контента |
Любая связь вне этой таблицы — скрытая зависимость, анти-паттерн (§ обмена данными).
Фоновая работа
Тестовая отправка — синхронный вызов транспорта с коротким таймаутом (ограничена rate-limit test_send_rate_limit); фактическая отправка по шаблону в проде идёт через очередь модуля-отправителя (cms/transport-providers или прямой mailer), не через очередь email-templates — сам модуль лишь отдаёт контент.
Производительность и кеш
Объёмы: десятки–сотни шаблонов на инсталляцию (по числу ключей, зарегистрированных модулями, × число локалей сайта); рендер письма — от единиц до тысяч в час, пропорционально объёму транзакционных писем сайта (заказы, лиды, уведомления).
Горячий путь: рендер письма при отправке вызывается синхронно из очереди модуля-отправителя — не должен быть узким местом рассылки. Бюджет: 0 доп. запросов к БД на рендер при попадании в кеш (шаблон уже скомпилирован), максимум 1 запрос при промахе (чтение активной версии по (key, locale)).
Индексы: уникальный (key, locale) на cms_email_templates (резолв активной версии — критичный индекс горячего пути); индекс на template_id в cms_email_template_versions (история/rollback); индекс на created_at там же (ограничение versions_retention).
Что кешируется: результат компиляции шаблона (тема+тело с точками вставки переменных, не сырой HTML из БД) на пару key+locale — тег email-templates.rendered:<key>:<locale>; отдельно — реестр зарегистрированных ключей (cms:email-templates:sync) под тегом email-templates.keys.
Инвалидация: EmailTemplateUpdated/EmailTemplateRolledBack сбрасывают тег только своей пары key+locale (не весь кеш модуля) — afterSave() в Filament издаёт событие после коммита. Изменение настроек группы (SettingChanged) — инвалидация кеша настроек штатным механизмом settings-store, а не кеша шаблонов.
Безопасность
Filament — переменные подставляются строго по whitelist на тип письма, без eval/произвольных Blade-выражений; API email-templates.manage — сохранение/ preview/rollback. Тема и тело шаблона — только доверенный HTML под studio-ролью (санитизация двойным барьером). Тестовая отправка ограничена rate-limit во избежание использования как спам-инструмента.
Конкретные векторы:
- XSS через payload-переменные — данные, подставляемые в шаблон при рендере (не сам текст шаблона), экранируются по умолчанию (
);{!! !!}доступен только для полей самого шаблона (тема/тело), заполняемых под studio-ролью, — не для данных изpayload(заказ, лид, пользовательский ввод); - инъекция в тему письма (email header injection) — подстановка переменных в
subjectсанитизируется отдельно (удаление\r\n/управляющих символов): без этого барьера данные лида/заказа в переменной темы могут дописать заголовки (Bcc:,Cc:) и превратить письмо в инструмент спама; - SSRF через внешние ресурсы — если шаблонизатор поддерживает загрузку внешних изображений/стилей по URL (в т.ч. MJML
<mj-image src>), произвольный внешний URL из пользовательских данных запрещён: изображения — только черезMediaService(медиатека ядра) по id, не по URL из payload; - ReDoS — если whitelist переменных допускает regex-валидацию значения, паттерн проходит через ReDoS-валидатор ядра, не собственную проверку модуля.
Тестовая отправка на произвольный (не свой) адрес — привилегия studio-роли: без неё это почти инструмент спам-рассылки под доверием к отправителю сайта. По умолчанию адрес предзаполнен e-mail текущего авторизованного пользователя; смена адреса дополняет, а не заменяет rate-limit per-пользователь/per-IP (см. «Крайние случаи»).
Матрица ролей (permissions email-templates.view / email-templates.manage):
| Роль | Просмотр | Редактирование | Preview | Test-send | Rollback |
|---|---|---|---|---|---|
| Админ | ✓ (view) | ✓ (manage) | ✓ | ✓ (свой адрес) | ✓ |
| Менеджер | ✓ (view) | — | — | — | — |
| Редактор | ✓ (view) | ✓ (manage) | ✓ | ✓ (свой адрес) | — |
| Studio | ✓ (view) | ✓ (manage) | ✓ | ✓ (свой + произвольный адрес) | ✓ |
При email-templates.preview_enabled_for_studio_only = true (kill-switch) столбцы «Preview» и «Test-send» доступны только Studio — остальным ролям, даже с manage, показывается блокировка с пояснением «предпросмотр ограничен studio-режимом».
Права: email-templates.view, email-templates.manage.
UX-требования
Админ:
- пустое состояние — ключ письма зарегистрирован (
cms:email-templates:sync), но кастомный шаблон не создан → в списке пометка «используется дефолтный шаблон пакета» со ссылкой «создать кастомный шаблон»; - предпросмотр с тестовыми данными — доступен из редактора без промежуточного сохранения («Показать превью» рендерит текущий черновик);
- тёмная тема почтовых клиентов — предпросмотр переключается между светлым и тёмным режимом рендера (эмуляция
prefers-color-scheme: dark, как в Gmail/Apple Mail), не только светлый вариант, — иначе нечитаемое письмо у части получателей замечают постфактум по жалобам; - список доступных переменных для текущего ключа — виден прямо в редакторе (сайдбар с
для вставки по клику), не вынесен в отдельную документацию; - человеческая ошибка — синтаксическая ошибка в теле шаблона (незакрытый тег, недопустимое выражение) подсвечивается при сохранении, с указанием строки — не должна впервые проявиться как сбой при реальной отправке письма;
- подтверждение необратимых операций — удаление шаблона, используемого действующими рассылками (модалка «письма продолжат уходить по дефолтному шаблону пакета»), откат на предыдущую версию (модалка с diff перед подтверждением);
- массовые действия — не применимо: каждый шаблон правится и версионируется индивидуально, списковых bulk-операций над содержимым шаблонов нет.
Посетитель: модуль не рендерит публичных страниц — получатель видит письмо в почтовом клиенте; поведение форм при ошибке (сохранение введённого, доступность) — зона модуля-инициатора отправки (например cms/forms), не email-templates.
Крайние случаи и типовые баги
- переменная не передана в payload при рендере → подставляется явный плейсхолдер (например
—, не пустая строка) вместо исключения; рендер одного письма не должен ронять всю рассылку — модуль-отправитель обрабатывает остальных получателей; - сломанный MJML/HTML в теле шаблона → валидация синтаксиса при сохранении в Filament, ошибка на человеческом языке с указанием строки/тега; сохранение блокируется — не должно проявляться как сбой при реальной отправке письма;
- мультиязычный шаблон без перевода на нужную locale → fallback-цепочка локалей ядра (настройка
email-templates.fallback_locale); если и дефолтная locale не переведена — деградация на blade-шаблон пакета + запись в лог для админа, не 500 и не пустое письмо; - шаблон ссылается на удалённую сущность (товар/страница, на которую вела ссылка в письме) → ссылка формируется через репозиторий/
route()на рендере, при отсутствии сущности — валидный fallback URL (главная/раздел), не битая ссылка и не исключение; - выключение модуля посреди активных рассылок → сервис-вызов от notifications-bus/модулей-отправителей недоступен, они переключаются на свой собственный blade-шаблон пакета (тот же fallback, что и при отсутствии кастомного шаблона) — деградация, не сбой рассылки;
- огромный шаблон/вложения → лимиты
max_body_size_kb/max_attachments_size_mbотклоняют сохранение с понятной ошибкой до отправки, не рвут письмо на транспорте; - одновременное редактирование одного шаблона двумя админами → optimistic locking на
PUT(версия/If-Match), второе сохранение получает 409 с diff, не «последний победил» молча; - XSS/инъекция через пользовательские данные в HTML-шаблоне → экранирование по умолчанию для payload-переменных,
{!! !!}— только для доверенных полей самого шаблона под studio-ролью (детали — раздел «Безопасность»); - ключ шаблона переименован/удалён модулем-владельцем без ресинхронизации →
cms:email-templates:syncдетектирует расхождение при следующем прогоне, шаблон помечается orphan в списке (не удаляется автоматически — защита от потери админского контента), health-чек сигнализирует; - тестовая отправка как канал спама → rate-limit
test_send_rate_limitper пользователь недостаточен сам по себе при компрометации аккаунта — дополнительно ограничивается per-IP на уровне ядра (forms/rate-limitмеханизм, не самодельная проверка); произвольный адрес получателя test-send — только под studio-ролью (см. «Безопасность»); - шаблон откатили на старую версию после отправки писем → уже отправленные письма ссылаются на
template_version_id, который был активен на момент их рендера (сохранён в журнале доставки модуля-отправителя), а не на текущую активную версию — диагностика «каким текстом ушло письмо N» остаётся точной и после последующих правок шаблона; - кастомный HTML не учитывает тёмную тему почтовика → Gmail/Apple Mail и другие клиенты применяют собственную инверсию цветов при отсутствии
color-schememeta-тега/инлайновых цветов — валидация при сохранении выдаёт предупреждение «шаблон не учитывает тёмную тему, проверьте предпросмотр» (не блокирует сохранение); дефолтный fallback-шаблон пакета обязан быть dark-mode-safe (явные цвета фона/текста инлайн-стилями + metacolor-scheme) — эталон для собственных шаблонов клиента.
Донорский код
Донор: — (новая разработка), исторических шаблонов писем для миграции нет по умолчанию.
Если клиент переезжает с прежней CMS с готовыми текстами писем — задел на будущее: cms:email-templates:import-legacy --source=<профиль> — маппинг старых текстов писем на новые key+locale (профиль описывает соответствие ключей и парсер формата донора), идемпотентна (повторный прогон обновляет по external_id, не дублирует), поддерживает --dry-run с отчётом расхождений (сколько прочитано/создано/обновлено/ пропущено и почему, построчные ошибки скачиваемы). Как и для прочих модулей без готового донора — команда не реализуется заранее, контракт фиксируется здесь на случай появления конкретного профиля переноса.
Тесты и приёмка
- [ ] Контрактные тесты: переменные вне whitelist не подставляются и не исполняются как код;
- [ ] health-чек модуля проверяет наличие fallback blade-шаблона для каждого зарегистрированного ключа;
- [ ] деградация при выключении — письма уходят по дефолтным blade-шаблонам без сбоя рассылки;
- [ ] тестовая отправка ограничена rate-limit во избежание спама (per-пользователь и per-IP);
- [ ] история версий доступна для отката, retention не даёт таблице расти бесконечно;
- [ ] инвалидация кеша шаблона происходит сразу при сохранении новой версии (тегово, не весь кеш модуля);
- [ ] отсутствующая переменная в payload рендерится плейсхолдером, не бросает исключение и не пустая строка;
- [ ] сохранение шаблона с невалидным MJML/HTML отклоняется на этапе сохранения с указанием строки ошибки;
- [ ] рендер на нелокализованный шаблон проходит fallback-цепочку локалей и деградирует с логом, если дефолтная тоже не переведена;
- [ ] XSS-инъекция через payload-переменную не исполняется — контрактный тест на экранирование
и запрет{!! !!}вне studio-полей; - [ ] header injection через
subjectневозможен — control-символы в переменных темы отфильтрованы; - [ ] optimistic lock на одновременное редактирование — второй
PUTполучает 409, а не тихую перезапись; - [ ] контрактный набор
cms-testingзелёный, пакет протестирован в testbench-изоляции; - [ ] манифест не содержит
provides: email-templates(регресс-тест на решение «Зависимости и выключение» —email-templatesне входит в канонический реестр provides-контрактов ядра); - [ ] ответ рендера содержит
template_version_id(id использованной версии шаблона,nullтолько при фактическом fallback на blade-шаблон пакета); - [ ] откат шаблона на старую версию не меняет
template_version_id, сохранённый в журнале доставки уже отправленных писем, — повторный рендер того же события после отката по-прежнему указывает на версию, активную на момент отправки; - [ ] тестовая отправка на произвольный (не свой) адрес отклоняется вне studio-роли; без studio-роли адрес получателя всегда совпадает с текущим пользователем;
- [ ] preview рендерится в обеих темах почтового клиента (светлая/тёмная) без ошибок, дефолтный fallback-шаблон пакета проходит dark-mode-валидацию без предупреждений;
- [ ] feature-тест на каждый роут API; тестовая БД только
email-templates_test,migrate:fresh/refresh/resetзапрещены.