Contact Form Checklist¶
Patterns for contact form submission — public-facing form that collects visitor inquiries, logs to database, sends admin notification, and returns a submission ID.
Uses Cloudflare Turnstile for bot protection, Resend for email delivery, and D1 for submission logging.
Reference impls:
- Next.js + OpenNext on Cloudflare Workers (default since 2026-06): keilen_family_services/components/contact/ContactForm.tsx, keilen_family_services/components/contact/actions.ts, keilen_family_services/wrangler.jsonc
- Vite SPA + Cloudflare Pages with Hono on Pages Functions (alternative): bonbon_dino_web/src/pages/Contact.tsx, bonbon_dino_web/functions/api/_routes.js, bonbon_dino_web/migrations/0003_contact_submissions.sql
1. Form Schema & Validation¶
What it does: Zod schema validates contact form fields before processing.
Required fields:
- firstName — string, min 1
- lastName — string, min 1
- email — string, valid email (via shared email zod type)
- phone — string, optional
- message — string, min 1
- services — array of strings, min 1 (checkboxes for service interests)
- turnstileToken — string, min 1 (from bot widget)
Pattern:
export const ContactSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email,
phone: z.string().optional(),
message: z.string().min(1),
services: z.array(z.string()).min(1),
turnstileToken: z.string().min(1),
})
Add this to lib/schemas.js. Reuse the shared email zod type if it exists; if not, define: z.string().email().toLowerCase().
2. Frontend Form Component¶
What it does: React component renders the contact form with Turnstile widget and multi-language support.
Key patterns:
2a. Turnstile Widget Integration¶
- Load Turnstile script lazily (don't block page load)
- Render widget in a container with a stable ID
- Store token in state when widget generates a token
- Reset token on widget expiry or error
Pattern (Next.js / OpenNext — default since 2026-06):
Use @marsidev/react-turnstile — it wraps the script load + widget lifecycle. Site key is process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY. Store the token in component state, include it in the form payload as turnstileToken, and reset the widget after any server action error (the token is single-use server-side, but the widget doesn't know that):
'use client'
import { useActionState, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? ''
export default function ContactForm() {
const [state, formAction, pending] = useActionState(submitContactForm, initialState)
const [turnstileToken, setTurnstileToken] = useState('')
const turnstileRef = useRef<TurnstileInstance>(null)
useEffect(() => {
if (state.error) {
turnstileRef.current?.reset()
setTurnstileToken('')
}
}, [state])
// ... form fields ...
<Turnstile
ref={turnstileRef}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={setTurnstileToken}
onError={() => setTurnstileToken('')}
onExpire={() => setTurnstileToken('')}
/>
// ... submit button disabled={!turnstileToken || pending}
}
Why the reset-on-error pattern: Cloudflare Turnstile tokens are single-use. The first submit (even one that fails Zod validation) consumes the token server-side. The widget doesn't know — it keeps showing the green checkmark, but a second submit sends a dead token. Resetting after any error prevents the "I fixed the field and resubmit, now it says Security check failed" trap. Reference impl: keilen_family_services/components/contact/ContactForm.tsx, see also debugging-lessons.md → Cloudflare Turnstile token consumed on first submit; retry always fails.
Layout gotcha — button sits above widget that enables it: when the submit button depends on the captcha token (it must — the captcha is the bot check), and the widget sits below the button (per the desired stacking order), users see a greyed-out "Send" button before they understand why. Add a one-line helper text between the button and the widget that ties the disabled state to the verification: "Complete the security check below to enable Send Message." Then switch to "Security check complete." after the token arrives.
Pattern (Vite SPA / Cloudflare Pages with Hono — alternative):
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
const turnstileContainerRef = useRef<HTMLDivElement | null>(null)
const turnstileWidgetIdRef = useRef<string | null>(null)
useEffect(() => {
if (!turnstileSiteKey) {
console.warn("VITE_TURNSTILE_SITE_KEY is not set")
return
}
// Lazy load script, render widget when window.turnstile is available
// Store widget ID to reset on expiry
}, [turnstileSiteKey])
const handleSubmit = async (e: React.FormEvent) => {
if (!turnstileToken) {
throw new Error("Please complete the bot verification challenge.")
}
// Submit form
}
2b. Form Payload Format¶
Send exact field names matching ContactSchema:
await api("/api/contact", {
method: "POST",
body: JSON.stringify({
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phone: formData.phone || undefined,
message: formData.message,
services: formData.services, // array, not joined string
turnstileToken, // required
}),
})
Do NOT send:
- name (send firstName + lastName separately)
- language (not in schema; remove if added for i18n)
2c. Success State¶
After successful POST, show a success card with: - Checkmark icon - "Thank you for contacting us" message - Button to reset form and send another
3. Database Schema & Migrations¶
What it does: contact_submissions table persists all contact submissions for audit trail and follow-up.
Schema:
CREATE TABLE IF NOT EXISTS contact_submissions (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
message TEXT NOT NULL,
services TEXT, -- comma-separated list, not JSON
language TEXT NOT NULL DEFAULT 'en',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_contact_submissions_email ON contact_submissions(email);
CREATE INDEX IF NOT EXISTS idx_contact_submissions_created_at ON contact_submissions(created_at);
Migration file: migrations/NNNN_contact_submissions.sql
Indexes:
- email — quick lookup by submitter email
- created_at — efficient time-range queries for reporting
Apply to remote: wrangler d1 execute <db-name> --remote --file migrations/NNNN_contact_submissions.sql
4. Backend Endpoint¶
What it does: POST /api/contact validates form, verifies Turnstile token, logs to D1, sends email, returns submission ID.
Endpoint structure:
app.post('/api/contact', async (c) => {
// 1. Parse & validate body against ContactSchema
const parsed = ContactSchema.safeParse(body)
if (!parsed.success) throw new ApiError(400, 'validation_failed', parsed.error.issues)
const data = parsed.data
// 2. Verify Turnstile token with Cloudflare
const verifyRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret: c.env.TURNSTILE_SECRET_KEY,
response: data.turnstileToken,
}),
})
const verifyResult = await verifyRes.json().catch(() => ({}))
if (verifyResult.success !== true) {
throw new ApiError(400, 'turnstile_failed', verifyResult['error-codes'])
}
// 3. Generate submission ID and insert into DB
const id = ulid()
const now = new Date().toISOString()
const db = getDb(c.env)
try {
await db.prepare(`
INSERT INTO contact_submissions (id, first_name, last_name, email, phone, message, services, language, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
id,
data.firstName,
data.lastName,
data.email,
data.phone || null,
data.message,
data.services.join(', '),
data.language || 'en',
now
).run()
} catch (dbErr) {
console.error('contact-db-insert:', dbErr)
throw new ApiError(500, 'submission_failed', dbErr.message)
}
// 4. Send admin notification (fire-and-forget)
const recipientEmail = c.env.CONTACT_EMAIL ?? 'default@domain.com'
const html = `
<h3>New contact form submission</h3>
<p><strong>From:</strong> ${escapeHtml(data.firstName)} ${escapeHtml(data.lastName)} <${escapeHtml(data.email)}></p>
${data.phone ? `<p><strong>Phone:</strong> ${escapeHtml(data.phone)}</p>` : ''}
<p><strong>Services:</strong> ${data.services.map(escapeHtml).join(', ')}</p>
<p><strong>Message:</strong></p>
<p>${escapeHtml(data.message).replace(/\n/g, '<br/>')}</p>
`
deliverEmail({
env: c.env,
params: {
to: [recipientEmail],
replyTo: data.email,
subject: `[Business Name] Contact form: ${data.firstName} ${data.lastName}`,
htmlBody: html,
},
}).catch(err => {
console.error('contact-email: failed to send', err)
})
// 5. Return success with submission ID
return c.json({ ok: true, id }, 201)
})
Error codes:
- validation_failed — 400, form data doesn't match schema
- turnstile_failed — 400, Turnstile token invalid or verification failed
- submission_failed — 500, database insertion error
5. Turnstile Bot Protection¶
What it does: Cloudflare Turnstile verifies the visitor is human before allowing form submission.
Setup:
1. Go to Cloudflare Dashboard → Turnstile → Create Site
2. Add domain (e.g., example.com)
3. Copy Site Key and Secret Key
Environment variables:
Frontend — Next.js / OpenNext (default since 2026-06, inlined at build time):
# .env.production at the repo root, NOT gitignored (site key is public)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADku...
Frontend — Vite (alternative, build-time):
VITE_TURNSTILE_SITE_KEY=0x4AAAAAADku...
Backend (Worker secret, via wrangler secret put):
echo "0x4AAAAAADku..." | npx wrangler secret put TURNSTILE_SECRET_KEY
Key gotcha — NEXT_PUBLIC_* is build-time, not runtime. A wrangler secret put NEXT_PUBLIC_TURNSTILE_SITE_KEY sets it at runtime, but the JS bundle was already built without it. The widget initializes with no site key and silently fails. See db-and-deploy-checklist.md §6 and debugging-lessons.md → Turnstile widget not rendering in production.
Keys must match: Site key and secret key come as a pair from the same Turnstile widget. If mismatched, verification will always fail.
6. Email Delivery¶
What it does: Admin receives contact form notification via Resend.
Follow email-checklist.md§1-10. Key requirements:
- Email provider: Resend (RESEND_API_KEY as secret)
- From address:
[Business Name] <noreply@domain.com>(domain-verified) oronboarding@resend.devduring dev - Reply-To: Set to submitter's email so admin can hit Reply
- Subject:
[Business Name] Contact form: FirstName LastName - Fire-and-forget: Email send never blocks response; log on failure only
Implementation (Vite + Hono):
deliverEmail({
env: c.env,
params: {
to: [recipientEmail],
replyTo: data.email,
subject: `[Business Name] Contact form: ...`,
htmlBody: html,
},
}).catch(err => {
console.error('contact-email: failed to send', err)
})
Implementation (Next.js / OpenNext — default):
try {
await deliverEmail({
to: [recipientEmail],
replyTo: data.email,
subject: `New inquiry from ${firstName} ${lastName}`,
htmlBody: html,
})
} catch (err) {
console.error('Failed to send email:', err)
return { success: false, error: `Failed to send message (${err.message}). Please try again or contact us directly.` }
}
'use server' action that returns a state object — the user can retry. Vite SPAs return a JSON response and have no good way to surface a partial failure, so they fire-and-forget.
See `lib/email.js` for the `deliverEmail` function.
---
## 7. Contact Email Recipient (Configurable)
**What it does:** Admin notification email recipient is a `CONTACT_EMAIL` secret, not hardcoded.
**Why:** Client may want to route inquiries to a different mailbox (shared inbox, support alias) without code change or redeploy.
**Pattern:**
```ts
const recipientEmail = c.env.CONTACT_EMAIL ?? 'default@example.com'
To change recipient:
echo "mediation@example.com" | npx wrangler pages secret put CONTACT_EMAIL
No redeploy needed — available immediately on next request.
8. Testing¶
What it does: Unit tests verify form validation, Turnstile verification, DB logging, and email send.
Test file: test/routes/contact.test.js
Mock setup:
- Mock globalThis.fetch to return Turnstile success + Resend success
- Use in-memory D1 stub (see test/d1-mem.js) with contact_submissions table defined
- Verify submission was logged to DB and response includes ID
Test cases:
1. Happy path: Turnstile passes, submission logged, email sent, returns 201 with id
2. Turnstile fails: Validation rejects token, returns 400 turnstile_failed
3. Missing fields: Validation rejects incomplete form, returns 400 validation_failed
Pattern:
it('happy path: Turnstile passes, submission logged to DB', async () => {
const db = await getD1()
globalThis.fetch = mockFetch({ turnstile: success, resend: success })
const res = await app.fetch(
new Request('http://x/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, lastName, email, message, services, turnstileToken }),
}),
mockEnv({ DB: db }),
)
expect(res.status).toBe(201)
const json = await res.json()
expect(json.id).toBeDefined()
const row = await db.prepare('SELECT * FROM contact_submissions WHERE id = ?').bind(json.id).first()
expect(row).toBeDefined()
})
9. Environment Variables & Secrets¶
Frontend — Next.js / OpenNext (default since 2026-06):
- Public build-time vars in committed .env.production at the repo root (NOT gitignored):
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADku...
NEXT_PUBLIC_SITE_URL=https://example.com
db-and-deploy-checklist.md §6, NEXT_PUBLIC_* is inlined into the JS bundle at build time. The build reads .env.production directly.
Frontend — Vite (alternative):
VITE_TURNSTILE_SITE_KEY=0x4AAAAAADku...
VITE_API_BASE_URL=https://example.com # or http://localhost:3000 for dev
Backend (Cloudflare Worker secrets, via wrangler secret put):
wrangler secret put TURNSTILE_SECRET_KEY
wrangler secret put RESEND_API_KEY
wrangler secret put CONTACT_EMAIL
Backend (vars in wrangler.jsonc — non-secret):
"vars": {
"RESEND_FROM_EMAIL": "noreply@example.com",
"RESEND_FROM_NAME": "Business Name"
}
10. Deployment¶
What it does: Build frontend + API, deploy to Cloudflare.
Default — Next.js / OpenNext on Cloudflare Workers:
npm run build:cf # opennextjs-cloudflare build
npx wrangler deploy # or push to main with Workers Builds (§db-and-deploy §9)
db-and-deploy-checklist.md §5 and §15 for why this is the default.
Alternative — Vite SPA + Cloudflare Pages with Hono on Pages Functions:
npm run build
npx wrangler pages deploy dist --project-name=<project-name>
Verify:
1. Check deployed URL in terminal output (or the Workers Builds PR comment)
2. Visit /contact form
3. Fill form, complete Turnstile verification, submit
4. Expect success page with submission ID
5. Check email inbox for admin notification
6. Query D1 to verify submission logged: wrangler d1 execute <db-name> --remote "SELECT * FROM contact_submissions ORDER BY created_at DESC LIMIT 1"
11. i18n (Multi-language) - Optional¶
What it does: Contact form supports multiple languages (EN / KO).
Pattern: - Store UI strings in translation object keyed by language - Language toggle button in header - Form submits selected language with submission - Admin email references language for context
Implementation:
const translations = {
en: { sendMessage: "Send Us A Message", ... },
ko: { sendMessage: "메시지 보내기", ... },
}
const [lang, setLang] = useState<'en' | 'ko'>('en')
const t = translations[lang]
// ...
await api("/api/contact", {
// ... form fields ...
language: lang,
})
Not required for basic implementation — include only if client needs it.
Quick Checklist — "Is contact form done?"¶
- [ ] Zod schema defined with all required fields
- [ ] React form component at
/contactwith Turnstile widget - [ ] Turnstile site key matches dashboard and is in the build-time public var —
NEXT_PUBLIC_TURNSTILE_SITE_KEYin committed.env.production(Next.js/OpenNext) orVITE_TURNSTILE_SITE_KEYin.env(Vite). Never setNEXT_PUBLIC_*viawrangler secret put— see §5 gotcha anddb-and-deploy-checklist.md §6 - [ ]
contact_submissionstable created and migrated to remote D1 (Next.js + D1) or to Turso (Vite + Drizzle path) - [ ] Backend endpoint validates, verifies Turnstile via shared
lib/turnstile.tshelper, inserts to DB, sends email, returns 200/201 + id - [ ] Email recipient uses
CONTACT_EMAILsecret with fallback - [ ] Email subject includes
[Business Name]prefix - [ ] Email reply-to set to submitter's email
- [ ] Email send is fire-and-forget (doesn't block response)
- [ ] All secrets set:
TURNSTILE_SECRET_KEY,RESEND_API_KEY,CONTACT_EMAIL - [ ] Turnstile widget reset on any server action error (token is single-use server-side; see §2a)
- [ ] Unit tests verify schema, Turnstile, DB logging, email send
- [ ] All tests pass (
npm test) - [ ] Built and deployed to Cloudflare Worker (OpenNext) or Cloudflare Pages (Vite + Hono) — see §10
- [ ] Manual test: submit form, verify success page, check email, query D1
12. Common Pitfalls & Debugging (Lessons from bonbon_dino_web)¶
This section documents real bugs encountered during implementation to help avoid the same mistakes in future projects.
12a. Frontend Payload Shape Mismatch (CRITICAL)¶
The Bug: Contact form submitted { name, language } but the schema required { firstName, lastName, services }.
Why it happened: Frontend was combining first + last name into a single field and was sending language which the schema doesn't expect.
Result: Server returned 400 validation_failed immediately.
How to prevent:
- Write the Zod schema first in lib/schemas.js
- Document the exact field names (case-sensitive)
- Update the frontend handleSubmit to match exactly
// WRONG: ❌
await api("/api/contact", {
body: JSON.stringify({
name: `${firstName} ${lastName}`, // schema wants firstName, lastName separate
language: lang, // schema has no language field
turnstileToken,
}),
})
// CORRECT: ✅
await api("/api/contact", {
body: JSON.stringify({
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phone: formData.phone || undefined,
message: formData.message,
services: formData.services, // array, not joined string
turnstileToken,
}),
})
it('sends correct payload shape to /api/contact', async () => {
const payload = {
firstName, lastName, email, phone, message, services, turnstileToken,
}
const parsed = ContactSchema.safeParse(payload)
expect(parsed.success).toBe(true) // catches schema mismatches
})
12b. Turnstile Sitekey Typo (CRITICAL)¶
The Bug: .env.local had 0x4AAAAAADku640ktMfJUZP4 (digit zero) but Cloudflare dashboard showed 0x4AAAAAADku64OKtMfJUZP4 (capital O).
Why it happened: The characters look identical in monospace fonts. Easy copy-paste mistake.
Result: Widget would render in browser, but Turnstile siteverify would always fail with invalid-input-secret or similar because Cloudflare's records are keyed to the canonical sitekey.
How to prevent:
- After creating a Turnstile widget in Cloudflare dashboard, copy the sitekey directly from the dashboard, don't type it
- Verify keys in CI: add a build check that compares the built bundle's sitekey to what curl returns from the Cloudflare API
VITE_SITEKEY=$(grep -o "0x[A-Za-z0-9_]*" dist/assets/index*.js)
API_SITEKEY=$(curl -s "https://api.cloudflare.com/.../widgets" | jq -r '.result[0].sitekey')
if [ "$VITE_SITEKEY" != "$API_SITEKEY" ]; then
echo "FAIL: Built sitekey mismatch. Built: $VITE_SITEKEY, API: $API_SITEKEY"
exit 1
fi
12c. Vite define Override Breaks .env.local Loading (SUBTLE)¶
The Bug: Added a define block to vite.config.ts thinking it would inject the env var:
define: {
"import.meta.env.VITE_TURNSTILE_SITE_KEY": JSON.stringify(process.env.VITE_TURNSTILE_SITE_KEY),
}
Why it happened: Tried to explicitly "ensure" the env var was available, not realizing Vite loads .env.local automatically.
Result: process.env.VITE_TURNSTILE_SITE_KEY was undefined at build time (not in the Node process environment), so JSON.stringify(undefined) became the string "undefined" in the bundle. Frontend got a falsy string and the widget didn't load.
How to prevent:
- Never use define to inject import.meta.env.VITE_* variables — Vite does this automatically
- Vite loads variables from:
1. .env (all environments)
2. .env.local (local only, gitignored)
3. .env.[NODE_ENV] (e.g., .env.production)
These are loaded automatically. Don't try to inject them via define.
- If a VITE_* variable isn't available, the issue is one of these:
- File not in the right place (must be project root, not subdirs)
- Variable name doesn't start with VITE_ (Vite only exposes vars with this prefix)
- Build ran in a CI environment where .env.local doesn't exist (expected; add the var to CI secrets instead)
12d. Pre-existing D1 Table with Wrong Schema (DATA ISSUE)¶
The Bug: Migration 0003 used CREATE TABLE IF NOT EXISTS contact_submissions (...), but a table with that name already existed in production D1 with a different schema (columns: name, email, message, language, ip_hash instead of first_name, last_name, phone, services).
Why it happened: The table was pre-created manually or by an earlier migration before this checklist existed.
Result: The IF NOT EXISTS condition silently skipped the migration. Form inserts tried to write first_name, last_name, phone, services columns that didn't exist. SQL errors at runtime → 500 submission_failed.
How to prevent: - Before creating a migration, check if the table already exists in production:
wrangler d1 execute [DB_NAME] --remote --command "SELECT sql FROM sqlite_master WHERE name='contact_submissions';"
DROP TABLE IF EXISTS contact_submissions;
CREATE TABLE contact_submissions (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
message TEXT NOT NULL,
services TEXT,
language TEXT NOT NULL DEFAULT 'en',
created_at TEXT NOT NULL
);
ALTER TABLE to add missing columns instead.
- Always verify schema match post-migration:
wrangler d1 execute [DB_NAME] --remote --command "PRAGMA table_info(contact_submissions);"
12e. Resend Domain Not Verified (PRODUCTION BLOCKER)¶
The Bug: Tried to send emails from noreply@bonbondino.com but Resend API returned 403:
{
"statusCode": 403,
"message": "The bonbondino.com domain is not verified. Please, add and verify your domain on https://resend.com/domains",
"name": "validation_error"
}
Why it happened: Assumed the domain would "just work" — didn't realize Resend requires DNS verification before you can send from a custom domain.
Result: All contact form emails silently failed. Fire-and-forget error logging prevented visibility — the user didn't know emails weren't being delivered until manual testing.
How to prevent: - Before deploying email code, verify the domain in Resend: 1. Go to https://resend.com/domains 2. Click "Add Domain" → enter your domain 3. Resend shows DNS records (SPF, DKIM, Return-Path CNAME) 4. Add them to your domain registrar (Cloudflare in this case) 5. Click "Verify" in Resend dashboard (usually takes <5 minutes) - Add to the checklist §9 "Deployment": "Verify email domain in Resend dashboard before sending production emails." - Test sending before deploying:
curl -X POST https://api.resend.com/emails \
-H "Authorization: Bearer $RESEND_API_KEY" \
-d '{
"from": "noreply@yourdomain.com",
"to": "admin@yourdomain.com",
"subject": "Test",
"html": "<p>Test email</p>"
}'
12f. Resend onboarding@resend.dev Sandbox Limitation (EMAIL DELIVERY TRAP)¶
The Bug: Switched to onboarding@resend.dev as a temporary workaround (until domain verified), assuming it would deliver to any address. It doesn't.
Why it happened: Misunderstood the sandbox nature of onboarding@resend.dev. Thought it was a universal fallback.
Result: Emails from onboarding@resend.dev only deliver to the Resend account's registered email address. Any other recipient (like bonbondino23@gmail.com) is silently dropped. No error, no bounce — just silent failure.
How to prevent:
- From the Resend skill mistake #11: "The onboarding@resend.dev address is a sandbox — it can only deliver to your Resend account email, not to arbitrary addresses."
- Never use onboarding@resend.dev for production email where the recipient is not the Resend account owner.
- Always verify your own domain first, then use noreply@yourdomain.com.
- If you must test before domain verification, send test emails to the Resend account email only, or use test addresses like delivered@resend.dev (which simulates success).
12g. Silent Fire-and-Forget Email Failures Hide Issues¶
The Bug: Email send was fire-and-forget (doesn't block form submission). When Resend failed, the error was logged but the user saw success anyway.
Why it happened: Following email-checklist.md §3, email send never blocks the response. This is correct for production (don't make users wait for SMTP), but it hides debugging issues if the email provider fails.
Result: Form appeared to work (201 success response), but emails never arrived. Took manual testing to discover the problem.
How to prevent: - In development (local env), add a flag to make email failures block the response for debugging:
const shouldBlockOnEmailFailure = c.env.ENVIRONMENT === 'development';
const emailPromise = deliverEmail({...}).catch(err => {
const message = `Email send failed: ${err.message}`;
console.error(message);
if (shouldBlockOnEmailFailure) {
throw new ApiError(500, 'email_failed', message);
}
});
if (shouldBlockOnEmailFailure) {
await emailPromise; // block in dev
}
// in prod, fire-and-forget naturally
app.post('/api/contact/test-email', async (c) => {
const result = await deliverEmail({...});
return c.json(result, result.error ? 500 : 200);
})
/api/contact/test-email to debug delivery issues without filling out the form."
How to extend this file¶
- This is a living document. Rewrite existing sections in place when they go stale (e.g., when the default deploy mechanism changes — see how §5/§9/§10 were rewritten for OpenNext in 2026-06). The old rule was append-only; it has been retired — outdated content is worse than no content.
- When adding new content, insert it as a new numbered section before the Quick Checklist. Match the house style: numbered section, What it does / Why / Pattern, code snippet.
- When existing sections cover something, reference them (e.g., "Follow email-checklist.md§X").
- When a bug or pitfall is discovered in a new project, add it to
debugging-lessons.md(with the right category) before closing the session — this file is for patterns, not bug logs. - Keep it practical — focus on what must be implemented, not theory.