Learn Build a Real SaaS with Claude Code Transactional Email with Resend

Transactional Email with Resend

Intermediate 🕐 26 min Lesson 14 of 25
What you'll learn
  • Create a free Resend account, generate an API key, and choose between the sandbox sender and a verified domain
  • Build escaped, plain-function HTML email templates that return subject/html/text
  • Fire a best-effort negative-review alert without letting an email failure fail the ingestion request
  • Read owner email addresses from Supabase auth.users via the Admin API instead of a Drizzle table
  • Secure a cron-triggered route with a bearer-token CRON_SECRET instead of user authentication
  • Configure a weekly schedule for the digest route via vercel.json's crons entry

Get a Resend Account Before Writing Any Code

You need your own free account at resend.com -- their free tier covers a few thousand emails a month, which is far more than this tutorial needs. After signing up:

  • Create an API key under the dashboard's API Keys section and put it in RESEND_API_KEY in your .env file. Never commit this key or hardcode it in source.
  • For sending real test emails without owning a domain yet, Resend provides a sandbox sender address, onboarding@resend.dev, that only delivers to a small set of Resend-controlled test recipient addresses (such as delivered@resend.dev) -- enough to confirm your integration works end to end.
  • For sending to real recipients (your own email address, a teammate's, anyone outside Resend's test addresses), you need a verified sending domain: add a domain under resend.com/domains and add the DNS records Resend gives you (SPF/DKIM) at your domain registrar. This is required before RESEND_FROM_EMAIL can use an address on that domain.

This reference build was developed without a live Resend account in that environment -- the code follows Resend's documented SDK usage but, per the project README, has not sent a real email there. Treat your own account and verified domain (or sandbox sender) as the way to actually exercise this lesson's code.

The Client: lib/resend.ts

import { Resend } from "resend";

const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
  throw new Error("RESEND_API_KEY is not set");
}

export const resend = new Resend(apiKey);
export const RESEND_FROM_EMAIL = process.env.RESEND_FROM_EMAIL ?? "alerts@example.com";
export const NEGATIVE_REVIEW_THRESHOLD = 2;

Same shared-client pattern as lib/stripe.ts from lesson 12: construct once, fail fast at import time if the API key is missing, export the constants other modules need.

Plain-Function HTML Templates: lib/emails/templates.ts

This app deliberately skips a JSX email framework (like react-email) to keep the dependency footprint small -- templates are plain functions returning subject/html/text strings, built with a shared layout() wrapper and an escapeHtml() helper applied to every piece of user-generated content (review author names, review bodies) before it goes into the HTML:

function escapeHtml(value: string) {
  return value
    .replace(/&/g, "&")
    .replace(//g, ">")
    .replace(/"/g, """);
}

That escaping matters specifically because review bodies come from third-party platforms (or, in this build, the seed script standing in for them) -- they are untrusted input, and skipping escaping would let a malicious or malformed review body break the email's HTML structure.

Every build*Email() function returns all three of subject, html, and text -- the plain-text version is not an afterthought; it is what renders in clients that block HTML email or in plain-text previews, and it is required by Resend's send call alongside the HTML body.

Negative-Review Alerts: lib/emails/send.ts

export async function sendNegativeReviewAlertIfNeeded(review: Review) {
  if (review.rating > NEGATIVE_REVIEW_THRESHOLD) return;

  const [business] = await db.select().from(businesses).where(eq(businesses.id, review.businessId)).limit(1);
  if (!business) return;

  const ownerEmail = await getOwnerEmail(business.ownerId);
  if (!ownerEmail) return;

  const { subject, html, text } = buildNegativeReviewAlertEmail({
    businessName: business.name,
    review,
    dashboardUrl: appUrl(),
  });

  await resend.emails.send({ from: RESEND_FROM_EMAIL, to: ownerEmail, subject, html, text });
}

This is called from the ingestion route (lesson 8) right after a review is inserted, and it is deliberately not awaited inline with the response -- the ingest route fires it and catches any error separately, because a Resend outage or bad address must never fail the underlying review ingestion:

sendNegativeReviewAlertIfNeeded(inserted).catch((err) => {
  console.error("Failed to send negative review alert:", err);
});

Notice getOwnerEmail does not read from a Drizzle table -- owner email addresses live in Supabase's own auth.users table, which Drizzle has no access to, so this app reads them through Supabase's service-role Admin API instead (lib/supabase/admin.ts), the same boundary that keeps auth data and application data cleanly separated.

The Weekly Digest: app/api/cron/weekly-digest/route.ts

One business at a time, this route gathers the last 7 days of review activity and sends a digest:

export async function GET(request: NextRequest) {
  const cronSecret = process.env.CRON_SECRET;
  if (cronSecret) {
    const authHeader = request.headers.get("authorization");
    if (authHeader !== `Bearer ${cronSecret}`) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }
  }

  const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  const results = await sendWeeklyDigests(since);

  return NextResponse.json({ sent: results.filter((r) => r.sent).length, results });
}

This route authenticates with CRON_SECRET, not user auth, because the caller is Vercel's scheduler, not a signed-in browser. Vercel Cron automatically sends Authorization: Bearer $CRON_SECRET when that env var is set on the project -- you generate any random string for CRON_SECRET yourself and Vercel handles attaching it to every scheduled invocation.

The schedule itself lives in vercel.json, not in code:

{
  "crons": [
    { "path": "/api/cron/weekly-digest", "schedule": "0 13 * * 1" }
  ]
}

That cron expression runs every Monday at 13:00 UTC. Vercel picks up vercel.json's crons entry automatically on deploy -- no separate dashboard configuration needed beyond CRON_SECRET being set as an environment variable.

Why send.ts Sends, Not Templates

The separation between lib/emails/templates.ts (pure functions, no I/O, return strings) and lib/emails/send.ts (database reads, the actual Resend call) means the templates can be tested or previewed without sending anything or hitting a database, while the send functions stay focused on orchestration: fetch the data, build the content, send it, log failures without throwing.

Ask Claude Code: "Add a third email -- a welcome email sent when a business first signs up. Follow the same template/send separation as buildWeeklyDigestEmail and sendWeeklyDigests."

Billing and email are now both wired end to end: a business can upgrade through Stripe, get billed correctly even under real-world webhook delivery quirks, and get notified by email both immediately (negative reviews) and on a schedule (weekly digest). Lesson 15 moves on to polishing the UX around everything built so far.

Key takeaways
  • Resend's free tier and sandbox sender (onboarding@resend.dev) are enough to test an integration end to end before verifying a real domain
  • A verified sending domain with SPF/DKIM DNS records is required before sending to recipients outside Resend's test addresses
  • User-generated content (review bodies, author names) must be HTML-escaped before insertion into an email template
  • Always return both an html and a text version from an email template function -- text is what renders when HTML is blocked or stripped
  • Best-effort side effects like alert emails should be fired and caught separately, never awaited inline with the response they must not block
  • Vercel Cron sends Authorization: Bearer $CRON_SECRET automatically once that env var is set -- no extra wiring needed beyond checking it in the route