Skip to content

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:


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_KEY is a secret (Wrangler secret on Cloudflare; never NEXT_PUBLIC_*, never in the bundle — see security-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 with Resend is not defined / RESEND_API_KEY not set.
  • How to implement:
    let client: Resend | null = null;
    function getResend() {
      if (!client) client = new Resend(process.env.RESEND_API_KEY!);
      return client;
    }
    
    See the build-time bug in 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 — see contact-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 replyTo to the submitter's email; keep from as 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";
    
    echo "inbox@example.com" | npx wrangler secret put CONTACT_EMAIL
    
    No redeploy — live on the next request. See contact-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 Last does.
  • 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:
    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>"}'
    
    A 403 means the domain isn't verified yet.
  • onboarding@resend.dev is a sandbox — it only delivers to the Resend account's own email, not arbitrary recipients. Never ship it to production. Full write-ups in contact-form-checklist.md §12e/§12f and db-and-deploy-checklist.md §13.
  • What it does: Sends a single-use, expiring reset link.
  • Why: Any email containing a ${DOMAIN}/... link breaks if DOMAIN is unset — the link becomes undefined/admin/reset?..., and the token row is already written, leaving an orphan.
  • How to implement:
  • Validate DOMAIN before 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_at on 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 in debugging-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 from address from an env var, not a literal:
    const from = env.MAIL_FROM ?? "onboarding@resend.dev"; // prod sets MAIL_FROM=noreply@<domain>
    
    Flip it as part of the production bring-up — see 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.dev to simulate a successful send before the real domain is verified.
  • Unit-test the send path with a mocked fetch — see contact-form-checklist.md §8.

Quick Checklist — "Is email done?"

  • [ ] RESEND_API_KEY is a secret, not in the bundle
  • [ ] Email client is lazy-initialized (no build-time construction)
  • [ ] Production sends are fire-and-forget; dev surfaces failures
  • [ ] replyTo set to the submitter; from is the verified no-reply address
  • [ ] Recipient is a CONTACT_EMAIL secret, not hardcoded
  • [ ] Subjects prefixed with the site/business name
  • [ ] Sending domain verified in Resend before go-live (no onboarding@resend.dev in prod)
  • [ ] DOMAIN validated before any DB write that produces an emailed link
  • [ ] from address 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.