import { readFileSync } from 'fs';
import { join } from 'path';
import Handlebars from 'handlebars';
import mjml2html from 'mjml';
import { htmlToText } from 'html-to-text';
export interface RenderedEmail {
html: string;
text: string;
}
const TEMPLATE_DIR = join(__dirname, 'templates');
const cache = new Map<string, HandlebarsTemplateDelegate>();
function compile(name: string): HandlebarsTemplateDelegate {
const cached = cache.get(name);
if (cached) return cached;
const source = readFileSync(join(TEMPLATE_DIR, `${name}.mjml.hbs`), 'utf8');
const template = Handlebars.compile(source);
cache.set(name, template);
return template;
}
export function renderTemplate(name: string, data: Record<string, unknown>): RenderedEmail {
const mjmlSource = compile(name)(data);
const { html, errors } = mjml2html(mjmlSource, { validationLevel: 'strict' });
if (errors.length > 0) {
throw new Error(`MJML errors in '${name}': ${errors.map((e) => e.message).join('; ')}`);
}
const text = htmlToText(html, { wordwrap: 80, selectors: [{ selector: 'img', format: 'skip' }] });
return { html, text };
}
import nodemailer, { Transporter } from 'nodemailer';
export interface SendOptions {
to: string;
subject: string;
html: string;
text: string;
}
export class Mailer {
private transport: Transporter;
constructor(private readonly from: string, private readonly maxAttempts = 4) {
this.transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
secure: process.env.SMTP_SECURE === 'true',
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
pool: true,
maxConnections: 5,
maxMessages: 100,
});
}
async verify(): Promise<void> {
await this.transport.verify();
}
private backoffDelay(attempt: number): number {
return Math.min(30_000, 2 ** attempt * 500) + Math.floor(Math.random() * 250);
}
async send(opts: SendOptions): Promise<string> {
return this.sendWithRetry(opts, 1);
}
private async sendWithRetry(opts: SendOptions, attempt: number): Promise<string> {
try {
const info = await this.transport.sendMail({ from: this.from, ...opts });
return info.messageId;
} catch (err) {
if (attempt >= this.maxAttempts) {
throw new Error(`Email to ${opts.to} failed after ${attempt} attempts: ${(err as Error).message}`);
}
await new Promise((r) => setTimeout(r, this.backoffDelay(attempt)));
return this.sendWithRetry(opts, attempt + 1);
}
}
}
import { Mailer } from './mailer';
import { renderTemplate } from './templates';
const mailer = new Mailer('Acme <no-reply@acme.io>');
interface WelcomeUser {
email: string;
firstName: string;
activationUrl: string;
}
export async function sendWelcomeEmail(user: WelcomeUser): Promise<string> {
const { html, text } = renderTemplate('welcome', {
firstName: user.firstName,
activationUrl: user.activationUrl,
year: new Date().getFullYear(),
});
return mailer.send({
to: user.email,
subject: `Welcome to Acme, ${user.firstName}!`,
html,
text,
});
}
This snippet shows how a transactional email layer is typically assembled in a Node service: a template renderer, a transport wrapper around Nodemailer, and a thin caller that sends a specific message. Splitting these concerns keeps template compilation, SMTP configuration, and business intent independent so each can be tested and swapped in isolation.
In templates.ts, MJML is used as the authoring format because raw HTML email is notoriously fragile across clients; MJML compiles to table-based HTML that renders consistently. The renderer keeps a Map cache of compiled Handlebars templates keyed by name, so the disk read and Handlebars.compile cost is paid once per template rather than per send. renderTemplate interpolates the data, runs the result through mjml2html, and also derives a plaintext body with htmlToText, since well-behaved senders include a text alternative for accessibility and spam scoring. The strict MJML option surfaces markup errors early instead of silently shipping broken email.
In mailer.ts, the Mailer class wraps a single Nodemailer transport created from nodemailer.createTransport. A shared transport matters because it maintains a connection pool (pool: true, maxConnections) instead of opening a new SMTP handshake per message, which is what dominates latency at volume. The core value is sendWithRetry: SMTP failures are frequently transient (greylisting, rate limits, brief network blips), so a naive single-attempt send drops legitimate mail. The method loops up to maxAttempts, and on failure waits an exponentially growing delay via backoffDelay before retrying, only surfacing the error once attempts are exhausted. The verify call lets startup fail fast when credentials or host are wrong rather than discovering it on the first user-facing send.
In sendWelcomeEmail.ts, the two layers meet: it renders the welcome template with user-specific data, then hands the compiled html, text, and subject to mailer.send. Keeping this function tiny means business code never touches SMTP or template internals. A subtle trade-off is that retry with backoff assumes the send operation is safe to repeat; because SMTP can accept a message and still fail the response, at-least-once delivery can occasionally duplicate mail, so truly critical flows pair this with an idempotency key or a delivery log. For most transactional email this pooled-transport-plus-retry shape is the pragmatic default.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
default from: 'noreply@example.com'
def welcome_email(user)
@user = user
ActionMailer advanced patterns for transactional emails
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
Share this code
Here's the card — post it anywhere.