Cloudflare Email a fondo: Email Routing, Email Workers y envío de correo desde tu Worker

Cómo recibir correo en tu dominio con Email Routing, procesarlo con un Email Worker (reenviar, rechazar, analizar) y enviar correo transaccional con el binding send_email: reglas, límites, precios y un Worker probado en local.

Portada: Cloudflare Email a fondo

Tener [email protected] sin pagar un servidor de correo, recibir los tickets de soporte en un Worker que los clasifica, mandar el correo de bienvenida de tu aplicación sin contratar otro proveedor. Cloudflare cubre las tres cosas con lo que ahora agrupa como Email Service:

  • Email Routing: recibe el correo de tu dominio y lo reenvía a una dirección verificada (tu Gmail, por ejemplo), lo descarta o se lo pasa a un Worker. Disponible en Free y Paid.
  • Email Workers: el handler email() de un Worker, que recibe cada mensaje y decide qué hacer con él.
  • Email Sending: envío de correo transaccional desde un Worker (binding send_email), una API REST o SMTP. Está en beta y necesita el plan Workers Paid para enviar a cualquier destinatario.

En este artículo vemos cómo encaja cada pieza, los límites y precios, y un Worker que recibe correo, reenvía, rechaza y envía, todo probado en local con el simulador de Wrangler.

🧪
El Worker de este artículo se ha ejecutado de verdad el 3 de octubre de 2026 con Wrangler 4.147.0, postal-mime 4.0.2 y Node.js 24.21.0, usando la simulación local de correo entrante y saliente de Wrangler. No se ha enviado ni recibido ningún correo real: eso necesita un dominio en Cloudflare, y el envío a cualquier destinatario, además, el plan Paid. Límites y precios comprobados en la documentación oficial ese mismo día.

Cómo funciona

Cuando activas Email Routing en un dominio, Cloudflare añade tres registros DNS: los MX que dirigen el correo hacia Cloudflare, un TXT de SPF y un TXT de DKIM. Desde ese momento, cada correo que llega a tu dominio pasa por tus reglas de enrutado. Cada regla asocia un patrón de dirección con una acción:

  • Reenviar a una dirección de destino verificada (Cloudflare manda un correo de verificación y la regla no funciona hasta que lo confirmas).
  • Enviar a un Worker, que ejecuta tu lógica.
  • Descartar (Drop): el mensaje se borra sin más. Útil para que una dirección parezca válida.

Además existe la regla catch-all, que recoge todo lo que no encaje con otra regla, incluidas las direcciones mal escritas.

Esquema de Email Service: el correo entrante llega por MX a Email Routing, que según la regla lo reenvía a una dirección verificada, lo descarta o lo pasa al handler email() de un Worker; el Worker puede reenviar, rechazar o responder y además enviar correo con el binding send_email
Entrada por MX y reglas; el Worker decide; la salida usa el binding send_email. Diagrama: devutilites.
⚠️
Todo esto exige que el dominio use Cloudflare DNS. La documentación de Email Service lo dice expresamente. Si tu dominio tiene los DNS en otro proveedor, primero tendrás que pasarlo a Cloudflare (lo cuento en el artículo de DNS).

El handler email()

Un Email Worker es un Worker normal con un handler más. Recibe un ForwardableEmailMessage con:

  • from y to: el remitente y el destinatario del sobre SMTP (no las cabeceras).
  • headers: las cabeceras del mensaje (subject, message-id…).
  • raw: el correo en bruto como ReadableStream, y rawSize, su tamaño en bytes.
  • forward(destino, cabeceras?): reenvía a una dirección verificada.
  • setReject(motivo): rechaza el mensaje en la propia sesión SMTP; el remitente recibe un rebote.
  • reply(mensaje): responde al remitente (no lo he probado).

Para leer el cuerpo, el asunto o los adjuntos necesitas un parser de MIME. La documentación usa postal-mime, que funciona en Workers sin dependencias.

Límites y precios

ConceptoWorkers FreeWorkers Paid
Correo entrante (Email Routing)IlimitadoIlimitado
Envío a cualquier destinatario (Email Sending, beta)No disponible3000 al mes incluidos, después 0,35 $ por cada 1000
Envío a tus direcciones de destino verificadasGratisGratis, no cuenta para la cuota
Ejecución del Email WorkerPrecio de WorkersPrecio de Workers

Los 3000 correos incluidos son por cuenta y mes. Los que rebotan cuentan; los que se rechazan antes de enviarse (por ejemplo, por estar en la lista de supresión) no. Además, las cuentas nuevas empiezan con una cuota diaria conservadora que sube sola según tu reputación de envío.

LímiteValor
Destinatarios por correo (to + cc + bcc)50
Tamaño de un correo enviado5 MiB (25 MiB a direcciones verificadas)
Asunto998 caracteres
Cabeceras personalizadas16 KB en total
Correo entrante25 MiB; los mayores se rechazan
Reglas de enrutado200 por dominio
Direcciones de destino200 por cuenta
Dominios con Email Routing o Sending30 por zona
⚠️
Email Sending es solo para correo transaccional. Bienvenidas, restablecer contraseña, confirmaciones de pedido, alertas. La FAQ oficial dice que el correo de marketing no está soportado todavía.

El código

La configuración incluye el binding de envío, que solo puede enviar desde las direcciones de allowed_sender_addresses. También existen allowed_destination_addresses (lista de destinatarios permitidos) y destination_address (un único destinatario fijo):

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "email-lab",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  // Binding de envío: solo puede enviar desde estas direcciones
  "send_email": [
    {
      "name": "EMAIL",
      "allowed_sender_addresses": ["[email protected]"]
    }
  ],
  "vars": { "BUZON_SOPORTE": "[email protected]" }
}

El Worker tiene dos handlers. email() atiende el correo entrante: rechaza a un remitente bloqueado, reenvía lo que llega a soporte@ y manda un aviso interno; cualquier otra dirección se rechaza. fetch() es un formulario de contacto que envía un correo:

import PostalMime from "postal-mime";

const BLOQUEADOS = ["[email protected]"];

export default {
  // Correo ENTRANTE: Email Routing llama a este handler (regla "Send to a Worker")
  async email(message, env, ctx) {
    if (BLOQUEADOS.includes(message.from)) {
      // Rechazo en la sesión SMTP: el remitente recibe un rebote con este texto
      message.setReject("Remitente no permitido");
      return;
    }

    // message.raw es un ReadableStream con el correo en bruto (RFC 5322)
    const correo = await PostalMime.parse(message.raw);
    console.log(
      JSON.stringify({
        de: message.from,
        para: message.to,
        asunto: correo.subject,
        bytes: message.rawSize,
        adjuntos: correo.attachments.map((a) => a.filename),
      }),
    );

    if (message.to.startsWith("soporte@")) {
      // Reenvío a una dirección de destino verificada
      await message.forward(env.BUZON_SOPORTE);

      // Aviso interno con el binding de envío (no bloquea la respuesta)
      ctx.waitUntil(
        env.EMAIL.send({
          from: { email: "[email protected]", name: "Avisos" },
          to: env.BUZON_SOPORTE,
          subject: `Nuevo ticket: ${correo.subject ?? "(sin asunto)"}`,
          text: `De: ${message.from}\n\n${(correo.text ?? "").slice(0, 500)}`,
        }),
      );
      return;
    }

    message.setReject("Dirección desconocida");
  },

  // Correo SALIENTE desde HTTP: p. ej., un formulario de contacto
  async fetch(request, env) {
    if (request.method !== "POST") return new Response("Usa POST", { status: 405 });
    const { nombre, mensaje } = await request.json<{ nombre: string; mensaje: string }>();
    try {
      const r = await env.EMAIL.send({
        from: "[email protected]",
        to: env.BUZON_SOPORTE,
        subject: `Contacto de ${nombre}`,
        text: mensaje,
        html: `<p><strong>${escapar(nombre)}</strong> escribe:</p><p>${escapar(mensaje)}</p>`,
      });
      return Response.json({ ok: true, id: r.messageId });
    } catch (e) {
      const err = e as Error & { code?: string };
      return Response.json({ ok: false, code: err.code, error: err.message }, { status: 502 });
    }
  },
} satisfies ExportedHandler<Env>;

function escapar(s: string) {
  return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}

Probarlo en local

wrangler dev simula los dos lados. Para el correo entrante expone el endpoint /cdn-cgi/local/email: le pasas el sobre (from y to) en la URL y el mensaje en bruto en el cuerpo, que debe llevar cabecera Message-ID. Este es el correo de prueba:

From: "Ana" <[email protected]>
To: [email protected]
Subject: No puedo entrar en mi cuenta
Date: Fri, 02 Oct 2026 22:40:00 +0000
Message-ID: <[email protected]>
Content-Type: text/plain; charset="utf-8"

Hola, desde ayer me sale "contraseña incorrecta".

Y estas, las cuatro pruebas: un ticket normal, un remitente bloqueado, una dirección desconocida y el formulario.

$ curl -s -X POST "localhost:8803/cdn-cgi/local/[email protected]&[email protected]" --data-binary @correo.eml
Worker successfully processed email
(HTTP 200)
$ curl -s -X POST "localhost:8803/cdn-cgi/local/[email protected]&[email protected]" --data-binary @correo.eml
Worker rejected email with the following reason: Remitente no permitido
(HTTP 400)
$ curl -s -X POST "localhost:8803/cdn-cgi/local/[email protected]&[email protected]" --data-binary @correo.eml
Worker rejected email with the following reason: Dirección desconocida
(HTTP 400)
$ curl -s -X POST localhost:8803/ -d '{"nombre":"Ana","mensaje":"Hola <b>equipo</b>"}'
{"ok":true,"id":"<[email protected]>"}
(HTTP 200)

En la consola de Wrangler se ve qué habría pasado en producción:

{"de":"[email protected]","para":"[email protected]","asunto":"No puedo entrar en mi cuenta","bytes":269,"adjuntos":[]}
[wrangler:info] Email handler forwarded message with
  rcptTo: [email protected]
[wrangler:info] send_email binding called with MessageBuilder:
From: "Avisos" <[email protected]>
To: [email protected]
Subject: Nuevo ticket: No puedo entrar en mi cuenta
Text: /home/yo/email-lab/.wrangler/tmp/email/miniflare-…/email-text/IQdDZ…@ejemplo.com.txt
[wrangler:warn] Provided MAIL FROM address doesn't match the email message's "From" header:
  MAIL FROM: [email protected]
  "From" header: [email protected]
[wrangler:error] Email handler rejected message with the following reason: "Remitente no permitido"

Los correos «enviados» no salen de tu máquina: Wrangler guarda el texto y el HTML en .wrangler/tmp/email/ para que los revises. El del formulario quedó así, con el HTML escapado:

<p><strong>Ana</strong> escribe:</p><p>Hola &#60;b&#62;equipo&#60;/b&#62;</p>

También comprobé que la restricción del binding se aplica en local: un send() con from: "[email protected]" lanza email from [email protected] not allowed. En local, ese error no traía la propiedad code que describe la documentación (por ejemplo, E_SENDER_NOT_VERIFIED), así que no te fíes de ella hasta probarla en producción.

Si quieres enviar correo de verdad mientras desarrollas, añade "remote": true al binding: el Worker sigue en local, pero send() usa el servicio real. Ojo, porque entonces los correos llegan de verdad.

Enviar desde tu propio dominio

Para enviar a cualquier destinatario tienes que dar de alta el dominio en Email Sending. Cloudflare añade en el subdominio cf-bounce los registros SPF y DKIM, y un DMARC en _dmarc. Hasta que lo hagas, solo puedes enviar a tus direcciones de destino verificadas (gratis, incluso en el plan Free). Además del binding, puedes enviar por API REST o por SMTP con autenticación, útil si tu aplicación no corre en Workers.

Errores comunes (y cómo evitarlos)

  • Confundir el sobre con las cabeceras. message.from y message.to son los del sobre SMTP; pueden no coincidir con las cabeceras From y To (listas de correo, CCO). Wrangler avisa cuando no coinciden.
  • Reenviar a una dirección sin verificar. forward() solo funciona con direcciones de destino verificadas de tu cuenta.
  • Rechazar después de reenviar. setReject() es para no aceptar el mensaje; decide antes de reenviar.
  • Usar message.raw dos veces. Es un stream: léelo una sola vez (por ejemplo, con PostalMime.parse) y trabaja con el resultado.
  • Meter texto del usuario en el HTML sin escapar. Un formulario de contacto es una vía clásica de inyección.
  • Adjuntos binarios en local. El simulador no sabe serializar ArrayBuffer en los adjuntos; usa base64 o el binding remoto.
  • Bucles de respuesta. Un autorresponder que contesta a otro autorresponder no acaba nunca. reply() lanza un error si el mensaje trae más de 100 entradas en References, pero mejor no llegar ahí: no respondas a correos con Auto-Submitted o de listas.

Cuándo usar Email Service (y cuándo no)

Úsalo para direcciones personalizadas que reenvían a tu buzón de siempre (gratis), para procesar correo entrante con código (tickets, crear tareas, alimentar un agente) y para correo transaccional de una aplicación que ya vive en Workers.

No es un servidor de correo con buzones (no guarda mensajes ni ofrece IMAP), ni sirve hoy para campañas de marketing. Si necesitas buzones de verdad, sigue con Google Workspace, Fastmail o similar; Email Routing puede convivir con ellos solo si no compiten por los registros MX del mismo dominio.

Sigue leyendo