API e automação

API de email temporário em Node.js: ler uma caixa com fetch

Um arquivo só, nada além do fetch que já vem com o Node 18, e nenhuma chave de API: um endereço que você inventa, uma espera com prazo, a mensagem como um objeto. Aqui está o módulo em TypeScript, a versão em JavaScript puro, as notas sobre Deno e Bun, paginação, anexos gravados em disco via streaming, um exemplo com Vitest, e as seis formas como isso quebra na primeira vez que roda sem supervisão.

  • Intermediário
  • 19 min de leitura
Um bloco hexagonal cinza com três cabos cinza conectados no topo e um envelope azul saindo de uma fenda na frente

A API, do jeito que o JavaScript vê

Não há nada para instalar do lado do servidor e nada contra o que autenticar: uma caixa de entrada em um domínio público pode ser lida por qualquer um que saiba o endereço dela, via HTTPS simples, como JSON. Toda a superfície são três chamadas:

GET /api/v1/mailbox?address=…
Tudo o que está esperando em um endereço, mais recente primeiro, como uma lista de resumos. Uma caixa de entrada vazia é 200 com count: 0 — nunca um 404. limit limita uma resposta (1–200, padrão 50) e before pagina além dela.
GET /api/v1/message/{id}?mailbox=…
Uma mensagem completa: remetente, destinatário, assunto, data, a parte em texto puro, a parte em HTML (ou null), e uma lista de anexos, cada um com uma URL já pronta.
DELETE /api/v1/message/{id}?mailbox=…
Remove a mensagem agora em vez de daqui a 5 dias. Idempotente: apagar duas vezes ainda responde 200.

Os tipos no módulo abaixo são exatamente os formatos das respostas. A listagem também carrega um alias: um segundo endereço em um domínio separado que entrega na mesma caixa de entrada e não pode ser usado para lê-la — o que você dá a um site quando prefere que ele não consiga abrir a caixa de entrada.

O módulo

Um arquivo, uma classe, nenhuma dependência. Ele roda sobre os globais fetch e crypto que o Node inclui desde a versão 18, então não há nada para adicionar ao package.json. É propositalmente sem graça: um loop com prazo e a única forma de repetição que é sempre correta, esperar o tempo de um 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}`);
  }
}

Usá-lo são quatro linhas. Imprima o endereço, use-o onde quer que um endereço seja pedido, e espere:

uma primeira execução
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 puro, Deno e Bun

O TypeScript acima é a referência; nada nele é específico do Node, exceto o streaming de anexos em uma seção posterior. Três notas para os outros lugares onde isso roda:

JavaScript puro
Remova os tipos e é o mesmo arquivo. A versão curta abaixo é tudo o que um script geralmente precisa — um endereço e uma espera.
Deno
Roda como está: fetch e crypto.randomUUID() são globais, e o script precisa de --allow-net=grabmail.io e nada mais. Salve um anexo com Deno.writeFile(path, new Uint8Array(await res.arrayBuffer())).
Bun
Roda como está, incluindo o TypeScript. Salve um anexo com Bun.write(path, res), que recebe o Response diretamente.
grabmail.mjs — a versão curta em JavaScript puro
// 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`);
}

Uma caixa de entrada movimentada: paginação com before

Uma listagem retorna no máximo 200 resumos. Uma caixa de entrada que recebe mais do que isso — um endereço catch-all no seu próprio domínio coletando um dia de bounces, por exemplo — é lida página por página: passe o valor de next de uma resposta como o parâmetro before da requisição seguinte, e pare quando next for null. Um gerador assíncrono transforma isso em um for await:

todas as mensagens, não importa quantas páginas
/** 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);
}

O cursor é o id da mensagem mais antiga que você já tem, então uma página fica estável mesmo enquanto novos e-mails chegam no topo. Automatizando uma caixa de entrada a partir de um script detalha o cursor com mais profundidade, junto com agendamento e retenção.

Anexos gravados em disco via streaming

Toda mensagem lista seus anexos com um nome de arquivo, um tipo declarado, um tamanho em bytes e uma URL. A URL já carrega o parâmetro ?mailbox=, então ela é buscada exatamente como está. A resposta é sempre application/octet-stream com um cabeçalho Content-Disposition: attachment, seja lá o que quem enviou rotulou o arquivo como — o tipo real é o campo mime no JSON. Faça streaming dele em vez de armazená-lo em buffer; o teto é 5 MB por mensagem, e um script que salva uma centena deles não deveria manter todos em memória.

salve todos os anexos de uma mensagem, via streaming
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}`));
}

Em um teste com Vitest ou Jest

Uma nova Inbox dentro do corpo do teste dá a cada teste sua própria caixa de entrada, que é a propriedade mais importante de um teste de e-mail: nenhuma execução jamais pode ler a mensagem de uma execução anterior, e workers paralelos nunca podem ler a mensagem um do outro. O padrão de extração é ancorado no texto do template, e não em “seis dígitos”, pelos motivos que códigos OTP em testes automatizados detalha.

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
});

O terceiro argumento de it é o timeout do teste, definido acima do prazo de sessenta segundos do e-mail; o padrão de cinco segundos encerraria todo teste antes de o e-mail conseguir chegar. Para uma versão da mesma ideia guiada por navegador, o guia do Playwright envolve essa classe em uma fixture; rodar qualquer uma delas em um runner de CI adiciona uma regra de saída e um timeout de job, os dois no guia do GitHub Actions.

Erros que aparecem na primeira vez que isso roda sem supervisão

Nenhum desses quebra em um notebook. Todos eles quebram em uma noite de terça-feira em um job agendado.

SintomaCausaCorreção
Passa sempre, mesmo quando quem envia está quebradoO mesmo endereço a cada execução; a primeira consulta encontra a mensagem da execução anterior.freshAddress() por execução. Esta é a que realmente importa.
429 no log, depois um crashUm loop sem espera, ou dois scripts fazendo polling em um endereço.Uma leitura por segundo por endereço; espere o tempo de Retry-After; um endereço por script.
Estoura o tempo em um dia lento, passa ao repetirUma contagem de tentativas em vez de um prazo, ou um prazo mais curto que a fila de quem envia.Um prazo com Date.now(), sessenta segundos para um e-mail transacional.
404 vindo de /mailboxO domínio não é hospedado aqui — um erro de digitação, ou o seu próprio domínio sem um MX configurado.Confira o endereço; para o seu próprio domínio, confira se o MX aponta para smtp.grabmail.io.
Lê a mensagem erradaPegou a mensagem mais recente quando o fluxo enviou duas.Filtre com subjectContains ou fromContains.
Funciona por uma semana, depois 404 em uma mensagemUm id de mensagem armazenado com mais de 5 dias.Nada sobrevive 5 dias. Busque de novo em vez de guardar em cache.

Antes de considerar concluído

  • Um endereço novo por execução, por teste ou por agente — nunca uma constante.
  • Um prazo baseado em tempo real; uma leitura por segundo; 429 tratado com espera, nunca lançado como erro.
  • Um filtro por assunto ou remetente quando um fluxo envia mais de uma mensagem.
  • A parte em texto analisada primeiro, com um padrão ancorado no seu próprio texto.
  • Anexos transmitidos via streaming, tratados como não confiáveis, salvos sob o id da mensagem.
  • Nenhum id de mensagem em cache entre dias; nada aqui sobrevive além de 5 dias.

Esse é o cliente inteiro. O mesmo módulo em Python, para requests e httpx, está no guia de Python; os formatos de requisição e resposta, com cada código de status, estão na referência da API, e há um documento OpenAPI 3.1 para quem preferir gerar o cliente a escrevê-lo.

Perguntas

Preciso de uma chave de API ou de um pacote npm?

Nenhum dos dois. Os domínios públicos não exigem chave, conta nem cabeçalho, e o módulo usa só o fetch que já vem com o Node 18 e versões posteriores. Só o conjunto pago de domínios mantidos fora das listas de bloqueio de e-mail descartável usa um cabeçalho Authorization: Bearer, e fora isso o código é idêntico para ele.

Isso funciona no navegador?

As mesmas chamadas funcionam a partir de uma página, mas um navegador é o lugar errado para um loop de polling de sessenta segundos, e a caixa de entrada é pública de qualquer forma — leia-a a partir do servidor ou do runner de teste. Se você está testando uma aplicação web, o guia do Playwright mantém o polling no processo de teste, onde ele pertence.

Quantas caixas de entrada um processo pode consultar ao mesmo tempo?

Vinte, tranquilamente: o limite por endereço é uma leitura por segundo e o teto por cliente é de 1200 requisições por minuto, o que equivale a vinte endereços consultados uma vez por segundo. Um Promise.all sobre vinte chamadas de waitFor fica dentro disso; além disso, o branch de 429 espera em vez de falhar.

Posso usar meu próprio domínio a partir do Node?

Sim, sem nenhuma mudança além da constante DOMAIN. Um registro MX apontando para smtp.grabmail.io e todo endereço no domínio vira uma caixa de entrada que o mesmo módulo lê — a configuração está aqui. É a resposta certa quando a sua aplicação recusa os domínios públicos descartáveis.

A caixa de entrada é privada enquanto meu script a usa?

Não. Qualquer um que saiba o endereço pode lê-la, em um domínio público e também no seu. Um endereço aleatório que guarda um único código de confirmação por alguns segundos não tem problema; um script que aponta e-mail real de clientes para um endereço desses tem.

Existe algo para um agente de IA em vez de um script?

Existe um servidor MCP na mesma origem, sem chave, cuja ferramenta wait_for_message mantém a chamada aberta até o e-mail chegar — o formato de que um agente precisa, já que todo polling custa tokens para ele. Uma caixa de entrada que um agente de IA consegue ler aborda isso.

Experimente enquanto ainda está fresco

Um endereço leva um clique, sem conta e sem cartão. Tudo neste guia funciona nele imediatamente.

Bem-vindo de volta

As suas caixas e os seus domínios, num só lugar.