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 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
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.
npx wrangler secret put EMAILSSH_API_KEYFor local development, put it in .dev.vars, which wrangler reads and which belongs in .gitignore.
EMAILSSH_API_KEY=esh_live_your_key_hereDo 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.
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.
{
"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" }]
}
}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.
{ "triggers": { "crons": ["0 9 * * *"] } }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 orprocess.env. awaitthe send for anything the user is waiting on,waitUntilfor 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_KEYfor deployed environments and.dev.varslocally. Read it from theenvargument. Workers has noprocess.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
emailhandler 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