Cómo enviar email desde Cloudflare Workers

Un Worker no puede ejecutar un servidor de correo, pero sí puede hablar con uno. Esto es lo que funciona, lo que falla y por qué, aprendido al construir MakiSend, una API de email que corre por completo en Workers.

Última actualización · Versión en Markdown (en inglés)

Tres formas de enviar

Un Worker tiene tres salidas. Elige según de quién quieres depender, no según el código: el código es corto en todos los casos.

EnfoqueCómo envía el WorkerQué necesitasCuidado con
API de email HTTPfetch() a la API del proveedorUna cuenta y una clave API en ese proveedorEl formato de petición de un solo proveedor y su precio por email
SMTP sobre TCPnodemailer (con nodejs_compat) sobre los sockets TCP de CloudflareCredenciales SMTP de cualquier proveedor o buzónPuertos permitidos, modo TLS y un viaje de ida y vuelta por cada comando SMTP
Cloudflare Email ServiceEl binding send_emailUn dominio remitente dado de alta en Email SendingEnviar a cualquier destinatario requiere Workers Paid

SMTP es la opción que te mantiene libre: el mismo código envía a través de Google Workspace, Zoho, Microsoft 365, Amazon SES, SendGrid, Mailgun, Postmark, Brevo o el SMTP de Resend. También es la que tiene trampas.

Qué puertos SMTP puede usar un Worker

Cloudflare bloquea las conexiones salientes por el puerto 25, así que un Worker no puede entregar directamente al servidor MX del destinatario. Tiene que pasar el mensaje a un servidor de envío, en uno de estos puertos:

PuertoSeguridadSignificado
465TLS implícito (tls)TLS desde el primer byte
587STARTTLS (starttls)Empieza en texto plano y pasa a TLS antes de autenticar
2465TLS implícito (tls)Alternativa al 465 que ofrecen algunos proveedores
2525, 2587STARTTLS (starttls)Alternativas al 587 que ofrecen algunos proveedores
25NingunaBloqueado en Workers

Combina el puerto con el modo correcto. El 465 con STARTTLS, o el 587 con TLS implícito, falla durante el handshake TLS (wrong version number), antes de enviar ninguna credencial. MakiSend rechaza el puerto 25 al guardar una configuración y deduce el modo a partir del puerto.

nodemailer en Workers

nodemailer funciona en Workers en cuanto activas el flag de compatibilidad nodejs_compat: node:net y node:tls se apoyan en los sockets TCP de Cloudflare. Aun así, tres cosas nos dieron problemas.

  1. nodemailer resuelve el nombre de host y se conecta a la dirección IP. workerd rechaza una conexión a una dirección IP con TLS implícito en el 465 (proxy request failed, cannot connect to the specified address), mientras que el mismo servidor abre sin problema por nombre. El error no dice nada de la causa real.
  2. dns.resolve4 y dns.resolve6 no están implementados en desarrollo local, así que un transporte por defecto puede quedarse colgado con wrangler dev.
  3. Sin family: 4, workerd registra avisos de red en cada envío.

La solución para los dos primeros es la opción getSocket de nodemailer: abre tú el socket, por nombre de host, y pásaselo. nodemailer sigue ejecutando STARTTLS cuando hace falta y sigue verificando el certificado contra el nombre de host.

smtp-transport.tsts
import * as net from "node:net";
import * as tls from "node:tls";
import nodemailer from "nodemailer";

export function createTransport(env: { SMTP_USER: string; SMTP_PASSWORD: string }) {
  const host = "smtp.example.com";
  const port = 465; // 465 or 2465: implicit TLS. 587, 2525, 2587: STARTTLS.
  const implicitTls = port === 465 || port === 2465;

  return nodemailer.createTransport({
    host,
    port,
    secure: implicitTls,
    requireTLS: !implicitTls,
    family: 4,
    tls: { rejectUnauthorized: true },
    auth: { user: env.SMTP_USER, pass: env.SMTP_PASSWORD },
    // Open the socket by name: workerd refuses implicit TLS to a bare IP address.
    getSocket(_options, callback) {
      const socket = implicitTls
        ? tls.connect({ host, port, servername: host, rejectUnauthorized: true })
        : net.connect({ host, port });
      socket.once(implicitTls ? "secureConnect" : "connect", () =>
        callback(null, implicitTls ? { connection: socket, secured: true } : { connection: socket }),
      );
      socket.once("error", (error) => callback(error, false));
    },
  });
}

La versión de MakiSend añade un tiempo límite y la comparten la comprobación de conexión, el envío de prueba y el envío real: smtp-connection.ts.

Cloudflare Email Service también habla SMTP en smtp.mx.cloudflare.net, pero un Worker no puede abrir un socket hacia ese host. Desde un Worker, envía con el binding send_email.

No envíes dentro de la petición

SMTP espera una respuesta a cada comando: TCP, TLS, EHLO, AUTH, MAIL FROM, RCPT TO, DATA, QUIT. En Workers un envío tarda de 300 a 400 ms en el mejor caso y unos 2 segundos en el peor, y un Worker abre una conexión nueva para cada email. Tu usuario no debería esperar eso, y un envío fallido no debería perderse.

  • Responde 202 Accepted en cuanto el email esté guardado y después pon su id en una Cloudflare Queue.
  • Envía desde un Cloudflare Workflow: cada paso es durable, así que un reintento continúa donde se detuvo la ejecución.
  • Reintenta solo lo que puede salir bien más tarde. MakiSend hace un intento más tres reintentos separados por 30 segundos; los errores de autenticación, TLS, DNS, configuración y 5xx se detienen de inmediato, y un email que agota sus intentos va a una cola de mensajes fallidos.
  • Acepta un encabezado Idempotency-Key para que un cliente que repite su propia petición no envíe dos veces.

Esperar a la red no cuenta para el límite de CPU (10 ms por invocación en Workers Free), así que el envío cabe en el plan gratuito. La página de arquitectura sigue un email a través de estos pasos.

IP fijas y la bandeja de entrada

  • Los Workers se conectan desde direcciones compartidas de Cloudflare que cambian. Un proveedor que solo acepta IP de una lista permitida rechazará la conexión: desactiva esa lista y confía en las credenciales SMTP, o envía desde un servidor con IP fija.
  • Que el correo llegue a la bandeja de entrada depende del dominio remitente (SPF, DKIM y DMARC) y de la reputación del proveedor, no del Worker. En nuestras pruebas, los emails de MakiSend llegan a la bandeja de entrada una vez configurados esos tres registros.
  • Gmail y Google Workspace limitan cuánto envía un buzón al día. Para volumen, usa un proveedor transaccional.

O deja que MakiSend lo haga

MakiSend es todo lo que hay en esta página, ya listo: un POST /send-email autenticado, una cola, un Workflow durable con reintentos, nodemailer sobre sockets abiertos por nombre de host, el binding de Cloudflare Email Service y un dashboard con los logs. Es open source y se despliega en tu propia cuenta de Cloudflare, incluida una de Workers Free.

Preguntas

¿Puede un Worker de Cloudflare enviar email por SMTP?

Sí. Con el flag nodejs_compat, nodemailer funciona sobre los sockets TCP de Cloudflare y habla con cualquier proveedor SMTP en los puertos 465, 587, 2465, 2525 o 2587. El puerto 25 está bloqueado.

¿Por qué no funciona el puerto 25 desde un Worker?

Cloudflare bloquea las conexiones salientes por el puerto 25. Usa el puerto de envío de tu proveedor: 465 con TLS implícito o 587 con STARTTLS.

¿Por qué nodemailer falla con "proxy request failed" en el puerto 465?

nodemailer se conecta a la dirección IP resuelta, y workerd rechaza TLS implícito hacia una IP sin nombre. Abre el socket por nombre de host con la opción getSocket de nodemailer.

¿Puedo usar Cloudflare Email Service en el plan gratuito?

No para enviar a cualquier destinatario: eso requiere Workers Paid ($5 al mes). En Workers Free, envía a través de otro proveedor SMTP.