Skip to content

ТЗ — Шаблоны писем (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_templateskey, locale, subject, body, variables (json), is_activeАктивная версия шаблона на пару ключ+локаль
cms_email_template_versionstemplate_id, subject, body, created_by, created_atИстория версий для отката

variables — JSON-столбец с cast array; is_active — bool cast. template_idconstrained('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_activeFormRequest, 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}/previewlocale, тестовые значения переменных (sample_data)Whitelist переменных ключа — неизвестная переменная → 422, а не тихий пропуск
API POST .../{key}/test-sendemail получателя (опционально — по умолчанию e-mail текущего авторизованного пользователя), localeFormRequest (формат 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История версий с diffJSON
Шина событийEmailTemplateUpdated, EmailTemplateRolledBackPayload события (канал 1)
cms:email-templates:sync --jsonРеестр зарегистрированных ключей и их whitelist-переменныхJSON

Модуль-отправитель сохраняет template_version_id в своём собственном журнале доставки (email-templates журнала отправки не ведёт — только рендерит и отдаёт ссылку на использованную версию); это позволяет точно установить, каким текстом ушло конкретное письмо, даже после последующего отката шаблона (см. «Крайние случаи»).

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

КлючТипДефолтaffectsPageCacheОписание
email-templates.fallback_to_packagebooltrueИспользовать blade-шаблон пакета при отсутствии кастомного
email-templates.fallback_localestringдефолтная локаль сайта (locale-группа ядра)Локаль рендера, если нет перевода ни на нужную, ни на дефолтную (используется в fallback-цепочке ядра)
email-templates.test_send_rate_limitint10Лимит тестовых отправок в час на пользователя
email-templates.versions_retentionint20Сколько последних версий шаблона хранить
email-templates.max_body_size_kbint256Лимит размера тела письма (HTML+инлайн-стили); превышение отклоняется при сохранении
email-templates.max_attachments_size_mbint10Лимит суммарного размера вложений/изображений письма (через MediaService)
email-templates.preview_enabled_for_studio_onlyboolfalseKill-switch: ограничить предпросмотр и тестовую отправку studio-ролью

API

МетодПутьДоступНазначение
GET/api/v1/admin/email-templatesemail-templates.manageСписок шаблонов (ключ, локаль, активность)
PUT/api/v1/admin/email-templates/{key}email-templates.manageСохранение шаблона (whitelist-валидация переменных)
POST/api/v1/admin/email-templates/{key}/previewemail-templates.manageРендер preview с тестовыми данными
POST/api/v1/admin/email-templates/{key}/test-sendemail-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):

РольПросмотрРедактированиеPreviewTest-sendRollback
Админ✓ (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_limit per пользователь недостаточен сам по себе при компрометации аккаунта — дополнительно ограничивается per-IP на уровне ядра (forms/rate-limit механизм, не самодельная проверка); произвольный адрес получателя test-send — только под studio-ролью (см. «Безопасность»);
  • шаблон откатили на старую версию после отправки писем → уже отправленные письма ссылаются на template_version_id, который был активен на момент их рендера (сохранён в журнале доставки модуля-отправителя), а не на текущую активную версию — диагностика «каким текстом ушло письмо N» остаётся точной и после последующих правок шаблона;
  • кастомный HTML не учитывает тёмную тему почтовика → Gmail/Apple Mail и другие клиенты применяют собственную инверсию цветов при отсутствии color-scheme meta-тега/инлайновых цветов — валидация при сохранении выдаёт предупреждение «шаблон не учитывает тёмную тему, проверьте предпросмотр» (не блокирует сохранение); дефолтный fallback-шаблон пакета обязан быть dark-mode-safe (явные цвета фона/текста инлайн-стилями + meta color-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 запрещены.

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