Add worker entry for contact form

This commit is contained in:
Calum Muir 2025-12-22 13:08:59 +00:00
parent 92848ce20d
commit a294c8593b
2 changed files with 169 additions and 0 deletions

167
src/worker.ts Normal file
View file

@ -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<string, unknown>, 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<string, string>();
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<Response> {
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 });
},
};

View file

@ -1,8 +1,10 @@
{ {
"name": "rda-2026", "name": "rda-2026",
"main": "src/worker.ts",
"compatibility_date": "2025-12-21", "compatibility_date": "2025-12-21",
"assets": { "assets": {
"directory": "./dist/public", "directory": "./dist/public",
"binding": "ASSETS",
"not_found_handling": "single-page-application", "not_found_handling": "single-page-application",
}, },
} }