---
title: Sending email from Cloudflare Workers, with no SMTP and no sockets
metaTitle: Sending email from Cloudflare Workers (no SMTP, no sockets)
description: Workers cannot open an SMTP connection, so every mail library fails there. What actually works: an HTTPS API, secrets via wrangler, queues, and cron triggers.
date: 2026-05-01
author: emails.sh
tags: Tutorials
---

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.

## 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 needs | Workers provides |
| --- | --- |
| `net.Socket` on port 587 | `fetch`, and `connect()` only to permitted destinations |
| A long-lived connection across requests | An isolate that may be evicted between requests |
| Node's `dns`, `tls`, `stream` internals | Web APIs, with partial Node compatibility |
| Tens of seconds of handshake time | A 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

```ts caption="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.

```bash
npx wrangler secret put EMAILSSH_API_KEY
```

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

```text caption=".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.

```ts
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.

```text caption="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" }]
  }
}
```

```ts caption="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](/blog/idempotency-keys-transactional-email).

## Scheduled sends

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

```text caption="wrangler.jsonc"
{ "triggers": { "crons": ["0 9 * * *"] } }
```

```ts
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](/blog/spf-dkim-dmarc-explained).

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](/blog/nodemailer-vercel), and the full platform guide is at [Cloudflare Workers](/with/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.

## Related

- [Why nodemailer does not work on Vercel](/blog/nodemailer-vercel)
- [Idempotency keys and retries for transactional email](/blog/idempotency-keys-transactional-email)
- [How to send email in Next.js with the App Router](/blog/send-email-nextjs-app-router)
- [Cloudflare Workers integration](/with/cloudflare-workers)
