BookinglyTech News
Infraestructura

Un buzón buscable con Cloudflare Email Workers y D1 en unas 30 líneas de TypeScript

Email Routing reenvía el correo y no guarda copia. La receta para tener un buzón consultable son unas 30 líneas de TypeScript, un Worker y una base D1, o clonar uno de los tres proyectos ya hechos.

4 min de lecturaDev.to0 vistas

Cloudflare Email Routing hace su trabajo y se olvida del mensaje: acepta la sesión SMTP, aplica las reglas y entrega los bytes al destino, sin quedarse copia. Si quieres un buzón que puedas consultar después, la salida es apuntar la regla a un Worker que guarde el correo en D1 y lo reenvíe. Son unas 30 líneas de TypeScript, y ya hay tres proyectos autoalojados con esa misma forma.

El motivo de que no exista un buzón integrado es que Cloudflare vende borde, no almacenamiento. Guardar correo implica retención, búsqueda, cuotas y gestión de abuso, y nada de eso está en el producto. El hueco lo tapa que un destino pueda ser un Worker, que es justo lo que hacen los tres proyectos.

El handler y lo que conviene saber

El Worker exporta un método email() junto a —o en lugar de— fetch(), y la documentación de Email Workers cubre el contrato del mensaje. El núcleo queda así:

import PostalMime from "postal-mime";

export default {
  async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
    const raw = await new Response(message.raw).arrayBuffer();
    const parsed = await PostalMime.parse(raw);
    await env.DB.prepare(
      "INSERT INTO messages (id, from_addr, to_addr, subject, text_body, received_at) VALUES (?,?,?,?,?,?)"
    )
      .bind(crypto.randomUUID(), message.from, message.to, parsed.subject ?? "", parsed.text ?? "", new Date().toISOString())
      .run();
    ctx.waitUntil(message.forward("you@personal.example"));
  },
};

Tres detalles antes del primer despliegue. message.raw es un ReadableStream, no una cadena: hay que envolverlo en un Response para leerlo una vez, porque a la segunda vuelve vacío. forward() solo acepta destinos verificados, los mismos de la lista del panel, y una dirección sin verificar lanza excepción con rebote para el remitente. Una regla mapea a un destino o a un Worker, así que el reparto a varias personas obliga a llamar a forward() una vez por dirección. Existe además setReject(reason): si el Worker decide que un mensaje es spam, corta la sesión SMTP con un 5xx y no se guarda ni se reenvía nada.

Esquema, límites y regla de enrutado

CREATE TABLE messages (
  id TEXT PRIMARY KEY,
  from_addr TEXT NOT NULL,
  to_addr TEXT NOT NULL,
  subject TEXT NOT NULL DEFAULT '',
  text_body TEXT NOT NULL DEFAULT '',
  received_at TEXT NOT NULL
);
CREATE INDEX messages_received ON messages (received_at DESC);

El wrangler.toml solo declara el binding de D1; la regla de enrutado se crea a mano en el panel (Compute, Email Service, Email Routing, Routing rules) o vía API, porque no hay comando de wrangler para ella. Un detalle que muerde: renombrar el Worker rompe el binding, así que renombra primero y vuelve a apuntar la regla después.

Con el plan gratuito de D1 —5 millones de filas leídas y 100.000 escritas al día— una dirección de soporte que reciba 100 mensajes diarios consume el 0,1% del presupuesto de escritura. El índice sobre received_at es lo que evita que un "últimos 50" recorra la tabla entera. Los adjuntos van a R2, no a D1; para correo de soporte solo texto, con D1 basta para empezar.

Los tres buzones ya hechos

  • cloudflare/agentic-inbox, Apache-2.0, monta Workers y D1 como una bandeja que los agentes de IA pueden leer y sobre la que pueden actuar.
  • HQBase/hqbase, AGPL-3.0, añade R2 y Queues y envía mediante el binding send_email de Cloudflare.
  • mirza-rizvi/ResolveHQ, source-available y no open source, combina Email Routing con Resend, D1, R2 y Queues.

Autoalojar cualquiera de ellos significa que las migraciones de D1, el bucket de R2, la ruta de respuesta y el aviso cuando algo se cae son tuyos. Si eso entra en tus planes y alguno encaja, clonar sale más barato que escribir el handler.

Quien publica el código avisa de que no lo ha probado en producción: su support@ sigue reenviando a un buzón normal, y el handler sigue la documentación y los tres proyectos, que sí lo corren. Lo que te llevas es un Worker que hace de buzón: cada mensaje guardado, consultable con SQL y reenviado igual al móvil. Lo que no hay es interfaz, hilos, un "quién lleva esto" ni forma de responder desde support@ una hora más tarde. Eso lo ponen los repositorios de arriba, y la versión alojada de todo esto es hoy una página de lista de espera, no un producto.