Одноразовая почта для QA и staging: как тестовые регистрации не отравляют продакшн-репутацию
QA-команды, которые регистрируют тестовые аккаунты на корпоративных ящиках или на плюс-алиасах Gmail, своими руками гробят domain reputation и смешивают тестовые данные с живыми клиентами. Разбираем, как спроектировать изолированный пул одноразовых адресов, sink-SMTP на staging и стерильную очистку тестовой базы — с кодом Playwright, конфигом Mailpit и DNS для blackhole-домена.
EvilMail Team4 августа 2026 г.10 мин чтения
Однажды утром продовый домен получил 40 000 hard bounce за ночь. Не рассылка, не взлом — ночной прогон автотестов. QA регистрировал аккаунты на адресах вида [email protected], [email protected], приложение честно отправляло welcome-письма через продовый релей, MX отвечал 550 5.1.1 unknown user, и к утру репутация домена в Google Postmaster Tools ушла в красную зону. Разгребали её две недели.
Проблема не в тестах. Проблема в том, что тестовый почтовый трафик пустили по продовым рельсам. Одноразовая почта — это не инструмент спамеров, а недооценённый примитив тестовой инфраструктуры: короткоживущий адрес с реальным API-доступом к инбоксу, который можно программно выдать эфемерному аккаунту и так же программно выбросить. Ниже — как построить это правильно.
Почему тестовые регистрации ломают почтовую репутацию
. Дальше происходит одно из двух, и оба варианта плохие.
Если адрес реальный (плюс-алиас), все welcome-письма, сбросы пароля и OTP из каждого прогона сыпятся в один живой инбокс. Данные не изолированы: при дедупликации по нормализованному адресу тестовый me+test01 и живой клиент me схлопываются в один аккаунт вашей БД. Многие формы плюс-адресацию (RFC 5233 subaddressing) ещё и режут валидацией, так что часть сценариев вы этим способом вообще не покроете.
Если адрес выдуманный, staging с продовым SMTP отправляет письмо на несуществующий MX и получает hard bounce 550 5.1.1. Каждый такой bounce копится в репутации отправляющего домена. Приёмные системы вроде Gmail считают массовую отправку на несуществующие ящики признаком грязного списка и роняют доставляемость всей вашей почты — включая живые транзакционные письма. Для ориентира: в Google Postmaster Tools spam rate выше 0.3% — уже красная зона, после которой репутация домена деградирует для всех получателей разом. Серия hard bounce уводит вас туда быстро.
Цель, к которой идём: тестовый трафик не касается ни живых людей, ни продакшн-репутации, ни продакшн-базы. Три разные вещи, и разводить их надо по отдельности.
Три уровня изоляции, которые надо развести
Одноразовая почта закрывает уровень 1 полностью и уровень 3 частично — даёт детерминированный маркер-домен для фильтрации. Но уровень 2 она сама по себе не решает: если staging всё ещё держит продовые SMTP-креды, ничто не мешает приложению отправить письмо на реальный внешний адрес и словить bounce. Поэтому одноразовый адрес — это идентификатор плюс реальный инбокс по API, а sink-SMTP — отдельный предохранитель на исходящем канале. Нужны оба.
Выделенный домен-песочница вместо плюс-алиасов
Первое решение — завести отдельный домен или поддомен под тестовые адреса, например qa-mail.evilmail.pro, а не размазывать субадресацию по продовому ящику. Что это даёт:
Изоляция репутации. Что бы ни творилось с этим доменом — он не про вашу транзакционную почту.
Тривиальная фильтрация. Все тестовые адреса заканчиваются на один суффикс — по нему база чистится одним LIKE.
Два режима на выбор. Домен можно направить в реальный инбокс (читаем письма) или в blackhole (просто гасим).
Для режима «домен ничего не принимает» есть легитимный стандартный способ — null MX из RFC 7505:
dns
; qa-mail.evilmail.pro не принимает почту вообще
qa-mail.evilmail.pro. IN MX 0 .
qa-mail.evilmail.pro. IN TXT "v=spf1 -all"
Точка вместо хоста в MX означает «у домена явно нет почтового сервера», а v=spf1 -all заявляет, что никто не вправе слать от его имени, — двойная страховка на случай, если тестовый адрес где-то утечёт в реальную рассылку. Альтернатива для локального перехвата — MX на хост, указывающий на ваш sink.
Когда письма нужно реально прочитать (подтверждение почты, magic link, OTP), поднимать свой Dovecot ради этого не нужно. Одноразовый сервис вроде evilmail.pro отдаёт готовый REST-доступ к инбоксу: создал адрес, дождался письма, вытащил токен.
Паттерн 1: реальный inbox через API для E2E happy-path
Сценарий: тест регистрирует аккаунт, приложение шлёт письмо со ссылкой подтверждения, тест должен эту ссылку открыть. Флоу из пяти шагов — получить адрес, зарегистрироваться в SUT, опросить инбокс, выдернуть токен регуляркой, завершить верификацию.
Ключевой момент — поллинг с бэкоффом и жёстким таймаутом. Письмо может идти секунды, но ждать вечно нельзя.
typescript
// e2e/helpers/mailbox.ts
const API = 'https://api.evilmail.pro/v1';
const KEY = process.env.TEMP_MAIL_API_KEY!; // из CI-vault, не из репо
export async function createInbox(worker: string) {
const r = await fetch(`${API}/inbox`, {
method: 'POST',
headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({ domain: 'qa-mail.evilmail.pro', label: worker }),
});
const { address, id } = await r.json();
return { address, id };
}
// Поллинг с экспоненциальным бэкоффом, потолок 60 секунд
export async function waitForLink(inboxId: string, re: RegExp, timeoutMs = 60_000) {
const started = Date.now();
let delay = 1000;
while (Date.now() - started < timeoutMs) {
const r = await fetch(`${API}/inbox/${inboxId}/messages`, {
headers: { authorization: `Bearer ${KEY}` },
});
const msgs = await r.json();
for (const m of msgs) {
const hit = (m.text ?? m.html ?? '').match(re);
if (hit) return hit[0];
}
await new Promise((res) => setTimeout(res, delay));
delay = Math.min(delay * 1.6, 8000);
}
throw new Error(`Письмо не пришло за ${timeoutMs}мс в inbox ${inboxId}`);
}
Регулярка /https?:\/\/\S+\/verify\/[A-Za-z0-9._-]+/ вытаскивает ссылку из тела письма без хрупкого HTML-парсинга. Обратите внимание на testInfo.workerIndex в лейбле адреса — зачем он, разберём в разделе про CI.
Паттерн 2: SMTP-sink для грязных и нагрузочных прогонов
Когда содержимое письма не важно, а важно, чтобы staging физически не мог доставить письмо наружу, ставим catch-all SMTP-приёмник. Современный выбор — Mailpit (axllent/mailpit), живая замена заброшенному MailHog: SMTP на :1025, веб-интерфейс и REST API на :8025.
yaml
# docker-compose.staging.yml
services:
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "127.0.0.1:1025:1025" # SMTP только на loopback
- "127.0.0.1:8025:8025" # UI + API
environment:
MP_MAX_MESSAGES: 5000
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
Приложение на staging получает единственно допустимую SMTP-конфигурацию:
bash
# .env.staging — прод-креды здесь НЕДОСТУПНЫ в принципе
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false
Всё, что приложение «отправит», осядет в Mailpit с ответом 250 OK. Ноль внешних bounce, ноль писем живым людям. Захотели проверить, что письмо вообще сформировалось, — читаете его тем же способом, что и реальный inbox:
Главное правило уровня 2: продовые SMTP-креды не должны существовать в staging-окружении. Не «указывают на sink», а именно отсутствуют в vault этого окружения. Тогда даже кривой конфиг не пробьёт периметр.
Изоляция данных: тестовые пользователи не в продакшн-базе
Даже с одноразовыми адресами тестовые регистрации не должны жить в той же таблице, что живые клиенты. Минимум — отдельная схема или база под staging. Практический слой поверх этого — детерминированный маркер-домен, по которому база чистится в одну строку:
sql
-- teardown после прогона: всё тестовое на одном домене
DELETE FROM users
WHERE email LIKE '%@qa-mail.evilmail.pro';
-- заодно всё, что на них завязано
DELETE FROM sessions
WHERE user_id NOT IN (SELECT id FROM users);
Это надёжнее, чем удалять по временным меткам или по флагу is_test, который кто-нибудь забудет проставить. Домен адреса — свойство, которое нельзя не задать: адрес либо на qa-mail, либо нет.
Про GDPR: тестовые PII — тоже персональные данные, держать их бессрочно нельзя. Здесь одноразовая почта закрывает вопрос сама: ящики на evilmail.pro живут коротко и автоочищаются по TTL. Не нужно писать свой cron на удаление инбоксов — встроенный ретеншн сервиса и есть ваша политика хранения тестовых данных.
Встраивание в CI: эфемерность по умолчанию
В пайплайне адрес рождается в setup-джобе, живёт минуты и умирает вместе с прогоном. Единственная тонкость — параллельность: если несколько воркеров тянут письма из общего инбокса, начинаются гонки за чужими токенами. Решение — уникальный адрес на воркер (в примере выше это w${testInfo.workerIndex}), плюс, если прогонов много, суффикс с ID джобы.
yaml
# .github/workflows/e2e.yml
- name: E2E tests
env:
TEMP_MAIL_API_KEY: ${{ secrets.TEMP_MAIL_API_KEY }} # из vault, не из .env
MAIL_DOMAIN: qa-mail.evilmail.pro
run: npx playwright test --workers=4
API-ключ темп-сервиса — секрет CI (GitHub Actions secrets / HashiCorp Vault), а не строка в .env репозитория. Утёкший ключ к одноразовому домену не так страшен, как утёкший прод-SMTP, но принцип один: креды окружения не коммитятся.
Чек-лист перед мержем
Staging-SMTP указывает на sink (Mailpit :1025), а не на прод-релей.
Продовые SMTP-креды физически отсутствуют в staging-окружении.
Тестовые адреса — на выделенном домене (@qa-mail...), не на плюс-алиасах.
Есть teardown по маркеру домена: DELETE ... WHERE email LIKE '%@qa-mail...'.
Уникальный адрес на каждый параллельный воркер — нет гонок за письмами.
TEMP_MAIL_API_KEY берётся из CI-vault, не из репозитория.
TTL на одноразовые ящики задан — тестовые PII не живут вечно.
Мониторинг bounce rate продового домена не смешан с тестовым трафиком.
Ни один тест не отправляет письмо на реальный MX (проверено на blackhole/sink).
Тестовая почта — отдельный контур: свой домен, свой SMTP-приёмник, своя база. Разведите эти три уровня один раз — и ночной прогон на 40 000 регистраций останется просто зелёной галочкой в пайплайне, а не инцидентом на две недели.