Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
81 changes: 80 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 3 additions & 4 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
43 changes: 43 additions & 0 deletions src/lib/contact/email.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const raw = buildMimeMessage(config, contact);
const message = new EmailMessage(config.sender, config.recipient, raw);
await binding.send(message);
}
90 changes: 0 additions & 90 deletions src/lib/contact/gmail.ts

This file was deleted.

9 changes: 4 additions & 5 deletions src/pages/api/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
);
Expand Down
11 changes: 11 additions & 0 deletions tests/shims/cloudflare-email.ts
Original file line number Diff line number Diff line change
@@ -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
) {}
}
5 changes: 5 additions & 0 deletions tests/shims/cloudflare-workers.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading