Back to writing

Resend + Cloudflare Workers — production email engineering

The most underestimated dependency in a Stripe funnel is email. Resend + workerd + a KV soft-fail fallback covers three live flows (subscribe / forgot-password / email-verification).

After the payment funnel works, the next dependency most likely to break in an indie site is email. Stripe webhook failures nobody notices, users not receiving password resets, no confirmation after subscribing — these aren’t payment issues, they’re email issues.

Why Resend

Three candidates:

  • AWS SES — cheap ($0.10 / 1k), but cards, IAM, SNS, signature versions. Half a day to set up, one wrong parameter and you get a 403.
  • Mailgun — old, API still works, free tier 100/day is too small.
  • Resend — newer, modern API, React Email / template inspector / console logs all polished. Free tier 100/day + 3,000/month, enough for an indie site.

I picked Resend. Reason: for an indie maker, API experience is 10× more important than per-email cost. The money you’d save on SES in a year doesn’t cover a half-day of misconfiguration.

Domain verification (SPF + DKIM + DMARC)

Before sending, verify the from-domain or 99% of your mail lands in spam. Resend’s console asks for laowe.club and auto-generates three records:

  • SPF: TXT for send.resend.com
  • DKIM: CNAME for resend._domainkey
  • DMARC: TXT for _dmarc (start with p=none, watch for two weeks)

After DNS updates, wait 5-30 minutes — Resend’s “Verify” button goes green. Resend returns 422 for unverified from addresses — that’s a feature. It back-proves the API key, network, and domain all work.

Sending from a Worker

No SDK. Plain fetch against the REST endpoint. An SDK adds a dependency, its Node transport is heavier than workerd needs, and we only POST:

const RESEND_ENDPOINT = "https://api.resend.com/emails";

await fetch(RESEND_ENDPOINT, {
  method: "POST",
  headers: {
    authorization: `Bearer ${apiKey}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    from: "hello@laowe.club",
    to: [input.email],
    subject: "Thanks for subscribing",
    html: "<p>...</p>",
    text: "Thanks!...",
  }),
});

The nodejs_compat flag has to be on in wrangler.toml (workerd doesn’t have node:crypto etc. by default).

Two-email pattern

For subscribe / order / signup, send two:

  1. Admin notification — to me, telling me “someone just X’d.” Set reply_to to the user’s address so I can reply directly.
  2. User confirmation — to the user, welcome / order details / reset link.

Resend counts both as two sends; on the free 3,000/month tier that’s fine.

Soft-fail fallback (the critical bit)

When a Worker handles a Stripe webhook, an email failure must not throw a 5xx — Stripe retries forever, the bill explodes. Right pattern:

try {
  await sendOrderConfirmation(env, input);
} catch (error) {
  // failure lands in KV, founder recovers manually
  await env.ORDERS.put(`order:${eventId}-email-failed`, JSON.stringify({...}));
  console.error("[order-email] Resend failure:", error);
}
// 200 to Stripe regardless of email outcome
return new Response(null, { status: 200 });

KV is the last line. The ORDERS namespace stores both the order itself and the email-failed flag — the founder can sweep KV later to recover customers whose mail was lost.

User privacy (don’t log plaintext emails)

console.log("[subscribe] captured: ${email}") looks harmless, but:

  • Cloudflare worker logs feed into third-party log systems (Datadog / Logflare / your own)
  • Email is PII, compliance issue (GDPR / CCPA)
  • If the log platform gets popped, every subscriber’s address is leaked

Fix: node:crypto sha256[:12] fingerprint. Log only fp=01a57457d588 locale=zh — enough to dedupe, nothing leaks.

import { createHash } from "node:crypto";
const fp = createHash("sha256").update(email.toLowerCase()).digest("hex").slice(0, 12);
console.log(`[subscribe] captured: fp=${fp} locale=${locale}`);

Debug lessons (0 → production)

  1. Send a test from the Resend console first — confirms from / API key / network. Failures are explicit (404 / 401 / 422), fix one at a time.
  2. Trigger with smoke-verify@example.com — Resend returns 422 (example.com not allowed), but that back-proves the API call, auth, and JSON shape are correct.
  3. Before going live, walk through the full flow with a real email (subscribe / forgot / verify). Confirm it lands in the inbox, not spam.
  4. Watch the Resend console / Emails page — send status, spam complaints, bounces all live there. 80% of production issues are one look-up.

After 30 days, all three flows (subscribe / forgot-password / email-verification) verified in production. Zero missed, zero mis-sent.

Closing

Email looks like “infrastructure” in an indie site, but it’s the last mile of the payment funnel. Resend + workerd fetch + KV soft-fail + fingerprinted logs turns “user complains, then you find out” into “console shows it, KV auto-catches it.”

If you’re building an indie site, half a day on this buys you months of peace.

— Lao Wei

键盘快捷键

先按 g 再按下面的键。? 打开这个面板。