Тестування пошти в CI/CD: перехоплюємо листи через Mailpit замість того, щоб мокати sendMail
Мок sendMail() перевіряє, що функцію викликали — і мовчки пропускає невідрендерений {{token}}, битий unsubscribe і зламану верстку. Показуємо, як підняти fake-SMTP на порту 1025, прочитати справжній лист через HTTP API і писати асерти, що ловлять реальні регресії ще в pipeline.
EvilMail Team20 липня 2026 р.11 хв читання
Ваш застосунок надсилає листи щодня: підтвердження реєстрації, скидання пароля, інвойси, сповіщення. А тепер чесне питання — коли ви востаннє бачили, який саме HTML долетів до користувача після останнього деплою? У більшості команд відповідь: ніколи. У CI лист або замоканий на рівні коду, або (гірше) реально йде на живу адресу й псує репутацію домену.
Обидва варіанти дірявіші, ніж здається. Мок sendMail перевіряє факт виклику, а не результат. Реальна відправка з CI — це прямий шлях до спам-скарг і просідання SPF/DKIM-репутації. Правильна відповідь давно існує й займає один рядок у docker-compose: підняти фейковий SMTP-сервер, який ловить усе, і читати перехоплений лист назад через HTTP API — рівно той MIME, що побачив би отримувач.
Зелений тест. І абсолютно нічого не сказано про те, чи лист узагалі можна відкрити. Мок обриває шлях рівно там, де починаються справжні проблеми. Він не бачить фінального MIME після рендеру шаблону, а саме там живуть найдорожчі регресії:
Тестування email у CI/CD: Mailpit і MailHog для перехоплення листів (2026) — EvilMail Blog
Шаблонізатор не підставив змінну — у тілі поїхало буквальне {{reset_url}} або {{token}}. Юзер клікає й потрапляє в нікуди.
Посилання абсолютне лише в теорії: у тесті APP_URL не заданий, і в лист зашилося http://localhost:3000/verify?token=.... Поштовий клієнт користувача таку кнопку відкриє на самого себе.
Лист зібрався як text/html без text/plain-альтернативи. Частина клієнтів і антиспам-фільтрів це карають, а користувачі зі скрінрідерами взагалі лишаються без тексту.
Загубився заголовок List-Unsubscribe або Reply-To вказує на no-reply, з якого приходять реальні відповіді.
Жодну з цих регресій мок транспорту не зловить — бо він завершує тест до того, як лист став листом. Єдиний спосіб перевірити пошту чесно: дати їй реально пройти через SMTP і прочитати те, що приїхало на інший бік. Для цього й існує fake-SMTP sink — сервер, який приймає з'єднання на 1025, нікуди його не пересилає, а складає лист у себе для інспекції.
MailHog vs Mailpit у 2026: що обрати
Обидва інструменти слухають SMTP на 1025 і віддають веб-UI та HTTP API на 8025 — дефолти збігаються, тому міграція зазвичай зводиться до заміни Docker-образу. Але на цьому паритет закінчується.
MailHog (mailhog/MailHog, Go) — по суті заморожений проєкт. Останній значущий реліз був близько 2020 року, API застряг на v2, сховище — in-memory або MongoDB. Він уміє показати, що лист прийшов, і віддати його тіло. І все. Ніякої перевірки посилань, ніякого аналізу сумісності HTML.
Mailpit (axllent/mailpit) — фактичний наступник, який його й витіснив. Активна розробка, SQLite-сховище з компресією, REST API v1 і, головне, вбудовані перевірки, заради яких раніше писали власні скрипти: html-check (сумісність верстки з поштовими клієнтами), link-check (чи всі посилання живі), spam-score через SpamAssassin, SMTP relay/release для контрольованого перевідправлення й chaos-mode для тестів на збої SMTP.
Вердикт простий: новий проєкт — тільки Mailpit. MailHog лишайте, тільки якщо вже глибоко вросли в його API v2 і немає бюджету на міграцію. У всіх інших випадках зусилля на перехід — це зміна одного рядка образу.
Піднімаємо Mailpit і перенаправляємо SMTP застосунку
MP_SMTP_AUTH_ACCEPT_ANY і MP_SMTP_AUTH_ALLOW_INSECURE знімають вимоги до логіна й TLS — у пісочниці вони лише заважають. MP_MAX_MESSAGES тримає буфер обмеженим, щоб довгий прогін не роздув базу.
Далі — ключовий момент, який і робить цей підхід чесним. У застосунку ви не міняєте код відправки. Міняється лише транспорт:
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
# SMTP_USER / SMTP_PASS — не потрібні
У Nodemailer це той самий createTransport({ host, port }), у Django — EMAIL_BACKEND зі стандартним SMTP-бекендом і EMAIL_HOST=localhost, у Rails — ActionMailer з delivery_method: :smtp. Логіка збирання листа, рендер шаблону, вкладення, заголовки — усе бойове, крізь справжній SMTP-стек. Ви тестуєте реальний шлях, а не його підміну. На evilmail.pro цей самий прийом ганяє шаблони password_reset, welcome та invoice перед кожним деплоєм — саме щоб зловити невідрендерений плейсхолдер до того, як його побачить клієнт.
Читаємо перехоплений лист: HTTP API замість очей
Лист приїхав у Mailpit — тепер його треба прочитати з коду. API v1 дає рівно те, що потрібно:
GET /api/v1/messages — список із пагінацією (From, To, Subject, Snippet, Created).
GET /api/v1/message/{ID} — повне повідомлення: HTML, Text, адреси, вкладення.
GET /api/v1/message/{ID}/headers — усі заголовки як map «ім'я → масив значень».
GET /api/v1/search?query=to:[email protected] — знайти конкретний лист за отримувачем.
GET /api/v1/message/{ID}/link-check — перевірка всіх посилань на живість.
GET /api/v1/message/{ID}/html-check — оцінка сумісності верстки з поштовими клієнтами.
DELETE /api/v1/messages — очистити скриньку.
Для MailHog аналоги: GET /api/v2/messages, GET /api/v2/search?kind=to&query=..., а видалення — DELETE /api/v1/messages.
Останній ендпоінт критичний, і про нього легко забути. Якщо не чистити скриньку між тестами, листи течуть один в одного: тест реєстрації бачить лист із попереднього кейсу скидання пароля, асерт випадково зелений, а в проді все зламано. Правило без винятків: DELETE /api/v1/messages у afterEach (або на старті кожного кейсу). Ізоляція скриньки — не опція, а умова того, що ваші асерти взагалі щось означають.
Асерти, що ловлять реальні регресії
Ось як виглядає інтеграційний тест, який перевіряє лист так, ніби ви — прискіпливий отримувач. Стиль Jest/Vitest, але логіка один-в-один переноситься на pytest:
Крок очікування SMTP — не формальність. Контейнер сервісу може ще не встигнути відкрити порт 1025, коли джоба вже стартує тести; без retry на конект ви отримаєте плаваючий ECONNREFUSED раз на десяток прогонів. Простий цикл на nc -z знімає цю race condition.
У GitLab CI логіка та сама — блок services: з тим самим образом і SMTP_HOST на ім'я сервісу. Ефемерний Mailpit не лишає слідів між прогонами: кожна джоба отримує чисту скриньку.
Окрема сильна сторона Mailpit — chaos-mode. Він змушує SMTP штучно повертати 4xx/5xx на частину з'єднань, і це єдиний зручний спосіб перевірити, що ваша retry/backoff-логіка справді працює, а не існує лише на папері. Увімкнули chaos, прогнали відправку, переконалися, що застосунок коректно повторив спробу й не втратив лист — і тільки тоді довіряєте своїй черзі.
Чекліст перед мерджем
Fake-SMTP (Mailpit) підключений; застосунок дивиться на localhost:1025, код відправки не мокнутий.
Скринька чиститься через DELETE /api/v1/messages між кожним тестом.
Асерт на тему + коректний токен у verify/reset-URL.
Асерт на відсутність {{-плейсхолдерів у тілі.
link-check зелений — жодного 404 чи битого unsubscribe.
Присутня text/plain-альтернатива поряд із HTML.
Заголовки List-Unsubscribe і Reply-To на місці й коректні.
Health-check/retry на SMTP-порт у CI перед прогоном тестів.
Chaos-тест підтверджує retry-логіку відправника.
Тест-конфіг ніколи не містить прод-SMTP облікових даних і не шле на реальні MX.
Різниця між expect(sendMail).toHaveBeenCalled() і повним читанням перехопленого MIME — це різниця між «ми думаємо, що лист відправляється» і «ми бачили точно той лист, який отримає клієнт». Перше зелене на моніторі; друге — зелене в поштовій скриньці користувача. Mailpit коштує один сервіс-контейнер, тож ставте другу планку.