Send email from Cloudflare Workers
Workers have no TCP sockets to an arbitrary mail server, so every SMTP library fails on workerd no matter how you configure it. An HTTPS API is the option that works. This page gives you the worker, the wrangler config, and the secret command.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Store it as a secret, not a var
npx wrangler secret put EMAILSSH_API_KEY. Anything in [vars] in wrangler.jsonc is plaintext in your repo and visible in the dashboard.
- 03
Add .dev.vars for local runs
wrangler dev reads .dev.vars for the same names. Add it to .gitignore, since it holds the real key.
- 04
Read the key from env, inside the handler
Workers pass env as the second argument to fetch. There is no process.env and no module scope access to bindings.
npm install @emails.sh/sdk{
"name": "acme-mail",
"main": "src/index.ts",
"compatibility_date": "2026-01-01",
"observability": { "enabled": true },
"vars": {
"EMAILSSH_FROM": "Acme <hello@acme.com>"
}
// EMAILSSH_API_KEY is a secret, not a var:
// npx wrangler secret put EMAILSSH_API_KEY
}The worker
src/index.ts (Workers, ES modules format)
import { Emailssh } from '@emails.sh/sdk';
interface Env {
EMAILSSH_API_KEY: string;
EMAILSSH_FROM: string;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Use POST', { status: 405 });
}
const body = (await request.json()) as { email?: string; name?: string };
if (!body.email?.includes('@')) {
return Response.json({ error: 'A valid email is required' }, { status: 400 });
}
const mail = new Emailssh({ apiKey: env.EMAILSSH_API_KEY });
const name = body.name ?? 'there';
const send = mail.send({
from: env.EMAILSSH_FROM,
to: [body.email],
subject: 'Welcome to Acme',
html: `<p>Hi ${escapeHtml(name)}, your Acme account is ready.</p>`,
text: `Hi ${name}, your Acme account is ready.`,
idempotencyKey: `welcome:${body.email.toLowerCase()}`
});
// waitUntil keeps the Worker alive for the send after the response has
// gone out. Without it, returning would cancel the in-flight request.
ctx.waitUntil(
send.catch((error) => console.error('emails.sh send failed', error))
);
return Response.json({ ok: true }, { status: 202 });
}
} satisfies ExportedHandler<Env>;
function escapeHtml(value: string) {
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
}A scheduled send
src/scheduled.ts, for a daily digest on a cron trigger
import { Emailssh } from '@emails.sh/sdk';
interface Env {
EMAILSSH_API_KEY: string;
EMAILSSH_FROM: string;
SUBSCRIBERS: KVNamespace;
}
// Add the trigger in wrangler.jsonc:
// "triggers": { "crons": ["0 9 * * *"] }
export default {
async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext) {
const mail = new Emailssh({ apiKey: env.EMAILSSH_API_KEY });
const list = await env.SUBSCRIBERS.list({ prefix: 'digest:' });
// One batch call instead of N requests. The endpoint takes up to 100
// messages, so chunk anything larger.
const messages = list.keys.slice(0, 100).map((key) => ({
from: env.EMAILSSH_FROM,
to: [key.name.replace('digest:', '')],
subject: 'Your Acme digest',
html: '<p>Here is what happened yesterday.</p>',
text: 'Here is what happened yesterday.'
}));
if (messages.length === 0) return;
ctx.waitUntil(mail.batch(messages));
}
} satisfies ExportedHandler<Env>;Worth knowing
There is no SMTP on Workers, at all
workerd exposes no raw TCP sockets to arbitrary hosts, so nodemailer and every other SMTP client fails there regardless of nodejs_compat. An HTTPS API is the only route out.
A response without waitUntil cancels the send
Returning from fetch ends the request lifetime, and any promise still in flight is cancelled. Pass it to ctx.waitUntil if you respond before the send resolves.
Secrets are not vars
[vars] in wrangler.jsonc is committed plaintext. wrangler secret put encrypts the value and keeps it out of the file. Use .dev.vars locally and gitignore it.
Subrequest limits apply
A Worker on the free plan gets 50 subrequests per invocation, 1000 on paid. Sending one at a time in a loop hits that fast, so use /v1/emails/batch for anything over a handful.
Questions
Can Cloudflare Workers send email?
Through an HTTP API, yes. Through SMTP, no: there are no raw TCP sockets, which is why nodemailer cannot run there.
How do I store the API key in a Worker?
npx wrangler secret put EMAILSSH_API_KEY, and .dev.vars for local development. Read it from the env argument inside the handler.
Why does my email not send when the Worker returns immediately?
The promise was cancelled with the request. Wrap it in ctx.waitUntil so the runtime keeps the invocation alive until it settles.
Does this work on Cloudflare Pages Functions?
Yes. The handler signature differs (an onRequestPost function receiving context), but env, secrets, and waitUntil behave the same way.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.