Skip to content

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)} &lt;${escapeHtml(data.email)}&gt;</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 DashboardTurnstileCreate 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) or onboarding@resend.dev during 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.` }
}
The Next.js path blocks on email failure and surfaces it to the user (vs. fire-and-forget) because the form is a '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
- Per 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)
Single deployable: one Worker that serves SSR pages, API routes, and static assets. Same-origin for the API. See 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 /contact with Turnstile widget
  • [ ] Turnstile site key matches dashboard and is in the build-time public varNEXT_PUBLIC_TURNSTILE_SITE_KEY in committed .env.production (Next.js/OpenNext) or VITE_TURNSTILE_SITE_KEY in .env (Vite). Never set NEXT_PUBLIC_* via wrangler secret put — see §5 gotcha and db-and-deploy-checklist.md §6
  • [ ] contact_submissions table created and migrated to remote D1 (Next.js + D1) or to Turso (Vite + Drizzle path)
  • [ ] Backend endpoint validates, verifies Turnstile via shared lib/turnstile.ts helper, inserts to DB, sends email, returns 200/201 + id
  • [ ] Email recipient uses CONTACT_EMAIL secret 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,
  }),
})
- Add a unit test that verifies the payload shape matches the schema:
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
- In the checklist item §5 "Turnstile Bot Protection", add: "Keys must match exactly — copy from dashboard, never retype."

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';"
- If it exists with a different schema, create a DROP + RECREATE migration (only safe if the table is empty or you don't care about the data):
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
);
- If the table has important data, use 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>"
  }'
If you get a 403, the domain isn't verified yet.

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
- Or, add a test endpoint that forces a test send and reports the result:
app.post('/api/contact/test-email', async (c) => {
  const result = await deliverEmail({...});
  return c.json(result, result.error ? 500 : 200);
})
- Document in the checklist: "Testing email: hit /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.