Skip to content

tutorials

Why Node SMTP fails in Cloudflare Workers — and what works

June 24, 2026

#cloudflare-workers#email#smtp#tinbox#tutorial
Why Node SMTP fails in Cloudflare Workers — and what works
Visual: Moss Cathedral on okslop

You wrote createTransport(), deployed to a Worker, hit send, and nothing happened. This is why, and here's a sender that actually works at the edge.

You're building on Cloudflare Workers. Everything is fast and cheap and lives at the edge. Then you need to send one email (a password reset, an order receipt) and you reach for the thing you've used a hundred times:

import nodemailer from "nodemailer";

const transport = nodemailer.createTransport({
  host: "smtp.example.com",
  port: 587,
  auth: { user: "...", pass: "..." },
});

await transport.sendMail({
  to: "user@example.com",
  from: "hello@yourdomain.com",
  subject: "Reset your password",
  text: "Click here…",
});

And it falls over. Depending on your bundler you'll get one of these:

Error: No such module "net".
  imported from "nodemailer/lib/smtp-connection/index.js"
Error: Connection timed out
  at TCPSocket.connect (port 587)
TypeError: Cannot read properties of undefined (reading 'createConnection')

This is not a bug you can fix with one compatibility flag. The supported path is different inside a Worker than it is from a Node server. Let's draw that boundary precisely.

Why nodemailer is the wrong Worker transport

There are two independent constraints for the common nodemailer approach.

1. connect() is not a drop-in Node SMTP stack

Cloudflare Workers run on workerd, not a normal Node process. Workers now have an outbound connect() TCP socket API, including implicit TLS and STARTTLS support, but that does not make every Node SMTP library compatible. Libraries such as nodemailer expect Node's net, tls, and connection lifecycle semantics. Replacing those assumptions is a protocol adapter, not a polyfill.

2. Port 25 is blocked at the network level

Even if the runtime gave you a socket, port 25 outbound is blocked across Cloudflare's network, the same way nearly every serious cloud provider (AWS, GCP, most VPS hosts) blocks it by default. Open port 25 egress is the single biggest spam vector on the internet. Providers shut it to protect their IP reputation. So the classic "connect to the recipient's MX on port 25 and speak SMTP" path, the thing every mail server does, is unavailable to you from a Worker, full stop.

Submission ports 465/587 to a third-party relay are a different category: Cloudflare does not document a blanket block on those ports. A purpose-built connect() client may be technically possible, but it is not the same as dropping nodemailer into a Worker and it adds protocol, timeout, and retry work. For a Worker, use a native binding or HTTPS API unless there is a tested reason not to.

What changed with Cloudflare authenticated SMTP?

Cloudflare Email Service now exposes smtp.mx.cloudflare.net:465 with implicit TLS and token authentication. That endpoint is for external SMTP clients. Workers cannot reach it through connect() because Workers block outbound TCP connections to Cloudflare IP ranges. From a Worker, use the Email Service binding or REST API.

Direct Cloudflare SMTP is transactional-only and sends outside Tinbox's authorization, suppression, and idempotency ledger unless the client first submits through Tinbox. It also does not add IMAP, POP3, or a mailbox.

What about Cloudflare Email Routing?

Reasonable question, and an important product distinction. Cloudflare Email Routing is inbound: it receives mail for your domain and forwards it or hands the raw message to a Worker's email() handler. Cloudflare Email Service is outbound and exposes a Worker binding, REST API, and external SMTP submission. Neither product currently exposes an IMAP or POP3 mailbox.

The fix: send over HTTPS, not SMTP

The runtime will happily make outbound HTTPS requests: that's fetch(), the thing Workers are built around. So the move is to hand your message to a delivery layer over HTTPS and let it do the SMTP-to-the-world part on infrastructure that's allowed to.

That delivery layer can be a hosted API. Or it can be Tinbox, running in your own Cloudflare account, so the whole thing stays on infrastructure you control. Here's the ten-line version.

Step 1: Initialize Tinbox on your domain

npx tinbox init yourdomain.com

This scaffolds the Worker app, provisions D1/R2/Queues, and walks you through the DNS records that make outbound mail deliverable: SPF, DKIM, and DMARC. Those three records are what convince receiving servers your mail is legitimately from your domain; without them you'll land in spam regardless of how you send. Tinbox prints the exact records to add:

✔ Provisioned D1 database (tinbox-yourdomain-db)
✔ Provisioned R2 bucket (tinbox-yourdomain-mail)
✔ Provisioned Queue (tinbox-yourdomain-send)

Add these DNS records to yourdomain.com:

  TXT  @               v=spf1 include:_spf.yourdomain.com ~all
  TXT  tinbox._domainkey   v=DKIM1; k=rsa; p=MIIBIjANBg...
  TXT  _dmarc          v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com

Once DNS propagates, run: npx tinbox doctor

Step 2: Send from your Worker

Now the part that broke before just works, because it's an HTTPS call under the hood:

import { tinbox } from "tinbox/cf";

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    await tinbox(env).send({
      to: "user@example.com",
      from: "hello@yourdomain.com",
      subject: "Reset your password",
      html: `<p>Click <a href="https://app.yourdomain.com/reset?t=abc">here</a> to reset.</p>`,
    });

    return new Response("sent");
  },
};

Bind the resources in wrangler.jsonc (the init command writes this for you):

{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",
  "d1_databases": [
    {
      "binding": "TINBOX_DB",
      "database_name": "tinbox-yourdomain-db",
    },
  ],
  "r2_buckets": [
    {
      "binding": "TINBOX_MAIL",
      "bucket_name": "tinbox-yourdomain-mail",
    },
  ],
  "queues": {
    "producers": [
      {
        "binding": "TINBOX_SEND",
        "queue": "tinbox-yourdomain-send",
      },
    ],
  },
}

Deploy:

npx wrangler deploy
Uploaded my-app (1.84 sec)
Published my-app (3.21 sec)
  https://my-app.yourdomain.workers.dev
Current Deployment ID: 6b1f...

Hit the route, and the mail goes out: DKIM-signed, SPF-aligned, with bounces and complaints tracked back into your D1 database. No net module. No port 25. No nodemailer.

What just happened

tinbox(env).send(...) enqueues your message onto a Cloudflare Queue and returns immediately, so your request stays fast. A consumer Worker picks it up, signs it with DKIM, and performs the actual delivery over a path that's permitted to talk to the world, handling retries and bounce parsing for you. Your Worker never opens a socket; it just produces a queue message over the bindings the runtime already allows. That's the whole trick: move the SMTP problem off the edge and keep only an HTTPS-shaped API at the edge.

Common failure modes (and what they mean)

A few things you'll hit, so you don't have to debug them blind:

TINBOX_SEND is not defined

You sent before binding the queue. Add the producer block to wrangler.jsonc and redeploy.

Mail accepted but landing in spam

DNS isn't fully there. Run npx tinbox doctor; it checks that SPF, DKIM, and DMARC actually resolve and that your DKIM selector matches the signing key. New domains also need warmup; reputation isn't instant.

550 5.7.1 Sender address rejected: not owned by user

Your from domain doesn't match a domain you've verified. You can only send as a domain you control and have DKIM-signed.

Where to go from here

Once you can send, the natural questions are should I self-host this at all, and what does it cost at volume. We answer both honestly:

Deploy this yourself: npx tinbox init yourdomain.com

Frequently asked questions

Why can't Cloudflare Workers send email over SMTP?

Workers do have an outbound connect() TCP API, but outbound port 25 and connections to Cloudflare-owned IP ranges are blocked. Node libraries such as nodemailer also expect Node socket APIs. Use the Email Service binding or REST API from a Worker; use authenticated SMTP on port 465 from an external application.

Can I send email from a Worker at all?

Yes, over HTTPS, by calling an email API (or a delivery layer like Tinbox) instead of speaking SMTP directly. The send becomes a normal fetch() the runtime allows.

Does Cloudflare Email Routing let me send?

Email Routing is the inbound product. Cloudflare Email Service is the separate outbound product and supports a Worker binding, REST, and authenticated SMTP submission for external applications. It is transactional-only today.