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:

Three layers, on purpose:
- Design system primitives —
Layout,Header,Footer,Button,Panel,Divider. Branded shell, used by every template. - Conditional content components —
ReservationSummary,QuoteSummary,WaiverCallout,BalanceDue,AddOnsList. Each one renders nothing if its data is absent. That last property is the whole game. - 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:
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.