Skip to content
Engineering

Send email from Cloudflare Workers

Run a local Worker and compare accepted, rejected and timed-out simulated requests.

THE SHORT ANSWER

Call your chosen email provider’s authenticated HTTPS API from Worker code, with credentials stored as server-side secrets. Validate the caller and payload before creating a send. Bound the request, and preserve an uncertain outcome when a timeout prevents you from knowing whether the provider accepted it.

Before you start #

For
Developers building and operating application email.
Bring
Node.js 22+, npm, and the pinned Wrangler development dependency. Local runs need no Cloudflare account.
Scope
The download runs locally with synthetic data. All email delivery is simulated; no provider credentials or live sends are needed.

Keep the email boundary inside the Worker #

A Worker can accept a product event and turn it into a provider API request, but the browser should not receive the provider key. The destination, sender, template, and operation identity should come from an authorized business action rather than arbitrary request fields.

This lab accepts one message field and uses a fixed synthetic destination. A local bearer credential exercises the request boundary. It is intentionally not a complete user authentication or permission system, and the transport never calls an email provider.

Choose an adapter supported by your provider and runtime. A familiar Node SMTP example may rely on APIs that are not available in the same way in Workers. An HTTPS API keeps this tutorial’s integration boundary explicit.

Start the Worker without deploying it #

Unzip the project and run npm ci, npm test, and npm run demo. The Node tests exercise the exported fetch handler with Web Request and Response objects. The separate interactive run uses Wrangler’s local Worker runtime.

Copy .dev.vars.example to .dev.vars, run npm run dev, and use the README’s curl command against http://127.0.0.1:3038. The included credential is an obvious fixture value. Do not put it in a deployed endpoint. Wrangler runs locally and the package has no deploy script.

Change TRANSPORT_MODE in wrangler.jsonc to rejected or timeout and restart the local run to see the failure responses. Successful output says the simulator accepted the request and that no email was sent.

Run from the extracted example foldersh
npm ci
npm test
npm run demo

Read the key part of the example #

This excerpt comes from worker.mjs, lines 19–48, in the download. It shows the central decision; run the complete package with the commands above.

Local simulation · worker.mjsjavascript
    if (request.method !== "POST") return reply(405, "Use POST.");
    if (
      !["accepted", "rejected", "timeout"].includes(
        env.TRANSPORT_MODE ?? "accepted",
      )
    )
      return reply(503, "Unknown local transport mode.");
    if (typeof env.LAB_SECRET !== "string" || !env.LAB_SECRET)
      return reply(503, "Configure the local lab secret first.");
    if (request.headers.get("authorization") !== `Bearer ${env.LAB_SECRET}`)
      return reply(401, "Use the local lab credential.");
    if (!isJson(request)) return reply(415, "Use JSON.");
    let data;
    try {
      data = JSON.parse(await readBody(request, 8192));
    } catch (error) {
      return reply(
        error.status || 400,
        error.status ? error.message : "Check your request.",
      );
    }
    if (
      !data ||
      Array.isArray(data) ||
      typeof data.message !== "string" ||
      data.message.trim().length < 10 ||
      data.message.length > 4000
    )
      return reply(400, "Write a message between 10 and 4,000 characters.");

Find the files you’ll change #

Pinned dependencies: wrangler 4.45.0. Install with npm ci so the lockfile controls the resolved versions.

FilePurpose
worker.mjsThe example behavior shown in this article.
test.mjsAcceptance checks and synthetic failure cases.
demo.mjs / expected-output.jsonA repeatable local experiment and its recorded result.
README.mdSetup commands, expected behavior and production boundaries.
BUILD-BRIEF.mdThe coding-agent brief below.
package.json / package-lock.jsonPinned dependencies and runnable commands.
LICENSEMIT license for adapting this example.

Compare the recorded local result #

Captured from this package’s demo command on October 5, 2026. These results use synthetic fixtures and simulated delivery; they do not measure a live provider or inbox placement.

Recorded local simulation · expected-output.jsonjson
{
  "simulated": true,
  "message": "The local simulator accepted the request. No email was sent.",
  "id": "simulated-worker-42"
}

Bound the call without inventing an outcome #

The example gives its simulated transport a 100 ms deadline so the timeout path is quick to reproduce. That number is a lab setting, not a production recommendation. Pick a real bound based on your workload, provider, and execution budget.

When the deadline fires, the response is uncertain. A client disconnect, timeout, or lost response does not establish that the remote provider rejected the message. A live integration needs a stable logical operation ID and a reconciliation path before retrying.

The timer is cleaned up after every request. Missing credentials, invalid JSON, oversized bodies, and short messages fail before the simulated transport is reached.

Use a durable job for work that must survive the response #

If email sending must continue after the user-facing request returns, persist the work and let a durable queue consumer perform it. Keep the queue’s retries separate from the provider’s own delivery attempts and from webhook redelivery.

A runtime extension such as waitUntil can be useful for bounded follow-up work, but it does not replace durable job storage. An in-memory promise can disappear with the runtime. Decide what evidence the app can honestly return before acknowledging the user’s action.

The local example does not provide that queue. It keeps the boundary small enough to inspect: one validated request, one simulated attempt, and one explicitly qualified outcome.

Connect your provider and application authorization #

Store the real provider key as a Worker secret. Configure a verified sender, an explicit provider endpoint, and allowed templates on the server. Translate your internal email object into the provider’s documented request and response fields; never accept a provider URL from the browser.

Replace the fixture credential with your real authentication and authorization checks. Add shared rate limiting, abuse controls, bounded input sizes, and durable operation tracking. Map provider acceptance to acceptance language in your UI, not an assertion of inbox arrival.

Keep local tests on the simulator when you add an adapter in your own application. Use provider documentation and a separate controlled validation process for the live configuration. The ZIP contains no live adapter or provider credentials.

Plan retry delays within the useful message lifetime →

Understand the transactional API boundary →

When does a Worker need a queue? #

Use durable jobs when the work must survive a request ending, support controlled retries, or await reconciliation. A short HTTP request alone cannot preserve pending work across a process failure. Keep a logical operation ID and original-provider evidence before retrying an uncertain result.

Use with your coding agent #

Download the example, then copy this brief into your coding agent. The same brief is included as BUILD-BRIEF.md.

Coding-agent build briefmarkdown
# Send email from Cloudflare Workers — coding-agent brief

## Objective

Run a local Wrangler Worker with server-side authorization, bounded JSON input and a simulated HTTPS-provider boundary.

## Read first

Read README.md, package.json, worker.mjs, test.mjs and demo.mjs. Keep dependency versions pinned to package-lock.json. Use Node.js 22+.

## Dependencies

wrangler 4.45.0. Install with npm ci and preserve the lockfile.

## Work

Start at `fetch` in worker.mjs. Run the existing synthetic fixtures before changing behavior. Preserve the guide's original scenario and add a regression check for each changed failure case.

## Constraints

All email is simulated. Do not add provider credentials, send email, deploy services, or use the application's database. Preserve unknown outcomes instead of claiming delivery. Do not turn the local demonstration into production authentication or a public mail relay. Explain the production setup separately.

## Verification

Run `npm ci`, `npm test`, and `npm run demo`.

## Acceptance

Test missing and wrong credentials, lookalike JSON media types, oversized and stalled bodies, unknown modes, explicit rejection and timeout uncertainty. Run Wrangler locally with the example secret file; no deployment or real provider calls.
No real tokens, signing secrets, or recipient data appear in logs. All fixtures use synthetic values.

Check the download #

The ZIP contains 18,522 bytes. Compare its SHA-256 digest with this value before extracting. Downloads are free and require no signup.

SHA-256 · send-email-cloudflare-workers.ziptext
6a484ff0c1c47ad08be13feecd03ff8509cd8e0d97e68bc553dce67750419eb2

Sources and further reading

Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.