Прямой ответ
Шаблон письма отделяет представление от данных: текст с placeholders и отдельный набор значений, подставляемых при отправке. Разделение позволяет менять формулировки без изменения кода отправки.
Рендер выполняется шаблонизатором:
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader("templates/email"),
autoescape=select_autoescape(["html"]),
)
ctx = {"user_name": "Иван", "order_id": 1001,
"items": [{"name": "Ноутбук", "qty": 1, "price": "74 000 ₽"}],
"total": "74 000 ₽"}
text_body = env.get_template("order_confirmed.txt.j2").render(**ctx)
html_body = env.get_template("order_confirmed.html.j2").render(**ctx)
Экранирование включается для HTML-шаблона, поскольку значение из данных пользователя без экранирования изменяет разметку письма.
Письмо содержит две версии представления:
| Часть | Назначение |
|---|---|
text/plain |
Читаемость без разметки, прохождение фильтров |
text/html |
Оформление, ссылки, структура |
Тип содержимого указывается в заголовке письма, а части объединяются как альтернативные представления одного сообщения. О структуре письма — в статье про транзакционные письма.
Версионирование шаблона требуется, поскольку уведомление создается до отправки, а шаблон может измениться между созданием и отправкой. Фиксация версии в записи уведомления определяет, какая версия используется.
CREATE TABLE email_templates (
code text NOT NULL,
version int NOT NULL,
subject text NOT NULL,
text_body text NOT NULL,
html_body text NOT NULL,
is_active boolean NOT NULL DEFAULT false,
PRIMARY KEY (code, version)
);
Активная версия одна на код шаблона, поэтому переключение выполняется изменением признака, а не заменой содержимого.
Почему шаблон хранится отдельно от кода отправки
Хранение текста письма в коде отправки выглядит простым, но создаёт четыре проблемы.
Изменение формулировки требует развёртывания. Правка текста проходит через сборку и выкладку, поэтому срочное изменение занимает часы.
Отсутствие истории изменений. Текст в коде меняется вместе с остальными правками, поэтому определить, какая версия была отправлена в конкретный день, требует разбора истории коммитов.
Невозможность проверки до отправки. Рендер шаблона с тестовыми данными выполняется только в работающем сервисе, поэтому ошибка выявляется на реальных письмах.
Отсутствие локализации. Текст на нескольких языках в коде усложняет сопровождение, тогда как отдельный шаблон на язык — обычная практика; о локализации — в статье про i18n приложения.
Практическое следствие: шаблон хранится как данные с версией, а код отправки содержит только ссылку на код шаблона и состав данных. О шаблонизации в Python — в статье про Jinja2.
Отдельный аспект — проверка рендера. Шаблон с необязательным полем падает при отсутствии значения, поэтому рендер выполняется с полным набором данных в тестах, включая случай отсутствующего значения.
rendered = env.get_template("order_confirmed.txt.j2").render(**ctx)
assert "{{" not in rendered and "{%" not in rendered
Проверка отсутствия незаменённых placeholders выявляет пропущенные переменные, которые иначе уходят в письмо пользователю.
Ограничения HTML в письмах и их влияние на шаблон
Поддержка HTML почтовыми клиентами неравномерна, поэтому шаблон письма отличается от шаблона веб-страницы. Четыре ограничения определяют состав разметки.
Табличная вёрстка вместо блочной. Часть клиентов не поддерживает современную модель позиционирования, поэтому структура строится таблицами.
Встроенные стили вместо внешних. Таблица стилей в заголовке игнорируется рядом клиентов, поэтому правила переносятся в атрибут стиля каждого элемента.
Отсутствие скриптов. Исполняемый код в письме не поддерживается, поэтому интерактивность ограничивается ссылками.
Ограничение ширины. Письмо читается в узкой области, поэтому ширина содержимого ограничивается, а сложные макеты не применяются.
Практическое следствие: один и тот же шаблон не используется для письма и для веб-страницы. Разделение представлений увеличивает число файлов, но исключает поломку отображения в почтовых клиентах.
Отдельный аспект — изображения. Часть клиентов блокирует загрузку изображений до явного действия пользователя, поэтому текст письма остаётся читаемым без них. Альтернативный текст для изображения обязателен, поскольку при блокировке отображается он.
Проверка отображения выполняется в нескольких клиентах, поскольку расхождения не выявляются просмотром в одном. Тестовая отправка на адреса разных сервисов показывает фактический результат.
Практика: организация шаблонов уведомлений
1. Храните шаблон отдельно от кода отправки. Изменение формулировки не требует развёртывания сервиса.
2. Версионируйте шаблон. Запись уведомления ссылается на версию, поэтому изменение шаблона не меняет уже созданные уведомления.
3. Включайте экранирование для HTML. Значение из данных пользователя без экранирования изменяет разметку письма.
4. Формируйте текстовую и HTML-версии. Письмо только в HTML фильтруется чаще, а текстовая версия обеспечивает читаемость.
5. Проверяйте рендер в тестах. Набор данных с отсутствующими значениями выявляет падение шаблона до отправки.
6. Проверяйте отсутствие незаменённых placeholder. Незаменённая переменная уходит в письмо и видна пользователю.
7. Ограничивайте число ссылок в письме. Ссылки на домены, отличные от домена отправителя, повышают вероятность фильтрации.
Рекомендации:
- Не подставляйте в шаблон необработанные данные пользователя. Экранирование обязательно, иначе содержимое письма изменяется.
- Разделяйте тему и тело. Тема письма также хранится в шаблоне и версионируется вместе с телом.
- Учитывайте длину строки в текстовой версии. Длинные строки без переноса читаются плохо в текстовых клиентах.
- Проверяйте отображение в нескольких клиентах. Поддержка HTML различается, поэтому оформление проверяется не в одном клиенте.
- Документируйте состав данных шаблона. Перечень переменных фиксируется, поскольку добавление переменной без обновления данных приводит к падению рендера.
Практический сценарий: письмо ушло с незаменённой переменной
Контекст: часть пользователей получила письмо с текстом «Здравствуйте, {{ user_name }}» вместо имени.
- Диагностика. В журнале отправки письмо зафиксировано как отправленное без ошибок. Проверка показала, что в наборе данных отсутствовало поле имени для пользователей без заполненного профиля.
- Причина. Шаблон обращался к переменной, которая передавалась только при заполненном профиле. Рендер в тестовой среде выполнялся с полным набором данных, поэтому случай отсутствующего значения не проверялся.
- Действия. (а) Добавили значение по умолчанию для необязательных переменных в шаблоне; (б) включили проверку рендера в тестах с набором данных без необязательных полей; (в) добавили проверку отсутствия незаменённых placeholder перед отправкой; (г) добавили журнал предупреждений при отсутствии ожидаемой переменной, чтобы пропуск выявлялся до отправки; (д) версионировали шаблон, чтобы исправление применялось к новым уведомлениям без изменения уже созданных.
- Результат. Письма с незаменёнными переменными не отправляются, а пропуск переменной выявляется проверкой до передачи провайдеру.
Вывод: рендер шаблона проверяется на наборе данных без необязательных полей, поскольку тестовый рендер с полным набором не покрывает реальные случаи. Проверка отсутствия незаменённых placeholder перед отправкой исключает отправку письма с текстом шаблона.
FAQ
Нужно ли версионировать шаблон письма? Да. Уведомление создаётся до отправки, поэтому изменение шаблона не должно менять уже созданные уведомления.
Зачем текстовая версия, если есть HTML? Текстовая версия читается в любом клиенте и повышает вероятность прохождения фильтров. Письмо только в HTML фильтруется чаще.
Нужно ли экранировать данные в шаблоне? Да, для HTML-версии обязательно. Значение из данных пользователя без экранирования изменяет разметку письма.
Как проверить шаблон до отправки? Рендером с тестовыми данными, включая случай отсутствующих необязательных полей, и проверкой отсутствия незаменённых placeholder.
Источники: Jinja2 templates, RFC 2046 (MIME), Python html — ссылки в шапке.