Transactional email from Workers with Resend
By Flavio Copes
Send transactional email from Cloudflare Workers with Resend over fetch, no Node SDK. Graceful no-op without an API key, plus KV deduped alerts.
Your Worker handles signups, password resets, or “something went wrong” alerts. You need email. Resend is a good fit.
The catch: most email SDKs assume Node. Cloudflare Workers don’t have Node. They have fetch.
Skip the SDK. POST to Resend’s REST API directly. It works everywhere.
I wired this up for StackPlan — admin alerts when someone creates a stack plan, plus deduped error emails so a bug loop can’t flood my inbox.
The send function
Resend’s endpoint is POST https://api.resend.com/emails. One fetch call:
async function sendEmail(env, message) {
if (!env.RESEND_API_KEY) {
console.log('[email] RESEND_API_KEY unset — would send:', {
to: message.to,
subject: message.subject,
text: message.text,
})
return { sent: false, error: 'not-configured' }
}
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: env.EMAIL_FROM || 'My App <onboarding@resend.dev>',
to: [message.to],
subject: message.subject,
html: message.html,
text: message.text,
}),
})
if (!response.ok) {
const body = await response.text()
console.error('[email] resend error:', response.status, body)
return { sent: false, error: `resend-${response.status}` }
}
return { sent: true }
}
Three things matter here.
Graceful no-op. When RESEND_API_KEY is unset, log the message and return { sent: false }. Local dev keeps working. You can copy password-reset links from the console.
Never throw. Email failure must not break auth or checkout. Catch network errors, log them, return a result object.
Plain text + HTML. Always send both. Some clients strip HTML. Some users prefer text.
A practical use case: notify yourself
When something interesting happens in your app, email yourself. New signup. New order. New report.
async function notifyAdminNewReport(env, input) {
if (!env.ADMIN_EMAIL) return
const reportUrl = `https://stackplan.dev/report/${input.reportId}`
await sendEmail(env, {
to: env.ADMIN_EMAIL,
subject: `New stack plan: ${input.appType}`,
html: `<p>Someone planned a ${input.appType} stack.</p>
<p><a href="${reportUrl}">Open report</a></p>`,
text: `New stack plan: ${input.appType}\n\n${reportUrl}`,
})
}
Fire-and-forget from your API route. Don’t await in the critical path if latency matters — but do catch errors inside sendEmail so nothing bubbles up.
ADMIN_EMAIL unset? Same pattern as the API key. Silent no-op. Self-hosted installs don’t need alerts configured.
Dedupe error alerts with KV
Unhandled 500s in a loop can send hundreds of identical emails. Fix that with a KV key and a TTL.
async function notifyAdminError(env, cache, input) {
if (!env.ADMIN_EMAIL) return
const dedupeKey = `err-alert:${input.path}:${input.message.slice(0, 80)}`
if (cache) {
if (await cache.get(dedupeKey)) return
await cache.put(dedupeKey, '1', { expirationTtl: 3600 })
}
await sendEmail(env, {
to: env.ADMIN_EMAIL,
subject: `500: ${input.path}`,
html: `<p>Path: ${input.path}</p><p>Error: ${input.message}</p>`,
text: `Path: ${input.path}\nError: ${input.message}`,
})
}
First occurrence sends the email and sets a KV flag for one hour. Same path + same error message within that hour? Muted.
No cache binding? Still send the first alert. Dedup is a nice-to-have, not a requirement.
HTML without a template engine
For simple alerts, inline HTML strings are fine. Escape user content before inserting it:
function esc(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
}
Wrap it in a minimal layout with inline styles. Email clients ignore external CSS anyway.
Secrets setup
Production:
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put ADMIN_EMAIL
npx wrangler secret put EMAIL_FROM
Local dev: add them to .dev.vars (never commit this file).
Resend’s test sender (onboarding@resend.dev) works for development. Verify your domain before sending from a custom address in production.
The pattern in one sentence
Fetch to Resend, no-op when unconfigured, never throw, dedupe noisy alerts with KV.
No Node. No SDK. Runs on Workers today.
Related posts about cloudflare: