Write a React Email template
New .tsx template, new Mailer method, one line in each adapter, one projector to fire it.
Walking example: an invite-expired email that fires when a WorkspaceInvite passes its TTL and the system garbage-collects it. Same five steps for any new transactional email.
1. Design the template
Templates live in apps/api/src/emails/ as .tsx files. Start by copying an existing one — workspace-invite-email.tsx is the shortest — and swap the content. Shared styles live in orbit-email-styles.ts, so every email looks like it came from the same brand.
apps/api/src/emails/invite-expired-email.tsx
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text,} from "@react-email/components";import { orbitEmailStyles } from "./orbit-email-styles.ts";
export interface InviteExpiredEmailProps { workspaceName: string; resendInviteUrl: string;}
export function InviteExpiredEmail({ workspaceName, resendInviteUrl,}: InviteExpiredEmailProps) { return ( <Html lang="en"> <Head /> <Preview>Your invitation to {workspaceName} expired</Preview> <Body style={orbitEmailStyles.page}> <Container style={orbitEmailStyles.container}> <Section style={orbitEmailStyles.card}> <Text style={orbitEmailStyles.eyebrow}>Orbit</Text> <Heading as="h1">Your invitation expired</Heading> <Text> Your invite to <strong>{workspaceName}</strong> expired before you used it. Ask whoever invited you for a fresh link. </Text> <Button href={resendInviteUrl}>Request a new invite</Button> </Section> </Container> </Body> </Html> );}React Email renders to HTML + plain-text at build time. The <Preview> element is the inbox-preview line; it doesn't render in the body. Keep it informative and shorter than 100 characters.
2. Extend the Mailer port
Open apps/api/src/infrastructure/mailer.ts and add the new method signature plus its input type:
export interface InviteExpiredEmail { to: string; workspaceName: string; resendInviteUrl: string;}
export interface Mailer { sendMagicLink(email: MagicLinkEmail): Promise<void>; sendInvite(email: InviteEmail): Promise<void>; sendChangeEmailVerification(email: ChangeEmailVerificationEmail): Promise<void>; sendChangeEmailNotice(email: ChangeEmailNoticeEmail): Promise<void>; sendAccountDeletionVerification(email: AccountDeletionVerificationEmail): Promise<void>; sendEmailVerification(email: EmailVerificationEmail): Promise<void>; sendInviteExpired(email: InviteExpiredEmail): Promise<void>; // ← new}3. Implement it on both adapters
Both implementations need the new method. The ConsoleMailer is a two-liner:
apps/api/src/infrastructure/mailer.ts (ConsoleMailer)
async sendInviteExpired(email: InviteExpiredEmail): Promise<void> { console.log(`[console-mailer] invite-expired -> ${email.to} (${email.workspaceName})`);}The ResendMailer renders the template and ships it:
apps/api/src/infrastructure/resend-mailer.tsx
async sendInviteExpired(email: InviteExpiredEmail): Promise<void> { const { html, text } = await renderEmail(InviteExpiredEmail, { workspaceName: email.workspaceName, resendInviteUrl: email.resendInviteUrl, }); await this.client.emails.send({ from: this.from, to: email.to, subject: `Your invitation to ${email.workspaceName} expired`, html, text, });}TypeScript enforces this — once you've extended the Mailer interface, both adapters fail to compile until they implement the new method. Don't // @ts-ignore it; the one you forget will be the one you need.
4. Fire it from a projector
Assuming the cleanup job emits a WorkspaceInviteExpired domain event, write a projector that subscribes to it and calls the mailer. Projectors live alongside services in application/:
apps/api/src/workspaces/application/invite-expired-mailer.projector.ts
import type { EventBus } from "@/kernel/events.ts";import type { UnitOfWork } from "@/kernel/uow.ts";import type { Mailer } from "@/infrastructure/mailer.ts";import type { WorkspaceInviteExpired } from "../domain/invite.ts";
export class InviteExpiredMailerProjector { constructor( private readonly bus: EventBus, private readonly uow: UnitOfWork, private readonly mailer: Mailer, private readonly webOrigin: string, ) {}
start(): void { this.bus.subscribe<WorkspaceInviteExpired>( "workspaces.invite.expired", async (event) => { const data = await this.uow.read(async (tx) => { const invite = await tx.workspaceInvites.findById(event.inviteId); if (!invite) return null; const workspace = await tx.workspaces.findById(invite.workspaceId); if (!workspace) return null; return { email: invite.email, workspaceName: workspace.name }; }); if (!data) return;
await this.mailer.sendInviteExpired({ to: data.email, workspaceName: data.workspaceName, resendInviteUrl: `${this.webOrigin}/invites/request?workspace=${encodeURIComponent(data.workspaceName)}`, }); }, ); }}5. Wire it in composition
Projectors only matter if something boots them. Add one line in composition.ts right next to the realtime publisher:
const inviteExpiredProjector = new InviteExpiredMailerProjector( bus, uow, mailer, config.webOrigin,);inviteExpiredProjector.start();Projectors are subscribers — forgetting to call start() produces silent no-ops. If a new email isn't firing, check composition first.
Previewing during development
React Email has a preview server: npx react-email dev in apps/api. It renders every .tsx in src/emails/ in a web UI with live reload, so you can iterate on layout without sending real mail.
For inbox QA: set RESEND_SEND_IN_DEV=1 in your apps/api/.env, restart the API, and the ResendMailer replaces the ConsoleMailer in dev. Trigger the event (cause an invite to expire, or call the projector handler directly) and the email lands in the inbox you configured as RESEND_FROM.
Style notes
orbit-email-styles.ts centralizes typography, spacing, and button styling. Don't inline hex colors; reach for the shared object. Apple Mail, Gmail, and every enterprise client behave slightly differently — staying within the vocabulary already in use means you're on tested ground.