Start with the pages

This site is HTML, CSS and a little JavaScript. A Python script expands shared headers and footers, generates the English and Dutch pages, and writes the public files into dist/. The blog is written in English.

That fits the job. Reading an article needs no application server or database query. The content is in Git, and an ordinary build produces the files Cloudflare Pages serves. Python runs during the build; it does not run when someone opens a page.

Pages settingValue for this site
Framework presetNone
Root directorywebsite
Build commandpython3 -X utf8 scripts/build.py --check
Output directorydist

The build also checks internal links, translations and metadata. Styles and scripts get a content hash in their URL, so an updated file has a different URL when a reader returns. Keeping the generator small is convenient today; adding more publishing features later will mean maintaining them or choosing a dedicated static-site generator.

Cloudflare currently recommends Workers for new projects. The settings above document the existing Pages setup behind this site.

A custom domain points the website at Pages. Mail DNS is a separate concern: moving the website must preserve the records that deliver business email.

Static responses use a Content-Security-Policy with explicit source permissions, frame-ancestors to prevent embedding, and X-Content-Type-Options: nosniff. The contact API sets its own response headers, including Cache-Control: no-store: Pages’ static headers file does not apply to Function responses.

The contact form needs a boundary

The browser submits JSON to /api/contact. That public Pages Function forwards the request through a service binding to one private Worker. The Worker owns validation, abuse controls and email sending.

From contact form to inboxThe browser posts to a public Pages Function. A service binding calls the private contact Worker, which validates input, applies rate limits and checks Turnstile. Only accepted submissions use the email binding to deliver to the fixed Microsoft 365 inbox. POSTService bindingVERIFYAccepted inputDELIVER BrowserContact formPages FunctionPublic /api/contact Private contact WorkerLimits + validationNo public endpointTurnstileServer verification Cloudflare emailRestricted send bindingMicrosoft 365Fixed recipientThe browser never receives the email binding or Turnstile secret.
The message path. Sending is available only after the private Worker’s checks.

The private Worker is configured with workers_dev: false, preview_urls: false and routes: []; it has no custom domain. Production Pages reaches it through CONTACT_SERVICE. The Pages Function is its public entry point. This keeps the email capability in one place, together with the checks that must run before using it. It also means the Pages deployment and the supporting Worker have separate deployment steps.

The browser sends form fields and a Turnstile token. It cannot choose an email recipient, access a mailbox password or call the email binding directly.

A service binding grants access to the Worker. The internal client-IP header is a contract between trusted services, not an authentication mechanism: any additional caller granted a binding must establish the same boundary.

Check the request before sending

A browser widget is only part of the check. The Worker sends its token to Turnstile’s server-side verification endpoint, then checks the result, the hostname and the contact action. Tokens expire after five minutes and can be verified only once. The public site key belongs in the browser; TURNSTILE_SECRET_KEY is stored as a Worker secret, outside the source tree and browser bundle.

The rest of the request still needs attention:

  • Origin and format. The request must come from an allowed website origin and contain JSON. An Origin header is a browser constraint, not proof of a person’s identity; a scripted client can forge it.
  • Bounded input. The Worker caps the body at 32 KiB while reading it, then checks field lengths and control characters. It does not rely only on a claimed Content-Length.
  • Abuse limits. Per-IP and aggregate limits run before challenge verification and sending. A hidden honeypot field catches some automated submissions.
  • Trusted client address. The Pages Function takes the address from CF-Connecting-IP and sets X-Contact-Client-IP itself. It does not forward an arbitrary address supplied by the caller.

The configured limits are three requests per IP and thirty requests in total per minute per Cloudflare location. Cloudflare documents these as local, approximate limits. They help with abuse; they are not a strict worldwide quota.

If required configuration, the limiter or token verification is unavailable, the Worker does not send. The application code also avoids logging the message body.

Missing configuration and unavailable checks fail closed. The Pages bridge also requires its enable flag and service binding; the Worker requires its own enable flag, limiter bindings, secret and email binding. Keep the contact form disabled until those dependencies are configured and delivery is verified.

Send from the website, reply to the visitor

The Worker uses Cloudflare’s native email binding. The application needs no SMTP password, Microsoft 365 login or separate email API token. The binding restricts allowed_sender_addresses and allowed_destination_addresses. Those platform-level restrictions match the fixed addresses in the application.

The addresses have different jobs:

FieldSource
FromA fixed address on the website’s verified sending domain
ToThe fixed business inbox
Reply-ToThe visitor’s validated email address

Using the visitor’s address as From would make the website claim to send on behalf of a domain it does not control. Reply-To lets me answer the enquiry while keeping the sender identity under my control. The subject is fixed, and the form content goes into a plain-text message. Caller-supplied recipients, HTML, attachments and arbitrary headers are not passed through.

For this setup, Cloudflare Email Routing is configured on forms.predicate.be, while the main domain’s MX records stay with Microsoft 365. A separate mail subdomain lets the form use Cloudflare without moving the business mailbox.

Domain authentication matters as well. SPF authorizes sending infrastructure for the envelope domain. DKIM adds a domain signature. DMARC checks whether a passing SPF or DKIM identity aligns with the visible From domain and publishes the domain’s policy. The email authentication documentation is the reference for those records. Check the domains in an actual delivered message: the envelope sender can differ from the address shown to a reader. In this setup, Cloudflare’s return-path uses the main domain, so its existing SPF record authorizes both Microsoft 365 and Cloudflare. That means merging the required sender permissions into one SPF record at that hostname, while preserving Microsoft 365’s MX records. The sending subdomain’s DMARC policy is currently p=none, which monitors authentication rather than asking receivers to reject failures.

This is a protected enquiry form delivering to a business mailbox. It is not an end-to-end encrypted messaging system, and authenticating the sending domain does not prove that a visitor owns the address they typed.

Accepted is not the same as in the inbox

The email API returns a messageId when it accepts the message. The form reports success only after receiving one. If sending fails or the result is unclear, it reports that delivery could not be confirmed.

There is no automatic retry after an uncertain result. The first attempt may already have been accepted, and retrying could deliver the enquiry twice. For a low-volume contact form, a clear failure state is a reasonable starting point. A workflow that cannot tolerate losing a submission needs durable storage and a deliberate retry strategy.

Inbox placement is another step. SPF, DKIM and DMARC can all pass while a receiving provider still classifies a message as spam. A delivery check therefore includes the destination mailbox and its Junk folder, as well as the API response.

For this site, the release checks cover rejected origins and tokens, oversized input, fixed recipient handling and ambiguous provider responses in tests. A controlled end-to-end delivery check then confirms the message and Reply-To in the actual mailbox. The form stores no submission database; email is the record of the enquiry.

Useful links