Sending Transactional Emails
- transactional-email
- nestjs
- nodejs
- aws
- ses
- deliverability
- saas
- system-design
- backend-development
In Building a Notification System we built a generic pipeline for telling users things happened: in-app, push, and a placeholder for email. Now we build that email piece properly, because email is the one channel every SaaS is legally and practically stuck with. Users lose their phones, mute push notifications, and ignore in-app banners. They almost always read the password reset email.
This is part of the Full Stack SaaS Masterclass, the series where we build a multi-tenant SaaS from an empty repo to something running in production. If you're joining here, the start of the series covers the stack decisions everything else builds on.
Transactional email looks simple from the outside: call an API, pass an address and a subject, done. It stops looking simple the first time a password reset lands in spam, or a webhook from your provider tells you an address bounced three weeks ago and you've been sending into a void since. This article is about the parts that matter once you're past "it sent once in dev."
Why transactional email needs its own design
The term "transactional" matters legally and practically. Transactional emails (password resets, invoices, order confirmations, security alerts) are exempt from a lot of the consent rules that govern marketing email, because the user directly triggered them. Mix a promotional line into a password reset and you've turned a transactional email into a marketing one, with all the compliance obligations that come with it. Keep the two firmly separate: different templates, ideally different sending domains or subdomains, and never let a marketing tool double as your transactional sender.
Deliverability is the other reason this deserves real design work. An email API call succeeding tells you almost nothing. The provider accepted the message; it says nothing about whether it reached an inbox. Getting mail delivered reliably depends on three DNS records that have nothing to do with your application code:
- SPF declares which servers are allowed to send mail for your domain.
- DKIM signs outgoing mail so receivers can verify it wasn't tampered with in transit.
- DMARC tells receiving servers what to do when SPF or DKIM checks fail, and gives you reporting on abuse.
Skip these and your mail works fine in testing, then starts landing in spam for a meaningful slice of real users once you have enough volume for spam filters to notice. Set them up before you need them, not after a support ticket says "I never got the reset email."
Choosing a provider
For a SaaS at any reasonable stage, you're not running your own mail server. The choice is between a few managed options, and the tradeoffs are mostly about deliverability tooling versus cost:
- Amazon SES is the cheapest at volume and integrates naturally if you're already on AWS, but its dashboard for bounce and complaint handling is minimal. You'll build more of that yourself.
- Postmark is built specifically for transactional email, with strong deliverability defaults and clean bounce/complaint webhooks. It costs more per email but saves engineering time.
- Resend is newer, has a good developer experience, and pairs naturally with React Email for templating (more on that below).
- SendGrid covers both marketing and transactional, which is convenient for smaller teams but blurs the separation you actually want.
I default to SES for cost at scale. Pair it with a dedicated subdomain (mail.yourapp.com) so a deliverability problem never touches your main domain's reputation. For an early-stage product where engineering time is scarcer than the marginal cost per email, Postmark or Resend get you there faster.
Sending through a queue, not the request thread
The mistake I see most often: an API call to a mail provider sitting directly inside a request handler. It works until the provider has a slow moment, and now your signup endpoint is blocked on an HTTP call to a third party that has nothing to do with whether the signup itself succeeded.
Route email through the same queue infrastructure from the notification system article. The request handler enqueues a job and returns immediately; a worker does the actual sending, with retries that don't hold up the user.
// email/email.types.ts
export interface SendEmailJob {
to: string;
template: 'welcome' | 'password-reset' | 'invoice';
data: Record<string, unknown>;
tenantId: string;
}
// email/email.producer.ts
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { SendEmailJob } from './email.types';
@Injectable()
export class EmailProducer {
constructor(@InjectQueue('email') private readonly queue: Queue<SendEmailJob>) {}
async enqueue(job: SendEmailJob): Promise<void> {
await this.queue.add('send-email', job, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: true,
removeOnFail: false,
});
}
}
// email/email.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { SendEmailJob } from './email.types';
import { SesEmailClient } from './ses-email.client';
import { renderTemplate } from './templates';
@Processor('email')
export class EmailProcessor extends WorkerHost {
constructor(private readonly ses: SesEmailClient) {
super();
}
async process(job: Job<SendEmailJob>): Promise<void> {
const { to, template, data } = job.data;
const { subject, html } = renderTemplate(template, data);
await this.ses.send({ to, subject, html });
}
}
removeOnFail: false matters here. Once a job exhausts its retries, you want it visible in the dashboard, not silently discarded, because a failed transactional email is often a signal something else broke (bad address, provider outage, template bug) that a human should see.
Templates that don't rot
Hand-written HTML email templates are miserable to maintain. They also degrade differently across clients (Outlook's rendering engine is famously its own problem). React Email solves this by letting you write templates as React components and compile them to email-safe HTML, so the same component skills your frontend team already has apply here.
// email/templates/PasswordReset.tsx
import { Html, Body, Container, Text, Button } from '@react-email/components';
interface PasswordResetProps {
resetUrl: string;
expiresInMinutes: number;
}
export function PasswordReset({ resetUrl, expiresInMinutes }: PasswordResetProps) {
return (
<Html>
<Body style={{ fontFamily: 'sans-serif' }}>
<Container>
<Text>Reset your password using the link below.</Text>
<Button href={resetUrl} style={{ background: '#000', color: '#fff', padding: '12px 20px' }}>
Reset password
</Button>
<Text>This link expires in {expiresInMinutes} minutes.</Text>
</Container>
</Body>
</Html>
);
}
Render these to static HTML at send time (React Email ships a render function for exactly this), cache the compiled output if the data is mostly static, and keep template logic out of your business logic entirely. The email processor should know how to render a template name. It shouldn't know why a password reset is happening.
Handling bounces, complaints, and idempotency
Two things bite teams that skip this: sending duplicate emails for the same event, and continuing to email addresses that have already bounced.
For duplicates, generate an idempotency key per logical send (tenantId:userId:eventType:eventId) and check it before enqueueing. A queue retry, a webhook firing twice, or a user double-clicking "resend invite" shouldn't produce two emails.
async function shouldSend(idempotencyKey: string, redis: Redis): Promise<boolean> {
const result = await redis.set(idempotencyKey, '1', 'EX', 86400, 'NX');
return result === 'OK';
}
For bounces and complaints, your provider sends webhooks (SES via SNS, Postmark and Resend directly). Handle them by writing to a suppression list, and check that list before every send, not just for marketing email. A hard bounce means the address is dead. Continuing to send to it hurts your sender reputation for every other email you send, including the ones that matter most.
create table email_suppressions (
email text primary key,
reason text not null,
suppressed_at timestamptz not null default now()
);
Production pitfalls
A few failure modes show up repeatedly once real users are involved:
- Sending from the app's main domain with no dedicated subdomain. One deliverability problem now affects every email you send, including ones from a completely unrelated system.
- No suppression list check before send. You keep emailing addresses that bounced weeks ago, and your reputation degrades without an obvious cause.
- Testing against real providers in staging. Route non-production environments to a catch-all inbox (Mailhog locally, a provider's sandbox mode in staging) so test data never reaches real users.
- Treating every template as urgent. A password reset needs to send in seconds. A weekly digest doesn't. Give them different queue priorities instead of one undifferentiated email queue.
- No visibility into failed sends. If a job silently disappears after exhausting retries, you find out about broken email only when a user complains, which is the worst possible time to find out.
Key takeaways
- Keep transactional and marketing email strictly separate: different consent rules, different domains where possible, never the same sending tool doing both.
- SPF, DKIM, and DMARC are the deliverability foundation. Set them up before volume, not after inbox placement starts degrading.
- Send through a queue with retries and backoff, never inline in a request handler.
- Use idempotency keys so retries and duplicate triggers never produce duplicate emails.
- Respect bounces and complaints via a suppression list checked on every send, not just for bulk mail.
- Pick a provider based on how much deliverability tooling you want to build yourself: SES is cheap and DIY, Postmark and Resend cost more but do more for you out of the box.
Frequently asked questions
Do transactional emails need an unsubscribe link?
Legally, most jurisdictions exempt purely transactional email (password resets, receipts, security alerts) from unsubscribe requirements. The moment you add promotional content, a product tip, a "you might also like," it's no longer purely transactional, and the same rules that apply to marketing email apply to it.
Can I use the same provider for transactional and marketing email?
You can, but I'd avoid it once you have real volume. Marketing sends have worse engagement rates than transactional ones, and if they share a sending reputation, a bad marketing campaign can drag down deliverability for password resets and invoices too. Separate subdomains, even on the same provider, solve most of this.
How do I test email templates without spamming real inboxes?
Run a local SMTP catcher like Mailhog or Maildev in development so every send lands in a local inbox you can inspect. In staging, most providers offer a sandbox or test mode that accepts sends but never delivers them externally.
What's a reasonable retry strategy for a failed email send?
Exponential backoff over a handful of attempts (four or five) covers transient provider issues without hammering a service that's already struggling. Beyond that, a job should fail loudly into a dead-letter queue or alert, not retry forever.
Should password reset and welcome emails go through the same queue as bulk notifications?
Give them separate priority, and ideally a separate queue, from anything high-volume like weekly digests or batch notifications. A password reset delayed behind ten thousand digest emails is a support ticket waiting to happen.
How do I know if my emails are actually reaching inboxes and not spam?
DMARC aggregate reports give you visibility into how receiving servers treat your mail. Beyond that, most providers report open and click rates, though those are rough signals since image blocking and privacy features undercount opens. Treat a rising bounce or complaint rate as the more reliable early warning.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.