Sending Transactional Emails — Nodemailer, Handlebars Templates, and AWS SES

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
Note: Never send emails synchronously in an HTTP request handler. Email delivery via SMTP takes 100–500ms, and third-party SMTP servers can be temporarily slow or unreachable. Always dispatch to the Bull email queue and return immediately. The Bull queue persists the job to Redis — if the server restarts before the email is sent, the job survives and is processed on restart. Synchronous sends would cause request timeouts and lost emails on server restarts.
Tip: Use Mailtrap for development. Mailtrap acts as a fake SMTP server that catches all outgoing email — nothing is actually delivered to real inboxes. The Mailtrap inbox shows the rendered HTML, plain text version, spam score, and raw headers. This lets you verify email rendering without accidentally spamming real addresses during development. Sign up at mailtrap.io, create an inbox, and copy the SMTP credentials into your .env file.
Warning: Always include a plain-text alternative (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}}
//   
// 
// 
//   
// Task Manager // {{{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

🧠 Test Yourself

A Bull email worker crashes after successfully sending a password reset email but before marking the job as complete. Bull re-queues the job and a new worker picks it up. Without idempotency, what happens? What prevents it?