diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..2632091 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,167 @@ +type Env = { + TURNSTILE_SECRET_KEY?: string; + AZURE_COMMUNICATION_CONNECTION_STRING?: string; + CONTACT_FROM_EMAIL?: string; + CONTACT_TO_EMAIL?: string; + ASSETS: Fetcher; +}; + +const jsonResponse = (data: Record, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); + +const readFormField = (value: FormDataEntryValue | null) => + typeof value === "string" ? value.trim() : ""; + +const parseConnectionString = (connectionString: string) => { + const parts = connectionString.split(";").filter(Boolean); + const map = new Map(); + for (const part of parts) { + const [rawKey, ...rest] = part.split("="); + const key = rawKey?.trim().toLowerCase(); + const value = rest.join("=").trim(); + if (key && value) { + map.set(key, value); + } + } + return { + endpoint: map.get("endpoint") ?? "", + accessKey: map.get("accesskey") ?? map.get("access_key") ?? "", + }; +}; + +const handleContact = async (request: Request, env: Env) => { + const formData = await request.formData(); + const name = readFormField(formData.get("name")); + const email = readFormField(formData.get("email")); + const message = readFormField(formData.get("message")); + const turnstileToken = readFormField( + formData.get("cf-turnstile-response"), + ); + + if (!name || !email || !message) { + return jsonResponse({ ok: false, error: "Missing required fields." }, 400); + } + + if (!env.TURNSTILE_SECRET_KEY) { + return jsonResponse( + { ok: false, error: "Turnstile secret key not configured." }, + 500, + ); + } + + if (!turnstileToken) { + return jsonResponse( + { ok: false, error: "Turnstile verification failed." }, + 400, + ); + } + + const verifyBody = new URLSearchParams({ + secret: env.TURNSTILE_SECRET_KEY, + response: turnstileToken, + remoteip: request.headers.get("CF-Connecting-IP") ?? "", + }); + + const verifyResponse = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + body: verifyBody, + }, + ); + const verifyResult = (await verifyResponse.json()) as { + success?: boolean; + }; + + if (!verifyResult.success) { + return jsonResponse( + { ok: false, error: "Turnstile verification failed." }, + 400, + ); + } + + if (!env.AZURE_COMMUNICATION_CONNECTION_STRING) { + return jsonResponse( + { ok: false, error: "Email provider not configured." }, + 500, + ); + } + + const fromEmail = env.CONTACT_FROM_EMAIL; + if (!fromEmail) { + return jsonResponse( + { ok: false, error: "Sender email not configured." }, + 500, + ); + } + + const toEmail = env.CONTACT_TO_EMAIL ?? "calum@muir.in"; + const { endpoint, accessKey } = parseConnectionString( + env.AZURE_COMMUNICATION_CONNECTION_STRING, + ); + if (!endpoint || !accessKey) { + return jsonResponse( + { ok: false, error: "Email provider not configured." }, + 500, + ); + } + + const emailPayload = { + senderAddress: fromEmail, + content: { + subject: "New contact form submission", + plainText: `Name: ${name}\nEmail: ${email}\n\n${message}`, + }, + recipients: { + to: [{ address: toEmail }], + }, + replyTo: [{ address: email }], + }; + + const response = await fetch( + `${endpoint.replace(/\\/$/, "")}/emails:send?api-version=2023-03-31`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": accessKey, + }, + body: JSON.stringify(emailPayload), + }, + ); + if (!response.ok) { + const errorText = await response.text(); + return jsonResponse( + { + ok: false, + error: "Failed to send email.", + details: errorText.slice(0, 500), + }, + 502, + ); + } + + return jsonResponse({ ok: true }); +}; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (url.pathname === "/api/contact") { + if (request.method !== "POST") { + return jsonResponse( + { ok: false, error: "Method not allowed." }, + 405, + ); + } + return handleContact(request, env); + } + if (env.ASSETS?.fetch) { + return env.ASSETS.fetch(request); + } + return new Response("Not found", { status: 404 }); + }, +}; diff --git a/wrangler.jsonc b/wrangler.jsonc index cb4eb22..66b36cb 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,8 +1,10 @@ { "name": "rda-2026", + "main": "src/worker.ts", "compatibility_date": "2025-12-21", "assets": { "directory": "./dist/public", + "binding": "ASSETS", "not_found_handling": "single-page-application", }, }