Transactional emails — welcome messages, password resets, task reminders, workspace invitations — are the backbone of user communication in any SaaS application. Unlike marketing emails, transactional emails are triggered by user actions and must be delivered reliably, quickly, and correctly formatted. Building a production email system requires three layers: a template engine for rendering HTML emails, Nodemailer for SMTP transmission, and the Bull job queue (established in the previous lesson) to handle delivery asynchronously with retries. This lesson builds the complete email pipeline for the Task Manager, from local development using Mailtrap through to production delivery via AWS SES.
Email Provider Comparison
| Provider | Use For | Cost | Key Feature |
|---|---|---|---|
| Mailtrap | Development — catches all outgoing email | Free tier | Inbox inspector, spam score, HTML preview |
| AWS SES | Production — high volume, low cost | $0.10 / 1,000 emails | High deliverability, IAM integration |
| SendGrid | Production — analytics and templates | Free tier (100/day), then paid | Built-in analytics, suppression management |
| Resend | Modern developer-focused API | Free tier (3,000/month) | React Email templates, simple API |
| Nodemailer + Gmail | Personal projects only | Free | Easy setup — NOT for production |
Email Architecture
| Layer | Technology | Responsibility |
|---|---|---|
| Template | Handlebars (hbs) |
Render HTML email bodies with data interpolation |
| Transport | Nodemailer | Send email via SMTP or provider API |
| Queue | Bull (emailQueue) |
Async dispatch, retry on failure, idempotency |
| Tracking | EmailLog Mongoose model |
Prevent duplicate sends, delivery audit trail |
mailtrap.io, create an inbox, and copy the SMTP credentials into your .env file.text: option in Nodemailer) alongside the HTML body. Some email clients (corporate, accessibility-focused) display plain text only. Without a text alternative, these users receive a blank email. The plain text content should be a readable version of the HTML without styling — just the essential information and any important URLs written out in full.Complete Email Implementation
# Install dependencies
npm install nodemailer nodemailer-express-handlebars hbs
npm install -D @types/nodemailer
// ── apps/api/src/config/email.js — transporter factory ───────────────────
const nodemailer = require('nodemailer');
const hbs = require('nodemailer-express-handlebars');
const path = require('path');
function createTransporter() {
// Development: Mailtrap catches all emails — nothing reaches real inboxes
if (process.env.NODE_ENV !== 'production') {
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'sandbox.smtp.mailtrap.io',
port: parseInt(process.env.SMTP_PORT) || 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
// Production: AWS SES via SMTP interface
return nodemailer.createTransport({
host: 'email-smtp.eu-west-1.amazonaws.com', // change to your SES region
port: 587,
secure: false, // use STARTTLS
auth: {
user: process.env.SES_SMTP_USER,
pass: process.env.SES_SMTP_PASS,
},
pool: true, // reuse connections
maxConnections: 5,
rateLimit: 14, // SES limit: 14 messages/second on standard tier
});
}
const transporter = createTransporter();
// Attach Handlebars template engine
transporter.use('compile', hbs({
viewEngine: {
extName: '.hbs',
partialsDir: path.join(__dirname, '../email-templates/partials'),
defaultLayout: false,
},
viewPath: path.join(__dirname, '../email-templates'),
extName: '.hbs',
}));
// Verify connection on startup (dev only — don't block prod startup)
if (process.env.NODE_ENV !== 'production') {
transporter.verify().then(() => {
console.log('Email transporter ready');
}).catch(err => {
console.warn('Email transporter not available:', err.message);
});
}
module.exports = transporter;
// ── apps/api/src/email-templates/base.hbs — base layout ──────────────────
// (save as: apps/api/src/email-templates/base.hbs)
//
//
//
//
//
// {{subject}}
//
//
//
//
//
// {{{body}}}
//
//
//
//
// ── apps/api/src/services/email.service.js ────────────────────────────────
const transporter = require('../config/email');
const EmailLog = require('../models/email-log.model');
const { logger } = require('../config/logger');
const FROM_EMAIL = process.env.FROM_EMAIL || 'noreply@taskmanager.io';
const APP_URL = process.env.CLIENT_URL || 'http://localhost:4200';
// Core send function — checks idempotency, sends, logs
async function sendEmail({ to, subject, template, context, jobId }) {
// Idempotency: skip if this jobId was already delivered
if (jobId) {
const existing = await EmailLog.findOne({ jobId });
if (existing) {
logger.info('Email already sent — skipping', { jobId, to });
return { skipped: true, messageId: existing.messageId };
}
}
const info = await transporter.sendMail({
from: `"Task Manager" <${FROM_EMAIL}>`,
to,
subject,
template, // matches filename in email-templates/ (without .hbs)
context: {
...context,
subject,
logoUrl: `${APP_URL}/assets/logo.png`,
unsubscribeUrl:`${APP_URL}/unsubscribe?email=${encodeURIComponent(to)}`,
year: new Date().getFullYear(),
},
// Plain-text fallback (always include)
text: context.textFallback || stripHtmlFallback(subject, context),
});
// Log successful delivery for idempotency
if (jobId) {
await EmailLog.create({
jobId,
to,
subject,
template,
messageId: info.messageId,
sentAt: new Date(),
});
}
logger.info('Email sent', { to, subject, template, messageId: info.messageId });
return { messageId: info.messageId };
}
function stripHtmlFallback(subject, context) {
// Minimal plain-text for clients that cannot render HTML
return [
subject,
'',
context.body || '',
context.ctaUrl ? `\n${context.ctaText || 'Open link'}: ${context.ctaUrl}` : '',
'',
`Task Manager — ${APP_URL}`,
].join('\n');
}
// ── Typed email senders ────────────────────────────────────────────────────
exports.sendWelcomeEmail = (to, { name }) =>
sendEmail({
to, subject: 'Welcome to Task Manager!',
template: 'welcome',
context: {
name,
loginUrl: `${APP_URL}/auth/login`,
textFallback: `Hi ${name},\n\nWelcome to Task Manager!\n\nGet started: ${APP_URL}/auth/login`,
},
});
exports.sendVerificationEmail = (to, { name, verifyUrl }, jobId) =>
sendEmail({
to, jobId, subject: 'Verify your email address',
template: 'verify-email',
context: {
name,
ctaUrl: verifyUrl,
ctaText: 'Verify Email',
expiry: '24 hours',
textFallback: `Hi ${name},\n\nVerify your email:\n${verifyUrl}\n\nThis link expires in 24 hours.`,
},
});
exports.sendPasswordResetEmail = (to, { name, resetUrl }, jobId) =>
sendEmail({
to, jobId, subject: 'Reset your password',
template: 'password-reset',
context: {
name,
ctaUrl: resetUrl,
ctaText: 'Reset Password',
expiry: '1 hour',
textFallback: `Hi ${name},\n\nReset your password:\n${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
},
});
exports.sendTaskAssignedEmail = (to, { name, assignerName, taskTitle, taskUrl }, jobId) =>
sendEmail({
to, jobId, subject: `${assignerName} assigned you a task`,
template: 'task-assigned',
context: {
name, assignerName, taskTitle,
ctaUrl: taskUrl,
ctaText: 'View Task',
textFallback: `Hi ${name},\n\n${assignerName} assigned you "${taskTitle}".\n\nView task: ${taskUrl}`,
},
});
exports.sendWorkspaceInviteEmail = (to, { inviterName, workspaceName, role, inviteUrl }, jobId) =>
sendEmail({
to, jobId, subject: `You've been invited to join ${workspaceName}`,
template: 'workspace-invite',
context: {
inviterName, workspaceName, role,
ctaUrl: inviteUrl,
ctaText: 'Accept Invitation',
expiry: '7 days',
textFallback: `${inviterName} invited you to ${workspaceName} as ${role}.\n\nAccept: ${inviteUrl}\n\nExpires in 7 days.`,
},
});
exports.sendTaskReminderEmail = (to, { name, taskTitle, dueDate, taskUrl }, jobId) =>
sendEmail({
to, jobId, subject: `Reminder: "${taskTitle}" is due tomorrow`,
template: 'task-reminder',
context: {
name, taskTitle,
dueDateFormatted: new Date(dueDate).toLocaleDateString('en-GB', { weekday:'long', day:'numeric', month:'long' }),
ctaUrl: taskUrl,
ctaText: 'View Task',
textFallback: `Hi ${name},\n\n"${taskTitle}" is due tomorrow.\n\nView task: ${taskUrl}`,
},
});
// ── EmailLog model ────────────────────────────────────────────────────────
// apps/api/src/models/email-log.model.js
const mongoose = require('mongoose');
const emailLogSchema = new mongoose.Schema({
jobId: { type: String, required: true, unique: true },
to: { type: String, required: true },
subject: { type: String, required: true },
template: { type: String },
messageId: { type: String },
sentAt: { type: Date, default: Date.now },
}, { timestamps: false });
emailLogSchema.index({ sentAt: 1 }, { expireAfterSeconds: 90 * 24 * 60 * 60 }); // 90-day TTL
module.exports = mongoose.model('EmailLog', emailLogSchema);
// ── Bull email queue processor ────────────────────────────────────────────
// apps/api/src/workers/email.worker.js
const { emailQueue } = require('../queues');
const emailService = require('../services/email.service');
const { logger } = require('../config/logger');
const HANDLERS = {
WELCOME: (data, jobId) => emailService.sendWelcomeEmail(data.to, data.data, jobId),
VERIFY_EMAIL: (data, jobId) => emailService.sendVerificationEmail(data.to, data.data, jobId),
PASSWORD_RESET: (data, jobId) => emailService.sendPasswordResetEmail(data.to, data.data, jobId),
TASK_ASSIGNED: (data, jobId) => emailService.sendTaskAssignedEmail(data.to, data.data, jobId),
WORKSPACE_INVITE: (data, jobId) => emailService.sendWorkspaceInviteEmail(data.to, data.data, jobId),
TASK_REMINDER: (data, jobId) => emailService.sendTaskReminderEmail(data.to, data.data, jobId),
};
emailQueue.process('*', 5, async job => {
const { type } = job.data;
const handler = HANDLERS[type];
if (!handler) {
logger.warn('Unknown email type — skipping', { type, jobId: job.id });
return { skipped: true };
}
await job.progress(10);
const result = await handler(job.data, job.id.toString());
await job.progress(100);
return result;
});
emailQueue.on('failed', (job, err) => {
logger.error('Email job failed', {
jobId: job.id,
type: job.data.type,
to: job.data.to,
attempt: job.attemptsMade,
error: err.message,
});
});
emailQueue.on('completed', job => {
logger.debug('Email job completed', { jobId: job.id, type: job.data.type });
});
How It Works
Step 1 — Environment-Switched Transporter Keeps Dev and Prod Separate
The createTransporter() function returns a different Nodemailer transport based on NODE_ENV. In development, all emails go to Mailtrap regardless of the recipient address — no real emails are ever sent during local development or testing. In production, emails go via AWS SES. This single switch means developers never accidentally send emails to real addresses, and the production transport only runs in production where SES credentials are available.
Step 2 — Handlebars Templates Produce Consistent HTML Emails
The nodemailer-express-handlebars plugin integrates Handlebars templating with Nodemailer. Each email type has its own .hbs template file (e.g. welcome.hbs, password-reset.hbs) that renders the HTML body using the context object passed to sendMail(). The base layout (logo, footer, unsubscribe link, copyright year) is included in every template via a Handlebars partial, ensuring brand consistency across all email types without duplication.
Step 3 — JobId-Based Idempotency Prevents Duplicate Emails
Before sending, the service checks EmailLog.findOne({ jobId }). If a record exists, the email was already sent and the function returns early. This handles Bull’s “at-least-once” delivery guarantee: if a worker crashes after sending but before acknowledging the job, Bull re-queues the job and retries. Without idempotency, a password reset email could be sent twice. The jobId is Bull’s job ID — stable and unique per job, persisted in Redis.
Step 4 — Plain Text Alternative Is Always Included
Every sendEmail call generates a text property alongside the HTML template. The stripHtmlFallback() function builds a minimal plain-text version from the context data — the subject, any body text, the CTA URL written out in full, and the app URL. Email clients that block HTML (many corporate filters, text-mode readers) display this plain text version. Without it, those users receive an empty email.
Step 5 — AWS SES Connection Pooling Handles High Volume
The production SES transporter uses pool: true with maxConnections: 5 and rateLimit: 14. Connection pooling reuses SMTP connections across multiple emails rather than opening a new TCP connection for each — critical for burst sending (e.g. sending 200 task reminders from the morning cron job). The rate limit respects SES’s default sending limit of 14 emails/second, preventing throttling errors. Adjust rateLimit if your SES account has a higher sending rate approved.
Template Structure
<!-- apps/api/src/email-templates/verify-email.hbs -->
<h1 style="font-size:24px;margin:0 0 16px">Verify your email</h1>
<p style="color:#444;line-height:1.6">Hi {{name}},</p>
<p style="color:#444;line-height:1.6">
Click the button below to verify your email address.
This link expires in <strong>{{expiry}}</strong>.
</p>
<p style="text-align:center;margin:32px 0">
<a href="{{ctaUrl}}" class="btn">{{ctaText}}</a>
</p>
<p style="color:#888;font-size:13px">
If you didn't create an account, you can safely ignore this email.<br>
Or copy this URL: <a href="{{ctaUrl}}">{{ctaUrl}}</a>
</p>
Common Mistakes
Mistake 1 — Sending email synchronously in a request handler
❌ Wrong — request waits for email delivery, times out if SMTP is slow:
app.post('/auth/register', async (req, res) => {
const user = await User.create(req.body);
await emailService.sendWelcomeEmail(user.email, { name: user.name }); // blocks!
res.status(201).json({ success: true, data: user });
});
✅ Correct — queue the job, return immediately:
app.post('/auth/register', async (req, res) => {
const user = await User.create(req.body);
await emailQueue.add('welcome', { type: 'WELCOME', to: user.email, data: { name: user.name } });
res.status(201).json({ success: true, data: user }); // responds in ~30ms
});
Mistake 2 — No plain-text alternative
❌ Wrong — blank email for plain-text clients:
await transporter.sendMail({ from, to, subject, html: renderedHtml });
// No 'text' property — plain-text clients see nothing
✅ Correct — always include text fallback:
await transporter.sendMail({ from, to, subject, html: renderedHtml,
text: `Hi ${name},\n\nReset your password: ${resetUrl}` });
Mistake 3 — No idempotency — duplicate emails on job retry
❌ Wrong — email sent twice if worker crashes mid-job:
emailQueue.process('*', async job => {
await transporter.sendMail({ ... }); // no duplicate check!
});
✅ Correct — check EmailLog before sending:
const existing = await EmailLog.findOne({ jobId: job.id.toString() });
if (existing) return { skipped: true };
// ... send and log
Quick Reference
| Task | Code |
|---|---|
| Create SMTP transporter | nodemailer.createTransport({ host, port, auth }) |
| Send with template | transporter.sendMail({ to, subject, template: 'name', context: {...} }) |
| Attach Handlebars | transporter.use('compile', hbs({ viewPath, extName })) |
| Queue email job | emailQueue.add('type', { type: 'WELCOME', to, data: {...} }) |
| Idempotency check | await EmailLog.findOne({ jobId: job.id.toString() }) |
| Log sent email | EmailLog.create({ jobId, to, subject, messageId, sentAt }) |
| Dev email capture | Mailtrap SMTP — nothing reaches real inboxes |
| Production transport | AWS SES with pool: true, maxConnections: 5, rateLimit: 14 |
| Verify transporter | await transporter.verify() — checks SMTP connection |