156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
type Env = {
|
|
TURNSTILE_SECRET_KEY?: string;
|
|
RESEND_API_KEY?: string;
|
|
CONTACT_FROM_EMAIL?: string;
|
|
CONTACT_TO_EMAIL?: string;
|
|
};
|
|
|
|
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 handlePost: PagesFunction<Env> = async ({ request, 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;
|
|
"error-codes"?: string[];
|
|
messages?: string[];
|
|
hostname?: string;
|
|
action?: string;
|
|
cdata?: string;
|
|
};
|
|
|
|
if (!verifyResult.success) {
|
|
const details = [
|
|
verifyResult["error-codes"]?.length
|
|
? `codes=${verifyResult["error-codes"].join(",")}`
|
|
: "",
|
|
verifyResult.messages?.length
|
|
? `messages=${verifyResult.messages.join(",")}`
|
|
: "",
|
|
verifyResult.hostname ? `hostname=${verifyResult.hostname}` : "",
|
|
verifyResult.action ? `action=${verifyResult.action}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ")
|
|
.slice(0, 500);
|
|
return jsonResponse(
|
|
{
|
|
ok: false,
|
|
error: "Turnstile verification failed.",
|
|
details: details || undefined,
|
|
},
|
|
400,
|
|
);
|
|
}
|
|
|
|
if (!env.RESEND_API_KEY) {
|
|
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 emailPayload = {
|
|
from: fromEmail,
|
|
to: [toEmail],
|
|
reply_to: email,
|
|
subject: "New contact form submission",
|
|
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
|
|
};
|
|
|
|
const response = await fetch("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(emailPayload),
|
|
});
|
|
if (!response.ok) {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
const errorText = contentType.includes("application/json")
|
|
? JSON.stringify(await response.json().catch(() => ({})))
|
|
: await response.text();
|
|
const errorCode = response.headers.get("x-ms-error-code");
|
|
const details = [
|
|
`${response.status} ${response.statusText}`.trim(),
|
|
errorCode ? `code=${errorCode}` : "",
|
|
errorText.trim(),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ")
|
|
.slice(0, 500);
|
|
return jsonResponse(
|
|
{
|
|
ok: false,
|
|
error: "Failed to send email.",
|
|
details,
|
|
},
|
|
502,
|
|
);
|
|
}
|
|
|
|
return jsonResponse({ ok: true });
|
|
};
|
|
|
|
export const onRequest: PagesFunction<Env> = async (context) => {
|
|
if (context.request.method !== "POST") {
|
|
return jsonResponse({ ok: false, error: "Method not allowed." }, 405);
|
|
}
|
|
return handlePost(context);
|
|
};
|