API глазами JavaScript
На серверной стороне ничего не нужно устанавливать и не к чему аутентифицироваться: ящик на публичном домене может прочитать любой, кто знает его адрес, — по обычному HTTPS, в виде JSON. Вся поверхность API — это три вызова:
GET /api/v1/mailbox?address=…- Всё, что ждёт по адресу, от новых к старым, в виде списка сводок. Пустой ящик — это
200сcount: 0, а не 404.limitограничивает один ответ (1–200, по умолчанию 50), аbeforeлистает дальше. GET /api/v1/message/{id}?mailbox=…- Одно письмо целиком: отправитель, получатель, тема, дата, часть с обычным текстом, HTML-часть (или
null) и список вложений с готовым URL у каждого. DELETE /api/v1/message/{id}?mailbox=…- Удаляет письмо сразу, а не через 5 дней. Идемпотентно: повторное удаление всё равно отвечает
200.
Типы в модуле ниже в точности повторяют форматы ответов. В списке писем также присутствует alias: второй адрес на отдельном домене, который доставляет почту в тот же ящик, но не может использоваться для его чтения, — тот, что стоит отдавать сайту, когда не хочется, чтобы он мог открыть ящик.
Модуль
Один файл, один класс, никаких зависимостей. Он работает на глобальных fetch и crypto, которые идут в комплекте с Node начиная с версии 18, так что в package.json добавлять нечего. Он намеренно скучный: цикл с дедлайном и единственный по-настоящему правильный вид повтора — сон при 429.
// grabmail.ts — a disposable inbox from Node 18+, Deno or Bun. No dependency, no key.
const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';
export type Summary = {
id: string; from: string; subject: string; date: string;
seen: boolean; attachments: number; expires_at: string;
};
export type Attachment = { filename: string; mime: string; size: number; url: string };
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null; attachments: Attachment[];
};
type Listing = { address: string; alias: string | null; count: number; next: string | null; messages: Summary[] };
type WaitOpts = { timeoutMs?: number; subjectContains?: string; fromContains?: string };
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
/** A mailbox nothing else is using. Nothing has to be created first. */
export function freshAddress(prefix = 'node'): string {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}@${DOMAIN}`;
}
/** One GET, with the only retry that is ever right: waiting out a 429. */
async function get(url: string): Promise<Response> {
for (;;) {
const res = await fetch(url);
if (res.status === 429) {
await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
continue;
}
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
return res;
}
}
export class Inbox {
constructor(readonly address: string = freshAddress()) {}
async list(limit = 50, before?: string): Promise<Listing> {
const q = new URLSearchParams({ address: this.address, limit: String(limit) });
if (before) q.set('before', before);
return (await get(`${API}/mailbox?${q}`)).json();
}
/** Block until a matching message arrives, then return it in full. */
async waitFor(opts: WaitOpts = {}): Promise<Message> {
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
while (Date.now() < deadline) {
const { messages } = await this.list();
const hit = messages.find(m =>
(!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
(!opts.fromContains || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
if (hit) return this.read(hit.id);
await sleep(1000); // one read a second, never throttled
}
throw new Error(`no message for ${this.address} within the deadline`);
}
async read(id: string): Promise<Message> {
return (await get(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`)).json();
}
/** Optional and idempotent: everything expires on its own. */
async delete(id: string): Promise<void> {
await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
}
/** The bytes of one attachment. Its URL already carries ?mailbox=. */
async download(a: Attachment): Promise<Response> {
return get(`https://grabmail.io${a.url}`);
}
}Использовать его — четыре строки. Выведите адрес, используйте его там, где требуется адрес, и ждите:
import { Inbox } from './grabmail';
const inbox = new Inbox();
console.log('sign up with:', inbox.address);
const message = await inbox.waitFor({ subjectContains: 'code' });
console.log(message.subject);
console.log(message.text); // the plain-text part; message.html is the HTML part or nullОбычный JavaScript, Deno и Bun
TypeScript выше — эталонная версия; в нём нет ничего специфичного для Node, кроме потоковой записи вложений в одном из следующих разделов. Три заметки о других средах, где это работает:
- Обычный JavaScript
- Уберите типы — и это тот же самый файл. Короткая версия ниже — это всё, что обычно нужно скрипту: адрес и ожидание.
- Deno
- Запускается как есть:
fetchиcrypto.randomUUID()— глобальные, а скрипту нужен только--allow-net=grabmail.ioи больше ничего. Сохраняйте вложение черезDeno.writeFile(path, new Uint8Array(await res.arrayBuffer())). - Bun
- Запускается как есть, включая TypeScript без изменений. Сохраняйте вложение через
Bun.write(path, res), который принимаетResponseнапрямую.
// grabmail.mjs — plain JavaScript, Node 18+: the same class without the types.
const API = 'https://grabmail.io/api/v1';
const sleep = ms => new Promise(r => setTimeout(r, ms));
export const freshAddress = (prefix = 'node') => `${prefix}-${crypto.randomUUID().slice(0, 8)}@grabmail.io`;
export async function waitFor(address, { timeoutMs = 60_000, subjectContains } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(address)}`);
if (res.status === 429) { await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000); continue; }
if (!res.ok) throw new Error(`GET /mailbox answered ${res.status}`);
const { messages } = await res.json();
const hit = messages.find(m => !subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase()));
if (hit) return (await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`)).json();
await sleep(1000);
}
throw new Error(`no message for ${address} within ${timeoutMs} ms`);
}Загруженный ящик: постраничное чтение через before
Список писем возвращает не больше 200 сводок за раз. Ящик, который получает больше, — например, catch-all-адрес на вашем собственном домене, собирающий возвраты за целый день, — читается постранично: передайте значение next из одного ответа как параметр before в следующем запросе и остановитесь, когда next станет null. Асинхронный генератор превращает это в for await:
/** Every summary in the mailbox, newest first, however many pages it takes. */
export async function* allMessages(inbox: Inbox): AsyncGenerator<Summary> {
let before: string | undefined;
for (;;) {
const page = await inbox.list(200, before);
yield* page.messages;
if (!page.next) return;
before = page.next;
}
}
for await (const m of allMessages(inbox)) {
console.log(m.date, m.from, m.subject, 'expires', m.expires_at);
}Курсор — это id самого старого письма, которое у вас уже есть, поэтому страница остаётся стабильной, даже пока сверху приходит новая почта. В руководстве «Автоматизация ящика из скрипта» курсор разобран подробнее, вместе с расписанием и сроком хранения.
Вложения, потоково сохраняемые на диск
У каждого письма перечислены вложения с именем файла, заявленным типом, размером в байтах и URL. URL уже содержит параметр ?mailbox=, так что его можно запрашивать как есть. Ответ всегда приходит как application/octet-stream с заголовком Content-Disposition: attachment, независимо от того, каким типом файл пометил отправитель, — настоящий тип лежит в поле mime в JSON. Сохраняйте его потоково, а не буферизируйте целиком; потолок — 5 MB на письмо, и скрипту, сохраняющему их сотнями, не стоит держать их все в памяти.
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const message = await inbox.waitFor({ subjectContains: 'invoice' });
await mkdir(`downloads/${message.id}`, { recursive: true });
for (const a of message.attachments) {
console.log(a.filename, a.mime, a.size, 'bytes');
const res = await inbox.download(a);
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(`downloads/${message.id}/${a.filename}`));
}В тесте на Vitest или Jest
Новый Inbox внутри тела теста даёт каждому тесту собственный ящик, а это самое важное свойство почтового теста: ни один прогон никогда не сможет прочитать письмо из другого прогона, а параллельные воркеры никогда не смогут прочитать чужую почту. Шаблон извлечения привязан к формулировке письма, а не к принципу «шесть цифр», по причинам, изложенным в «OTP-коды в автотестах».
import { describe, it, expect } from 'vitest';
import { Inbox } from '../src/grabmail';
import { app } from '../src/app'; // whatever starts your server in-process
const CODE = /code is\D{0,12}(\d{6})/i; // anchored on YOUR template's wording
describe('sign-up', () => {
it('emails a code that confirms the account', async () => {
const inbox = new Inbox(); // a brand-new mailbox for this test only
await app.request('/signup', { method: 'POST', body: JSON.stringify({ email: inbox.address, password: 'hunter2hunter2' }) });
const message = await inbox.waitFor({ subjectContains: 'confirm' });
const code = `${message.text ?? ''} ${message.html ?? ''}`.match(CODE)?.[1];
expect(code).toBeDefined();
const res = await app.request('/confirm', { method: 'POST', body: JSON.stringify({ email: inbox.address, code }) });
expect(res.status).toBe(200);
}, 120_000); // above the 60 s mail deadline
});Третий аргумент it — это таймаут теста, выставленный выше шестидесятисекундного дедлайна ожидания почты; значение по умолчанию в пять секунд завершило бы каждый тест раньше, чем успела бы прийти почта. Для версии того же теста, управляемой браузером, руководство по Playwright оборачивает этот класс в фикстуру; запуск любого из вариантов на раннере CI добавляет правило для исходящего трафика и таймаут задания — оба вопроса разобраны в руководстве по GitHub Actions.
Ошибки, которые проявляются с первого же запуска без присмотра
Ни одна из них не ломается на ноутбуке. Все они ломаются во вторник ночью в задании по расписанию.
| Симптом | Причина | Решение |
|---|---|---|
| Проходит каждый раз, даже когда отправитель сломан | Один и тот же адрес при каждом прогоне; первый же опрос находит письмо из прошлого прогона. | freshAddress() на каждый прогон. Вот это действительно важно. |
429 в логе, затем падение | Цикл без sleep, либо два скрипта опрашивают один адрес. | Одно чтение в секунду на адрес; сон на время Retry-After; один адрес на скрипт. |
| Истекает по таймауту в неудачный день, проходит при повторе | Счётчик попыток вместо дедлайна, либо дедлайн короче очереди отправителя. | Дедлайн на Date.now(), шестьдесят секунд для транзакционного письма. |
404 от /mailbox | Этот домен здесь не обслуживается — опечатка, либо у собственного домена отсутствует MX. | Проверьте адрес; для собственного домена проверьте, что MX указывает на smtp.grabmail.io. |
| Читает не то письмо | Взял самое новое письмо, когда сценарий отправил два. | Фильтруйте через subjectContains или fromContains. |
Работает неделю, а потом 404 на письме | Сохранённый id письма старше 5 дней. | Ничто не переживает 5 дней. Запрашивайте заново, а не кэшируйте. |
Прежде чем считать задачу закрытой
- Новый адрес на каждый прогон, тест или агента — никогда не константа.
- Дедлайн по реальному времени; одно чтение в секунду;
429засыпает, а не выбрасывает исключение. - Фильтр по теме или отправителю, когда сценарий отправляет больше одного письма.
- В первую очередь разбирается текстовая часть, с шаблоном, привязанным к вашей формулировке.
- Вложения сохраняются потоково, обрабатываются как недоверенные, хранятся под id письма.
- Никакого кэширования id письма на несколько дней; здесь ничто не живёт дольше 5 дней.
Вот и весь клиент. Тот же модуль на Python, для requests и httpx, — в руководстве по Python; форматы запросов и ответов, со всеми кодами состояния, — в справочнике по API, а ещё есть документ OpenAPI 3.1 для тех, кто предпочитает сгенерировать клиент, а не писать его вручную.
Вопросы
Нужен ли API-ключ или npm-пакет?
Ни то, ни другое. Публичные домены не требуют ни ключа, ни аккаунта, ни заголовка, а модуль использует только fetch, который идёт в комплекте с Node начиная с версии 18. Заголовок Authorization: Bearer используется только для платного пула доменов, которые держат подальше от чёрных списков одноразовой почты, а в остальном код для него точно такой же.
Работает ли это в браузере?
Те же самые вызовы работают и со страницы, но браузер — неподходящее место для шестидесятисекундного цикла опроса, да и ящик в любом случае публичный — читайте его с сервера или из тестового раннера. Если вы тестируете веб-приложение, руководство по Playwright держит опрос в процессе теста, где ему и место.
Сколько ящиков может опрашивать один процесс одновременно?
Двадцать, с запасом: лимит на адрес — одно чтение в секунду, а потолок на клиента — 1200 запросов в минуту, то есть двадцать адресов, опрашиваемых раз в секунду. Promise.all над двадцатью вызовами waitFor укладывается в этот предел; при превышении ветка 429 засыпает, а не падает.
Можно ли использовать собственный домен из Node?
Да, без каких-либо изменений, кроме константы DOMAIN. Одна MX-запись, указывающая на smtp.grabmail.io, — и любой адрес на домене становится ящиком, который читает тот же самый модуль — настройка здесь. Это правильный ответ, когда ваше приложение отказывает публичным одноразовым доменам.
Приватен ли ящик, пока им пользуется мой скрипт?
Нет. Прочитать его может любой, кто знает адрес, — и на публичном домене, и на вашем собственном. Для случайного адреса, хранящего один код подтверждения несколько секунд, это нормально; для скрипта, направляющего туда настоящую почту клиентов, — нет.
Есть ли что-то для AI-агента, а не для скрипта?
Есть MCP-сервер на том же домене, без ключа, чей инструмент wait_for_message держит вызов открытым, пока не придёт почта, — именно такая форма и нужна агенту, ведь каждый опрос стоит ему токенов. Подробнее в «Ящик, который может прочитать AI-агент».


