Тестирование писем в CI/CD через Mailpit: перехват SMTP без реальной отправки
Письмо — это артефакт сборки, а не то, что «проверяют глазами после деплоя». Ставим локальный SMTP-сток Mailpit в пайплайн, перехватываем всё на :1025 и гоняем детерминированные ассерты по заголовкам, MIME и вёрстке через REST. Плюс честная граница: что сток проверить не может.
EvilMail Team31 июля 2026 г.11 мин чтения
Почему реальная отправка в CI — это баг, а не фича
Сценарий, который повторяется в каждой второй кодовой базе. В фикстуре welcome-письма жёстко зашит [email protected], кто-то меняет его на свой рабочий адрес для отладки, забывает откатить — и с этого момента каждый прогон интеграционных тестов на CI шлёт живому человеку «Добро пожаловать в сервис». Дальше веселее: тест дёргает внешний SMTP, релей включает greylisting, первая попытка отбивается 450 4.2.0, тест флакает через раз, команда добавляет sleep 5 и ретраи, зелёный билд превращается в лотерею. А в CI-секретах при этом лежит логин и пароль от продового релея — потому что «ну надо же куда-то отправлять».
И самое обидное: сломанный <table>-лейаут, поехавший в Outlook, всё это не ловит. Он всплывает в проде, когда письмо уже ушло десяти тысячам подписчиков.
Корень проблемы в том, что письмо воспринимают как побочный эффект, а не как артефакт сборки. API-ответ вы проверяете ассертами по схеме, а письмо — «глазами после деплоя». Лечится это одним архитектурным решением: в пайплайн ставится SMTP-сток — процесс, который принимает любое соединение на порту 1025, складывает письмо в память или SQLite и отдаёт его тестам по HTTP. Ноль исходящего трафика с раннера. Приложение думает, что отправило письмо; на деле оно попало в ловушку, где его можно распотрошить и проверить.
Mailpit против MailHog: почему в 2026 берём Mailpit
Долгие годы дефолтом был MailHog, и по инерции его до сих пор пихают в свежие пайплайны. Не надо. Репозиторий mailhog/MailHogархивирован, последний релиз — v1.0.1 от 2020 года. Это Go-бинарь, который тянет за собой зависимости шестилетней давности с непропатченными CVE, и никто их уже не закроет. Единственная фича MailHog сверх базового перехвата — Jim, встроенный chaos-monkey, который рвёт соединения и эмулирует медленный сервер. Полезно ровно один раз в жизни.
Mailpit от axllent — это то, чем MailHog должен был стать. Активная разработка, хранение в SQLite (переживает рестарт контейнера, либо полностью in-memory для чистого прогона), совместимые порты 1025/8025. Из коробки: REST API v1, встроенный html-check по данным «Can I email», link-check, проверка через SpamAssassin, POP3-доступ, теги и message release. Миграция почти бесплатная — приложение конфигурится теми же SMTP_HOST/SMTP_PORT, меняется только путь к API (у Mailpit это /api/v1/, у MailHog был /api/v2/).
Когда MailHog всё ещё приемлем — только замороженный legacy-пайплайн, который нельзя трогать по политическим причинам. Для всего нового — Mailpit, без обсуждений.
Поднимаем сток в пайплайне
Локально хватает одной строки:
bash
docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit
В GitHub Actions правильнее поднять его как сервис-контейнер с healthcheck, чтобы job не стартовал раньше, чем сток готов принимать соединения. Здоровье проверяем через /readyz, а не «сырым» портом:
Два ENV здесь критичны. MP_SMTP_AUTH_ACCEPT_ANY=1 заставляет сток принимать любой логин и пароль — приложению не нужны валидные креды, и вы не тащите продовые секреты в CI. MP_SMTP_AUTH_ALLOW_INSECURE=1 разрешает аутентификацию без TLS, чтобы не городить самоподписанные сертификаты внутри job. MP_MAX_MESSAGES=500 включает кольцевую ротацию — сток не распухнет за долгий прогон. Для абсолютно чистого запуска оставьте MP_DATABASE пустым: тогда хранилище in-memory и умирает вместе с контейнером.
Если у вас монорепо или нужен идентичный запуск локально и на CI без привязки к синтаксису services: конкретной платформы — поднимайте Mailpit через Testcontainers прямо из тестового кода. Контейнер живёт ровно на время тестовой сессии, порт пробрасывается на случайный, коллизий с занятым 1025 не будет.
Ассерты по REST: письмо как тестируемый объект
Ядро подхода. Тест прогоняет реальный бизнес-флоу — например, регистрацию, которая триггерит welcome-письмо — затем поллит сток, пока сообщение не появится. Никаких фиксированных sleep: ждём total > 0 с таймаутом, иначе на медленном раннере тест ляжет, а на быстром будет тратить секунды впустую.
bash
# ждём письмо поллингом, не sleep
for i in $(seq 1 20); do
n=$(curl -s localhost:8025/api/v1/messages | jq '.total')
[ "$n" -gt 0 ] && break
sleep 0.5
done
id=$(curl -s localhost:8025/api/v1/messages | jq -r '.messages[0].ID')
# тема совпадает И в HTML нет незамещённых плейсхолдеров шаблонизатора
curl -s "localhost:8025/api/v1/message/$id" \
| jq -e '.Subject == "Добро пожаловать" and (.HTML | contains("{{") | not)'
Что реально стоит проверять на объекте письма:
Получатель и тема. Тему стоит гонять через MIME encoded-word (=?UTF-8?B?...?=) — на кривой кодировке кириллицы тут ловится баг, который в интерфейсе почтовика выглядит как крякозябры. Mailpit возвращает уже декодированный Subject, но если дёргаете /headers напрямую — декодируйте сами.
Обе части `multipart/alternative`. И text/plain, и text/html должны присутствовать. Письмо без текстовой части — минус к репутации у спам-фильтров и пустой экран у тех, кто читает в plain-режиме.
`List-Unsubscribe` и `List-Unsubscribe-Post`. Для bulk-отправки это не опция: с февраля 2024 года Gmail и Yahoo требуют one-click-отписку по RFC 8058 (List-Unsubscribe-Post: List-Unsubscribe=One-Click). Проверяйте наличие обоих заголовков в рассыльных письмах прямо в тесте.
`Message-ID` — должен быть и должен быть уникальным.
Отсутствие `{{плейсхолдеров}}` в теле. Классический баг шаблонизатора, когда переменная не подставилась и клиент получает буквальное {{firstName}}. Одна jq-строка ловит это навсегда.
Вложения через массив Attachments[] — имя, MIME-тип, размер.
И обязательно чистите сток между тестами: DELETE /api/v1/messages. Иначе первый же тест, оставивший письмо, отравит выборку messages[0] в следующем — кросс-тестовые протечки на пустом месте.
Вёрстка и ссылки без реального почтового клиента
Здесь Mailpit делает то, чего MailHog не умел в принципе. Два эндпоинта превращают «проверку глазами» в автоматический gate.
GET /api/v1/message/{ID}/html-check прогоняет HTML письма по базе поддержки CSS-фич из проекта «Can I email» и возвращает массив Warnings — по одной записи на каждую потенциально несовместимую фичу, с долей клиентов, которые её поддерживают (Score.Supported), плюс разбивку по конкретным клиентам: Outlook, Gmail, Apple Mail. Использовали position: absolute или современный flex, который Outlook на Word-движке не переварит? Соответствующее предупреждение просядет по поддержке — и вы завалите сборку до того, как вёрстка доедет до подписчика. В gate удобно брать худшую фичу письма:
GET /api/v1/message/{ID}/link-check обходит все URL в письме и возвращает их HTTP-коды. Это ловит битые CTA и протухшие ссылки на ассеты (картинки в CDN, промо-лендинги) до того, как по ним кликнет клиент. Превращаем в условие фейла просто: любой ответ 4xx или 5xx — красный билд.
Порог по html-check держите реалистичным. Ставить 100% бессмысленно — всегда найдётся клиент, не поддерживающий что-то безобидное. 90% как стартовая планка, дальше калибруете под свою аудиторию и матрицу клиентов.
Где проходит граница: что сток НЕ проверяет
Раздел, без которого вся статья была бы вредным советом. Сток проверяет ровно одно: что приложение сформировало и попыталось отправить структурно корректное, верно свёрстанное письмо. Он не проверяет, дойдёт ли это письмо до инбокса. Это разные слои, и путать их — прямой путь к ложной уверенности.
Конкретно, сток не делает следующего:
Не подписывает DKIM. Подпись ставит исходящий MTA на своём приватном ключе — в контуре стока её просто нет и проверять нечего.
Не считает SPF и выравнивание DMARC — для этого нужен реальный IP отправителя и публичные DNS-записи.
Не даёт reputation и inbox-placement. Попадёте вы в «Входящие» или в «Спам» — вопрос репутации домена и IP, а не структуры письма.
Не воспроизводит настоящий greylisting и rate-limit принимающей стороны.
Всё это живёт в другом слое: staging с реальным исходящим релеем и seed-тестами (mail-tester и аналоги, которые рассылают на набор ящиков в Gmail/Outlook/Yahoo и меряют, куда упало). Этот слой медленный, флакающий по своей природе и зависит от внешнего мира — поэтому он гоняется в nightly, а не на каждом PR. DKIM в проде верифицируется на исходящем MTA, а не в юнит-контуре. Правило простое: в PR-пайплайне — детерминированный сток (структура и вёрстка), в nightly/staging — доставляемость на живом транспорте.
Чек-лист внедрения
Сток поднят как сервис-контейнер с healthcheck на /readyz, а не как «сырой» проброшенный порт — job не должен стартовать раньше готовности стока.
ENV приложения указывают на localhost:1025, SMTP_SECURE=false, auth accept-any — никаких продовых кредов в CI.
Письмо ждём поллингом `total > 0` с таймаутом, фиксированный sleep под запретом.
DELETE /api/v1/messages до и/или после каждого теста — изоляция от кросс-тестовых протечек.
Ассертим структуру: получатель, декодированная тема, обе MIME-части, List-Unsubscribe + List-Unsubscribe-Post для bulk, Message-ID, отсутствие незамещённых {{плейсхолдеров}}.
html-check (score выше порога) и link-check (нет 4xx/5xx) — жёсткие fail-условия gate.
Хранилище эфемерное: in-memory или MP_MAX_MESSAGES с ротацией.
Слои разнесены: структура и вёрстка — в PR-пайплайне, доставляемость и DKIM — в staging/nightly.
Как только этот контур встаёт в gate, сломанная вёрстка и пустой List-Unsubscribe перестают доезжать до прода. Письмо становится ровно тем, чем и должно быть — обычным артефактом сборки, у которого есть красный и зелёный статус.