Wiring the contact form to email on Cloudflare#
Everything here is platform setup. The endpoint itself is
functions/api/contact.ts and documents its own
behavior — read it there rather than looking for a second description here.
This guide previously taught a retired architecture. It used Astro Actions with the Cloudflare adapter and
mimetext. That approach is out: the adapter forces server rendering and its image service ships unoptimized assets, andmimetexteither fails the esbuild step on node built-ins or throws inside workerd at runtime. The endpoint is now a host function outside the Astro build, so the site stays static. See components.forms and deploy.static.
What already works#
functions/api/contact.ts runs as soon as it is deployed. Without an email
binding it validates submissions and returns success, so the form works
end-to-end from the first deploy — it just doesn't deliver anything yet. Wiring
email is the step below.
One-time Cloudflare setup#
The native send_email binding only works on a domain hosted at Cloudflare with
Email Routing enabled, sending to verified destination addresses.
- The client's domain is on Cloudflare (registrar or nameservers).
- Dashboard → the zone → Email → Email Routing → enable.
- Destination addresses → add the client's inbox and have them click the verification link. An unverified destination fails at send time, not at deploy time, so this is easy to miss until the first real submission.
- Optionally add a routing rule (
hello@clientdomain.tld→ their inbox) so replies to the notification reach a real mailbox. - Add the binding and vars — the commented block in
wrangler.jsonchas the shape:FROM_EMAILmust be an address on the Cloudflare-managed zone.NOTIFY_TOand the binding'sdestination_addressare the verified inbox.ALLOWED_ORIGINis the production origin; submissions from any other origin are rejected.
Rate limiting — required, and deliberately not in the repo#
Add a WAF rate-limiting rule covering /api/* in the dashboard. This cannot
live in the function: edge isolates are per-colo and ephemeral, so an in-memory
counter resets constantly and counts nothing — while looking like protection.
The launch audit marks this NEEDS HUMAN because nothing in the repository can
prove it exists. Confirm it before launch.
Testing#
A preview deployment is the reliable test. Deploy, submit the form, confirm the mail arrives at the verified destination, then confirm a submission from another origin is rejected.
Locally, Miniflare does not deliver mail — it writes each message to a .eml
file under the temp directory and logs the path, so the composed body and
headers can be inspected. Useful for checking the message, but it exercises
Miniflare's stub rather than Email Routing: a verification or binding problem
still only surfaces on a real deployment.
Checklist#
- Domain on Cloudflare, Email Routing enabled, destination verified
-
send_emailbinding namedNOTIFY_EMAILon the project -
ALLOWED_ORIGIN,NOTIFY_TO,FROM_EMAIL,FROM_NAMEset on the host - WAF rate-limiting rule on
/api/* - Something watches for the
[contact:error]log prefix — a form that stops delivering silently is the failure that costs a client real leads - A real submission received end to end on the production domain
Worth adding when a project needs it#
Reply-Tois already set to the submitter's address by the endpoint.- An HTML body alongside the plain-text one.
- Persistence to D1 if submissions need to be auditable beyond the inbox — note that this brings retention and data-handling obligations with it.
- A challenge (Turnstile) if the honeypot and rate limit prove insufficient.