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.
Contenido
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.
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.

El handler email()
Un Email Worker es un Worker normal con un handler más. Recibe un ForwardableEmailMessage con:
fromyto: el remitente y el destinatario del sobre SMTP (no las cabeceras).headers: las cabeceras del mensaje (subject,message-id…).raw: el correo en bruto comoReadableStream, yrawSize, 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
| Concepto | Workers Free | Workers Paid |
|---|---|---|
| Correo entrante (Email Routing) | Ilimitado | Ilimitado |
| Envío a cualquier destinatario (Email Sending, beta) | No disponible | 3000 al mes incluidos, después 0,35 $ por cada 1000 |
| Envío a tus direcciones de destino verificadas | Gratis | Gratis, no cuenta para la cuota |
| Ejecución del Email Worker | Precio de Workers | Precio 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ímite | Valor |
|---|---|
| Destinatarios por correo (to + cc + bcc) | 50 |
| Tamaño de un correo enviado | 5 MiB (25 MiB a direcciones verificadas) |
| Asunto | 998 caracteres |
| Cabeceras personalizadas | 16 KB en total |
| Correo entrante | 25 MiB; los mayores se rechazan |
| Reglas de enrutado | 200 por dominio |
| Direcciones de destino | 200 por cuenta |
| Dominios con Email Routing o Sending | 30 por zona |
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 <b>equipo</b></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.fromymessage.toson los del sobre SMTP; pueden no coincidir con las cabecerasFromyTo(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.rawdos veces. Es un stream: léelo una sola vez (por ejemplo, conPostalMime.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
ArrayBufferen 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 enReferences, pero mejor no llegar ahí: no respondas a correos conAuto-Submittedo 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
- Cloudflare a fondo: la visión general de la plataforma.
- Cloudflare Workers a fondo: el runtime donde corre el handler
email(). - Cloudflare Queues a fondo: para procesar el correo entrante en segundo plano.
- Cloudflare DNS a fondo: MX, SPF, DKIM y DMARC.
- Cloudflare Turnstile a fondo: protege el formulario de contacto antes de enviar nada.