—here’s a drop-in setup for Cloudflare Pages that:
• validates Turnstile (anti-spam),
• emails the submission (free) using Cloudflare Email Routing → Email Workers,
• (optional) logs each submission to KV for backup,
• works with your existing static HTML form.
I’ll give you the exact files/snippets and the 3-step setup at the end.
Create functions/api/contact.ts (or .js) in your repo:
// /functions/api/contact.ts
// Cloudflare Pages Function: validates Turnstile token, sends email via Email Workers,
// and optionally logs to KV.
import { createMimeMessage } from "mimetext";
import type { Env } from "./types"; // optional (shown below)
export const onRequestPost: PagesFunction
const { request, env } = context;
// 1) Parse form data
const form = await request.formData();
const name = (form.get("name") || "").toString().trim();
const email = (form.get("email") || "").toString().trim();
const phone = (form.get("phone") || "").toString().trim();
const message = (form.get("message") || "").toString().trim();
// Honeypot (bots fill this)
if ((form.get("company") || "").toString().length > 0) {
return json({ ok: true }); // silently accept
}
// 2) Validate Turnstile (required for real protection)
const token = (form.get("cf-turnstile-response") || "").toString();
const ip = context.request.headers.get("CF-Connecting-IP") ?? "";
const verifyRes = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({
secret: env.TURNSTILE_SECRET,
response: token,
remoteip: ip,
}),
});
const verify: { success: boolean } = await verifyRes.json();
if (!verify.success) {
return json({ ok: false, error: "turnstile_failed" }, 400);
}
// 3) Build the email (simple text + a tiny HTML fallback)
const msg = createMimeMessage();
msg.setSender({ name: "REISS BUILDS Website", addr: env.MAIL_FROM });
msg.setRecipient(env.MAIL_TO); // primary recipient
if (env.MAIL_BCC && env.MAIL_BCC.length) {
// You can add multiple BCCs if you like, comma-separated
env.MAIL_BCC.split(",").forEach((addr) => msg.setBcc(addr.trim()));
}
msg.setSubject(New Contact Form: ${name || "Website Lead"}
);
const textBody =
`New website inquiry
Name: ${name}
Email: ${email}
Phone: ${phone}
Source: ${request.headers.get("Referer") || "unknown"}
Message:
${message}
— Sent ${new Date().toISOString()}
`;
msg.addMessage({ contentType: "text/plain", data: textBody });
msg.addMessage({
contentType: "text/html",
data: <pre style="font:14px/1.4 -apple-system,Segoe UI,Roboto,Arial,sans-serif">${escapeHtml( textBody )}</pre>
,
});
// 4) Send the email via Email Workers service binding
// Binding name "SEND" is configured in wrangler.toml (see below)
// @ts-ignore - global in Workers
const emailMessage = new (globalThis as any).EmailMessage(
env.MAIL_FROM,
env.MAIL_TO,
msg.asRaw()
);
try {
await env.SEND.send(emailMessage);
} catch (e: any) {
return json({ ok: false, error: "send_failed", detail: e?.message }, 500);
}
// 5) (Optional) Store to KV for backup
if (env.LEADS_KV) {
const id = lead:${Date.now()}:${cryptoRandom()}
;
await env.LEADS_KV.put(
id,
JSON.stringify({ name, email, phone, message, ts: new Date().toISOString() }),
{ expirationTtl: 60 * 60 * 24 * 365 } // 1 year
);
}
// 6) Return success JSON (AJAX) or redirect (form POST)
const accept = request.headers.get("accept") || "";
if (accept.includes("application/json")) {
return json({ ok: true });
}
return Response.redirect("/thank-you/", 303);
};
// Helpers
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
}
function cryptoRandom() {
const a = new Uint32Array(2);
crypto.getRandomValues(a);
return ${a[0].toString(16)}${a[1].toString(16)}
;
}
optional TypeScript env typings (handy but not required):
// /functions/api/types.d.ts or /functions/api/types.ts
export interface Env {
TURNSTILE_SECRET: string;
MAIL_FROM: string; // e.g. no-reply@reissbuilds.com (must be authorized)
MAIL_TO: string; // where you want to receive leads
MAIL_BCC?: string; // optional comma-separated
SEND: EmailSender; // Email service binding name
LEADS_KV?: KVNamespace; // optional KV
}
Add Turnstile + a honeypot field. Point the form action to your function URL.
In your project root (same repo), add or update wrangler.toml:
name = "reiss-builds-contact"
compatibility_date = "2025-08-01"
[[email]]
binding = "SEND"
[[kv_namespaces]]
binding = "LEADS_KV"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # after creating KV namespace
[vars]
MAIL_FROM = "no-reply@reissbuilds.com"
MAIL_TO = "neil@agilemedia.agency"
MAIL_BCC = "jeff@example.com"
Add the Turnstile secret:
wrangler secret put TURNSTILE_SECRET
wrangler pages deploy ./ --project-name reiss-builds
If you ever want more analytics or templates, you can swap the send block to Resend or Postmark with a single fetch/SDK call from the Worker. Cloudflare has first-party tutorials for both. 
⸻
Notes & why this stack
• No monthly SaaS just for forms: Using Email Workers means the form posts to your own function and emails you without Formspree. (MailChannels’ free Worker API was discontinued in 2024, so the modern path is Email Workers or an ESP like Resend/Postmark.)  
• Spam protection: Turnstile + honeypot stops most junk before it hits your inbox. Server-side Turnstile check is critical. 
• Pages Functions are the recommended way to handle form posts in a Pages site. 
If you want, I can tailor this to your exact folder structure (Eleventy/Pages) and wire it to your current contact page.