API и автоматизация

API временной почты в Node.js: читаем ящик через fetch

Один файл, ничего сверх fetch, который идёт в комплекте с Node 18, и никакого API-ключа: адрес, который вы придумываете сами, ожидание с дедлайном, письмо в виде объекта. Вот модуль на TypeScript, версия на обычном JavaScript, заметки про Deno и Bun, постраничное чтение, вложения, потоково сохраняемые на диск, пример на Vitest и шесть способов, которыми это ломается с первого же запуска без присмотра.

  • Средний
  • 19 мин на чтение
Серый шестигранный блок с тремя серыми кабелями сверху и синим конвертом, выезжающим из прорези на передней стороне

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.

src/grabmail.ts
// 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 — короткая версия на обычном JavaScript
// 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-коды в автотестах».

tests/signup.test.ts
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-агент».

Читать дальше

Попробуйте, пока свежо

Адрес — это один клик, без аккаунта и карты. Всё из этого руководства сразу заработает на нём.

С возвращением

Ваши ящики и ваши домены в одном месте.