Naalya Handbook
Sending Emails

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:

libs/shared/src/email/email.types.ts
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 union

BaseEmailPayload 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.

emails/term-report-ready.tsx
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:

apps/worker/src/processors/email.processor.ts
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:

apps/server/src/app/.../term-report.service.ts
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

On this page