> ## Content Index
> Fetch the complete content index at: https://blog.devutilites.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Cloudflare Email a fondo: Email Routing, Email Workers y envío de correo desde tu Worker
- URL: https://blog.devutilites.com/cloudflare-email-a-fondo/
- Published: 2026-10-03T03:14:02.000Z
- Updated: 2026-10-03T03:14:02.000Z
- Description: 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.
- Author: Luis Collantes
- Tags: Cloudflare, Backend

Tener `hola@tudominio.com` 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](https://blog.devutilites.com/content/images/2026/10/cf-email-esquema.png)

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](https://blog.devutilites.com/cloudflare-dns-a-fondo/)).

### 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

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

⚠️

**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):

```json
{
  "$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": ["avisos@ejemplo.com"]
    }
  ],
  "vars": { "BUZON_SOPORTE": "luis@ejemplo.net" }
}
```

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:

```typescript
import PostalMime from "postal-mime";

const BLOQUEADOS = ["spam@malo.example"];

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: "avisos@ejemplo.com", 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: "avisos@ejemplo.com",
        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:

```text
From: "Ana" <ana@cliente.example>
To: soporte@ejemplo.com
Subject: No puedo entrar en mi cuenta
Date: Fri, 02 Oct 2026 22:40:00 +0000
Message-ID: <ticket-001@cliente.example>
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.

```text
$ curl -s -X POST "localhost:8803/cdn-cgi/local/email?from=ana@cliente.example&to=soporte@ejemplo.com" --data-binary @correo.eml
Worker successfully processed email
(HTTP 200)
$ curl -s -X POST "localhost:8803/cdn-cgi/local/email?from=spam@malo.example&to=soporte@ejemplo.com" --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?from=ana@cliente.example&to=ventas@ejemplo.com" --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":"<yjUHO1ZeexInA8fECFArzwRb5BpvfY7VC9le@ejemplo.com>"}
(HTTP 200)
```

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

```text
{"de":"ana@cliente.example","para":"soporte@ejemplo.com","asunto":"No puedo entrar en mi cuenta","bytes":269,"adjuntos":[]}
[wrangler:info] Email handler forwarded message with
  rcptTo: luis@ejemplo.net
[wrangler:info] send_email binding called with MessageBuilder:
From: "Avisos" <avisos@ejemplo.com>
To: luis@ejemplo.net
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: spam@malo.example
  "From" header: ana@cliente.example
[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:

```html
<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: "otro@ejemplo.com"` lanza `email from otro@ejemplo.com 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

- [Cloudflare a fondo](https://blog.devutilites.com/cloudflare-a-fondo/): la visión general de la plataforma.
- [Cloudflare Workers a fondo](https://blog.devutilites.com/cloudflare-workers-a-fondo/): el runtime donde corre el handler `email()`.
- [Cloudflare Queues a fondo](https://blog.devutilites.com/cloudflare-queues-a-fondo/): para procesar el correo entrante en segundo plano.
- [Cloudflare DNS a fondo](https://blog.devutilites.com/cloudflare-dns-a-fondo/): MX, SPF, DKIM y DMARC.
- [Cloudflare Turnstile a fondo](https://blog.devutilites.com/cloudflare-turnstile-a-fondo/): protege el formulario de contacto antes de enviar nada.