Sending email from Cloudflare Workers, with no SMTP and no sockets

The first thing people try on Workers is `npm install nodemailer`, and it fails at runtime with something about `net` not existing. That is not a bundler problem to work around. The runtime genuinely does not have the thing SMTP needs, and once you accept that, the correct design is shorter than the one you were attempting.

4 min read

Why no mail library works here

The Workers runtime is not Node. It gives you fetch, Web Crypto, streams, and a set of bindings. It does not give you arbitrary outbound TCP, which is what every SMTP client is built on.

SMTP needsWorkers provides
net.Socket on port 587fetch, and connect() only to permitted destinations
A long-lived connection across requestsAn isolate that may be evicted between requests
Node's dns, tls, stream internalsWeb APIs, with partial Node compatibility
Tens of seconds of handshake timeA per-request CPU budget measured in milliseconds

Setting nodejs_compat shims enough of Node that the import resolves, which makes things worse: the code now fails at connect time rather than at build time, in production, intermittently.

An HTTPS API needs exactly one thing Workers has in abundance: fetch.

The minimal Worker

src/index.ts
export interface Env {
  EMAILSSH_API_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });

    const { to, subject, html, text } = await request.json<{
      to: string;
      subject: string;
      html: string;
      text: string;
    }>();

    const res = await fetch('https://emails.sh/v1/emails', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${env.EMAILSSH_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        from: 'Acme <hello@acme.com>',
        to: [to],
        subject,
        html,
        text
      })
    });

    if (!res.ok) {
      console.error('send failed', res.status, await res.text());
      return new Response('send failed', { status: 502 });
    }

    return Response.json(await res.json());
  }
} satisfies ExportedHandler<Env>;

The key comes from env, not from process.env. Workers has no process environment, and a key read from a bundled constant is a key in your deployment artifact.

Shell
npx wrangler secret put EMAILSSH_API_KEY

For local development, put it in .dev.vars, which wrangler reads and which belongs in .gitignore.

.dev.vars
EMAILSSH_API_KEY=esh_live_your_key_here

Do not make the user wait

An email send should not sit in the response path of a request the user is waiting on. Workers gives you waitUntil, which keeps the isolate alive to finish work after the response is returned.

TypeScript
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const { email } = await request.json<{ email: string }>();

    ctx.waitUntil(sendWelcome(env, email));

    return Response.json({ ok: true });
  }
} satisfies ExportedHandler<Env>;

waitUntil gets you speed and loses you the error. If the send fails, the user has already been told everything worked. Use it for messages that are genuinely optional, and await the send for anything the user is waiting on, like a verification code.

Use a queue when it must not be lost

For anything where losing the message is unacceptable, put a queue between the request and the send. The producer returns immediately, the consumer retries on failure, and a message that keeps failing lands in a dead letter queue rather than disappearing.

wrangler.jsonc
{
  "name": "acme-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-05-01",
  "queues": {
    "producers": [{ "queue": "outbound-email", "binding": "EMAIL_QUEUE" }],
    "consumers": [{ "queue": "outbound-email", "max_batch_size": 10, "max_retries": 5, "dead_letter_queue": "outbound-email-dlq" }]
  }
}
src/index.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { email } = await request.json<{ email: string }>();
    await env.EMAIL_QUEUE.send({ to: email, template: 'welcome', key: `welcome:${email}` });
    return Response.json({ queued: true });
  },

  async queue(batch: MessageBatch<{ to: string; template: string; key: string }>, env: Env) {
    for (const msg of batch.messages) {
      const res = await fetch('https://emails.sh/v1/emails', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${env.EMAILSSH_API_KEY}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          from: 'Acme <hello@acme.com>',
          to: [msg.body.to],
          subject: 'Welcome to Acme',
          html: '<p>Your account is ready.</p>',
          text: 'Your account is ready.',
          idempotency_key: msg.body.key
        })
      });

      if (res.ok) msg.ack();
      else msg.retry();
    }
  }
} satisfies ExportedHandler<Env>;

The idempotency_key is what makes msg.retry() safe. Queue delivery is at-least-once, so a retry after a send that actually succeeded is normal operation, not an edge case. See idempotency keys and retries for transactional email.

Scheduled sends

A cron trigger runs a Worker on a schedule, which covers digests, trial expiry notices, and dunning mail.

wrangler.jsonc
{ "triggers": { "crons": ["0 9 * * *"] } }
TypeScript
export default {
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    const due = await env.DB.prepare('select email, name from trials where ends_on = date("now", "+3 day")').all();

    ctx.waitUntil(
      fetch('https://emails.sh/v1/emails/batch', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${env.EMAILSSH_API_KEY}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(
          due.results.map((row: { email: string; name: string }) => ({
            from: 'Acme <hello@acme.com>',
            to: [row.email],
            subject: 'Your Acme trial ends in three days',
            html: `<p>Hi ${row.name}, your trial ends on Friday.</p>`,
            text: `Hi ${row.name}, your trial ends on Friday.`,
            idempotency_key: `trial-3d:${row.email}:${new Date().toISOString().slice(0, 10)}`
          }))
        )
      })
    );
  }
} satisfies ExportedHandler<Env>;

The batch endpoint takes up to a hundred messages in one call, which matters on Workers because each fetch costs against your request budget. Date-stamping the idempotency key means a cron that fires twice in one day cannot mail anyone twice.

Receiving mail is a different mechanism

Cloudflare Email Routing can deliver inbound mail to a Worker's email handler. That is a separate capability from sending and does not give you an outbound path for arbitrary recipients. If you need to reply to inbound mail, receive it through the email handler and send the reply over HTTPS like everything else.

The checklist

  • No mail library in package.json. If you see one, the design is wrong.
  • Key in a wrangler secret, read from env, never from a constant or process.env.
  • await the send for anything the user is waiting on, waitUntil for the rest.
  • A queue with an idempotency key for anything that must not be lost.
  • A verified sending domain before real users see the mail: SPF, DKIM, and DMARC explained for developers.

The same runtime constraints apply on Vercel's Edge runtime and on Deno Deploy. The reasoning is spelled out in why nodemailer does not work on Vercel, and the full platform guide is at Cloudflare Workers.

Questions

Can I use nodemailer on Cloudflare Workers?
No. Workers has no arbitrary outbound TCP, so no SMTP client can connect. Enabling Node compatibility makes the import resolve and the connection fail at runtime, which is worse than failing at build time.
How do I store an API key in a Worker?
Use npx wrangler secret put EMAILSSH_API_KEY for deployed environments and .dev.vars locally. Read it from the env argument. Workers has no process.env, and a key inlined into the bundle ships with your code.
Does waitUntil guarantee the email is sent?
It keeps the isolate alive to finish the promise, but it does not retry and it does not report failures to the user. For anything that must not be lost, use a queue with retries and a dead letter queue.
Can a Worker both send and receive email?
Yes, through different mechanisms. Email Routing invokes the email handler for inbound messages, and outbound sending goes over an HTTPS API. They are configured separately.

Give your agent an address it can answer from.

Create an inbox