Меня зовут Дима. Я Frontend разработчик в компании fuse8. В одном из проектов мы столкнулись с необходимостью упростить процесс верстки и тестирования HTML-писем. В итоге решили вынести шаблоны в отдельный репозиторий и собирать их с помощью MJML.
В этой статье разберём два ключевых этапа: сначала создадим репозиторий для верстки писем, а затем настроим локальную тестовую отправку через SMTP.
Вот ссылка на репозиторий, где можно взять готовый шаблон, запустить его и проверить. А о том, из чего он состоит и как формируется, читайте ниже.
Зачем всё это?
На проекте мы активно используем HTML-письма. Изначально шаблоны располагались прямо в микросервисах бэкенда — разрозненно, с повторяющимися частями. Это создавало сложности при сопровождении и тестировании писем. Захотелось упростить процесс разработки, а главное — сделать верстку и проверку писем удобной.
А верстать письма, как известно, больно. Это как будто вы верстаете под Internet Explorer — если вы, конечно, помните, что это такое.
Первый шаг: выносим письма в отдельный репозиторий
Мы решили создать отдельный репозиторий, в котором будут храниться все шаблоны писем. Бэкенд сможет подтягивать их централизованно.
Следующий шаг — выбор инструментов для верстки и тестирования. Рассматривались:
MJML
Maizzle
Foundation HTML
Хотелось писать на более высокоуровневом языке, используя предустановленные компоненты. В Maizzle и Foundation HTML показалось, что придется писать больше кода на чистом HTML.
В итоге остановились на MJML — языке разметки для email-писем, который компилируется в полноценный HTML, адаптированный под специфику email-клиентов.
Что такое MJML и чем он удобен?
MJML – это язык, предназначенный для верстки HTML писем, который компилируется в обычный HTML. Это значит, что можно писать в абстрактном синтаксисе, который затем превратится в HTML-код с поддержкой разных почтовых клиентов. Это облегчает адаптацию под разные почтовые клиенты и даёт возможность использовать:
адаптивную верстку;
предустановленные компоненты;
переиспользование кода с помощью mj-include.
Здесь можно посмотреть, как код mjml превращается в html.
Базовая настройка проекта
Разберём базовую настройку проекта — без углубления в синтаксис MJML.
Установим зависимости:
npm install mjml live-server concurrently
Что делает каждая из них:
mjml
— компиляция MJML → HTMLlive-server
— запуск dev-сервера с live reload (можно использовать любой сервер)concurrently
— параллельный запуск команд
Настройка package.json
Добавим в scripts
:
"scripts": {
"start": "mjml --watch ./src/templates/**/*.mjml --output ./templates",
"server": "live-server --host=localhost --watch=templates --open=templates --ignorePattern=\".*.mjml\"",
"dev": "concurrently \"npm run start\" \"npm run server\"",
"build": "mjml ./src/templates/**/*.mjml --output ./templates"
}
Что делает каждая команда:
start
— следит за файлами*.mjml
вsrc/templates
, компилирует в./templates
server
— поднимает сервер и отслеживает изменения в./templates
dev
— запускаетstart
иserver
параллельноbuild
— однократная сборка шаблонов без вотчера
Создаём первый шаблон
Создадим файл example.mjml
по пути /src/templates/example.mjml
, в папке /src/templates
будут все шаблоны. В файл добавим следующий код:
<mjml>
<mj-body background-color="#f5f5f5">
<mj-section padding="40px 0 20px">
<mj-column>
<mj-text align="center" font-size="28px" font-weight="bold">Письмо на mjml</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column>
<mj-image src="https://placehold.jp/300x200.png" />
</mj-column>
<mj-column>
<mj-text font-size="18px" font-weight="bold">Привет, мир!</mj-text>
<mj-text>Это письмо отправлено с помощью MJML.</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-button href="#" background-color="#4CAF50">Подписаться</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>
Запускаем npm run dev
— и в браузере откроется HTML, сгенерированный из MJML. При каждом изменении шаблона страница будет автоматически обновляться. Также полезно создать папку ./src/parts
для переиспользуемых частей верстки.
В результате получим десктопную и мобильную версии.


В папке ./src/parts
создаём переиспользуемые части писем:
./src/parts/global-settings.mjml
<mj-style inline="inline">
body { background-color: #f0f4f6; font-family: Arial, sans-serif; }
a { color: #1d5cdb; text-decoration: none; }
</mj-style>
<mj-attributes>
<mj-text
font-size="17px"
line-height="24px"
color="#000"
padding-top="5px"
padding-bottom="5px"
/>
</mj-attributes>
./src/parts/header.mjml
<mj-section>
<mj-column>
<mj-image
src="https://placehold.jp/82x47.png"
alt="Логотип"
width="82px"
height="47px"
/>
</mj-column>
</mj-section>
Подключим их в example.mjml
и удалим лишние стили:
<mjml>
<mj-head>
<mj-include path="../parts/global-settings.mjml" />
<mj-title>Пример</mj-title>
</mj-head>
<mj-body>
<mj-include path="../parts/header.mjml" />
<mj-section padding="40px 0 20px">
<mj-column>
<mj-text align="center" font-size="28px" font-weight="bold">Письмо на mjml</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column>
<mj-image src="https://placehold.jp/300x200.png" />
</mj-column>
<mj-column>
<mj-text font-size="18px" font-weight="bold">Привет, мир!</mj-text>
<mj-text>Это письмо отправлено с помощью MJML.</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-button href="#" background-color="#4CAF50">Подписаться</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>
Получаем результат:

В итоге:
Можем глобально задавать общие стили и настройки для компонентов с помощью global-settings.
Можем выносить по аналогии части кода, как это сделано в header.
Разрабатываем и видим изменения в браузере в реальном времени за счет настроенного dev сервера.
Использование шаблонов на бэкенде (Go)
У нас на проекте бэкенд написан на Go. Собранные шаблоны писем попадают в папку ./templates
. Оттуда бэкенд может их подтягивать и отправлять письма. В нашем случае подключение шаблонов выглядит так:
В корне проекта —
go.mod
:
module gitlab.site.ru/front-html-email-templates
go 1.22.0
В
./templates/templates.go
:
package templates
import _ "embed"
//go:embed example.html
var Example string
При добавлении нового шаблона нужно добавить аналогичную переменную в templates.go
.
Также в письмах есть переменные, которые подставляет бэкенд с помощью своего шаблонизатора. В нашем случае для переменных следующий синтаксис {{.variableName}}
<mjml>
<mj-body>
<mj-section padding="40px 0 20px">
<mj-column>
<mj-text align="center" font-size="28px" font-weight="bold">{{.title}}</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
Чтобы понимать, какие переменные нужны в шаблоне, мы создали папку ./docs/templates
, где хранятся md файлы с одноименным названием шаблона и описанием того, какие переменные используются, например:
## Candidate
Шаблон письма 'Отклик без вакансии'
### Переменные
- ``{{.date}}`` Дата отклика
- ``{{.title}}`` Название вакансии
- ``{{.region}}`` Регион
- ``{{.name}}`` ФИО
- ``{{.phone}}`` Телефон
- ``{{.email}}`` Email
- ``{{.comment}}`` Комментарий
- ``{{.resume_link}}`` Ссылка на резюме
- ``{{.year}}`` Актуальный год отправки письма
Сами переменные в файлы может занести бэкендер, а фронтендеру остается расставить их в верстке по правильным местам.
Локальное тестирование по smtp
Напишем простой js скрипт, который будет отправлять наши шаблоны на реальную почту.
Для начала установим следующие пакеты:
npm install dotenv nodemailer
dotenv
— пакет для загрузки .env файлов в js;nodemailer
— пакет для отправки писем с поддержкой SMTP.
После этого создадим файл ./send-test-email.js
и добавим код:
import nodemailer from 'nodemailer'
import dotenv from 'dotenv'
import fs from 'fs/promises'
dotenv.config()
async function sendTestEmail() {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: true, // true для 465, false для других портов
auth: {
user: process.env.SEND_FROM_EMAIL,
pass: process.env.SEND_FROM_EMAIL_PASSWORD,
},
})
const htmlEmailString = await fs.readFile(
./templates/${process.env.TEMPLATE_NAME}.html,
'utf-8'
)
const mailOptions = {
from: "Test Sender" <${process.env.SEND_FROM_EMAIL}>,
to: process.env.SEND_TO_EMAIL,
subject: 'Test HTML Email',
html: htmlEmailString,
}
const info = await transporter.sendMail(mailOptions)
console.log('Message sent: %s', info.messageId)
}
sendTestEmail().catch(console.error)
Это простая реализация отправки письма по SMTP. Также нам нужно создать .env файл с нужными переменными:
TEMPLATE_NAME=email-confirmation
SEND_TO_EMAIL=test@fuse8.online
SMTP_HOST=smtp.mail.ru
SMTP_PORT=465
SEND_FROM_EMAIL=test@mail.ru
SEND_FROM_EMAIL_PASSWORD=BcsftTdfdsf
TEMPLATE_NAME
— название шаблона, который будет отправлен;SEND_TO_EMAIL
— куда будет отправлено письмо;SMTP_HOST
— smtp хост;SMTP_PORT
— порт;SEND_FROM_EMAIL
— откуда будет уходить письмо.SEND_FROM_EMAIL_PASSWORD
— пароль от отправляемой почты.
В основном меняются переменные TEMPLATE_NAME
и SEND_TO_EMAIL
. Для тестирования разных шаблонов и разных почтовых клиентов.
Остальные переменные нужно настроить один раз. Например, как подключиться по SMTP в mail почте https://help.mail.ru/mail/login/mailer.
После всей настройки для отправки письма нужно вызвать скрипт:
node ./send-test-email.js
Для удобства можно добавить скрипт в package.json
, также можно сделать поддержку массовой отправки шаблона на разные почтовые клиенты.
Заключение
Текущую концепцию можно применить и с другими инструментами. В результате у нас есть:
централизованный репозиторий для html писем;
верстка писем с лучшими практиками и переиспользуемыми частями;
локальное тестирование по smtp.
Нужно иметь в виду, что верстка все равно может разваливаться в старых outlook клиентах и с такими решениями как MJML, где даются эталонные шаблоны писем. Даже использование лучших практик не избавляет от этой проблемы. Чтобы избежать «разваливания», для старых решений можно использовать максимально тривиальную верстку: простые тексты и заголовки, отсутствие стилизаций и визуальных элементов. Если вы знаете, как иначе можно выйти из ситуации, расскажите в комментариях – будет полезно ?