Fifteen Transactional Emails In One Claude Session

Brett Ridenour Brett Ridenour · June 1, 2026

For about two weeks, every customer-facing email Freebo sent looked the same. The subject line was real. The recipient was real. The body said, in plain serif text on a white page:

(set in Novu Dashboard)

That’s it. That’s the email. Booking confirmation, payment received, refund issued, two-hour reminder — all of them, the same six-word koan. We had wired Novu, the notification platform, end-to-end. We had fifteen distinct workflows triggering on the right business events. We just hadn’t built any actual email content.

This morning I woke up to fifteen real branded transactional emails sitting in my inbox, sent in a single overnight test run. Here’s how that happened in one Claude Code session.

The shape of the problem

Freebo is a booking platform for tour operators. The financial side is double-entry through Formance. The notification side runs through Novu — every state change (reservation created, payment captured, waiver requested, refund issued) fires a workflow, which Novu turns into an email, SMS, and in-app notification.

The fifteen workflows we needed were straightforward to list out:

ReservationConfirmed   PaymentReceived    WaiverRequested    Reminder24h
ReservationPending     PaymentFailed      WaiverSigned       Reminder3h
ReservationCancelled   RefundIssued                          Reminder1h
ReservationModified                                          PaymentReminder
ReservationRebooked                                          AccountCreated

Each one had its own triggering logic already in the API. What none of them had was a body. The Novu docs encourage you to author email content in their cloud dashboard, in a WYSIWYG editor. That’s fine for a marketing team. It’s a disaster for an engineer who wants the templates in git, type-checked, conditionally rendered, and testable.

So the goal: render email content in code, on our side, and hand Novu finished HTML.

The plan I handed Claude

I’m a big believer in writing the plan as a separate artifact before any code gets generated. The plan for this one lived at docs/plans/feature-notification-email-templates.md. Eight tasks, one new package, zero new HTTP endpoints, zero database migrations. The whole feature was a backend-only carve-out.

The key architectural call was making a new package, @freebo/emails, that the API depends on but the frontend never sees:

The package skeleton — components, conditional sections, and 15 templates

Three layers, on purpose:

  1. Design system primitivesLayout, Header, Footer, Button, Panel, Divider. Branded shell, used by every template.
  2. Conditional content componentsReservationSummary, QuoteSummary, WaiverCallout, BalanceDue, AddOnsList. Each one renders nothing if its data is absent. That last property is the whole game.
  3. Fifteen templates — each one a thin composition of the layers below it.

The one trick: React in a backend package

React Email gives you JSX components that compile to inlined-CSS HTML via @react-email/render. The catch is that render() is async in the current version, and pulls in react-dom/server. That means the API has to await it inside the Novu workflow step, and the package has to ship a tsconfig that emits JS the API can actually import:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "commonjs",
    "target": "es2022",
    "declaration": true,
    "outDir": "dist",
    "skipLibCheck": true
  }
}

The Novu workflow step then becomes a one-liner:

await step.email("send-email", async () => ({
  subject: "Your reservation is confirmed",
  body: await renderEmail("ReservationConfirmed", payload),
}), {
  skip: () => payload.email_enabled === false,
});

That skip is the global kill-switch we’d already wired into notification_settings. If a location flips it off, every email step short-circuits. SMS and in-app keep going.

The thing that has to never happen

A broken email template can’t take down a reservation.

That sounds obvious. It’s also the first thing that breaks if you’re not careful. The trigger service runs inside the request that creates the reservation. If renderEmail throws because a payload field is undefined, and you don’t catch it, you’ve just made your booking endpoint fail because of a CSS bug.

So the contract is: render errors fall back to a minimal text body. The reservation always finishes. The customer always gets some email, even if it’s plain.

A broken template should degrade the email, not the booking.

— Rule of thumb for transactional code

The overnight test

The last task in the plan was a script — scripts/novu-test-trigger.ts — that fires one of every workflow at a real inbox using @novu/api. I pointed it at my own address, hit go, and went to bed.

This morning, fifteen messages from noreply@freebo.ai:

1
Reservation Confirmed
Full quote summary, waiver CTA, balance-due block
2
Payment Received
$2,700 charged, receipt formatted
3
Refund Issued
$2,700 back, with reason and timeline
4
Reminder 2h
Stripped to the essentials — time, location, what to bring
5
And eleven more
Cancellation, reschedule, waiver, payment reminder, account created...

Every one of them branded. Every one of them with the right conditional blocks present or absent based on the test payload. No “(set in Novu Dashboard)” anywhere on Earth.

The takeaway

The shape of this work — a plan doc, a fenced file-touch list, a no-shortcuts rules block, an explicit out-of-scope list — is becoming my default for anything I want to ship in one session. It’s not magic. It’s just that an agent can hold an entire eight-task backend feature in its head if you give it the boundaries up front, and it cannot if you don’t.

The fifteen emails were the easy part. The plan that made them possible took ninety minutes of human thinking the day before. That ratio keeps showing up.