Transactional email from Workers with Resend

By

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. For email, Resend is a good fit. See how Resend compares with other providers.

Most email SDKs assume Node, while Cloudflare Workers have fetch.

Skip the SDK and POST to Resend’s REST API directly. This works anywhere fetch is available.

I wired this up for StackPlan. It sends 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 }
}

When RESEND_API_KEY is unset, log the message and return { sent: false }. This graceful no-op keeps local development working, and you can copy password-reset links from the console.

Email failure must not break auth or checkout. Catch network errors, log them, and return a result object instead of throwing.

Always send both plain text and HTML. Some clients strip HTML, and some users prefer text.

A practical use case: notify yourself

When something interesting happens in your app, email yourself. It might be a new signup, order, or 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 when latency matters, without await in the critical path. Catch errors inside sendEmail so nothing bubbles up.

If ADMIN_EMAIL is unset, use the same silent no-op pattern as the API key. 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}`,
  })
}

The first occurrence sends the email and sets a KV flag for one hour. The same path and error message remain muted during that hour.

Without a 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

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.

Use fetch to call Resend, no-op when email is unconfigured, and dedupe noisy alerts with KV. You don’t need a Node SDK to run this on Workers.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cloudflare: