From 1a73347315cc091f9b452b19422d03fca58728f5 Mon Sep 17 00:00:00 2001 From: Jad wauthier Date: Sun, 9 Aug 2026 15:17:08 -0500 Subject: [PATCH] Send contact form email via Cloudflare send_email binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stalled Gmail API/OAuth2 approach with Cloudflare's native Email Routing send_email binding (legacy EmailMessage/mimetext API). This account doesn't have the Workers Paid plan the newer Email Sending product requires, so the classic binding is used instead, restricted to an already-verified destination address — free, and no third-party credentials needed. Verified end to end with a live send via a temporary local wrangler dev session in remote mode. Adds route-level unit tests for the full POST /api/contact pipeline (validation, reCAPTCHA, rate limit, send) against mocked bindings, plus cloudflare:email/cloudflare:workers test shims since those are Workers-runtime-only virtual modules. Co-Authored-By: Claude Sonnet 5 --- docs/SECURITY.md | 30 ++++--- package-lock.json | 81 ++++++++++++++++++- package.json | 3 +- src/env.d.ts | 7 +- src/lib/contact/email.ts | 43 ++++++++++ src/lib/contact/gmail.ts | 90 --------------------- src/pages/api/contact.ts | 9 +-- tests/shims/cloudflare-email.ts | 11 +++ tests/shims/cloudflare-workers.ts | 5 ++ tests/unit/contact/email.test.ts | 42 ++++++++++ tests/unit/contact/gmail.test.ts | 67 ---------------- tests/unit/contact/route.test.ts | 129 ++++++++++++++++++++++++++++++ vitest.config.ts | 12 +++ wrangler.toml | 21 ++++- 14 files changed, 366 insertions(+), 184 deletions(-) create mode 100644 src/lib/contact/email.ts delete mode 100644 src/lib/contact/gmail.ts create mode 100644 tests/shims/cloudflare-email.ts create mode 100644 tests/shims/cloudflare-workers.ts create mode 100644 tests/unit/contact/email.test.ts delete mode 100644 tests/unit/contact/gmail.test.ts create mode 100644 tests/unit/contact/route.test.ts diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d605de2..4cafaf9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -65,11 +65,14 @@ relying on a filter to exclude private rows. * Rate limiting via a Cloudflare KV-backed counter, keyed by client IP (`src/lib/contact/rateLimit.ts`), capped per time window. * Submitted data is not publicly queryable and is not exposed through any public - API route. Messages are relayed via the Gmail API (OAuth2, not raw SMTP — - Cloudflare Workers does not reliably support raw SMTP), using credentials - supplied by the primary contributor, stored as Cloudflare Worker secrets. + API route. Messages are relayed via Cloudflare's own Email Routing `send_email` + Worker binding (the legacy `EmailMessage`/`mimetext` API, not the newer Email + Sending product, which requires a Workers Paid plan this account does not + have). The binding is restricted to a single, account-verified destination + address (`destination_address` in `wrangler.toml`), so no third party (Google + or otherwise) is involved in delivery and no OAuth credentials are needed. * All four contact-form logic modules (validation, reCAPTCHA verification, - rate limiting, Gmail send) are pure/testable and have unit test coverage, + rate limiting, email send) are pure/testable and have unit test coverage, per the CI policy in `PROJECT.md`. ## Third-Party Services @@ -88,12 +91,13 @@ relying on a filter to exclude private rows. ## Open Items -* The contact form's KV namespace (`RATE_LIMIT`), Gmail API OAuth2 - credentials, and reCAPTCHA site/secret key pair are not yet provisioned. - All three require manual setup outside this repository (Cloudflare KV - namespace creation; a Google Cloud project with Gmail API enabled and an - OAuth consent flow run once to obtain a refresh token; reCAPTCHA site - registration in Google's admin console) before the contact form is - functional in production. The code is written against these as named - bindings/secrets (see `wrangler.toml`, `src/env.d.ts`) and will fail - clearly, not silently, if they are unset. +* The contact form's KV namespace (`RATE_LIMIT`) and reCAPTCHA site/secret + key pair are not yet provisioned. Both require manual setup outside this + repository (Cloudflare KV namespace creation; reCAPTCHA site registration + in Google's admin console) before the contact form is functional in + production. The `EMAIL` send binding and `CONTACT_SENDER`/ + `CONTACT_RECIPIENT` secrets are configured in `wrangler.toml`, pointing at + an already-verified Email Routing destination address, so no further + manual provisioning is needed for email delivery itself. The code is + written against these as named bindings/secrets (see `wrangler.toml`, + `src/env.d.ts`) and will fail clearly, not silently, if they are unset. diff --git a/package-lock.json b/package-lock.json index d437201..271ab0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "Apache-2.0", "dependencies": { "@astrojs/cloudflare": "^13.7.0", - "astro": "^6.4.8" + "astro": "^6.4.8", + "mimetext": "^3.0.28" }, "devDependencies": { "@axe-core/playwright": "^4.9.0", @@ -187,6 +188,27 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz", + "integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", @@ -3459,6 +3481,20 @@ "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, + "node_modules/core-js-pure": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", + "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5094,6 +5130,12 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-base64": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.2.tgz", + "integrity": "sha512-6zayE8QlUdiweYI6cETD/XBSqFcoCUlufn/29PJR99r82x1yDnIprRca0YvAYpAW+ez0GuQkVBC6xG5QkD7OjA==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -6355,6 +6397,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimetext": { + "version": "3.0.28", + "resolved": "https://registry.npmjs.org/mimetext/-/mimetext-3.0.28.tgz", + "integrity": "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@babel/runtime-corejs3": "^7.26.0", + "js-base64": "^3.7.7", + "mime-types": "^2.1.35" + }, + "funding": { + "type": "patreon", + "url": "https://patreon.com/muratgozel" + } + }, "node_modules/miniflare": { "version": "5.20260801.1-alpha", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260801.1-alpha.tgz", diff --git a/package.json b/package.json index a583c6a..431b0a3 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ }, "dependencies": { "@astrojs/cloudflare": "^13.7.0", - "astro": "^6.4.8" + "astro": "^6.4.8", + "mimetext": "^3.0.28" }, "overrides": { "undici": "^8.10.0", diff --git a/src/env.d.ts b/src/env.d.ts index b68cce3..d7375f8 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -4,10 +4,9 @@ declare namespace Cloudflare { interface Env { RATE_LIMIT: KVNamespace; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; - GOOGLE_REFRESH_TOKEN: string; - GMAIL_SENDER: string; + EMAIL: SendEmail; + CONTACT_SENDER: string; + CONTACT_RECIPIENT: string; RECAPTCHA_SECRET: string; } } diff --git a/src/lib/contact/email.ts b/src/lib/contact/email.ts new file mode 100644 index 0000000..76f22fa --- /dev/null +++ b/src/lib/contact/email.ts @@ -0,0 +1,43 @@ +import { EmailMessage } from 'cloudflare:email'; +import { createMimeMessage, Mailbox } from 'mimetext'; + +export interface EmailConfig { + sender: string; + recipient: string; +} + +export interface ContactMessage { + name: string; + email: string; + topic: string; + message: string; +} + +export function buildMimeMessage(config: EmailConfig, contact: ContactMessage): string { + const msg = createMimeMessage(); + msg.setSender({ addr: config.sender }); + msg.setRecipient(config.recipient); + msg.setHeader('Reply-To', new Mailbox({ addr: contact.email }, { type: 'From' })); + msg.setSubject(`BlindTechMage contact form: ${contact.topic}`); + msg.addMessage({ + contentType: 'text/plain', + data: [ + `Name: ${contact.name}`, + `Email: ${contact.email}`, + `Topic: ${contact.topic}`, + '', + contact.message, + ].join('\n'), + }); + return msg.asRaw(); +} + +export async function sendContactEmail( + binding: SendEmail, + config: EmailConfig, + contact: ContactMessage +): Promise { + const raw = buildMimeMessage(config, contact); + const message = new EmailMessage(config.sender, config.recipient, raw); + await binding.send(message); +} diff --git a/src/lib/contact/gmail.ts b/src/lib/contact/gmail.ts deleted file mode 100644 index 6db6f75..0000000 --- a/src/lib/contact/gmail.ts +++ /dev/null @@ -1,90 +0,0 @@ -const TOKEN_URL = 'https://oauth2.googleapis.com/token'; -const SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'; - -export interface GmailCredentials { - clientId: string; - clientSecret: string; - refreshToken: string; - sender: string; -} - -export interface ContactMessage { - name: string; - email: string; - topic: string; - message: string; -} - -function base64UrlEncode(input: string): string { - const bytes = new TextEncoder().encode(input); - let binary = ''; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -export function buildRawMessage(credentials: GmailCredentials, contact: ContactMessage): string { - const lines = [ - `From: ${credentials.sender}`, - `To: ${credentials.sender}`, - `Reply-To: ${contact.email}`, - `Subject: BlindTechMage contact form: ${contact.topic}`, - 'Content-Type: text/plain; charset=utf-8', - '', - `Name: ${contact.name}`, - `Email: ${contact.email}`, - `Topic: ${contact.topic}`, - '', - contact.message, - ]; - return base64UrlEncode(lines.join('\r\n')); -} - -async function getAccessToken( - credentials: GmailCredentials, - fetchImpl: typeof fetch -): Promise { - const response = await fetchImpl(TOKEN_URL, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: credentials.clientId, - client_secret: credentials.clientSecret, - refresh_token: credentials.refreshToken, - grant_type: 'refresh_token', - }), - }); - - if (!response.ok) { - throw new Error(`Failed to obtain Gmail access token: ${response.status}`); - } - - const data = (await response.json()) as { access_token?: string }; - if (!data.access_token) { - throw new Error('Gmail token response did not include an access token.'); - } - return data.access_token; -} - -export async function sendContactEmail( - credentials: GmailCredentials, - contact: ContactMessage, - fetchImpl: typeof fetch = fetch -): Promise { - const accessToken = await getAccessToken(credentials, fetchImpl); - const raw = buildRawMessage(credentials, contact); - - const response = await fetchImpl(SEND_URL, { - method: 'POST', - headers: { - authorization: `Bearer ${accessToken}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ raw }), - }); - - if (!response.ok) { - throw new Error(`Failed to send contact email: ${response.status}`); - } -} diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 858125d..6929255 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -3,7 +3,7 @@ import { env } from 'cloudflare:workers'; import { validateContactForm } from '../../lib/contact/validation'; import { verifyRecaptcha } from '../../lib/contact/recaptcha'; import { checkRateLimit } from '../../lib/contact/rateLimit'; -import { sendContactEmail } from '../../lib/contact/gmail'; +import { sendContactEmail } from '../../lib/contact/email'; export const prerender = false; @@ -59,11 +59,10 @@ export const POST: APIRoute = async ({ request, clientAddress }) => { try { await sendContactEmail( + env.EMAIL, { - clientId: env.GOOGLE_CLIENT_ID, - clientSecret: env.GOOGLE_CLIENT_SECRET, - refreshToken: env.GOOGLE_REFRESH_TOKEN, - sender: env.GMAIL_SENDER, + sender: env.CONTACT_SENDER, + recipient: env.CONTACT_RECIPIENT, }, input ); diff --git a/tests/shims/cloudflare-email.ts b/tests/shims/cloudflare-email.ts new file mode 100644 index 0000000..34b9cd6 --- /dev/null +++ b/tests/shims/cloudflare-email.ts @@ -0,0 +1,11 @@ +// Vitest runs in Node, not the Workers runtime, so the virtual +// `cloudflare:email` module isn't resolvable. This test-only shim +// mirrors just enough of the real `EmailMessage` shape (from/to/raw) +// for unit tests that construct one and pass it to a mocked binding. +export class EmailMessage { + constructor( + public readonly from: string, + public readonly to: string, + public readonly raw: string + ) {} +} diff --git a/tests/shims/cloudflare-workers.ts b/tests/shims/cloudflare-workers.ts new file mode 100644 index 0000000..dbbb606 --- /dev/null +++ b/tests/shims/cloudflare-workers.ts @@ -0,0 +1,5 @@ +// Vitest runs in Node, not the Workers runtime, so the virtual +// `cloudflare:workers` module isn't resolvable. This test-only shim +// exposes a mutable `env` object that route-level tests populate with +// mock bindings before invoking the handler under test. +export const env = {} as Cloudflare.Env; diff --git a/tests/unit/contact/email.test.ts b/tests/unit/contact/email.test.ts new file mode 100644 index 0000000..e811800 --- /dev/null +++ b/tests/unit/contact/email.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildMimeMessage, sendContactEmail } from '../../../src/lib/contact/email'; + +const config = { + sender: 'notify@blindtechmage.com', + recipient: 'blindtechmage+jwauthier@gmail.com', +}; + +const contact = { + name: 'Jad', + email: 'jad@example.com', + topic: 'general', + message: 'Hello there, this is a test message.', +}; + +describe('buildMimeMessage', () => { + it('embeds the sender as Reply-To so replies go to the actual submitter', () => { + const raw = buildMimeMessage(config, contact); + expect(raw).toContain(`Reply-To: <${contact.email}>`); + }); + + it('includes the contact message body', () => { + const raw = buildMimeMessage(config, contact); + expect(raw).toContain(contact.message); + }); +}); + +describe('sendContactEmail', () => { + it('sends an EmailMessage through the provided binding', async () => { + const send = vi.fn().mockResolvedValue(undefined); + await sendContactEmail({ send } as unknown as SendEmail, config, contact); + + expect(send).toHaveBeenCalledTimes(1); + }); + + it('propagates errors from the binding', async () => { + const send = vi.fn().mockRejectedValue(new Error('send failed')); + await expect( + sendContactEmail({ send } as unknown as SendEmail, config, contact) + ).rejects.toThrow('send failed'); + }); +}); diff --git a/tests/unit/contact/gmail.test.ts b/tests/unit/contact/gmail.test.ts deleted file mode 100644 index a9c5f8d..0000000 --- a/tests/unit/contact/gmail.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { buildRawMessage, sendContactEmail } from '../../../src/lib/contact/gmail'; - -const credentials = { - clientId: 'client-id', - clientSecret: 'client-secret', - refreshToken: 'refresh-token', - sender: 'me@example.com', -}; - -const contact = { - name: 'Jad', - email: 'jad@example.com', - topic: 'general', - message: 'Hello there, this is a test message.', -}; - -describe('buildRawMessage', () => { - it('produces a base64url-encoded MIME message with no padding characters', () => { - const raw = buildRawMessage(credentials, contact); - expect(raw).not.toContain('+'); - expect(raw).not.toContain('/'); - expect(raw).not.toContain('='); - }); - - it('embeds the sender as Reply-To so replies go to the actual submitter', () => { - const raw = buildRawMessage(credentials, contact); - const decoded = atob(raw.replace(/-/g, '+').replace(/_/g, '/')); - expect(decoded).toContain(`Reply-To: ${contact.email}`); - }); -}); - -describe('sendContactEmail', () => { - it('exchanges the refresh token then sends via the Gmail API', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ access_token: 'access-token' }), - }) - .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); - - await sendContactEmail(credentials, contact, fetchImpl as unknown as typeof fetch); - - expect(fetchImpl).toHaveBeenCalledTimes(2); - const [sendUrl, sendOptions] = fetchImpl.mock.calls[1]; - expect(sendUrl).toContain('gmail.googleapis.com'); - expect(sendOptions.headers.authorization).toBe('Bearer access-token'); - }); - - it('throws when the token exchange fails', async () => { - const fetchImpl = vi.fn().mockResolvedValueOnce({ ok: false, json: async () => ({}) }); - await expect( - sendContactEmail(credentials, contact, fetchImpl as unknown as typeof fetch) - ).rejects.toThrow(); - }); - - it('throws when the send request fails', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: 'access-token' }) }) - .mockResolvedValueOnce({ ok: false, json: async () => ({}) }); - await expect( - sendContactEmail(credentials, contact, fetchImpl as unknown as typeof fetch) - ).rejects.toThrow(); - }); -}); diff --git a/tests/unit/contact/route.test.ts b/tests/unit/contact/route.test.ts new file mode 100644 index 0000000..d098594 --- /dev/null +++ b/tests/unit/contact/route.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { POST } from '../../../src/pages/api/contact'; +import { env } from '../../../tests/shims/cloudflare-workers'; + +type Handler = typeof POST; +type Context = Parameters[0]; + +function makeRequest(fields: Record): Request { + const body = new URLSearchParams(fields); + return new Request('https://blindtechmage.com/api/contact', { + method: 'POST', + body, + }); +} + +function makeContext(fields: Record): Context { + return { + request: makeRequest(fields), + clientAddress: '203.0.113.7', + } as unknown as Context; +} + +const validFields = { + name: 'Jad', + email: 'jad@example.com', + topic: 'general', + message: 'This is a long enough message to pass validation checks.', + 'g-recaptcha-response': 'valid-token', +}; + +describe('POST /api/contact', () => { + let kvStore: Map; + let emailSend: ReturnType; + + beforeEach(() => { + kvStore = new Map(); + emailSend = vi.fn().mockResolvedValue(undefined); + + env.RECAPTCHA_SECRET = 'recaptcha-secret'; + env.CONTACT_SENDER = 'notify@blindtechmage.com'; + env.CONTACT_RECIPIENT = 'blindtechmage+jwauthier@gmail.com'; + env.RATE_LIMIT = { + get: vi.fn((key: string) => Promise.resolve(kvStore.get(key) ?? null)), + put: vi.fn((key: string, value: string) => { + kvStore.set(key, value); + return Promise.resolve(); + }), + } as unknown as Cloudflare.Env['RATE_LIMIT']; + env.EMAIL = { send: emailSend } as unknown as Cloudflare.Env['EMAIL']; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends an email through the EMAIL binding for a valid submission', async () => { + const response = await POST(makeContext(validFields)); + const body = (await response.json()) as { ok: boolean }; + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(emailSend).toHaveBeenCalledTimes(1); + + const [message] = emailSend.mock.calls[0] as [{ from: string; to: string }]; + expect(message.from).toBe(env.CONTACT_SENDER); + expect(message.to).toBe(env.CONTACT_RECIPIENT); + }); + + it('rejects an invalid submission without sending an email', async () => { + const response = await POST(makeContext({ ...validFields, email: 'not-an-email' })); + + expect(response.status).toBe(400); + expect(emailSend).not.toHaveBeenCalled(); + }); + + it('rejects a submission that fails reCAPTCHA without sending an email', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: false }), + }) + ); + + const response = await POST(makeContext(validFields)); + + expect(response.status).toBe(400); + expect(emailSend).not.toHaveBeenCalled(); + }); + + it('silently accepts a honeypot-triggered submission without sending an email', async () => { + const response = await POST(makeContext({ ...validFields, website: 'i-am-a-bot' })); + const body = (await response.json()) as { ok: boolean }; + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(emailSend).not.toHaveBeenCalled(); + }); + + it('rejects submissions once the rate limit is exceeded', async () => { + for (let i = 0; i < 5; i += 1) { + await POST(makeContext(validFields)); + } + emailSend.mockClear(); + + const response = await POST(makeContext(validFields)); + + expect(response.status).toBe(429); + expect(emailSend).not.toHaveBeenCalled(); + }); + + it('returns a 502 and does not swallow the error when sending fails', async () => { + emailSend.mockRejectedValueOnce(new Error('binding unavailable')); + + const response = await POST(makeContext(validFields)); + const body = (await response.json()) as { ok: boolean }; + + expect(response.status).toBe(502); + expect(body.ok).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1c485c9..b682ac9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,18 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + // `cloudflare:email` is a virtual module only resolvable in the + // Workers runtime; unit tests run in Node, so it's aliased to a + // minimal shim. See tests/shims/cloudflare-email.ts. + 'cloudflare:email': new URL('./tests/shims/cloudflare-email.ts', import.meta.url).pathname, + // `cloudflare:workers` (the `env` export) is likewise only resolvable + // in the Workers runtime. See tests/shims/cloudflare-workers.ts. + 'cloudflare:workers': new URL('./tests/shims/cloudflare-workers.ts', import.meta.url) + .pathname, + }, + }, test: { globals: true, environment: 'node', diff --git a/wrangler.toml b/wrangler.toml index 35f442e..0eb9863 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,18 @@ name = "blindtechmage-website" compatibility_date = "2026-01-01" +# mimetext (used to build the contact form's outbound MIME message) requires +# the nodejs_compat flag. +compatibility_flags = ["nodejs_compat"] + +# Contact form outbound email, via the classic Email Routing `send_email` +# binding (legacy EmailMessage/mimetext API) rather than the newer Email +# Sending product, which requires a Workers Paid plan this account does not +# have. `destination_address` restricts the binding to a single, already +# account-verified address, so no sending-domain SPF/DKIM onboarding is +# needed. See docs/SECURITY.md. +[[send_email]] +name = "EMAIL" +destination_address = "blindtechmage+jwauthier@gmail.com" # @astrojs/cloudflare (Workers-targeting, not classic Pages) generates a # complete, ready-to-deploy wrangler.json at dist/server/wrangler.json on @@ -25,9 +38,11 @@ compatibility_date = "2026-01-01" # binding = "RATE_LIMIT" # id = "REPLACE_WITH_REAL_ID" -# Contact form secrets (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, -# GOOGLE_REFRESH_TOKEN, GMAIL_SENDER, RECAPTCHA_SECRET) are set via -# `wrangler secret put `, never committed here. See docs/SECURITY.md. +# Contact form secrets (CONTACT_SENDER, CONTACT_RECIPIENT, RECAPTCHA_SECRET) +# are set via `wrangler secret put `, never committed here. Neither +# CONTACT_SENDER nor CONTACT_RECIPIENT is sensitive by nature, but they are +# kept as secrets rather than [vars] so they aren't committed to a public +# repo. See docs/SECURITY.md. # # The reCAPTCHA site key (PUBLIC_RECAPTCHA_SITE_KEY) is not secret — it is a # build-time public environment variable, set via a `.env` file locally