From aabaaeaa9b98d8fb9cb98741509600ef99cc9388 Mon Sep 17 00:00:00 2001 From: Calum Muir Date: Mon, 22 Dec 2025 12:32:05 +0000 Subject: [PATCH] Use Azure Communication Services for contact emails --- functions/api/contact.ts | 64 +++++------ package-lock.json | 241 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 271 insertions(+), 35 deletions(-) diff --git a/functions/api/contact.ts b/functions/api/contact.ts index c30b8f9..0d8d597 100644 --- a/functions/api/contact.ts +++ b/functions/api/contact.ts @@ -1,6 +1,8 @@ +import { EmailClient } from "@azure/communication-email"; + type Env = { TURNSTILE_SECRET_KEY?: string; - RESEND_API_KEY?: string; + AZURE_COMMUNICATION_CONNECTION_STRING?: string; CONTACT_FROM_EMAIL?: string; CONTACT_TO_EMAIL?: string; }; @@ -14,36 +16,31 @@ const jsonResponse = (data: Record, status = 200) => const readFormField = (value: FormDataEntryValue | null) => typeof value === "string" ? value.trim() : ""; -export const onRequestPost: PagesFunction = async ({ - request, - env, -}) => { +export const onRequestPost: PagesFunction = 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") - ); + const turnstileToken = readFormField(formData.get("cf-turnstile-response")); if (!name || !email || !message) { return jsonResponse( { ok: false, error: "Missing required fields." }, - 400 + 400, ); } if (!env.TURNSTILE_SECRET_KEY) { return jsonResponse( { ok: false, error: "Turnstile secret key not configured." }, - 500 + 500, ); } if (!turnstileToken) { return jsonResponse( { ok: false, error: "Turnstile verification failed." }, - 400 + 400, ); } @@ -58,7 +55,7 @@ export const onRequestPost: PagesFunction = async ({ { method: "POST", body: verifyBody, - } + }, ); const verifyResult = (await verifyResponse.json()) as { success?: boolean; @@ -67,14 +64,14 @@ export const onRequestPost: PagesFunction = async ({ if (!verifyResult.success) { return jsonResponse( { ok: false, error: "Turnstile verification failed." }, - 400 + 400, ); } - if (!env.RESEND_API_KEY) { + if (!env.AZURE_COMMUNICATION_CONNECTION_STRING) { return jsonResponse( { ok: false, error: "Email provider not configured." }, - 500 + 500, ); } @@ -82,34 +79,31 @@ export const onRequestPost: PagesFunction = async ({ if (!fromEmail) { return jsonResponse( { ok: false, error: "Sender email not configured." }, - 500 + 500, ); } const toEmail = env.CONTACT_TO_EMAIL ?? "calum@muir.in"; + const emailClient = new EmailClient( + env.AZURE_COMMUNICATION_CONNECTION_STRING, + ); const emailPayload = { - from: fromEmail, - to: [toEmail], - reply_to: email, - subject: "New contact form submission", - text: `Name: ${name}\nEmail: ${email}\n\n${message}`, + senderAddress: fromEmail, + content: { + subject: "New contact form submission", + plainText: `Name: ${name}\nEmail: ${email}\n\n${message}`, + }, + recipients: { + to: [{ address: toEmail }], + }, + replyTo: [{ address: email }], }; - const emailResponse = 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 (!emailResponse.ok) { - return jsonResponse( - { ok: false, error: "Failed to send email." }, - 502 - ); + const poller = await emailClient.beginSend(emailPayload); + const result = await poller.pollUntilDone(); + if (result.status !== "Succeeded") { + return jsonResponse({ ok: false, error: "Failed to send email." }, 502); } return jsonResponse({ ok: true }); diff --git a/package-lock.json b/package-lock.json index a73b047..da399e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@azure/communication-email": "^1.1.0", "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4", @@ -116,6 +117,179 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azure-rest/core-client": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz", + "integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/communication-common": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@azure/communication-common/-/communication-common-2.4.0.tgz", + "integrity": "sha512-wwn4AoOgTgoA9OZkO34SKBpQg7/kfcABnzbaYEbc+9bCkBtwwjgMEk6xM+XLEE/uuODZ8q8jidUoNcZHQyP5AQ==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "events": "^3.3.0", + "jwt-decode": "^4.0.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/communication-email": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/communication-email/-/communication-email-1.1.0.tgz", + "integrity": "sha512-n9ATpXyxb4MIhEp/Vtv5BU8GdUZH0vYpm/1pKXVu9AeiQ78MG3OIaiQQ2PmQfA0XPdTx5/g+tUxkwVuD6U4u4w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/communication-common": "^2.3.1", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-lro": "^2.7.2", + "@azure/core-rest-pipeline": "^1.18.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", + "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -4106,6 +4280,20 @@ "@types/node": "*" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", + "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -4140,6 +4328,15 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -5173,6 +5370,15 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -5635,6 +5841,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5808,6 +6040,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/lightningcss": { "version": "1.29.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", diff --git a/package.json b/package.json index 7b708c7..4b5c019 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "db:push": "drizzle-kit push" }, "dependencies": { + "@azure/communication-email": "^1.1.0", "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", "@neondatabase/serverless": "^0.10.4",