Adding an Email
The four-step walkthrough for a new email type — extend the contract, add the template, wire the switch, send it, plus the gotchas.
Say a guardian should be emailed when their child's term report is ready. The discriminated union makes this almost mechanical: add the variant, and TypeScript drags you through every place that has to change. Four steps, then the traps worth knowing.
Step 1: Extend the contract
Add the enum value, a payload interface, and — the part people forget — the new variant in the EmailJobPayload union. All three live in @app/shared:
export enum EmailType {
// ...existing values...
TERM_REPORT_READY = 'TERM_REPORT_READY', // new
}
interface TermReportReadyEmailPayload extends BaseEmailPayload {
type: EmailType.TERM_REPORT_READY;
data: { firstName: string; termName: string; reportUrl: string };
}
export type EmailJobPayload =
| /* ...existing variants... */
| TermReportReadyEmailPayload; // add to the unionBaseEmailPayload already covers to, subject, and the optional campusId, so your interface only declares what's unique. Until the variant is in the union, the producer can't pass it and the processor can't see it — the enum and interface alone do nothing.
Step 2: Add the template
Copy an existing template — otp.tsx is the simplest fully-built one — rename the component and its props interface, and design the body inside <EmailLayout>. Give it a PreviewProps static so you can preview it, then iterate with pnpm email:dev until it looks right. The Templates page covers that loop in full.
export const TermReportReadyEmail = ({ firstName, termName, reportUrl }: Props) => (
<EmailLayout preview={`${termName} report ready`} title="Your term report is ready">
<Text>Hi {firstName}, your {termName} report is now available.</Text>
{/* a button linking to reportUrl, etc. */}
</EmailLayout>
);
TermReportReadyEmail.PreviewProps = { firstName: 'Ada', termName: 'Term 1', reportUrl: '#' } as Props;Step 3: Add the switch case
The build won't compile until you do — the never check sees the new EmailType and fails. Add a case that returns your template:
case EmailType.TERM_REPORT_READY:
return createElement(TermReportReadyEmail, {
firstName: payload.data.firstName,
termName: payload.data.termName,
reportUrl: payload.data.reportUrl,
});Inside that case, payload.data is already narrowed to your interface's data, so the props are typed — a typo in a field name is a compile error. createElement is just the non-JSX way to instantiate a component, because the processor is plain .ts, not .tsx.
Step 4: Send it
EmailService is @Global(), so inject it anywhere and call send with a fully-typed payload. That's the entire producer side:
this.emailService.send({
type: EmailType.TERM_REPORT_READY,
to: guardian.email,
subject: 'Your child’s term report is ready',
data: { firstName: guardian.firstName, termName, reportUrl },
});For reference, the two live producers today are the staff login OTP (fired when a staff member requests a login code) and the social-agent escalation (fired when the AI enquiry assistant hands off to a human supervisor). Both follow this exact shape.
Finally, add a producer test that asserts send enqueues the right job name and payload (mirror email.service.spec.ts) and a processor test that asserts ResendService.sendEmail is called with a rendered template (mirror email.processor.spec.ts). You never test the two together — they run in different processes, and the @app/shared contract is what guarantees they line up. See Testing for the mocking setup.
Gotchas
The pipeline is forgiving in some places and silent in others. These are the traps worth knowing before they bite.
Trust the never check. When the build breaks on a new email type, it's tempting to slap a default that returns something or to cast the new type away. Don't — that defeats the entire point. Add the real case. The compile error is the system working.
Dry-run hides template bugs. With no RESEND_API_KEY, the transport short-circuits before Resend renders anything, so a template that would throw on real data sails through as a [DRY RUN] log. Preview with pnpm email:dev, and send one real email with a key before relying on it in production.
send is fire-and-forget — by design. It returns void, swallows-then-logs enqueue failures, and never throws, so a login can't fail because Redis hiccupped. The flip side: you get no return value to check. Don't write code whose success depends on the email landing.
A stubbed type sends nothing useful. Five EmailTypes currently have placeholder templates. Producing one runs the full pipeline and "succeeds" with an empty render. If you add a producer for an existing type, confirm its template is real first.
Job name vs. job data
emailQueue.add(payload.type, payload) passes the EmailType as the job name and the whole payload as the job data. The name is just a dashboard label and a routing convenience — the processor reads job.data, not the name. Don't switch logic on the job name; switch on payload.type inside the data, the way getTemplate does.
Where to go next
Templates
The React Email layout, the pnpm email:dev preview loop, and developing with dry-run.
Notification channels
The second way an email is produced — from a notification type’s EMAILS builder.
Sending Emails
The overview: the producer → queue → worker → Resend pipeline your new type plugs into.
Add a Background Job
The general recipe for any queued job, of which email is the worked example.
Testing
How to mock a queue and a transport to test the producer and processor in isolation.