Naalya Handbook
Sending Emails

Sending Emails

How an email travels from a service call through the BullMQ queue to a rendered React Email template and out via Resend.

Sending an email looks like it should be one line — call something, an email goes out. In this codebase it's deliberately not one line, and the reason is worth understanding before you touch any of it.

Talking to an email provider is slow and unreliable. The provider's API can be up to a second away, it can rate-limit you, it can be down entirely. If a user's HTTP request had to wait for Resend to accept the message, every login and every form submission would be hostage to a third party's uptime. So the work is split across two processes, and this overview walks the whole path before the sub-pages drill in.

Two processes

The server app — the one handling the request — does almost nothing: it drops a small typed message onto a queue and returns immediately. A separate worker app picks that message up later, renders the actual email, and deals with the provider. If Resend hiccups, the job retries; the user's request already succeeded.

The glue between those two processes is a queue: a Redis-backed list of pending jobs, managed by BullMQ. The server is the producer (it adds jobs); the worker is the consumer (it runs them). They live in different deployments and never call each other directly. The only thing they share is the shape of the payload — defined once in @app/shared, which is what makes the whole thing type-safe end to end.

The mental model: one contract, two processes

An email is a typed message. The server describes it (EmailService.send) and drops it on the EMAIL_QUEUE. The worker picks it up (EmailProcessor), turns the type into a React template, and sends it through ResendService. Server is fast and dumb; worker is slow and does the real work. Keep that picture and the rest of this section is just detail.

If you haven't met BullMQ before, read Queues & Messaging first — this section assumes you know what "enqueue a job" means.

The contract

Everything starts with the contract, because both apps are written against it. It lives in libs/shared/src/email/. The queue name is a plain constant — so both apps refer to the exact same Redis queue:

libs/shared/src/email/email.constants.ts
export const EMAIL_QUEUE = 'email';

The payload is a discriminated union: every variant carries a literal type field — the discriminant — so once the compiler sees payload.type === EmailType.STAFF_LOGIN_OTP, it narrows the type and knows exactly which data shape goes with it. It starts with an enum of every email the system knows about, and a base interface for the fields they all share:

libs/shared/src/email/email.types.ts
export enum EmailType {
  WELCOME = 'WELCOME',
  PASSWORD_RESET = 'PASSWORD_RESET',
  APPLICATION_STATUS = 'APPLICATION_STATUS',
  ADMISSION_RECEIVED = 'ADMISSION_RECEIVED',
  INQUIRY_CONFIRMATION = 'INQUIRY_CONFIRMATION',
  STAFF_LOGIN_OTP = 'STAFF_LOGIN_OTP',
  SOCIAL_AGENT_ESCALATION = 'SOCIAL_AGENT_ESCALATION',
}

interface BaseEmailPayload {
  to: string | string[];
  subject: string;
  campusId?: string;
}

Each email then gets its own interface that extends BaseEmailPayload, pins the type to one enum value, and declares the data its template needs — and all of them are collected into one exported union. This union is the contract. If a variant isn't in it, neither the producer nor the processor can see it:

libs/shared/src/email/email.types.ts
export type EmailJobPayload =
  | WelcomeEmailPayload
  | PasswordResetEmailPayload
  | ApplicationStatusEmailPayload
  | AdmissionReceivedEmailPayload
  | InquiryConfirmationEmailPayload
  | StaffLoginOtpEmailPayload
  | SocialAgentEscalationEmailPayload;

The discriminant is doing real work

Because every variant has a distinct literal type, passing the wrong data for a given type is a compile error at the call site — you literally cannot send an OTP payload with welcome data. The same discriminant lets the processor's switch narrow each case to one exact shape.

The producer

The server side is almost boringly small, and that's the point. EmailModule is marked @Global(), so any module can inject EmailService without importing it first, and the whole service is one method that adds a job:

apps/server/src/app/email/email.service.ts
send(payload: EmailJobPayload): void {
  this.emailQueue.add(payload.type, payload).catch((err) => {
    this.logger.error('Failed to enqueue email job', err);
  });
}

The job name is the EmailType (payload.type), so you can see at a glance which kind of email ran in the dashboard. send returns void and never throws — it catches the enqueue error, logs it, and moves on, because a login must not fail because Redis hiccupped. Retries are configured globally: the root Bull config sets three attempts for every queue, so a job that throws in the worker is retried up to three times before it's marked failed.

The consumer

Over in the worker app, EmailProcessor is decorated @Processor(EMAIL_QUEUE): whenever a job lands on 'email', BullMQ calls its process method. Its whole job is "turn the payload into a rendered email and hand it to the transport." The interesting part is getTemplate, a switch on payload.type where the union has already narrowed payload.data to the right shape:

apps/worker/src/processors/email.processor.ts
private getTemplate(payload: EmailJobPayload): ReactElement {
  switch (payload.type) {
    case EmailType.STAFF_LOGIN_OTP:
      return createElement(OtpEmail, {
        firstName: payload.data.firstName,
        otp: payload.data.otp,
        expiresInMinutes: payload.data.expiresInMinutes,
      });
    // ...other cases
    default: {
      const _exhaustive: never = payload;
      throw new Error(`Unhandled email type: ${(_exhaustive as EmailJobPayload).type}`);
    }
  }
}

That default branch assigns payload to a variable typed never. Add a new EmailType and forget a case, and payload is not never there, so the build breaks. The compiler does the remembering for you.

The transport

The last hop wraps the Resend SDK. Its constructor only creates a real client if an API key is present — with no key, sendEmail logs a [DRY RUN] line and returns, so the entire pipeline works locally with zero provider setup. With a key, it sends, and on a provider error it throws, which is what lets BullMQ retry the job. The Templates page covers dry-run as a development workflow in full.

Where to read next

The two cards below split this pipeline into its two day-to-day workflows: building and previewing a template, and the full step-by-step of adding a brand-new email type end to end.

Where to go next

On this page