Skip to content

Email

RedwoodSDK integrates with Cloudflare Email Workers so your application can send transactional messages, receive inbound mail, and reply in the same Worker runtime.

Production deliveries currently require recipients to be verified through Cloudflare Email Routing, but Cloudflare’s forthcoming Email Service beta expands reach to general addresses.

This guide walks through the configuration steps, highlights important sending considerations, and demonstrates common patterns for end-to-end email workflows.

Update your wrangler.jsonc to include the EMAIL binding:

wrangler.jsonc
{
"send_email": [
{
"name": "EMAIL",
},
],
}

Next, run pnpm generate to update the generated type definitions.

Once you have a zone with Email Routing enabled, follow the Enable Email Workers documentation to deploy your Worker in production.

Outbound email must target a destination address that you have verified in Email Routing. When calling env.EMAIL.send(), pass either a verified address or leave the recipient undefined when you use a binding that specifies destination_address or allowed_destination_addresses.

ℹ️ For broader transactional delivery to arbitrary recipients, see Cloudflare’s Email Service beta or an external provider such as Resend.

The example below demonstrates how to integrate the worker with email sending and receiving.

To send and email, you can simply call the env.EMAIL.send() method with the email message as shown in the example below route("/email", async () => { ... }).

ℹ️ Note: In production, this must be a verified address in Email Routing.

However, to receive an email, you need to implement the email handler and export it in the default export of the worker.

This is a change to how defineApp is often used in the worker.

The default export of the worker is the DefaultWorker class that extends the WorkerEntrypoint class.

This tells Cloudflare that the worker is also email worker and can be selected to route inbound emails to.

Note: If you are just sending emails, you can still use defineApp as usual and just call env.EMAIL.send() in the route handler or in a server function.

worker.ts
import * as PostalMime from "postal-mime";
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";
import { render, route } from "rwsdk/router";
import { defineApp } from "rwsdk/worker";
import { Document } from "@/app/Document";
import { setCommonHeaders } from "@/app/headers";
import { env, WorkerEntrypoint } from "cloudflare:workers";
const app = defineApp([
setCommonHeaders(),
/**
* This route is used to send an email from the worker
* First, we create a MIME message with the sender, recipient,
* and the content of the email.
* Then, we create a new EmailMessage object with the
* sender, recipient, and the raw content of the email.
* Finally, we send the email using the `env.EMAIL.send()` method.
* Ensure the `[email protected]` address is verified in Cloudflare Email Routing, or adjust the binding configuration accordingly.
*/
route("/email", async () => {
const msg = createMimeMessage();
msg.setSender({ name: "Sending email test", addr: "[email protected]" });
msg.setRecipient("[email protected]");
msg.setSubject("An email generated in a worker");
msg.addMessage({
contentType: "text/plain",
data: `Congratulations, you just sent an email from a worker.`,
});
const message = new EmailMessage(
msg.asRaw()
);
await env.EMAIL.send(message);
return Response.json({ ok: true });
}),
]);
/**
* This is the default worker entrypoint for the Worker.
* It extends the WorkerEntrypoint class and implements the email and fetch handlers.
*/
// It extends the WorkerEntrypoint class and implements the email and fetch handlers.
export default class DefaultWorker extends WorkerEntrypoint<Env> {
/**
* Email handler for the Worker.
* The `message` parameter is an ForwardableEmailMessage object
*
* You can call `message.reply()` to respond directly to the
* inbound sender without additional verification steps.
*/
async email(message: ForwardableEmailMessage) {
const parser = new PostalMime.default();
const rawEmail = new Response((message as any).raw);
const email = await parser.parse(await rawEmail.arrayBuffer());
console.log(email);
}
/**
* Fetch handler for the Worker.
* Needed so that the worker can handle the request and pass it to the app.
*/
override async fetch(request: Request) {
return await app.fetch(request, this.env, this.ctx);
}
}

You can reply directly to an inbound message without pre-verifying the recipient.

The Worker runtime preserves threading headers and delivers the response through the original route. Construct your response with mimetext and pass the raw payload to message.reply(), as shown in the Reply from Workers guide.

It is important to note that the In-Reply-To header is required to reply to the inbound email.

Replying inside the email handler
async email(message: ForwardableEmailMessage) {
console.log("📧 Email received");
// Parse the inbound email
const parser = new PostalMime.default();
const rawEmail = new Response((message as any).raw);
const receivedEmail = await parser.parse(await rawEmail.arrayBuffer());
console.log("📧 Email received and parsed", receivedEmail);
// Create a new message to reply to the inbound email
const replyToMessage = createMimeMessage();
// ❗️ Important:This In-Reply-To header is required to reply to the inbound email
replyToMessage.setHeader(
"In-Reply-To",
message.headers.get("Message-ID") ?? ""
);
replyToMessage.setSender({ name: "Contact Person", addr: "<SENDER>@example.com" });
replyToMessage.setRecipient(receivedEmail.from);
replyToMessage.setSubject(`Re: ${receivedEmail.subject}`);
replyToMessage.addMessage({
contentType: "text/plain",
data: "Thanks for contacting us. We'll get back to you shortly.",
});
console.log("📧 New message created", replyToMessage.asRaw());
const replyMessage = new EmailMessage(
"<SENDER>@example.com",
message.from,
replyToMessage.asRaw()
);
console.log("📧 Sending reply email");
await message.reply(replyMessage);
console.log("📧 Reply email sent");
}
  • By default, the worker is not an email worker. You need to extend the WorkerEntrypoint class and implement the email handler to make it an email worker.
  • By extending the WorkerEntrypoint class, you are telling Cloudflare that the worker is also email worker and can be selected to route inbound emails to.
  • The message parameter is an ForwardableEmailMessage object that contains the inbound email message.
  • The In-Reply-To header is required to reply to the inbound email.
  • Use PostalMime to parse inbound messages for headers, text, HTML, and attachments.
  • Construct outbound MIME content with mimetext to control subject, sender, and body.
  • Call env.EMAIL.send() to deliver new messages, or message.reply() / message.forward() inside the email handler.
  • A fetch handler is needed so that the worker can handle all requests and pass it to the app.

RedwoodSDK can emulate both inbound and outbound email interactions locally.

To test the sending of an email by the email handler locally, you can use the following command:

Terminal window
pnpm dev

This will start the local development server and you can send emails to the [email protected] address.

Now, visit http://localhost:5173/email to see the email in the console output of the local development server.

For this example, you’ll see the following response:

Response
{
"ok": true
}

and in the console output, you’ll see something like the following log:

Terminal window
send_email binding called with the following message:
/var/folders/ft/8320mthj6gbdd2pmc42x13480000gn/T/miniflare-288e7109e15f898bd9877d7857386f8b/files/email/2dad29db-0a7d-498d-89ab-e961746835c4.eml

You can also see the email in the .eml file in the temporary directory.

Terminal window
cat /var/folders/ft/8320mthj6gbdd2pmc42x13480000gn/T/miniflare-288e7109e15f898bd9877d7857386f8b/files/email/2dad29db-0a7d-498d-89ab-e961746835c4.eml
288e7109e15f898bd9877d7857386f8b/files/email/2dad29db-0a7d-498d-89ab-e961746835c4.eml
Date: Sun, 09 Nov 2025 01:54:06 +0000
From: =?utf-8?B?U2VuZGluZyBlbWFpbCB0ZXN0?= <[email protected]>
Message-ID: <[email protected]>
Subject: =?utf-8?B?QW4gZW1haWwgZ2VuZXJhdGVkIGluIGEgd29ya2Vy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
Congratulations, you just sent an email from a worker.%

Note: The path to the .eml file is different for each operating system.

To test the receiving of an email by the email handler locally, you can use the following command:

Terminal window
pnpm dev

This will start the local development server and you can send emails to the [email protected] address.

Then, you can send an email to the [email protected] address using the following command:

Terminal window
curl --request POST 'http://localhost:5173/cdn-cgi/handler/email' \
--url-query '[email protected]' \
--url-query '[email protected]' \
--header 'Content-Type: application/json' \
--data-raw 'Received: from smtp.example.com (127.0.0.1)
by cloudflare-email.com (unknown) id 4fwwffRXOpyR
for <[email protected]>; Tue, 27 Aug 2024 15:50:20 +0000
From: "John" <[email protected]>
Subject: Testing Email Workers Local Dev
Content-Type: text/html; charset="windows-1252"
X-Mailer: Curl
Date: Tue, 27 Aug 2024 08:49:44 -0700
Message-ID: <6114391943504294873000@ZSH-GHOSTTY>
Hi there'

You should see the content of the simulated email in the console output of the local development server.

Terminal window
{
headers: [
{
key: 'received',
value: 'from smtp.example.com (127.0.0.1) by cloudflare-email.com (unknown) id 4fwwffRXOpyR for <[email protected]>; Tue, 27 Aug 2024 15:50:20 +0000'
},
{ key: 'from', value: '"John" <[email protected]>' },
{ key: 'reply-to', value: '[email protected]' },
{ key: 'to', value: '[email protected]' },
{ key: 'subject', value: 'Testing Email Workers Local Dev' },
{ key: 'content-type', value: 'text/html; charset="windows-1252"' },
{ key: 'x-mailer', value: 'Curl' },
{ key: 'date', value: 'Tue, 27 Aug 2024 08:49:44 -0700' },
{
key: 'message-id',
value: '<6114391943504294873000@ZSH-GHOSTTY>'
}
],
from: { address: '[email protected]', name: 'John' },
to: [ { address: '[email protected]', name: '' } ],
replyTo: [ { address: '[email protected]', name: '' } ],
subject: 'Testing Email Workers Local Dev',
messageId: '<6114391943504294873000@ZSH-GHOSTTY>',
date: '2024-08-27T15:49:44.000Z',
html: 'Hi there\n',
attachments: []
}

To enable email handling in production, you need to have a Cloudflare zone with Email Routing enabled and at least one verified destination address.

You can refer to the following documentation: