# Transactional email from Workers with Resend

> Send transactional email from Cloudflare Workers with Resend over fetch, no Node SDK. Graceful no-op without an API key, plus KV deduped alerts.

Author: Flavio Copes | Published: 2026-07-14 | Canonical: https://flaviocopes.com/resend-transactional-email-workers/

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](https://stackplan.dev) — 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:

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

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

```js
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:

```js
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:

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