Astro Build Standards docs

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, and mimetext either 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.

  1. The client's domain is on Cloudflare (registrar or nameservers).
  2. Dashboard → the zone → EmailEmail Routing → enable.
  3. 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.
  4. Optionally add a routing rule (hello@clientdomain.tld → their inbox) so replies to the notification reach a real mailbox.
  5. Add the binding and vars — the commented block in wrangler.jsonc has the shape:
    • FROM_EMAIL must be an address on the Cloudflare-managed zone.
    • NOTIFY_TO and the binding's destination_address are the verified inbox.
    • ALLOWED_ORIGIN is 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_email binding named NOTIFY_EMAIL on the project
  • ALLOWED_ORIGIN, NOTIFY_TO, FROM_EMAIL, FROM_NAME set 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-To is 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.