Abrir un buzón

API y automatización

API de correo temporal en Node.js: leer un buzón con fetch

Un archivo, nada más allá del fetch que trae Node 18, y sin clave de API: una dirección que inventas, una espera con un plazo, el mensaje como un objeto. Aquí está el módulo en TypeScript, la versión en JavaScript puro, las notas para Deno y Bun, la paginación, los adjuntos transmitidos en streaming a disco, un ejemplo con Vitest, y las seis formas en que esto se rompe la primera vez que se ejecuta sin supervisión.

  • Intermedio
  • 19 min de lectura
Un bloque hexagonal gris con tres cables grises conectados arriba y un sobre azul saliendo de una ranura en su frente

La API, vista desde JavaScript

No hay nada que instalar del lado del servidor ni nada contra lo que autenticarse: un buzón en un dominio público lo puede leer cualquiera que conozca su dirección, por HTTPS normal, como JSON. Toda la superficie son tres llamadas:

GET /api/v1/mailbox?address=…
Todo lo que espera en una dirección, lo más reciente primero, como una lista de resúmenes. Un buzón vacío es 200 con count: 0 — nunca un 404. limit limita una respuesta (1–200, 50 por defecto) y before pagina más allá de eso.
GET /api/v1/message/{id}?mailbox=…
Un mensaje completo: remitente, destinatario, asunto, fecha, la parte en texto plano, la parte en HTML (o null), y una lista de adjuntos con una URL ya preparada cada uno.
DELETE /api/v1/message/{id}?mailbox=…
Lo elimina ahora en lugar de dentro de 5 días. Idempotente: borrar dos veces sigue respondiendo 200.

Los tipos del módulo de más abajo son exactamente la forma de las respuestas. El listado también lleva un alias: una segunda dirección en un dominio distinto que entrega en el mismo buzón y que no se puede usar para leerlo — la que le das a un sitio cuando prefieres que no pueda abrir el buzón.

El módulo

Un archivo, una clase, ninguna dependencia. Funciona sobre las globales fetch y crypto que trae Node desde la versión 18, así que no hay nada que añadir a package.json. Es deliberadamente aburrido: un bucle con plazo y el único reintento que alguna vez es correcto, dormir el tiempo de un 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}`);
  }
}

Usarlo son cuatro líneas. Imprime la dirección, úsala donde se pida una dirección, y espera:

una primera ejecución
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 y Bun

El TypeScript de arriba es la referencia; nada en él es específico de Node, salvo el streaming de adjuntos de una sección posterior. Tres notas para los otros sitios donde se ejecuta:

JavaScript puro
Quita los tipos y es el mismo archivo. La versión corta de más abajo es todo lo que un script suele necesitar — una dirección y una espera.
Deno
Se ejecuta tal cual: fetch y crypto.randomUUID() son globales, y el script solo necesita --allow-net=grabmail.io. Guarda un adjunto con Deno.writeFile(path, new Uint8Array(await res.arrayBuffer())).
Bun
Se ejecuta tal cual, TypeScript incluido. Guarda un adjunto con Bun.write(path, res), que toma el Response directamente.
grabmail.mjs — la versión corta en 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`);
}

Un buzón con mucho tráfico: paginar con before

Un listado devuelve como máximo 200 resúmenes. Un buzón que recibe más que eso — una dirección catch-all en tu propio dominio que recoge un día de rebotes, por ejemplo — se lee página por página: pasa el valor next de una respuesta como el parámetro before de la siguiente solicitud, y para cuando next sea null. Un generador asíncrono convierte eso en un for await:

todos los mensajes, sean cuantas páginas sean
/** 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);
}

El cursor es el id del mensaje más antiguo que ya tienes, así que una página es estable incluso mientras llega correo nuevo por arriba. Automatizar un buzón desde un script repasa el cursor con más detalle, junto con la programación y la retención.

Adjuntos transmitidos en streaming a disco

Cada mensaje lista sus adjuntos con un nombre de archivo, un tipo declarado, un tamaño en bytes y una URL. La URL ya lleva el parámetro ?mailbox=, así que se descarga tal cual. La respuesta es siempre application/octet-stream con una cabecera Content-Disposition: attachment, sea lo que sea que el remitente etiquetó como el archivo — el tipo real es el campo mime del JSON. Transmítelo en streaming en lugar de guardarlo en un buffer; el límite es 5 MB por mensaje, y un script que guarda cien de ellos no debería tenerlos todos en memoria a la vez.

guardar todos los adjuntos de un mensaje, en 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}`));
}

En un test de Vitest o Jest

Un Inbox nuevo dentro del cuerpo del test le da a cada test su propio buzón, que es la propiedad más importante de todas en un test de correo: ninguna ejecución puede leer jamás el mensaje de una ejecución anterior, y los workers en paralelo nunca pueden leer el correo del otro. El patrón de extracción está anclado en la redacción de la plantilla en lugar de en «seis dígitos», por las razones que explica códigos OTP en tests automatizados.

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

El tercer argumento de it es el tiempo de espera del test, puesto por encima del plazo de correo de sesenta segundos; el valor por defecto de cinco segundos terminaría cada test antes de que el correo pudiera llegar. Para una versión de este mismo test guiada por el navegador, la guía de Playwright envuelve esta clase en un fixture; ejecutar cualquiera de las dos en un ejecutor de CI añade una regla de salida y un tiempo de espera de job, los dos en la guía de GitHub Actions.

Errores que aparecen la primera vez que se ejecuta sin supervisión

Ninguno de estos falla en un portátil. Todos fallan un martes por la noche en un trabajo programado.

SíntomaCausaSolución
Pasa siempre, incluso cuando el remitente está rotoLa misma dirección en cada ejecución; la primera consulta encuentra el mensaje de la ejecución anterior.freshAddress() por ejecución. Esta es la que importa.
429 en el log, y después un cuelgueUn bucle sin espera, o dos scripts consultando una misma dirección.Una lectura por segundo por dirección; duerme el tiempo de Retry-After; una dirección por script.
Agota el tiempo de espera en un día lento, pasa al reintentarUn número de intentos en lugar de un plazo, o un plazo más corto que la cola del remitente.Un plazo con Date.now(), sesenta segundos para un correo transaccional.
404 desde /mailboxEl dominio no está alojado aquí — una errata, o tu propio dominio sin registro MX.Comprueba la dirección; para tu propio dominio, comprueba que el MX apunta a smtp.grabmail.io.
Lee el mensaje equivocadoCogió el mensaje más reciente cuando el flujo envió dos.Filtra con subjectContains o fromContains.
Funciona una semana, y después 404 en un mensajeUn id de mensaje guardado con más de 5 días.Nada sobrevive 5 días. Vuelve a pedirlo en lugar de guardarlo en caché.

Antes de darlo por terminado

  • Una dirección nueva por ejecución, por test o por agente — nunca una constante.
  • Un plazo de reloj real; una lectura por segundo; los 429 dormidos, nunca lanzados.
  • Un filtro por asunto o remitente cuando un flujo envía más de un mensaje.
  • La parte de texto analizada primero, con un patrón anclado en tu propia redacción.
  • Los adjuntos transmitidos en streaming, tratados como no confiables, guardados bajo el id del mensaje.
  • Ningún id de mensaje guardado en caché de un día para otro; nada aquí sobrevive 5 días.

Eso es todo el cliente. El mismo módulo en Python, para requests y httpx, está en la guía de Python; la forma de las solicitudes y las respuestas, con todos los códigos de estado, está en la referencia de la API, y hay un documento OpenAPI 3.1 para quien prefiera generar el cliente antes que escribirlo.

Preguntas

¿Necesito una clave de API o un paquete npm?

Ninguna de las dos cosas. Los dominios públicos no piden clave, ni cuenta, ni cabecera, y el módulo usa solo el fetch que trae Node 18 y versiones posteriores. Solo el fondo de pago de dominios que se mantienen fuera de las listas de bloqueo de correo desechable usa una cabecera Authorization: Bearer, y el código es idéntico salvo por eso.

¿Funciona en el navegador?

Las mismas llamadas funcionan desde una página, pero un navegador es el sitio equivocado para un bucle de consulta de sesenta segundos, y el buzón es público de todas formas — léelo desde el servidor o desde el ejecutor de tests. Si estás probando una aplicación web, la guía de Playwright mantiene la consulta en el proceso de test, que es donde debe estar.

¿Cuántos buzones puede consultar un proceso a la vez?

Veinte, sin problema: el límite por dirección es una lectura por segundo y el límite por cliente es de 1200 solicitudes por minuto, que son veinte direcciones consultadas una vez por segundo. Un Promise.all sobre veinte llamadas a waitFor se queda dentro de eso; por encima, la rama de 429 duerme en lugar de fallar.

¿Puedo usar mi propio dominio desde Node?

Sí, sin ningún cambio más allá de la constante DOMAIN. Un registro MX que apunte a smtp.grabmail.io y cada dirección del dominio se convierte en un buzón que lee el mismo módulo — la configuración está aquí. Es la respuesta correcta cuando tu aplicación rechaza los dominios públicos desechables.

¿Es privado el buzón mientras lo usa mi script?

No. Cualquiera que conozca la dirección puede leerlo, tanto en un dominio público como en el tuyo propio. Una dirección aleatoria que contiene un código de confirmación durante unos segundos no supone ningún problema; un script que apunta correo real de clientes a una sí lo es.

¿Hay algo pensado para un agente de IA en lugar de un script?

Hay un servidor MCP en el mismo origen, sin clave, cuya herramienta wait_for_message mantiene la llamada abierta hasta que llega el correo — la forma que necesita un agente, ya que cada consulta le cuesta tokens. Un buzón que un agente de IA puede leer lo cubre.

Pruébalo mientras está reciente

Una dirección lleva un clic, sin cuenta y sin tarjeta. Todo lo de esta guía funciona con ella de inmediato.

Bienvenido de nuevo

Tus buzones y tus dominios, en un solo lugar.