A contact form that only writes to D1 is a silent inbox. The message is there, but nobody looks at wrangler d1 execute every morning. This article is the notification piece that the Turnstile tutorial leaves out: how to email yourself (and optionally the visitor) from the Worker after a submission lands.
The important rule is simple. Store the row first. Send mail second. If the mail API is down, you should still have the lead.
The original, canonical version of this guide lives on the Formharbor site: Send email from a Cloudflare Worker contact form.
Don’t follow MailChannels tutorials
A lot of older posts tell you to POST to MailChannels from a Worker for free. That API was withdrawn on 30 June 2024. Those articles still rank. The code in them will not send anything. Use one of the two paths below instead.
Two paths that work in 2026
-
Notify yourself for free with Cloudflare Email Service. If Email Routing is already on your domain, a
send_emailbinding can send to a verified destination address on your account at no cost, on the Workers Free plan. You cannot use this free path to email arbitrary addresses, so it is not how you send “we got your message” notes to visitors. Sending to anyone requires Workers Paid (or a transactional provider). - Send to anyone with Resend, Postmark, or a similar API. Resend’s free plan has been 100 emails per UTC day and 3,000 a month — confirm the current numbers on their site before you rely on them. You need a domain you have verified with SPF, DKIM, and DMARC. This is the path to use if you also want a confirmation to the visitor.
Do not try to send from @workers.dev. Inbox providers will not treat it kindly, and most APIs will refuse it anyway.
Option A: email yourself with send_email
In the Cloudflare dashboard, add Email Routing to the domain you will send from, and verify the inbox that should receive notifications (you will get a confirmation link). Then restrict the binding to that address so a compromised Worker cannot be turned into a relay:
# wrangler.toml
[[send_email]]
name = "EMAIL"
destination_address = "you@example.com"
[vars]
MAIL_FROM = "Forms <forms@example.com>"
MAIL_TO = "you@example.com"
MAIL_FROM must be an address on a domain that uses Email Routing. MAIL_TO must match a verified destination (and the binding restriction above).
// send-me.js — call this after the D1 insert succeeds
async function notifyMe(env, { name, email, message }) {
const safeName = String(name).replace(/[\r\n]+/g, " ").slice(0, 80);
const safeEmail = String(email).replace(/[\r\n]+/g, "").slice(0, 254);
const safeMessage = String(message).slice(0, 5000);
await env.EMAIL.send({
to: env.MAIL_TO,
from: env.MAIL_FROM,
replyTo: safeEmail,
subject: "New contact form message from " + safeName,
text:
"Name: " + safeName + "\n" +
"Email: " + safeEmail + "\n\n" +
safeMessage,
});
}
Plain text is enough for a notification, and it means you never have to worry about a visitor’s message injecting HTML into your inbox. replyTo is the whole point: hitting Reply in your mail app writes to the person who filled in the form, not to forms@example.com.
Strip carriage returns and newlines from anything that might end up in a header (name, email, the subject). A JSON API is safer than raw SMTP, but it is still a good habit.
Option B: Resend over fetch (no SDK)
Create a Resend account, add your domain, and paste the DNS records they show you into Cloudflare DNS. Then create an API key and store it as a Worker secret, not in wrangler.toml:
npx wrangler secret put RESEND_API_KEY
# wrangler.toml
[vars]
MAIL_FROM = "Forms <forms@example.com>"
MAIL_TO = "you@example.com"
You do not need the Resend npm package. A single fetch is enough, and it keeps the Worker small:
async function notifyMe(env, { name, email, message }) {
const safeName = String(name).replace(/[\r\n]+/g, " ").slice(0, 80);
const safeEmail = String(email).replace(/[\r\n]+/g, "").slice(0, 254);
const safeMessage = String(message).slice(0, 5000);
const res = 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.MAIL_FROM,
to: [env.MAIL_TO],
reply_to: safeEmail,
subject: "New contact form message from " + safeName,
text:
"Name: " + safeName + "\n" +
"Email: " + safeEmail + "\n\n" +
safeMessage,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error("Resend " + res.status + ": " + body);
}
}
The field name on the HTTP API is reply_to (snake_case). The official SDK uses replyTo. If you mix them up, the notification still arrives, but Reply goes to the wrong place and you will not notice until you try to answer someone.
Store first, then waitUntil
Hook the send into the Worker you already have. After validation and the D1 insert, return the redirect (or JSON) immediately, and let the email finish in the background:
// Inside fetch(request, env, ctx), after the INSERT:
ctx.waitUntil(
notifyMe(env, { name, email, message }).catch((err) => {
console.error("notify failed", err);
})
);
return Response.redirect(env.THANKS_URL, 303);
Two details matter here. First, the visitor is not waiting on Resend or Email Service. Second, a thrown error inside waitUntil will not undo the redirect, which is what you want — but you must log it, or a broken API key can fail silently for weeks. Check the Workers logs after you deploy, and send a test message to yourself.
Optional: a confirmation to the visitor
Only do this on the Resend (or paid sending) path, and only after the address has passed your server-side email check. Send a short, canned message from your verified domain. Do not echo the visitor’s own message back as HTML, and do not include a link you did not put there on purpose.
Treat visitor mail as a privilege, not a default. If you skip rate limits, a bot that slips past Turnstile can use your domain to spam strangers, and you will pay for that with your reputation. A tight per-IP cap (see the architecture guide) is what makes a confirmation email safe enough to turn on.
What usually goes wrong
-
An unverified
fromaddress. Resend will reject it. Email Routing will too if the domain is not set up. - Waiting on the mail API before you respond. A 3-second delay becomes a 20-second one on a bad day, and a timeout looks like “the form is broken.”
- No record of a failed send. Store first. Log failures. A webhook to Discord or Slack is a perfectly good alternative if you would rather not run mail at all.
-
HTML bodies with unsanitized input. Stick to
textfor notifications.
The ready-made version
Formharbor does this the same way: the message is written to your D1 database first, then Resend notifications and an optional visitor confirmation run in the background, with Discord or Slack as a backup. The $19 kit also includes the rest of the form stack — Turnstile Siteverify, honeypot, timing trap, rate limits, a dashboard with CSV export, plus a referral waitlist and R2 uploads. If you only needed the mail part, the functions above are enough to add it to the tutorial Worker.
Related guides, all free to read:
Top comments (0)