Bonbon Dino — Email Checklist¶
These are best practices and references, not hard requirements. They cover transactional email on a client site: admin notifications, password-reset links, and anything else the app sends programmatically. Patterns are drawn from past projects; adapt or skip based on what the project actually sends.
Default provider is Resend. The principles (lazy init, fire-and-forget, verified sending domain, configurable recipient) carry over to Cloudflare Email or any other provider.
Companion files¶
These siblings live next to this file. Follow the cross-references instead of duplicating:
contact-form-checklist.md— the contact form that triggers most notification emails (delivery, recipient, Resend bugs)admin-panel-checklist.md— password-reset flow that sends the reset linkdb-and-deploy-checklist.md— Resend domain verification, Wrangler secrets, production bring-up orderdebugging-lessons.md— Email category: real bugs (build-time init, "undefined" links, generic subjects)security-checklist.md— keep the Resend API key server-side; don't leak it to the bundle
1. Provider & Setup¶
- What it does: Sends transactional email via Resend.
- Why: Resend is simple, has a generous free tier, and works from both Node (Vercel) and Cloudflare Workers runtimes.
- How to implement:
RESEND_API_KEYis a secret (Wrangler secret on Cloudflare; neverNEXT_PUBLIC_*, never in the bundle — seesecurity-checklist.md §8).- Wrap all sending in one helper (
lib/email.ts,deliverEmail(...)) so addressing, error handling, and retries live in one place.
2. Lazy-Init the Email Client¶
- What it does: Constructs the Resend client only on first send, not at module load.
- Why:
new Resend(process.env.RESEND_API_KEY!)at the top of a module runs during the build, when the key isn't set — the build fails withResend is not defined/RESEND_API_KEY not set. - How to implement:
See the build-time bug in
let client: Resend | null = null; function getResend() { if (!client) client = new Resend(process.env.RESEND_API_KEY!); return client; }debugging-lessons.md(Deploy / "Resend is not defined").
3. Fire-and-Forget vs Blocking¶
- What it does: Decides whether a failed email send blocks the user's response.
- Why: Users shouldn't wait on SMTP, but a silently dropped email hides real failures during development.
- How to implement:
- Production: fire-and-forget. Send after the DB insert returns; log on failure, never block.
- Server-action forms (Next.js
'use server'): you can block and surface the error, because the action returns a state object the user can retry against — seecontact-form-checklist.md §6. - Dev: add a flag so failures throw, so you notice them:
const blockOnFail = process.env.ENVIRONMENT === "development"; const p = deliverEmail({ /* ... */ }).catch((err) => { console.error("email send failed:", err.message); if (blockOnFail) throw new ApiError(500, "email_failed", err.message); }); if (blockOnFail) await p; - The silent-failure trap is documented in
contact-form-checklist.md §12g.
4. Reply-To and Addressing¶
- What it does: Routes replies to the right human.
- Why: Admin should be able to hit "Reply" on a contact notification and reach the submitter directly.
- How to implement: set
replyToto the submitter's email; keepfromas the verified no-reply address.
5. Recipient Is a Configurable Secret¶
- What it does: The notification recipient is a secret (
CONTACT_EMAIL), not hardcoded. - Why: The client may want inquiries routed to a shared inbox or alias without a code change or redeploy.
- How to implement:
const recipientEmail = env.CONTACT_EMAIL ?? "default@example.com";No redeploy — live on the next request. Seeecho "inbox@example.com" | npx wrangler secret put CONTACT_EMAILcontact-form-checklist.md §7.
6. Subject Lines Carry the Site Name¶
- What it does: Every subject is prefixed with the business / site name.
- Why: One admin often manages several client sites from one inbox. "New question" tells them
nothing;
[<Business Name>] New inquiry: First Lastdoes. - How to implement: prefix in the shared helper, e.g.
subject: \[] Contact form: ${firstName} ${lastName}` . Bug reference: [debugging-lessons.md`](debugging-lessons.md) (Email / "subject too generic").
7. Verify the Sending Domain Before Production¶
- What it does: Confirms Resend can send from the custom domain before go-live.
- Why: Sending from an unverified domain returns 403 and all mail silently fails — fire-and-forget logging hides it until someone tests by hand.
- How to implement:
- In the Resend dashboard → Domains → add the domain → add the SPF/DKIM/Return-Path DNS records at the registrar (Cloudflare) → Verify (<5 min).
- Smoke-test before deploy:
A 403 means the domain isn't verified yet.
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</p>"}' onboarding@resend.devis a sandbox — it only delivers to the Resend account's own email, not arbitrary recipients. Never ship it to production. Full write-ups incontact-form-checklist.md §12e/§12fanddb-and-deploy-checklist.md §13.
8. Password Reset & Link-Bearing Emails¶
- What it does: Sends a single-use, expiring reset link.
- Why: Any email containing a
${DOMAIN}/...link breaks ifDOMAINis unset — the link becomesundefined/admin/reset?..., and the token row is already written, leaving an orphan. - How to implement:
- Validate
DOMAINbefore the DB insert. No domain → return 500 before writing the token; no row, no email, no orphan. - Token: random 32-byte hex; store only the hash; expire in 30 min; mark
used_aton first use; invalidate other active tokens after a successful reset. - Link to the exact destination, not a dashboard the user then has to search.
- Full flow in
admin-panel-checklist.md §5; bugs indebugging-lessons.md(Email / "undefined link", "link goes to dashboard").
9. From-Address Flip: Dev vs Prod¶
- What it does: Uses a sandbox sender in dev and the verified domain sender in prod.
- Why: Dev usually runs before the domain is verified; prod must use the verified address or mail silently drops (see §7).
- How to implement: drive the
fromaddress from an env var, not a literal:Flip it as part of the production bring-up — seeconst from = env.MAIL_FROM ?? "onboarding@resend.dev"; // prod sets MAIL_FROM=noreply@<domain>db-and-deploy-checklist.md §13.
10. Testing Email Delivery¶
- What it does: Lets you verify delivery without filling out the real form.
- Why: Fire-and-forget hides failures; you need a direct way to confirm mail actually lands.
- How to implement:
- Add a guarded test endpoint that forces one 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); }); - Use
delivered@resend.devto simulate a successful send before the real domain is verified. - Unit-test the send path with a mocked
fetch— seecontact-form-checklist.md §8.
Quick Checklist — "Is email done?"¶
- [ ]
RESEND_API_KEYis a secret, not in the bundle - [ ] Email client is lazy-initialized (no build-time construction)
- [ ] Production sends are fire-and-forget; dev surfaces failures
- [ ]
replyToset to the submitter;fromis the verified no-reply address - [ ] Recipient is a
CONTACT_EMAILsecret, not hardcoded - [ ] Subjects prefixed with the site/business name
- [ ] Sending domain verified in Resend before go-live (no
onboarding@resend.devin prod) - [ ]
DOMAINvalidated before any DB write that produces an emailed link - [ ]
fromaddress driven by env var; flipped to the verified domain in prod - [ ] A way to test delivery exists (test endpoint or mocked unit test)
How to extend this file¶
Living document — rewrite sections in place when they go stale (the append-only rule was
retired; outdated content is worse than none). Match the house style: numbered What it does / Why /
How to implement, code snippet where useful, <domain> / <Business Name> placeholders, no real
client names in section bodies (see RETROSPECTIVE.md Hard Rules). When the
lesson is a bug with a non-obvious root cause, it belongs in
debugging-lessons.md (Email category) — cross-link to it from here.