Три вызова, и настраивать нечего
Весь интерфейс — это три эндпоинта под https://grabmail.io/api/v1, плюс один адрес для вложений, который остальные вызовы отдают уже полностью готовым. Вызова создать ящик здесь нет, и это не упущение: адрес начинает существовать в момент, когда на него приходит письмо, так что такому вызову попросту нечего было бы делать.
| Вызов | Что возвращает | Что передавать |
|---|---|---|
GET /mailbox | Всё, что скопилось по адресу, от нового к старому. | address, а также необязательные limit и before |
GET /message/{id} | Одно письмо целиком: текстовая часть, HTML-часть и все вложения, для которых URL уже готов. | mailbox |
DELETE /message/{id} | Удаляет письмо сразу, не дожидаясь, пока закончится срок хранения. | mailbox |
GET /attachment/{id} | Байты одного файла, ровно в том виде, в каком они пришли. | mailbox |
Каждый ответ — это JSON, включая любую ошибку. Каждое время — UTC в формате RFC 3339. Идентификаторы писем непрозрачны: передавайте их обратно как есть, никогда не пытайтесь разобрать их на части.
Первый вызов и что отвечает пустой адрес
Придумайте имя, поставьте за ним один из публичных доменов и прочитайте его. Ничего не должно существовать заранее, и сам запрос ничего не создаёт.
$ curl -sG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=k7fq2m@grabmail.io"{
"address": "k7fq2m@grabmail.io",
"alias": "q4v8n2mt7xkd@example.net",
"count": 1,
"next": null,
"messages": [
{
"id": "3QK7ZB5M9WVXR2HD4TNFJ0PC6A",
"from": "no-reply@example.com",
"from_name": "Example",
"subject": "Your verification code",
"preview": "Your code is 481920. It expires in 10 minutes.",
"has_html": false,
"date": "2026-08-29T09:14:02Z",
"seen": false,
"attachments": 0,
"expires_at": "2026-09-03T09:14:02Z"
}
]
}Пять полей, и два из них интереснее, чем кажутся на первый взгляд:
count- Сколько писем в этом ответе — а не сколько их всего в ящике. Как только вы передаёте
limit, это уже два разных числа. next- Курсор для страницы, следующей за этой, либо
null, если дальше ничего нет. Это идентификатор последнего письма из только что полученных, поэтому постраничный переход не стоит отдельного вызова на то, чтобы его найти. messages- Сам список, от нового к старому. У каждой записи уже есть
subject,from,date,seen, короткийpreviewтекста, отметка о наличии HTML-части и число вложений. alias- Второй адрес, который доставляет сюда же, но ничего не выдаёт об этом ящике. Отдавайте его форме вместо настоящего адреса: кто бы ни ввёл его потом в этот сервис, найдёт пустой ящик.
address- Адрес в том виде, как он был понят: приведённый к нижнему регистру и без лишних пробелов. Сравните его с тем, что вы отправили, если собираете адрес из частей.
Дальше первых пятидесяти писем
Один вызов по умолчанию отвечает не больше чем пятьюдесятью письмами, а по верхней границе — двумя сотнями. У загруженного catch-all-ящика оба значения набегают за один день, и здесь читатели чаще всего ошибаются в том, что идёт дальше, — потому что это не номер страницы.
limit- Сколько вернуть в этом вызове, от 1 до 200. Значения вне диапазона не отклоняются, а обрезаются до границ, так что
limit=5000молча даёт вам 200. before- Идентификатор самого старого письма, которое у вас уже есть. Вы получите то, что идёт после него. Передайте обратно то же значение, что предыдущий ответ положил в
next. nextnullозначает, что вы дошли до конца ящика. Это единственный надёжный признак конца списка: короткая страница им не является, потому что короткой страница бывает только тогда, когда так решил сервер.
limit ограничивает один ответ, next называет место, на котором этот ответ остановился, а before запрашивает то, что лежит после него.ADDR="k7fq2m@grabmail.io"
CURSOR=""
while :; do
PAGE=$(curl -fsG https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=$ADDR" \
--data-urlencode "limit=200" \
${CURSOR:+--data-urlencode "before=$CURSOR"})
printf '%s' "$PAGE" | jq -c '.messages[]'
CURSOR=$(printf '%s' "$PAGE" | jq -r '.next // empty')
[ -n "$CURSOR" ] || break
sleep 1
doneПовторяйте, пока next не станет null, — и вы получите весь ящик целиком, каким бы большим он ни вырос. Каждый вызов — это выборка диапазона по индексу, а не по смещению, поэтому тысячная страница обходится не дороже первой.
Курсор из другого ящика или курсор, который с тех пор истёк, — это не ошибка: вы получите пустую страницу и next: null. Это правильный ответ — если бы вместо этого повторялась самая новая страница, скрипт получил бы письма, которые уже обработал, — но это же значит, что устаревший курсор выглядит точно так же, как конец списка.
Открытие одного письма, и когда это не нужно
Идентификатор из списка плюс ящик, в который письмо доставлено, дают само письмо. Нужны оба: идентификатор, утёкший из одного ящика, нельзя использовать для чтения другого, потому что каждый запрос привязан ещё и к адресу.
$ curl -sG https://grabmail.io/api/v1/message/3QK7ZB5M9WVXR2HD4TNFJ0PC6A \
--data-urlencode "mailbox=k7fq2m@grabmail.io"{
"id": "3QK7ZB5M9WVXR2HD4TNFJ0PC6A",
"from": "no-reply@example.com",
"to": "k7fq2m@grabmail.io",
"subject": "Your verification code",
"date": "2026-08-29T09:14:02Z",
"expires_at": "2026-09-03T09:14:02Z",
"text": "Your code is 481920. It expires in 10 minutes.",
"html": null,
"attachments": []
}text- Текстовая часть. Разбирайте именно её, когда она есть: она стабильна, не несёт разметки, и шестизначный код в ней — это просто шестизначный код.
html- HTML-часть, либо
null, если отправитель её не прислал. Ссылки для подтверждения часто существуют только здесь. attachments- По одной записи на файл, и в каждой уже готов URL для скачивания. Пустой список, а не
null, если вложений нет. expires_at- Когда это письмо будет удалено, в том же формате RFC 3339, что и
date. Читайте это значение, а не вычисляйте его сами — срок хранения не та настройка, в которой можно быть уверенным извне.
Очень часто этот вызов можно вообще пропустить. Список уже возвращает тему, отправителя, дату и короткий предпросмотр текста — этого достаточно, чтобы понять, что письмо не то, которого вы ждёте. Запрашивать каждое письмо в ящике только для того, чтобы выяснить, что ни одно из них не подошло, — самый частый способ сделать скрипт медленным.
Получение файла
У каждого вложения есть собственный url, и деталь, которую стоит знать до того, как вы напишете цикл, — это то, что перед вами путь на этом origin, а не абсолютный адрес, — с уже вставленным параметром mailbox. Допишите origin в начало, запросите получившийся адрес — и больше нечего ни передавать, ни авторизовывать.
ADDR="k7fq2m@grabmail.io"
ID="3QK7ZB5M9WVXR2HD4TNFJ0PC6A"
curl -fsG https://grabmail.io/api/v1/message/$ID \
--data-urlencode "mailbox=$ADDR" \
| jq -r '.attachments[] | "\(.url)\t\(.filename)"' \
| while IFS=$'\t' read -r path name; do
curl -fs "https://grabmail.io$path" -o "$name"
doneОн всегда отвечает application/octet-stream с заголовком Content-Disposition: attachment, независимо от того, каким типом файл пометил отправитель. Это осознанное решение — вернуть чужой text/html как есть значило бы позволить вложению запуститься в виде страницы на этом origin, — поэтому скрипт, которому важен настоящий тип, читает его из JSON письма, где это данные, а не инструкция.
Всё письмо целиком, вместе со всеми файлами, ограничено 5 MB. То, во что этот потолок превращается после того, как base64 поработал над бинарным файлом, — тема отдельная, и для неё есть руководство о вложениях.
Два лимита скорости, а не один
Вот деталь, которую стоит знать и легко упустить: получение списка по адресу и чтение из него ограничиваются раздельно, потому что риски здесь разные. Опрашивать список может кто угодно, кто знает адрес; чтение письма требует идентификатора, а его угадать нечем.
| Что вы вызываете | Лимит | Что это значит на практике |
|---|---|---|
GET /mailbox | Один запрос в секунду на адрес | Это и есть предусмотренный ритм опроса, и на этой скорости запрос никогда не ограничивается. Быстрее — отказ, и толку от этого всё равно бы не было. |
GET /message/{id}, GET /attachment/{id}, DELETE | Гораздо щедрее, на адрес | Можно вычерпать целую страницу писем разом, без пауз между запросами. Именно поэтому интерфейс способен открыть письмо в ту же секунду, когда прошёл опрос. |
| Всё вместе | 1200 запросов в минуту на клиента | Двадцать адресов, опрашиваемых раз в секунду, — с большим запасом сверх любой реальной автоматизации, и заслон для одного хоста, перебирающего десять тысяч адресов. |
При превышении любого из них вы получаете 429, а время ожидания в секундах — в заголовке Retry-After. Следуйте ему, а не отступайте на придуманное самостоятельно число: сервер прямо говорит, когда именно он ответит согласием.
read_box() {
local wait
while :; do
BODY=$(curl -s -D /tmp/gm.h -G https://grabmail.io/api/v1/mailbox \
--data-urlencode "address=$1")
grep -qi '^HTTP/[0-9.]* 429' /tmp/gm.h || { printf '%s' "$BODY"; return 0; }
wait=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' /tmp/gm.h)
sleep "${wait:-1}"
done
}Как ждать письмо, которое ещё не пришло, — по крайнему сроку, а не по числу попыток, — и что делать, когда этот срок истёк, разобрано в руководстве о сквозном тестировании проверки email. Цикл там — тот же самый, что нужен и запланированной задаче.
Удаление, и жёсткий предел под всем этим
Письмо, с которым вы закончили, можно удалить сразу, не дожидаясь конца срока хранения. Вызов идемпотентен: повторное удаление того же идентификатора оба раза отвечает 200, так что повторённый запрос никогда не выглядит как сбой.
$ curl -s -X DELETE -G https://grabmail.io/api/v1/message/3QK7ZB5M9WVXR2HD4TNFJ0PC6A \
--data-urlencode "mailbox=k7fq2m@grabmail.io"- Удаляйте, как только получили то, за чем пришли
- Скрипт, который обрабатывает письмо и оставляет его на месте, обработает его снова при следующем запуске, если только не ведёт собственный список уже виденного. Удаление — это учёт, который обходится дешевле.
- Не полагайтесь на это ради приватности
- Между доставкой и удалением письмо мог прочитать кто угодно, знающий адрес. Удаление закрывает это окно, но не отменяет то, что уже произошло.
- Всё пропадает через 5 дней в любом случае
- Прочитано или нет, удалено или нет — письмо исчезает через 5 дней после того, как пришло. Это жёсткий предел, а не настройка, и ни один параметр его не продлевает.
Слаги для ветвления и поле, которое не стоит читать
Каждая ошибка — это JSON с одними и теми же двумя полями. error — устойчивый машиночитаемый слаг; message — для людей, и формулировка может измениться в любой момент. Ветвление по второму полю — способ сломать скрипт в день, когда на самом деле ничего не изменилось.
| Статус и слаг | Что произошло | Что должен делать скрипт |
|---|---|---|
400 invalid_address | Адрес не передан либо по форме не похож на адрес. | Завершайтесь с ошибкой сразу. Никакие повторы не исправят опечатку. |
400 bad_cursor | before — не идентификатор письма. | Завершайтесь с ошибкой сразу и проверьте, что вы передаёте обратно именно next, а не что-то, собранное самостоятельно. |
404 unknown_domain | Этот домен здесь не обслуживается. | Завершайтесь с ошибкой сразу. На собственном домене за это отвечает MX-запись — см. подключение домена. |
404 not_found | В этом ящике нет такого письма, либо у него истёк срок хранения. | Считайте, что письмо пропало. То же самое вы получите и для действительного идентификатора, прочитанного не в том ящике. |
429 rate_limited | Один из лимитов выше. | Подождите столько секунд, сколько указано в Retry-After, и продолжайте. Никогда не засчитывайте это как неудачный запуск. |
Задача, которая каждый час опустошает адрес
Сложите всё вместе — и запланированная задача получается короткой. Эта берёт каждое письмо, ожидающее по адресу, записывает его на диск как JSON и удаляет — так что следующий запуск начинается с пустого ящика и никогда не может обработать одно и то же письмо дважды.
#!/usr/bin/env bash
set -euo pipefail
ADDR="orders@example.com"
OUT="/var/lib/mailsink"
API="https://grabmail.io/api/v1"
mkdir -p "$OUT"
while :; do
page=$(curl -fsG "$API/mailbox" \
--data-urlencode "address=$ADDR" \
--data-urlencode "limit=200")
ids=$(printf '%s' "$page" | jq -r '.messages[].id')
[ -n "$ids" ] || break
for id in $ids; do
curl -fsG "$API/message/$id" \
--data-urlencode "mailbox=$ADDR" > "$OUT/$id.json"
curl -fs -X DELETE -G "$API/message/$id" \
--data-urlencode "mailbox=$ADDR" > /dev/null
done
sleep 1
done17 * * * * /usr/local/bin/drain.shСтоит назвать четыре свойства — именно они отделяют задачу, которую можно оставить работать без присмотра, от той, за которой придётся следить:
- Её безопасно запустить дважды. Две копии, запущенные одновременно, выполняют ту же работу в другом порядке и удаляют одни и те же письма; вторая находит пустой ящик и останавливается.
- Она записывает раньше, чем удаляет. Если диск переполнен или процесс убит, письмо всё ещё будет в ящике при следующем запуске. Обратный порядок теряет почту именно в тот день, когда это важно.
- Она опустошает, а не просто читает. Поскольку каждое письмо уходит, как только надёжно оказывается на диске, следующий запрос списка возвращает следующие двести — так что ящик, в который между запусками пришло четыреста писем, опустошается полностью, а не только до пятидесяти новейших.
- Она громко сообщает о сбоях. Именно ненулевой код завершения заставляет cron прислать вам вывод. Задача, которая проглатывает собственные ошибки, — это задача, которая уже месяц как сломана.
Чего этот API не сделает за вас
Четыре вещи, которых он не делает, — каждая осознанно, и ни одна не появится позже. Лучше учесть это в проекте сейчас, чем обнаружить это через скрипт, который тихо работал лишь наполовину:
- Он никогда не отправляет
- Только приём. Здесь нет эндпоинта, который отправлял бы письмо во внешний мир, поэтому ничего здесь нельзя использовать, чтобы отправить что-то с адреса, которым вы не владеете.
- Он никогда не уведомляет сам
- Никаких вебхуков и никаких колбэков: вы спрашиваете — он отвечает. ИИ-агенту, которому нужно блокироваться до прихода письма, вместо этого подойдёт
wait_for_messageчерез MCP — см. руководство для агентов. - Он никогда не ищет
- Здесь нет параметра запроса для отправителя или темы. Фильтрация — на вашей стороне, по списку, — и это одна из причин, почему список несёт предпросмотр.
- Он никогда не аутентифицирует — на публичном домене
- Ящик читает любой, кто знает адрес. Адрес — это весь секрет целиком, так и обращайтесь с ним: никогда не выводите его из имени клиента и никогда не направляйте на общий домен ничего, что вам было бы неприятно прочитать вслух.
Ответ на последний пункт — домен, которым владеете вы сами. Направьте его MX-запись на smtp.grabmail.io, и любой адрес на нём начнёт отвечать на те же три эндпоинта — без второго API, который нужно изучать, и без ключа, который нужно менять, — а по запросу его можно закрыть так, что открыть его сможет только bearer-ключ. Подключение домена занимает одну DNS-запись.
10 smtp.grabmail.io
Перед тем как оставить это без присмотра
Шесть вещей, которые стоит проверить в задаче, которая будет работать без вашего присмотра:
- Опрашивайте не чаще раза в секунду на адрес, и следуйте
Retry-After, когда вас просят подождать. - Доходите по
nextдо самого конца, а не считайте, что один вызов — это весь ящик. - Ветвите логику по коду статуса и по
error, никогда — поmessage. - Записывайте всё, что нужно сохранить, до того как удалите это, и помните, что 5 дней — это предел, который нельзя сдвинуть.
- Что бы вы ни извлекали, привязывайте это к своему собственному шаблону письма. Голый шаблон «шесть цифр подряд» с готовностью совпадёт с годом, ценой или номером заказа, который пришёл раньше.
- Считайте адрес публичным, если он не на домене, который контролируете вы, и всё важное держите на том, который контролируете.
Ничего из этого не требует аккаунта. Если публичных доменов вам стало мало, меняется только домен в адресе — три вызова выше остаются ровно такими же.
Вопросы
Нужен ли API-ключ?
Нет. На публичных доменах нет ни аккаунта, ни токена, ни какой-либо регистрации, а домен, который вы направите сюда, отвечает на тех же эндпоинтах точно так же без ключа. Единственное исключение — домен, закрытый по запросу: его читают с заголовком Authorization: Bearer.
Как быстро можно опрашивать?
Раз в секунду на адрес для получения списка — это и есть предусмотренный ритм, и он никогда не ограничивается. Чтение письма или вложения ограничивается отдельно и гораздо щедрее, так что можно вычерпать целую страницу писем разом. Всё вместе ограничено 1200 запросами в минуту на клиента.
Как понять, что я прочитал весь ящик?
Когда next возвращается как null. Не делайте вывод по короткой странице: что считать страницей, решает сервер, и страница короче limit сама по себе ещё не означает конец.
Можно ли вызывать это из браузера?
Да. Ответы несут заголовок Access-Control-Allow-Origin: *, так что страница с любого origin может вызывать эти эндпоинты напрямую, без вашего прокси посередине. Авторизация здесь никогда не строится на cookie, так что открыть доступ настолько широко ничего не стоит.
Что будет, если запросить письмо, срок которого истёк?
404 с not_found, точно так же, как для идентификатора, которого никогда не существовало. Всё удаляется через 5 дней после прихода, прочитано оно или нет, и ни один параметр этот срок не продлевает.
Можно ли получить вебхук, когда приходит письмо?
Нет — REST API устроен по принципу «спросил — получил ответ», без колбэков. Если нужен код, который блокируется до прихода письма, у MCP-сервера есть wait_for_message — он делает именно это и рассчитан на агентов.
Безопасно ли использовать публичный адрес в продакшене?
Только для того, что вам не жалко, если прочитает посторонний. Ящик может прочитать любой, кто знает адрес, — через API точно так же, как через сайт. Для всего остального направьте сюда домен, которым владеете сами, — вызовы при этом не меняются.
Почему письмо, которое я никогда не открывал, отмечено как прочитанное?
Потому что его что-то открыло. Чтение письма через API выставляет его флаг seen, и этот флаг общий для всех, кто смотрит на этот адрес. Скрипт и человек, следящие за одним и тем же ящиком, будут постоянно удивлять друг друга, так что фильтруйте по уже обработанным идентификаторам, а не по seen.
Обязательно ли удалять письма?
Нет — всё само истекает через 5 дней. В запланированной задаче удалять всё равно стоит, потому что опустошённый ящик — это самая простая из возможных записей о том, что вы уже обработали.


