Push и pull: что стоит каждый из них
Есть только два способа, которыми ваш код может узнать, что письмо пришло. Либо другая сторона сообщает вам, либо вы спрашиваете сами. Всё остальное — клиентская библиотека с методом waitFor внутри, SDK, который «стримит» почтовый ящик, тестовый хелпер, который блокирует поток, — это один из этих двух способов со скрытой механикой, и стоит знать, какой из них у вас в руках, ещё до того, как придётся его отлаживать.
Почти всё, что предлагается, укладывается в четыре схемы.
- Вебхук
- Сервис сам делает HTTP-запрос на адрес, которым вы владеете, каждый раз, когда приходит почта. Это самое дешёвое из возможных ожиданий — вы вообще ничего не делаете, пока не появится, что делать, — а цена за это: адрес в публичном интернете, слушатель, который работает именно в момент прихода почты, общий секрет, подтверждающий, что запрос пришёл именно от них, и ваш собственный ответ на вопрос, что происходит, если слушателя на месте не было.
- Долгий опрос
- Вы делаете запрос, а сервер держит его открытым до тех пор, пока не придёт почта или не истечёт тайм-аут. От вас требуется только исходящее соединение, а сервер платит за это воркером на каждого, кто ждёт, — именно поэтому любой сервис, предлагающий такой режим, ограничивает и длительность ожидания, и число одновременных ожидающих.
- Обычный опрос
- Вы спрашиваете, снова и снова, и каждый запрос получает немедленный ответ с тем, что есть на данный момент. Это единственная схема, которая работает и с ноутбука за роутером, и с CI-раннера без входящего маршрута, и с агента, запущенного в чужой песочнице, — и именно ей посвящено всё это руководство.
- Протокол почтового ящика
- В IMAP есть
IDLE— тот же долгий опрос, только в другой одежде: соединение остаётся открытым, и сервер сам объявляет по нему о новой почте. Это действительно похоже на push, но для этого нужен почтовый ящик с учётными данными, клиент, способный держать сокет открытым и переподключаться, если тот обрывается, и сервер, который поддерживает эту команду, — а это очень много механики для задачи, которой нужно всего одно сообщение.
Если сравнить их напрямую, выбор оказывается не столько об элегантности, сколько о том, чего каждый вариант требует от машины, на которой работает ваш код.
| Что требуется от вас | Вебхук | Опрос |
|---|---|---|
| Адрес, по которому можно достучаться до вашего кода | Да: публичный URL с сертификатом, доступный из интернета. | Нет. Всё, что нужно, — один исходящий запрос. |
| Секрет, который нужно хранить и обновлять | Да: ключ подписи, иначе кто угодно сможет отправить вам поддельное сообщение. | Нет. Здесь нечего проверять, потому что ничего никогда не приходит без запроса. |
| Что-то, что работает именно в момент прихода почты | Да — и если оно не работает, получите ли вы сообщение вообще, решает политика повторов отправителя, а не вы. | Нет. Ничего не потеряется, пока вы не смотрите: почтовый ящик в любом случае хранит его 5 дней. |
| Запросы, сделанные, когда почты нет | Ни одного. В этом и вся привлекательность. | Один на каждый интервал — вот настоящая цена, и именно об этом всё остальное руководство. |
Цикл, который все пишут первым
В нём четыре строки, он работает в тот день, когда его написали, а все его проблемы проявляются потом и где-то в другом месте: в пайплайне в три часа ночи, в агенте, который уже одиннадцать минут «думает», в почтовом ящике, который отвечает 429 коллеге, потому что весь лимит забрал себе ваш цикл.
import time
import requests
while True:
r = requests.get("https://grabmail.io/api/v1/mailbox",
params={"address": "signup-42@grabmail.io"})
if r.json()["messages"]:
break
time.sleep(1)У него пять проблем, и только первая из них очевидна.
- Он никогда не сдаётся
- У него нет дедлайна, так что когда сообщение по-настоящему не придёт — форма отклонила адрес, очередь отправителя застряла, кто-то опечатался в домене, — этот цикл не завершается с ошибкой. Он просто зависает. А зависшая задача хуже упавшей, потому что лог заканчивается, не сказав, почему.
- Он считает попытки и называет их секундами
- Даже если ограничить число проходов, тридцать попыток по «одной секунде» никогда не равны тридцати секундам: каждый проход стоит ещё и время самого запроса, а запрос длительностью 400 мс превращает ваши тридцать секунд в сорок две. Добавьте один повтор — и арифметика перестаёт быть арифметикой.
- Все раннеры спрашивают в один и тот же момент
- Запустите двадцать задач из одного пайплайна — и они начнут опрашивать синхронно, потому что все они стартовали в пределах нескольких миллисекунд друг от друга и все ждут одну и ту же целую секунду. Пик оказывается в двадцать раз выше среднего, и отказ получает именно пик.
- Он берёт самое новое сообщение, а не ваше
- Первая запись в списке — это то, что оказалось наверху этого почтового ящика, а это на публичном адресе может быть чужая почта, а на переиспользуемом адресе — письмо недельной давности. Цикл, который завершается на первом увиденном сообщении, спокойно завершится ещё до того, как придёт то, чего он на самом деле ждал.
- Он считает успехом любой ответ
- Попытка прочитать список сообщений из
429или404выбрасывает ошибку на три вызова в стороне от всего, что могло бы её объяснить, а попытка прочитать его из500может вообще ничего не выбросить. Код статуса — это то, на что нужно смотреть первым, а не последним.
Останавливаться по часам, а не по счётчику
Возьмите дедлайн один раз, до первого запроса, от монотонных часов — тех, что не могут прыгнуть назад, когда машина корректирует время, — и сравнивайте с ним в начале каждого прохода. Тогда всё остальное в цикле можно менять без того, чтобы это меняло длительность ожидания: можно расширять интервал, повторять запрос после отказа или добавить второй фильтр, а девяносто секунд так и останутся девяносто секундами.
Вопрос «сколько ждать достаточно» — это вопрос об отправителе, а не о вас. Письмо, которое машина генерирует в ответ на форму, обычно доставляется за считаные секунды; очередь с накопившимся отставанием, получатель с грейлистингом или почасовая рассылка — это совсем другой порядок величины, и никакой выбранный вами интервал не заставит письмо прийти быстрее.
| Что вы ждёте | Честный дедлайн | Что делать, когда он прошёл |
|---|---|---|
| Письмо регистрации или подтверждения внутри теста | От 60 до 120 секунд | Завалите тест и выведите адрес в лог. Девять раз из десяти почтовый ящик пуст потому, что форма отклонила адрес, и адрес — первое, что нужно увидеть тому, кто читает лог. |
| Сброс пароля, который человек только что запросил | От 30 до 60 секунд | Скажите, что письмо ещё не пришло, и предложите отправить его снова. Не крутите спиннер на молчащем экране: человек всё равно попросит письмо повторно, и тогда у него окажутся два кода. |
| Агент, который сам заканчивает регистрацию | Два-три серверных ожидания, то есть от 50 до 75 секунд | Скажите об этом прямо в ответе. «Письма с подтверждением нет спустя минуту» — это результат, на который агент может отреагировать; вызов инструмента, который никогда не возвращается, — нет. |
| Рассылка, чек, что угодно, отправляемое пакетно | Минуты — или вообще не ждать | Опрашивайте по расписанию и дайте процессу завершиться. Всё, что висит на сокете десять минут, рано или поздно убьёт прокси, раннер или лимит контейнера. |
Дедлайн — ещё и честное место для сообщения об ошибке. «Ничего похожего на “Confirm your email” не пришло на signup-42@grabmail.io за 90 с» называет адрес, фильтр и бюджет времени — три из четырёх вещей, нужных, чтобы понять, что случилось. Четвёртую — то, что всё-таки пришло, — тоже стоит выводить: список тем, которые цикл увидел и отклонил, одним взглядом превращает «это просто нестабильный тест» в «изменилась строка темы».
Как часто спрашивать и когда увеличивать интервал
Минимум — это то, что разрешает сервис, а здесь это один запрос в секунду на адрес. Это не предостережение — опрос раз в секунду и есть предполагаемый режим работы, здесь нет ни дневной квоты, ни месячной квоты, ни запаса на всплеск нагрузки, — но это именно минимум, и цикл, который спрашивает два раза в течение одной секунды, получит на второй запрос 429 вместо более быстрого ответа.
- Постоянный интервал
- Одна секунда, каждый раз, до самого дедлайна. Отлично подходит для ожидания, которое закончится за десять секунд, и это правильный вариант по умолчанию для одного теста на одном раннере. Единственный его недостаток — он продолжает спрашивать с той же частотой ещё долго после того, как стало ясно, что письмо не придёт.
- Растущий интервал
- Одна секунда, пока сообщение, скорее всего, ещё в пути, а затем удвоение — два, четыре, восемь — с потолком. Это стоит немного задержки для письма, которое приходит позже обычного, но экономит большинство запросов в ожидании, которое всё равно должно было закончиться неудачей. Обязательно ставьте потолок: интервал, который удваивается без ограничения, проспит вторую половину двухминутного дедлайна.
- Джиттер: только добавляется, никогда не вычитается
- Разносите проходы во времени на случайную долю секунды, чтобы двадцать раннеров не спрашивали в один и тот же момент. Обычный рецепт — случайное значение между нулём и интервалом — здесь не годится, потому что половина этого диапазона попадает ниже минимума в одну секунду. Вместо этого добавляйте случайность сверху: интервал — это минимум, и джиттер может только сделать проход позже, но никогда раньше.
- Пауза, которую выбираете не вы
- Когда в ответ приходит
429, интервал — это ровно то, что сказано вRetry-After, а отклонённый проход не считается попыткой. Засчитайте его как попытку — и цикл, который упирается в ограничение частоты, потратит весь дедлайн на сбор отказов, ни разу не прочитав почтовый ящик.
Пятнадцать секунд по одной секунде, затем удвоение до потолка в восемь, а сверху джиттер — почти любое ожидание из этого руководства укладывается в шесть строк:
def delay(attempt: int) -> float:
# One second while the message is probably still in flight, then
# wider. Never below a second: the list endpoint allows one call
# per second, per address, so jitter is added and never taken off.
step = 1.0 if attempt < 15 else min(8.0, 2.0 ** (attempt - 14))
return step + random.uniform(0.0, step / 2)Показатель степени смещён, чтобы рост интервала начинался после постоянного участка, а не с самого первого прохода. Без этого смещения интервал уже достиг бы восьми секунд к моменту, когда придёт медленное письмо регистрации, и ожидание, которое должно было занять двенадцать секунд, заняло бы двадцать.
Всё это не касается первого запроса. Спрашивайте немедленно, ещё до какой-либо паузы: сообщение, которое уже лежало в почтовом ящике к моменту старта цикла, — обычный случай для всего, что было спровоцировано до начала ожидания, — не должно стоить вам лишней секунды задержки на то, чтобы это заметить.
Как читать отказ
Каждый ответ от эндпоинта списка — это JSON, и те из них, что не являются почтовым ящиком, имеют одну и ту же форму: слаг error, который стабилен и по которому стоит принимать решения в коде, и message, который — просто текст и может быть переформулирован в любой момент. Отказ за слишком частые запросы несёт с собой ещё и заголовок:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json; charset=utf-8
{"error":"rate_limited","message":"one request per second, per address"}Retry-After указан в целых секундах, и это настоящая цифра — она берётся из того, сколько на самом деле осталось от бюджета этого адреса, а не из константы в документации. Подождать ровно столько — это одновременно и самый вежливый, и самый быстрый вариант: более короткая пауза снова получит отказ, а более длинная — просто потерянное время. Вот всё, что может встретить цикл опроса, и что на самом деле означает каждый ответ.
| Что приходит в ответ | Что это значит | Что должен делать цикл |
|---|---|---|
200 с count: 0 | Почтовый ящик существует и пуст. Это нормальный ответ на протяжении большей части ожидания. | Продолжайте ждать. Это не ошибка, и она никогда ею не станет. |
429 — rate_limited | Слишком часто: второй запрос списка в пределах одной секунды для этого адреса, либо больше 1,200 запросов в минуту с этого источника. | Подождите столько секунд, сколько указано в Retry-After, и спросите снова. Не засчитывайте отказ как попытку. |
404 — unknown_domain | Часть после @ не обслуживается здесь. Почти всегда это опечатка либо домен, у которого MX-запись никогда не указывала сюда. | Остановитесь. Никакое ожидание не исправит домен. Выведите в лог адрес, который вам передали. |
400 — invalid_address | Параметр address отсутствует, длиннее 320 символов либо не имеет формы name@domain. | Остановитесь. Это ошибка на стороне вызывающего кода, и она будет повторяться на каждом проходе. |
400 — bad_cursor | Значение before вообще не похоже по форме на id сообщения. Id, у которого форма правильная, но срок действия истёк, — это не эта ошибка: в ответ придёт 200 с пустой страницей. | Прекратите постраничный обход и начните заново с первой страницы. |
404 — not_found, у отдельного сообщения | Этого id нет в этом почтовом ящике — либо был, но с тех пор истёк срок действия или сообщение удалили. | Считайте его пропавшим, а не запоздавшим. Id, который вы видели в списке секунду назад, не вернётся. |
500 — storage_failed | На нашей стороне произошёл сбой при чтении почтового ящика. | Спросите снова, но пусть решает дедлайн, и не спрашивайте быстрее, чем обычно. |
Два из этих семи означают «стоп», и именно о них стоит говорить громче всего. Цикл, который воспринимает unknown_domain как «пока нет», потратит целых девяносто секунд, чтобы доказать себе то, что сервис сообщил ему за первые сорок миллисекунд.
Какое сообщение — ваше
Почтовый ящик — это не очередь, и самое новое в нём не обязательно то, чего вы ждёте. На публичном домене отправить письмо может кто угодно, кто угадал адрес; в тестовом наборе один и тот же адрес часто переиспользуется между прогонами; а одна регистрация нередко присылает два сообщения — приветственное и подтверждающее, — и код есть только в одном из них. Решение — маркер, и его нужно взять до того, что вызывает письмо.
- Прежде чем отправить форму, запросите список почтового ящика с
limit=1и запомните id самого нового сообщения — или ничего, если ящик пуст. Этот id и есть маркер. - Сделайте само действие — отправьте форму, вызовите эндпоинт, нажмите кнопку.
- Опрашивайте список. Сообщения возвращаются от самого нового к самому старому, так что идите сверху вниз и остановитесь в момент, когда встретите маркер: всё, что ниже, старше вашего действия, и это можно игнорировать без чтения.
- Отфильтруйте то, что выше маркера, по отправителю, по теме или по обоим сразу. Обычно достаточно подстроки, и она должна быть той частью, которую точно не будут локализовать, — тест, сравнивающий с « Confirm your email », сломается в тот день, когда тестовый аккаунт переключат на другой язык.
- И только теперь откройте его. В списке есть короткий
preview, а не тело письма, и код, который вам нужен, очень часто находится за его пределами. Ещё один запрос получает всё сообщение целиком, и он списывается с отдельного и намного большего бюджета, чем запрос списка.
В командной строке эти два обращения выглядят так — сначала маркер, потом опрос:
curl -sG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=signup-42@grabmail.io" --data-urlencode "limit=1"
curl -sG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=signup-42@grabmail.io" --data-urlencode "limit=25"Оба запроса называют адрес целиком, потому что здесь адрес и есть почтовый ящик: нет ни сессии, ни курсора, который хранился бы за вас, и ни один вызов ничего не запоминает о том, что будет следующим. Именно поэтому один и тот же адрес безопасно наблюдать из двух мест одновременно — чтение ничего не расходует, так что два цикла на одном почтовом ящике оба видят каждое сообщение, и ни один не может выхватить его из-под другого.
Действие ровно один раз
Опрос, который повторили, может увидеть одно и то же сообщение дважды, и это не редкость: сервер отвечает, соединение обрывается до того, как тело ответа доходит до вас, ваш HTTP-клиент повторяет запрос, и второй ответ содержит то же сообщение, что уже было в первом. Если то, что вы делаете с сообщением, — это переход по ссылке, подтверждение платежа или публикация в канал, то сделать это дважды — баг с последствиями за пределами вашего процесса.
- Храните id, которые вы уже обработали
- Набора id в памяти достаточно для ожидания, которое живёт и умирает внутри одной функции. Для всего, что должно переживать перезапуск, — почтового ящика, который вычищает запланированная задача, агента, разбирающего накопившееся, — это нужно записывать где-то, что переживёт перезапуск вместе с ним.
- Удаление идемпотентно
- Удаление сообщения отвечает
200и во второй раз, и в первый, так что повторное удаление никогда не выглядит как сбой и никогда не требует особого случая. Удаляйте после того, как подействовали, а не до этого: тогда сбой между этими двумя шагами обойдётся вам повторным чтением, что поправимо, а не потерей сообщения, что не поправимо. - Здешний id — это не Message-ID отправителя
- Id в API — наш собственный: он привязан к одному почтовому ящику и перестаёт существовать, когда истекает срок сообщения. Заголовок
Message-IDпринадлежит отправителю, он путешествует вместе с сообщением, и именно он нужен вам, если вы сопоставляете одно и то же письмо между двумя системами, — где его искать, рассказывает руководство по заголовкам.
Ничего из этого не нужно тесту, который ждёт один код и затем выбрасывает почтовый ящик. Но всё это нужно в тот момент, когда цикл работает без присмотра, потому что сбой, который это предотвращает, не похож на сбой: он похож на работу, которая была выполнена дважды и правильно.
Когда ожидание — на стороне сервера
Здесь есть одно место, где цикл писать не вам, и оно существует для тех вызывающих, которым цикл не по карману. AI-агент платит за каждый шаг, потраченный на проверку, так что инструмент, который девять раз отвечает «пока ничего», — это девять шагов впустую. Вместо этого wait_for_message на MCP-сервере держит запрос открытым, опрашивает на нашей стороне и отвечает один раз — либо сообщением, либо простым утверждением, что он ждал и ничего не пришло.
$ curl -sX POST https://grabmail.io/mcp -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"wait_for_message","arguments":{
"address":"signup-42@grabmail.io","subject_contains":"code",
"timeout_seconds":25}}}'Прежде чем на это полагаться, стоит знать о нём четыре вещи.
- Он ждёт не больше 25 секунд
timeout_secondsможет запросить меньше и никогда — больше. Этот потолок не случаен: каждый ожидающий — это воркер, который занят только тем, что ждёт, а запрос, открытый на минуты, — это запрос, который умрёт от чьего-то тайм-аута на прокси задолго до того, как вернётся сам.- Он фильтрует на входе
from_contains,subject_containsиsince_id— это те же три решения, что и в разделе выше, только принятые на сервере.since_id— это маркер, и здесь он важен больше, чем где бы то ни было: без него вызов вернётся немедленно с тем, что уже лежало в почтовом ящике.- Тайм-аут — это ответ, а не ошибка
- Когда ничего не приходит, он возвращает установленный
timed_outвместе с тем, сколько он на самом деле ждал, и прямо говорит, что продолжить ожидание — значит вызвать его снова. Два-три вызова — нормальное ожидание для письма регистрации: это и есть цикл, только три шага вместо девяноста. - Есть 8 мест ожидания, и очереди нет
- Когда все они заняты, вызов сразу же возвращается и сообщает об этом, а не встаёт в очередь за семью другими агентами. Это правильный вид отказа: агент, которому сказали «слишком много ожиданий одновременно», может запросить список почтового ящика и продолжить работу, а агент, сидящий в очереди, может только сидеть.
Лимит на адрес действует и внутри этого механизма — наш цикл ограничен по частоте точно так же, как ваш, так что серверное ожидание — это не способ обойти минимум, а лишь способ не платить за него шагами. Для тестового набора всё это не стоит усилий: тест — это и так процесс, которому разрешено ждать, а цикл на языке, на котором написан тест, отлаживать гораздо проще, чем удалённый. Остальные инструменты описывает руководство по MCP.
Весь цикл целиком
Всё, что было выше, в одном файле: дедлайн от монотонных часов, интервал, который растёт с односторонним джиттером, учёт Retry-After без засчитывания его как попытки, маркер для решения, что нового, фильтр по теме, и один дополнительный запрос, чтобы получить сообщение, которое список только предпросматривает.
import random
import time
import requests
API = "https://grabmail.io/api/v1"
ADDRESS = "signup-42@grabmail.io"
def delay(attempt: int) -> float:
# One second while the message is probably still in flight, then
# wider. Never below a second: the list endpoint allows one call
# per second, per address, so jitter is added and never taken off.
step = 1.0 if attempt < 15 else min(8.0, 2.0 ** (attempt - 14))
return step + random.uniform(0.0, step / 2)
def watermark(s):
# Read this BEFORE the form is submitted. Every id above it
# afterwards is mail that arrived because of what you did.
r = s.get(f"{API}/mailbox", params={"address": ADDRESS, "limit": 1})
r.raise_for_status()
seen = r.json()["messages"]
return seen[0]["id"] if seen else None
def wait_for(s, subject, since, timeout=120.0):
deadline = time.monotonic() + timeout
attempt = 0
rejected = set()
while time.monotonic() < deadline:
r = s.get(f"{API}/mailbox", params={"address": ADDRESS, "limit": 25})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "1")))
continue # refused, so it was not an attempt
if r.status_code == 200:
for m in r.json()["messages"]: # newest first
if m["id"] == since:
break # older than the watermark
if subject.lower() in m["subject"].lower():
full = s.get(f"{API}/message/{m['id']}",
params={"mailbox": ADDRESS})
full.raise_for_status()
return full.json()
rejected.add(m["subject"])
elif r.status_code < 500:
raise RuntimeError(r.json().get("error", r.status_code))
# a 5xx falls through: transient, and the deadline still governs
time.sleep(delay(attempt))
attempt += 1
raise TimeoutError(
f"nothing matching {subject!r} at {ADDRESS} in {timeout:.0f}s; "
f"saw {sorted(rejected) or 'nothing at all'}")Это намеренно около пятидесяти строк стандартной библиотеки и один HTTP-клиент. Здесь ничего не нужно устанавливать, ничего настраивать и нигде нет секрета — в этом и суть: та же форма без изменений переносится на Node, в shell-скрипт, или во что угодно, что ваш тестовый фреймворк уже использует для запросов.
- Считайте маркер до действия, которое вызывает письмо, никогда — после.
- Сначала сразу спросите один раз, и только потом ждите. Никогда не ждите первым делом.
- Берите дедлайн от монотонных часов и проверяйте его в начале каждого прохода.
- Держите интервал на уровне одной секунды на адрес или выше и добавляйте джиттер только вверх.
- Ждите ровно столько, сколько говорит
Retry-After, и не засчитывайте отказ как попытку. - Принимайте решение по слагу
error:unknown_domainиinvalid_addressозначают «стоп», а не «ждать». - Сравнивайте по отправителю или теме и прекращайте обход списка, когда дойдёте до маркера.
- Открывайте сообщение, прежде чем разбирать его, — список несёт предпросмотр, а не тело.
- Завершайтесь ошибкой, указывая адрес, фильтр, бюджет времени и темы, которые цикл отклонил.
Девять правил, и восемь из них существуют из-за сбоя, который кому-то пришлось восстанавливать по логу. Единственное, что не про сбой, — второе: если спросить один раз перед первой паузой, ожидание сообщения, которое уже пришло, занимает четыре миллисекунды вместо секунды, — а на набор из двухсот тестов это три минуты реального времени, которые потом никому не придётся объяснять.
Вопросы
У GrabMail есть вебхук?
Нет, и это не пробел, который вот-вот заполнят. Сервис принимает почту и отдаёт её по HTTP без ключа: здесь нет аккаунта за публичным адресом, к которому можно привязать колбэк, и нет очереди, которая держала бы доставку, отклонённую вашим эндпоинтом. Если ваш процесс на самом деле не может опрашивать, страница сравнения называет сервисы, которые вебхук всё же предлагают.
Как часто можно опрашивать один адрес?
Раз в секунду на адрес — и это предполагаемый режим работы, а не его крайний край. Здесь нет ни дневной квоты, ни месячной квоты, ни запаса на всплеск, которым нужно управлять. Двадцать почтовых ящиков, опрашиваемых раз в секунду с одного раннера, — обычное использование; единственный другой потолок — 1,200 запросов в минуту с одного источника, а это ровно те же двадцать, и ни одного двадцать первого.
Почему мой цикл вернул сообщение из предыдущего прогона теста?
Потому что он взял первую запись в списке, не спросив, когда она пришла. Почтовый ящик хранит всё, что в него отправили, 5 дней, а переиспользуемый адрес полон сообщений с прошлого прогона. Считывайте id самого нового сообщения до того, как спровоцировать письмо, и игнорируйте всё от этого id и ниже — либо удаляйте содержимое почтового ящика в начале теста, что стоит одного запроса на сообщение и полностью снимает неоднозначность.
Пустой почтовый ящик — это 404?
Нет. Пустой почтовый ящик — это намеренно 200 с count: 0 и пустым списком, чтобы циклу опроса никогда не приходилось отдельно обрабатывать случай «пока ничего». 404 от эндпоинта списка означает, что домен здесь не обслуживается; 404 от отдельного сообщения означает, что этого id нет в этом почтовом ящике либо его срок истёк.
Сколько ждать письмо с подтверждением?
От шестидесяти до ста двадцати секунд в автоматическом тесте, от тридцати до шестидесяти — для человека, ждущего перед экраном. Большая часть писем, сгенерированных машиной, приходит за считаные секунды; длинный хвост распределения — это очередь на стороне отправителя, а не сама доставка. Если вы регулярно упираетесь в свой дедлайн, увеличение дедлайна — не ответ: дело в чём-то другом.
Могут ли два процесса опрашивать один адрес одновременно?
Да. Чтение ничего не расходует, так что оба видят каждое сообщение, и ни один не прячет почту от другого. Правда, они делят один и тот же бюджет — один запрос в секунду на этот адрес, — так что два цикла, спрашивающие каждую секунду, будут получать отказ примерно в половине случаев: дайте каждому по две секунды либо пусть опрос делает только один, а результаты передаёт второму.
Опрашивать самому или использовать инструмент ожидания через MCP?
Опрашивайте сами, если пишете тест или скрипт: процессу, которому разрешено ждать, лучше просто ждать, а цикл на вашем собственном языке отладить проще, чем удалённый. Используйте wait_for_message, когда вызывающий платит за шаг, а не за секунду, а на практике это означает AI-агента. Он ждёт до 25 секунд за вызов, фильтрует по отправителю и теме и возвращает простой тайм-аут, после которого можно просто вызвать его снова.


