Тимчасова пошта для QA: одноразові адреси в автотестах реєстрацій та email-флоу
Плюс-адресація Gmail розвалюється в CI на масштабі: антифрод ріже +tag, спільна скринька дає race condition, а ручний polling робить тести флакі. Розбираємо, як побудувати детермінований email-флоу на власному catch-all піддомені, тягнути листи через HTTP API або IMAP IDLE і асертити не лише тіло, а й SPF/DKIM/DMARC.
EvilMail Team21 липня 2026 р.11 хв читання
Ви пишете E2E-тест на реєстрацію. Треба отримати лист із посиланням підтвердження, клікнути, перевірити, що акаунт активувався. Найшвидше рішення, яке спадає на думку — [email protected], [email protected] і так далі. Воно працює. Рівно до 200-го прогону.
Потім починається справжнє життя. Антифрод застосунку, який ви тестуєте, нормалізує +tag і бачить, що всі ці адреси — одна й та сама скринька, після чого банить реєстрацію за «дубль акаунта». Gmail вмикає rate-limit на IMAP, бо десять паралельних воркерів довбають одну скриньку раз на секунду. А самі воркери влаштовують race condition: тест A читає лист підтвердження, призначений тесту B, підтверджує чужий акаунт і зелено проходить, тоді як тест B падає по таймауту. Ви додаєте sleep 5, тести стають повільними та все одно флакі.
Проблема не в тому, що ви погано написали тест. Проблема в тому, що спільна скринька з плюс-адресацією — це не тестова інфраструктура, а її імітація. Розберемо, як виглядає нормальна.
Чому Gmail і плюс-адресація розвалюються в CI
Тимчасова пошта для QA: одноразові адреси для тестування реєстрацій | EvilMail — EvilMail Blog
Субадресація (local+tag@domain) описана в RFC 5233 і призначена для сортування власної пошти, а не для генерації ізольованих ідентичностей. Чотири конкретні режими відмови, на які ви наступите:
`+tag` нормалізується або блокується застосунком. Будь-який серйозний сервіс із захистом від фрода зводить [email protected] і [email protected] до одного [email protected]. Ваш набір унікальних адрес перетворюється на один акаунт, і реєстрація другого падає з «email вже зайнятий».
Спільний inbox — це race condition за замовчуванням. Усі листи падають в одну скриньку. При паралельному запуску немає гарантії, що воркер прочитає саме свій лист. Ви фільтруєте за темою чи адресатом — і все одно ловите гонки на resend.
Немає детермінованої адреси на тест-кейс. Ви не можете наперед сказати «цей тест володіє цією адресою», тому не можете зробити teardown і не можете дебажити конкретний флакі-прогін.
OAuth/2FA на реальному акаунті ламає headless-раннер. Gmail періодично вимагає повторної авторизації, показує капчу «незвичний вхід» з IP CI-раннера, і ваш IMAP-логін відвалюється серед ночі.
Висновок один: потрібна адреса, якою володіє команда, унікальна на прогін, з машиночитним доступом і без стороннього антифрода між вами й листом.
Що насправді треба покрити в email-флоу
«Перевірити, що лист прийшов» — це не тест-план, це перший рядок тест-плану. Реальний email-флоу розкладається на вузли, кожен зі своїм сигналом асерту:
Double opt-in — verification-лінк веде на ендпоінт, який має віддати 302 і перевести акаунт у стан verified. Асерт: HTTP-редірект + статус у БД або через API.
OTP / 6-значний код — код у тілі листа збігається з тим, що приймає форма. Асерт: успішний вхід після введення.
Password reset — одноразовий токен із TTL. Асерт: токен працює один раз, після використання або після закінчення TTL повертає помилку.
Magic link — те саме, що reset, але веде одразу в сесію.
Resend і дедуплікація — повторний запит створює новий валідний лист; старий токен інвалідовано (або ні — це теж треба зафіксувати як рішення).
Expiry — прострочений токен відхиляється.
Локалізація — лист приходить мовою, обраною при реєстрації.
Автентичність — те, що забувають усі: spf=pass dkim=pass dmarc=pass у заголовку Authentication-Results.
Останній пункт — не формальність. Функціональний тест перевіряє, що лист дійшов і код правильний. Він не ловить ситуацію, коли реліз зламав DKIM-підпис: лист усе ще доходить у вашу тестову скриньку, тест зелений, а Gmail реальних користувачів кладе цей самий лист у спам. Асерт на транспортні заголовки ловить цю регресію в CI, а не через тиждень від служби підтримки.
Архітектура: власний catch-all піддомен замість скрейпінгу temp-mail
Ядро підходу — виділений піддомен, де приймається будь-яка адреса. Не десять заздалегідь заведених скриньок, а wildcard-роутинг: <uuid>@qa.evilmail.pro існує в ту мить, коли на нього прийшов лист, і складається в один керований потік, доступний через API.
Чому окремий піддомен, а не основний домен? Тестовий трафік — це сотні реєстрацій на день, bounce'и від навмисно кривих сценаріїв, resend-шторми. Усе це псує reputation домену, з якого ви шлете реальну пошту, і забруднює вашу DMARC-агрегатну статистику. Ізолюйте його на qa. з окремою, навмисно м'якою DMARC-політикою.
DNS для catch-all QA-піддомену:
dns
qa.evilmail.pro. 300 IN MX 10 mail.evilmail.pro.
qa.evilmail.pro. 300 IN TXT "v=spf1 mx -all"
mail._domainkey.qa.evilmail.pro. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQ..."
_dmarc.qa.evilmail.pro. IN TXT "v=DMARC1; p=none; rua=mailto:[email protected]"
p=none тут навмисно: тестовий трафік не карати, лише агрегувати. TTL 300 с — щоб швидко ротувати MX, коли переносите чергу. І принципова відмінність від плюс-адресації: catch-all — це wildcard на рівні домену, а не subaddressing. Антифрод застосунку не бачить спільного local-part, тому не схлопує ваші адреси в одну.
Перед тим як писати перший тест, прострільте пайплайн вручну через swaks:
Другий — IMAP. Для push-доставки використовуйте IDLE (RFC 2177): сервер сам сповіщає про новий лист, замість того щоб ви його опитували. Dovecot тут віддає пошту по TLS на порту 993, схема пароля PLAIN. IMAP виграє, коли вам потрібні сирі заголовки без проміжного JSON-парсингу — а вони потрібні, щоб асертити Authentication-Results.
Головне правило для обох підходів: ніякого sleep 5 наосліп. Поллінг має мати deadline і backoff, бо перша доставка на новий домен часто затримується greylisting-ом. Фіксована пауза або занадто коротка (флакі), або занадто довга (повільно). expect.poll з інтервалами, що зростають, вирішує обидві проблеми: він повертається одразу, щойно лист прийшов, і не здається до 30 секунд.
Витягти токен і зробити жорсткий асерт
Парсинг тіла — це рутинні regex, але тримайте їх точними, щоб не хапати випадкові збіги:
OTP: \b\d{6}\b
verification link: https?://[^\s"'<>]+/verify\?token=[A-Za-z0-9._-]+
JWT у query: розбити значення token по "." на 3 base64url-сегменти
А тепер частина, яку пропускають майже всі. Асертьте не лише тіло, а й транспорт. У сирому листі має бути:
Сценарій, заради якого це існує: реліз змінив конфіг MTA і зламав DKIM-підпис. Листи ще доставляються — ваш функціональний тест зелений, бо код у тілі правильний. Але реальним користувачам у Gmail ці листи тепер летять у спам. Тест на заголовок ловить це в тому ж CI-прогоні, де регресія народилася.
Вбудувати в Playwright: паралелізм, ізоляція, teardown
Повний цикл у фікстурі. Ключові рішення — унікальна адреса на кожен тест, поллінг замість sleep, teardown у afterEach:
Для паралелізму прив'язуйте адресний неймспейс до test.info().parallelIndex — так у логах видно, який воркер володів яким листом, і дебаг флакі-прогону перестає бути археологією. Teardown адреси робіть в afterEach: щойно тест завершився, дьорніть DELETE /inbox/{addr}, щоб проекспайрити скриньку і не тягнути її в наступний прогін. Якщо потрібен детермінований local-part для відтворення конкретного падіння — зашивайте seed замість чистого UUID.
Пастки, на яких горять
Greylisting. Перша доставка на свіжий домен затримується типово на 60–300 секунд. Саме тому backoff, а не fixed sleep. Або внесіть ваш CI-релей у whitelist на приймальному MTA, щоб прибрати затримку зовсім.
DNS-кеш після зміни MX. Поки старий запис живе в кеші резолвера (до кінця TTL), частина листів піде на стару чергу. Тримайте TTL 300 с і не змінюйте MX за п'ять хвилин до релізу.
Resend створює два листи. Беріть найсвіжіший за заголовком Date/internalDate, ніколи не messages[0] наосліп — порядок у видачі не гарантований.
Провайдери батчать. SendGrid і подібні можуть згрупувати відправку так, що прийде дубль. Дедуплікуйте за Message-ID.
Секрети в логах. Verification-лінк одноразовий, але це все одно секрет. Не логуйте повний URL у CI-артефакти — маскуйте токен.
Чого НЕ варто робити через одноразову пошту
Інструмент має межі, і чесно їх окреслити важливіше, ніж продати. Одноразова адреса — для ефемерних тестових ідентичностей, і тільки. Не реєструйте через неї продакшн-платіжні акаунти. Не використовуйте її, щоб обходити ліміти чужих сервісів — це вже не тестування. Не зберігайте на ній нічого з PII і не будуйте на ній стейджинг із реальними даними користувачів. Адреса, яку ви за пів години проекспайрите teardown'ом, — погане місце для будь-чого, що має пережити тестовий прогін.
Чекліст перед мержем email-тестів
Кожен тест-кейс отримує унікальну адресу, teardown у afterEach.
Жодного фіксованого sleep — тільки poll із deadline і backoff.
Асерт не лише на тіло листа, а й на spf=pass dkim=pass dmarc=pass.
Береться найсвіжіший лист за Date; дублікати resend оброблені.
Verification-лінки й токени не потрапляють у CI-логи та артефакти.
Тестовий домен — окремий піддомен, DMARC p=none, reputation прод-домену не зачеплено.
Ніякого PII і продакшн-акаунтів на одноразових адресах.
Різниця між флакі-набором, який усі відключають через місяць, і стабільним email-тестуванням — не в бібліотеці й не в раннері. Вона в тому, чи володієте ви адресою, чи скрейпите чужу скриньку. Підніміть catch-all піддомен, тягніть листи через API або IMAP IDLE, асертьте заголовки — і email-флоу перестане бути найненадійнішою частиною вашого CI.