HeySales sends password resets, team invitations and notifications about deals, tasks and form submissions. HelloRestaurant sends sign-in links, booking confirmations, changes, cancellations, reminders and a morning summary for restaurant owners.
Before the move to Cloudflare Workers, HeySales sent through AWS SES or Resend, and HelloRestaurant through AWS SES. With the move we switched both to Cloudflare Email Sending, which a Worker reaches through a send_email binding. Neither app holds an email API key now, and the SES and Resend clients are gone.
Set up the binding
First, onboard the sending domain in the Cloudflare dashboard under Email Service, then Email Sending. Then declare the binding in wrangler.jsonc and list every address the Worker may send from.
"send_email": [
{
"name": "EMAIL",
"allowed_sender_addresses": [
"notifications@heysales.co.uk",
"noreply@heysales.co.uk"
]
}
]
Every sender must be on that list. Wrangler's local runtime checks it too, so a typo in a sender address fails in development. Type it in your environment as EMAIL: SendEmail from @cloudflare/workers-types.
Build the message
The binding takes a plain object with from, to, subject, html, text and replyTo, so there's no raw MIME to build. HeySales wraps it in one helper with a default sender.
import { env } from "cloudflare:workers";
export const NOREPLY_SENDER = {
name: "HeySales",
email: "noreply@heysales.co.uk",
};
export async function sendEmail({
from = NOREPLY_SENDER,
...message
}: {
from?: { name: string; email: string };
to: string;
subject: string;
html: string;
text?: string;
replyTo?: string;
}) {
await env.EMAIL.send({ from, ...message });
}
HelloRestaurant runs on OpenNext, so its helper reads the binding through getCloudflareContext().env.EMAIL and always sends from one address.
Two details are worth copying. Form notifications set replyTo to the address of the person who filled in the form, so whoever gets the notification can reply straight to the enquirer. The password reset email carries a plain-text part as well as HTML.
Both apps keep their longer emails in Handlebars templates. Workers can't read .hbs files at runtime and block the code generation the Handlebars compiler relies on. A small script precompiles every template into a TypeScript module before each build, and the server only uses the Handlebars runtime to render them.
Send after the response without losing the email
On Workers, work you don't await is cancelled once the response has gone out. A bare sendEmail(...) with no await can be cut off before the message leaves.
HeySales's public form endpoint saves the submission first, then hands the notification emails to after() from next/server.
const result = await submitForm({ slug, values: body, ipAddress, userAgent });
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status });
// The submission is saved, so nothing in here can fail the response
after(result.notify);
return NextResponse.json({ success: true, contactId: result.contactId });
HeySales runs on vinext, where after() passes the task to the platform's waitUntil. The Worker keeps running until the emails have gone, and a failure is logged instead of reaching the visitor.
HelloRestaurant takes a simpler route in its booking mutations. It awaits the email inside a try/catch, so a failed send is logged and never fails the booking change that triggered it. Either approach works.
Send reminders from a cron trigger
Booking reminders and owners' morning summaries come from a cron trigger that runs every 15 minutes ("crons": ["*/15 * * * *"]). The Worker's scheduled handler holds no app logic. It calls the app's own /api/cron route through the same fetch handler, so the job gets the database and email bindings like any request and never leaves the Worker.
async scheduled(_controller, env, ctx) {
const response = await handler.fetch(
new Request("https://localhost/api/cron", {
method: "POST",
headers: { authorization: `Bearer ${env.CRON_SECRET}` },
}),
env,
ctx,
);
if (!response.ok) {
throw new Error(`Scheduled jobs failed with ${response.status}`);
}
}
The route compares the bearer token with a Worker secret in constant time, so nobody else can set off a batch of emails by calling the URL.
Each run looks at confirmed bookings in the next few days with no reminderSentAt. A booking gets its reminder once it falls inside the restaurant's reminder window, which defaults to 24 hours. Bookings made inside that window are skipped, because their confirmation is recent enough. After each send the booking is stamped, so later runs leave it alone. The guest emailer logs a failed send rather than throwing, so the booking is stamped either way. That rules out duplicate reminders from retries, at the cost of never retrying a failure. A reminder can go out up to one run late.
The morning summary shares the trigger. It goes out only in the 9am run, UK time, to owners who switched it on, and skips quiet days.
Domain records and DMARC
After onboarding, each of our domains has a cf-bounce subdomain with its own MX and SPF records, and a DKIM key published under the cf-bounce selector. Bounces go to that subdomain, so Email Sending sits alongside whatever already handles mail on the root domain. HelloRestaurant's root domain keeps its mailbox provider's MX and SPF records.
Choose the DMARC policy per domain. heysales.co.uk is at p=reject, which is safe because nothing else sends from it. HelloRestaurant's domain also lists a mailbox provider in its SPF record. There we kept the existing p=none policy, which still collects aggregate reports. Move a domain like that to quarantine or reject once the reports show every legitimate sender passing.
Test locally without sending anything
By default wrangler dev doesn't deliver mail. It writes each message's text and HTML parts to temporary files and prints their paths in the log. For HeySales they land under .wrangler/tmp next to the built config, and our local tests read password reset and invitation links from those files.
HelloRestaurant's binding sets "remote": true, which makes wrangler dev use the real binding. For local runs we use a scratch copy of wrangler.jsonc without it. HelloRestaurant's test sign-in mode also prints magic links to the log, so tests never wait on an inbox.
What to take away
- Declare a
send_emailbinding with an explicit sender allow-list. - Precompile email templates, because Workers can't compile them at runtime.
- Send after the response with
after()orwaitUntil, never with a bare promise. - Run reminders from a cron trigger that calls your app, and stamp each record once it's sent.
- Set DMARC per domain, and leave
remoteoff the binding when you test locally.