Envello
Tutorial

Sending email from a Cloudflare Worker

Envello Team·2026-07-17·8 min read

Cloudflare Workers run on a V8 isolate, not Node.js, so a Node-specific SDK isn't guaranteed to work without adjustment even if the package itself doesn't obviously depend on Node APIs. The safe, dependency-free path is the Worker's built-in fetch, which is exactly what any SDK would be calling under the hood anyway.

The whole integration

A complete send function in a Cloudflare Worker:

  • const response = await fetch('https://api.envello.dev/emails', { method: 'POST', headers: { Authorization: `Bearer ${env.ENVELLO_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'Acme <[email protected]>', to: recipient, subject: 'Welcome!', html }) })
  • Store the API key as a Worker secret (wrangler secret put ENVELLO_API_KEY), never hardcoded in the Worker script
  • Check response.ok before assuming the send succeeded

Configuration

Worker secrets are the right place for the API key. They're encrypted at rest and available as properties on the env parameter in your Worker's fetch handler.

  • wrangler secret put ENVELLO_API_KEY (paste the key when prompted)
  • Access in the Worker: export default { async fetch(request, env) { env.ENVELLO_API_KEY } }
  • For the from address and other non-secret config, use wrangler.toml vars: [vars] ENVELLO_FROM = 'YourApp <[email protected]>'

Error handling

Handle API errors explicitly. A Worker that returns 200 when the email send actually failed is worse than one that surfaces the error.

  • Check response.ok: if false, read the error body with response.json() or response.text()
  • 400 errors: validation failure (bad address, unverified domain). Return a 400 to your caller with a user-facing message, don't retry.
  • 429 errors: rate limited. Return a 429 to your caller or, if using Queues, re-queue with a delay.
  • 5xx errors: server error. Retry once with a short delay; if still failing, return a 502 to your caller.
  • Network errors (fetch throws): catch the exception, log it, return a 502.

Async sending with Cloudflare Queues

For Workers handling web requests where you don't want the email send to block the response, use Cloudflare Queues to decouple the trigger from the send.

  • Define a Queue in wrangler.toml: [[queues.producers]] queue = 'email-sends' and [[queues.consumers]] queue = 'email-sends'
  • In the request handler: await env.EMAIL_QUEUE.send({ to, subject, html }); return new Response('ok')
  • In the queue consumer: export default { async queue(batch, env) { for (const msg of batch.messages) { await sendEmail(msg.body, env); msg.ack(); } } }
  • Queues handle retry automatically on failure; messages that fail repeatedly go to a dead-letter queue

Rendering the HTML body

Workers don't have a templating engine built in, so the html field is usually either a plain template string with interpolated values for simple emails, or output from a lightweight templating approach that runs in the Workers runtime. Avoid anything that assumes Node-specific APIs (the file system, most npm packages built for a Node environment) since the Workers runtime doesn't have them.

For more complex templates, bundle a lightweight template library (like Handlebars or Mustache, both of which work in V8 without Node APIs) and compile templates at build time. Store the compiled template functions in the Worker bundle.

Suppression with KV

Use Workers KV to maintain a suppression list so the Worker skips sends to addresses that have previously bounced or complained.

  • Create a KV namespace: wrangler kv:namespace create EMAIL_SUPPRESSIONS
  • Bind it in wrangler.toml: [[kv_namespaces]] binding = 'SUPPRESSIONS' id = '...'
  • Before sending: const suppressed = await env.SUPPRESSIONS.get(to); if (suppressed) return new Response('suppressed', { status: 200 })
  • In the webhook handler (below): await env.SUPPRESSIONS.put(email, reason) on bounce or complaint events

Webhook handling

Create a route in your Worker to receive Envello's delivery event webhooks.

  • Match the path: if (url.pathname === '/webhooks/envello' && request.method === 'POST')
  • Read the raw body: const rawBody = await request.text()
  • Get the signature: request.headers.get('x-envello-signature')
  • Verify HMAC-SHA256 using the Web Crypto API: await crypto.subtle.importKey('raw', ...) then crypto.subtle.sign('HMAC', key, body)
  • Compare the computed signature with the header value
  • Handle events: delivered (log or no-op), bounced (write to KV suppression list), complained (write to KV)
  • Return new Response('ok', { status: 200 })

Cron Triggers for scheduled sends

Cloudflare Workers support Cron Triggers for scheduled execution. Use them for periodic transactional sends like digest emails, usage reports, or certificate expiration warnings.

  • Define in wrangler.toml: [triggers] crons = ['0 9 * * 1'] (every Monday at 9am UTC)
  • Implement the scheduled handler: export default { async scheduled(event, env) { /* fetch users, send emails */ } }
  • Keep the Worker's execution time under the 30-second limit for Cron Triggers; batch large sends across multiple invocations

Where this typically gets called from

A Worker handling a form submission, a webhook from another service, or a scheduled Cron Trigger are the common entry points for a transactional send in this environment. The fetch call above works identically regardless of what triggered the Worker, since it's just an outbound HTTP request like any other the Worker might make.

Testing

Use Miniflare (Cloudflare's local dev environment) to test Workers locally without deploying. Mock the Envello API endpoint with a local HTTP server or intercept fetch in your test setup.

Wrangler's --local mode runs the Worker with Miniflare automatically: wrangler dev --local. Test the full flow: incoming request, email send, response. Use --test-scheduled to trigger Cron Trigger handlers locally.

Free tool

Check your domain's SPF, DKIM, and DMARC records

Paste in a domain and see what's missing, plus the exact DNS records to fix it. Free, no account needed.

Check your domain →
Envello

EU-hosted transactional email, done right by default.