How to send email from Cloudflare Workers

A Worker cannot run a mail server, but it can talk to one. Here is what works, what fails and why, from building MakiSend, an email API that runs entirely on Workers.

Last updated · Markdown version

Three ways to send

A Worker has three ways out. Pick by who you want to depend on, not by the code: the code is short in every case.

ApproachHow the Worker sendsWhat you needWatch out for
HTTP email APIfetch() to the provider's APIAn account and API key at that providerOne provider's payload format, and its per-email pricing
SMTP over TCPnodemailer (with nodejs_compat) on Cloudflare's TCP socketsSMTP credentials from any provider or mailboxAllowed ports, TLS mode and one round trip per SMTP command
Cloudflare Email ServiceThe send_email bindingA sender domain onboarded to Email SendingSending to any recipient needs Workers Paid

SMTP is the one that keeps you portable: the same code sends through Google Workspace, Zoho, Microsoft 365, Amazon SES, SendGrid, Mailgun, Postmark, Brevo or Resend's own SMTP. It is also the one with traps.

Which SMTP ports a Worker can use

Cloudflare blocks outbound connections on port 25, so a Worker cannot deliver straight to a recipient's MX server. It has to hand the message to a submission server, on one of these ports:

PortSecurityMeaning
465Implicit TLS (tls)TLS from the first byte
587STARTTLS (starttls)Starts in clear text, upgrades to TLS before authenticating
2465Implicit TLS (tls)Alternative to 465 offered by some providers
2525, 2587STARTTLS (starttls)Alternatives to 587 offered by some providers
25NoneBlocked on Workers

Pair the port with the right mode. 465 with STARTTLS, or 587 with implicit TLS, fails during the TLS handshake (wrong version number), before any credential is sent. MakiSend refuses port 25 when you save a configuration and derives the mode from the port.

Running nodemailer on Workers

nodemailer runs on Workers once the nodejs_compat compatibility flag is on: node:net and node:tls map onto Cloudflare's TCP sockets. Three things still bit us.

  1. nodemailer resolves the hostname and connects to the IP address. workerd refuses a connection to an IP address with implicit TLS on 465 (proxy request failed, cannot connect to the specified address), while the same server opens fine by name. The error says nothing about the real cause.
  2. dns.resolve4 and dns.resolve6 are not implemented in local development, so a default transport can hang under wrangler dev.
  3. Without family: 4, workerd logs network warnings on every send.

The fix for the first two is nodemailer's getSocket option: open the socket yourself, by hostname, and hand it over. nodemailer still runs STARTTLS when it has to and still checks the certificate against the hostname.

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));
    },
  });
}

MakiSend's version adds a timeout and is shared by the connection check, the test send and the real send: smtp-connection.ts.

Cloudflare Email Service also speaks SMTP at smtp.mx.cloudflare.net, but a Worker cannot open a socket to that host. From a Worker, send through the send_email binding instead.

Do not send inside the request

SMTP waits for a reply to every command: TCP, TLS, EHLO, AUTH, MAIL FROM, RCPT TO, DATA, QUIT. On Workers a send takes 300 to 400 ms in the best cases and about 2 seconds in the worst, and a Worker opens a fresh connection for every email. Your user should not wait for that, and a failed send should not be lost.

  • Answer 202 Accepted as soon as the email is stored, then put its id on a Cloudflare Queue.
  • Send from a Cloudflare Workflow: each step is durable, so a retry resumes where the run stopped.
  • Retry only what can succeed later. MakiSend makes one attempt plus three retries 30 seconds apart; authentication, TLS, DNS, configuration and 5xx errors stop at once, and an exhausted email goes to a dead-letter queue.
  • Accept an Idempotency-Key header so a client that retries its own request does not send twice.

Waiting on the network does not count toward the CPU limit (10 ms per invocation on Workers Free), so the send itself fits the free plan. The architecture page follows one email through these steps.

Fixed IPs and the inbox

  • Workers connect from shared Cloudflare addresses that change. A provider that only accepts allow-listed IPs will refuse the connection: turn allow-listing off and rely on the SMTP credentials, or send from a server with a fixed IP.
  • Whether mail reaches the inbox depends on the sender domain (SPF, DKIM and DMARC) and on the provider's reputation, not on the Worker. In our tests, MakiSend emails land in the inbox once those three records are set.
  • Gmail and Google Workspace cap how much one mailbox sends per day. Use a transactional provider for volume.

Or let MakiSend do it

MakiSend is everything on this page, packaged: an authenticated POST /send-email, a queue, a durable Workflow with retries, nodemailer over sockets opened by hostname, the Cloudflare Email Service binding and a dashboard with the logs. It is open source and deploys on your own Cloudflare account, including a Workers Free one.

Questions

Can a Cloudflare Worker send email over SMTP?

Yes. With the nodejs_compat flag, nodemailer runs on Cloudflare's TCP sockets and talks to any SMTP provider on ports 465, 587, 2465, 2525 or 2587. Port 25 is blocked.

Why does port 25 not work from a Worker?

Cloudflare blocks outbound connections on port 25. Use your provider's submission port instead: 465 with implicit TLS or 587 with STARTTLS.

Why does nodemailer fail with "proxy request failed" on port 465?

nodemailer connects to the resolved IP address, and workerd refuses implicit TLS to a bare IP. Open the socket by hostname with nodemailer's getSocket option.

Can I use Cloudflare Email Service on the free plan?

Not to send to any recipient: that needs Workers Paid ($5 a month). On Workers Free, send through another SMTP provider.