Naalya Handbook
Sending Emails

Templates

React Email components, the shared EmailLayout, the pnpm email:dev preview loop, and developing with dry-run instead of a real provider key.

Templates are the visible half of the pipeline — the React components the worker renders into HTML. This page covers where they live, the shared layout that keeps them looking like one family, and the two ways you iterate on them without ever touching a queue.

React Email

Templates are React Email components in the top-level emails/ directory. Every template renders its content inside the shared EmailLayout (logo, brand accent strip, header, body, footer) so all emails look like one family. Here's the OTP template — short, prop-driven, wrapped in the layout:

emails/otp.tsx
export const OtpEmail = ({ firstName, otp, expiresInMinutes }: OtpEmailProps) => (
  <EmailLayout preview={`Your verification code is ${otp}`} title="Verification Code">
    <Text>Hi {firstName},</Text>
    {/* OTP code in a styled box */}
    <Section className="text-center my-[28px] bg-[#f0f4fa] rounded-[8px] ...">
      <Text className="text-[32px] font-bold tracking-[6px] text-[#4169B2]">{otp}</Text>
    </Section>
    <Text>This code expires in {expiresInMinutes} minutes. {/* ... */}</Text>
  </EmailLayout>
);

OtpEmail.PreviewProps = { firstName: 'John', otp: '482916', expiresInMinutes: 10 } as OtpEmailProps;

The component is plain props in, JSX out. It is never rendered to HTML in the template file itself — the worker passes it as a react element straight to Resend, which does the rendering. The emails/ folder has its own tsconfig.json that sets "jsx": "react-jsx" so these .tsx files compile on their own.

Preview props

That PreviewProps static at the bottom of the file is sample data for the preview server. It's the realistic-looking input a designer would want to see the template against — a believable name, a six-digit code, a sensible expiry. Give every template a PreviewProps static so it has something to render with no live job behind it.

The preview loop

Run the preview server and you get a live, hot-reloading view of every template, with no queue, worker, or Redis involved — the fast loop for building a template:

terminal
pnpm email:dev

Open the served URL, pick a template from the sidebar, and it renders using that component's PreviewProps. Edit the .tsx, save, and the preview updates instantly. This is where you do the actual design work — never by sending real emails.

Dry-run

The other way to exercise a template is to run the full pipeline with no provider key. ResendService only builds a real client when RESEND_API_KEY is set; with no key it short-circuits, logs a line, and returns:

apps/worker/src/providers/resend.service.ts
async sendEmail(params): Promise<void> {
  if (!this.client) {
    this.logger.log(`[DRY RUN] Email to=${JSON.stringify(params.to)} subject="${params.subject}"`);
    return;
  }
  // ...real send + throw on error
}

Leaving RESEND_API_KEY empty locally is the intended way to develop. You get the full produce → enqueue → consume → render path exercised, with the final send replaced by a log line. The three config keys (RESEND_API_KEY, RESEND_FROM_EMAIL, RESEND_FROM_NAME) come from the env schema — see Config & Environment for how that mapping works.

Dry-run is a feature, not a fallback

Dry-run is the intended local workflow, but it hides one class of bug: the transport short-circuits before Resend renders anything, so a template that would throw on real data sails through as a [DRY RUN] log. Always preview your template with pnpm email:dev and, before relying on it in production, send at least one real email with a key set.

Stubbed templates

Five of the seven EmailType values currently have placeholder templates in the processor — the contract knows about them, but the real design hasn't been built yet. Producing one of those will run the full pipeline and "succeed" with an empty render. The contract being satisfied doesn't mean the email is real: if you wire a producer for an existing type, confirm its template is fully built first.

Where to go next

On this page