Loading
Build a transactional email service with a template engine, variable substitution, send queue, delivery status tracking, and retry logic.
Transactional emails are the automated messages triggered by user actions: welcome emails, password resets, order confirmations, and notification digests. Unlike marketing emails, transactional emails are expected, time-sensitive, and critical to user experience. When a password reset email does not arrive, the user cannot access their account.
In this tutorial, you will build a transactional email service from scratch. You will create a template engine with variable substitution and layouts, a send queue that processes emails asynchronously, delivery status tracking, retry logic for failed sends, and an API for triggering emails from your application. The service is designed to work with any SMTP provider — Postmark, SendGrid, Mailgun, or even a self-hosted mail server.
This is the kind of internal service that every company over a certain size builds. The patterns here — templating, queuing, retry, status tracking — are universal in backend systems.
Initialize the project with the email and database dependencies.
Define the types:
Create a template engine that supports variable substitution, conditionals, and layouts.
The double-brace {{variable}} syntax escapes HTML to prevent XSS injection. The triple-brace {{{variable}}} syntax outputs raw HTML for cases where you intentionally want to inject styled content. This distinction is critical for security — user-provided data should always go through the escaped path.
Build a template storage system with a layout wrapper.
Store queued emails in SQLite for persistence and status tracking.
Create a sender that delivers emails via SMTP using Nodemailer.
Custom headers X-Email-Id and X-Template-Id are included so you can trace emails back to your system in SMTP logs or webhook events from your provider.
Process queued emails with retry logic and backoff.
Wire templates and the queue together into a high-level API.
Expose the email service through an HTTP API.
The /send endpoint returns 202 Accepted immediately. The email is queued, not sent synchronously. This means your API responds in milliseconds regardless of SMTP latency, and temporary SMTP failures do not cause user-facing errors.
Build an endpoint that renders a template without sending, useful for development and testing.
Wire everything together.
Test the service:
For local development without an SMTP server, use a tool like Mailpit or Ethereal Email as a catch-all inbox. In production, connect to a transactional email provider like Postmark or Amazon SES by updating the SMTP environment variables.
You now have a production-quality email service. The architecture — template rendering, queue persistence, async processing, retry logic, status tracking — is the same architecture used by every serious email service at scale.
mkdir email-service && cd email-service
npm init -y
npm install express nodemailer better-sqlite3
npm install -D typescript @types/node @types/express @types/nodemailer @types/better-sqlite3 tsx
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext
mkdir -p templates// src/types.ts
export type EmailStatus = "queued" | "sending" | "sent" | "failed" | "bounced";
export interface EmailMessage {
id: string;
to: string;
from: string;
subject: string;
html: string;
text: string;
templateId: string;
status: EmailStatus;
attempts: number;
maxAttempts: number;
lastError: string | null;
createdAt: string;
sentAt: string | null;
}
export interface EmailTemplate {
id: string;
name: string;
subject: string;
htmlBody: string;
textBody: string;
}
export interface SendRequest {
to: string;
templateId: string;
variables: Record<string, string>;
}// src/template-engine.ts
export function renderTemplate(template: string, variables: Record<string, string>): string {
let result = template;
// Variable substitution: {{variableName}}
result = result.replace(/\{\{(\w+)\}\}/g, (_, key: string) => {
return escapeHtml(variables[key] ?? "");
});
// Raw variable substitution (no escaping): {{{variableName}}}
result = result.replace(/\{\{\{(\w+)\}\}\}/g, (_, key: string) => {
return variables[key] ?? "";
});
// Conditional blocks: {{#if variableName}}content{{/if}}
result = result.replace(
/\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_, key: string, content: string) => {
return variables[key] ? content : "";
}
);
// Each blocks for simple lists: {{#each items}}{{.}}{{/each}}
result = result.replace(
/\{\{#each (\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g,
(_, key: string, content: string) => {
const items = variables[key]?.split(",") ?? [];
return items.map((item) => content.replace(/\{\{\.\}\}/g, item.trim())).join("");
}
);
return result;
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
return text.replace(/[&<>"']/g, (char) => map[char] ?? char);
}// src/template-store.ts
import { EmailTemplate } from "./types.js";
const LAYOUT = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; padding: 40px 20px; }
.card { background: #ffffff; border-radius: 8px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.button { display: inline-block; background: #3b82f6; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500; }
.footer { text-align: center; padding: 20px; color: #6b7280; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="card">
{{{content}}}
</div>
<div class="footer">
<p>{{companyName}} · {{companyAddress}}</p>
</div>
</div>
</body>
</html>`;
const templates = new Map<string, EmailTemplate>();
export function registerTemplate(template: EmailTemplate): void {
templates.set(template.id, template);
}
export function getTemplate(id: string): EmailTemplate | null {
return templates.get(id) ?? null;
}
export function wrapInLayout(content: string, variables: Record<string, string>): string {
const merged = {
...variables,
content,
companyName: variables.companyName ?? "Your Company",
companyAddress: variables.companyAddress ?? "",
};
let result = LAYOUT;
result = result.replace(/\{\{\{(\w+)\}\}\}/g, (_, key: string) => merged[key] ?? "");
result = result.replace(/\{\{(\w+)\}\}/g, (_, key: string) => merged[key] ?? "");
return result;
}
// Register default templates
registerTemplate({
id: "welcome",
name: "Welcome Email",
subject: "Welcome to {{appName}}, {{name}}!",
htmlBody: `
<h1>Welcome, {{name}}!</h1>
<p>We are excited to have you on board. Your account has been created and is ready to use.</p>
<p><a href="{{dashboardUrl}}" class="button">Go to Dashboard</a></p>
`,
textBody: "Welcome, {{name}}! Your account is ready. Visit {{dashboardUrl}} to get started.",
});
registerTemplate({
id: "password-reset",
name: "Password Reset",
subject: "Reset your password",
htmlBody: `
<h1>Password Reset</h1>
<p>Hi {{name}}, we received a request to reset your password.</p>
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
<p>This link expires in 1 hour. If you did not request this, you can safely ignore this email.</p>
`,
textBody: "Hi {{name}}, reset your password here: {{resetUrl}} (expires in 1 hour).",
});
registerTemplate({
id: "notification",
name: "Notification",
subject: "{{subject}}",
htmlBody: `
<h2>{{title}}</h2>
<p>{{message}}</p>
{{#if actionUrl}}<p><a href="{{actionUrl}}" class="button">{{actionLabel}}</a></p>{{/if}}
`,
textBody: "{{title}}: {{message}}",
});// src/database.ts
import Database from "better-sqlite3";
import { EmailMessage, EmailStatus } from "./types.js";
export class EmailDatabase {
private db: Database.Database;
constructor() {
this.db = new Database("emails.db");
this.db.pragma("journal_mode = WAL");
this.initialize();
}
private initialize(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS emails (
id TEXT PRIMARY KEY,
recipient TEXT NOT NULL,
sender TEXT NOT NULL,
subject TEXT NOT NULL,
html TEXT NOT NULL,
text_body TEXT NOT NULL,
template_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
sent_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_emails_status ON emails(status);
`);
}
enqueue(email: Omit<EmailMessage, "createdAt" | "sentAt">): void {
this.db
.prepare(
`
INSERT INTO emails (id, recipient, sender, subject, html, text_body, template_id, status, attempts, max_attempts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
)
.run(
email.id,
email.to,
email.from,
email.subject,
email.html,
email.text,
email.templateId,
email.status,
email.attempts,
email.maxAttempts
);
}
claimNext(): EmailMessage | null {
const row = this.db
.prepare(
`
SELECT * FROM emails WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1
`
)
.get() as Record<string, unknown> | undefined;
if (!row) return null;
this.db.prepare("UPDATE emails SET status = 'sending' WHERE id = ?").run(row.id);
return this.rowToMessage(row);
}
markSent(id: string): void {
this.db
.prepare("UPDATE emails SET status = 'sent', sent_at = datetime('now') WHERE id = ?")
.run(id);
}
markFailed(id: string, error: string): void {
this.db
.prepare(
"UPDATE emails SET status = 'failed', attempts = attempts + 1, last_error = ? WHERE id = ?"
)
.run(error, id);
}
requeueFailed(): number {
const result = this.db
.prepare(
"UPDATE emails SET status = 'queued' WHERE status = 'failed' AND attempts < max_attempts"
)
.run();
return result.changes;
}
getStats(): Record<EmailStatus, number> {
const rows = this.db
.prepare("SELECT status, COUNT(*) as count FROM emails GROUP BY status")
.all() as Array<{ status: EmailStatus; count: number }>;
const stats: Record<string, number> = { queued: 0, sending: 0, sent: 0, failed: 0, bounced: 0 };
for (const row of rows) {
stats[row.status] = row.count;
}
return stats as Record<EmailStatus, number>;
}
getRecent(limit = 20): EmailMessage[] {
const rows = this.db
.prepare("SELECT * FROM emails ORDER BY created_at DESC LIMIT ?")
.all(limit) as Array<Record<string, unknown>>;
return rows.map((r) => this.rowToMessage(r));
}
private rowToMessage(row: Record<string, unknown>): EmailMessage {
return {
id: row.id as string,
to: row.recipient as string,
from: row.sender as string,
subject: row.subject as string,
html: row.html as string,
text: row.text_body as string,
templateId: row.template_id as string,
status: row.status as EmailStatus,
attempts: row.attempts as number,
maxAttempts: row.max_attempts as number,
lastError: row.last_error as string | null,
createdAt: row.created_at as string,
sentAt: row.sent_at as string | null,
};
}
}// src/sender.ts
import nodemailer from "nodemailer";
import { EmailMessage } from "./types.js";
export class EmailSender {
private transporter: nodemailer.Transporter;
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST ?? "localhost",
port: Number(process.env.SMTP_PORT ?? 587),
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER ?? "",
pass: process.env.SMTP_PASS ?? "",
},
});
}
async send(email: EmailMessage): Promise<string> {
const info = await this.transporter.sendMail({
from: email.from,
to: email.to,
subject: email.subject,
html: email.html,
text: email.text,
headers: {
"X-Email-Id": email.id,
"X-Template-Id": email.templateId,
},
});
return info.messageId;
}
async verify(): Promise<boolean> {
try {
await this.transporter.verify();
return true;
} catch {
return false;
}
}
}// src/worker.ts
import { EmailDatabase } from "./database.js";
import { EmailSender } from "./sender.js";
export class EmailWorker {
private running = false;
private pollIntervalMs: number;
constructor(
private db: EmailDatabase,
private sender: EmailSender,
pollIntervalMs = 2000
) {
this.pollIntervalMs = pollIntervalMs;
}
async start(): Promise<void> {
this.running = true;
console.log("Email worker started.");
while (this.running) {
const email = this.db.claimNext();
if (email) {
try {
const messageId = await this.sender.send(email);
this.db.markSent(email.id);
console.log(`Sent email ${email.id} to ${email.to} (${messageId})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.db.markFailed(email.id, message);
console.error(`Failed to send ${email.id}: ${message}`);
}
} else {
// No emails to process, requeue eligible failures
const requeued = this.db.requeueFailed();
if (requeued > 0) {
console.log(`Requeued ${requeued} failed emails for retry.`);
}
}
await this.sleep(this.pollIntervalMs);
}
}
stop(): void {
this.running = false;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}// src/composer.ts
import { EmailDatabase } from "./database.js";
import { getTemplate, wrapInLayout } from "./template-store.js";
import { renderTemplate } from "./template-engine.js";
import { SendRequest } from "./types.js";
const DEFAULT_FROM = process.env.DEFAULT_FROM ?? "noreply@example.com";
export class EmailComposer {
constructor(private db: EmailDatabase) {}
send(request: SendRequest): string {
const template = getTemplate(request.templateId);
if (!template) {
throw new Error(`Template not found: ${request.templateId}`);
}
const subject = renderTemplate(template.subject, request.variables);
const htmlContent = renderTemplate(template.htmlBody, request.variables);
const html = wrapInLayout(htmlContent, request.variables);
const text = renderTemplate(template.textBody, request.variables);
const id = crypto.randomUUID();
this.db.enqueue({
id,
to: request.to,
from: DEFAULT_FROM,
subject,
html,
text,
templateId: request.templateId,
status: "queued",
attempts: 0,
maxAttempts: 3,
lastError: null,
});
return id;
}
sendBatch(requests: SendRequest[]): string[] {
return requests.map((req) => this.send(req));
}
}// src/api.ts
import express from "express";
import { EmailComposer } from "./composer.js";
import { EmailDatabase } from "./database.js";
export function createApi(composer: EmailComposer, db: EmailDatabase): express.Express {
const app = express();
app.use(express.json());
app.post("/send", (req, res) => {
try {
const { to, templateId, variables } = req.body;
if (!to || !templateId) {
res.status(400).json({ error: "to and templateId are required" });
return;
}
const id = composer.send({ to, templateId, variables: variables ?? {} });
res.status(202).json({ id, status: "queued" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(400).json({ error: message });
}
});
app.post("/send/batch", (req, res) => {
try {
const { messages } = req.body;
const ids = composer.sendBatch(messages);
res.status(202).json({ ids, count: ids.length });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(400).json({ error: message });
}
});
app.get("/status/:id", (req, res) => {
const emails = db.getRecent(100);
const email = emails.find((e) => e.id === req.params.id);
if (!email) {
res.status(404).json({ error: "Email not found" });
return;
}
res.json({
id: email.id,
to: email.to,
subject: email.subject,
status: email.status,
attempts: email.attempts,
sentAt: email.sentAt,
lastError: email.lastError,
});
});
app.get("/stats", (_, res) => {
res.json(db.getStats());
});
app.get("/recent", (req, res) => {
const limit = Number(req.query.limit) || 20;
res.json(db.getRecent(limit));
});
return app;
}// src/preview.ts
import { Router } from "express";
import { getTemplate, wrapInLayout } from "./template-store.js";
import { renderTemplate } from "./template-engine.js";
export function createPreviewRouter(): Router {
const router = Router();
router.post("/preview/:templateId", (req, res) => {
const template = getTemplate(req.params.templateId);
if (!template) {
res.status(404).json({ error: "Template not found" });
return;
}
const variables = req.body.variables ?? {};
const subject = renderTemplate(template.subject, variables);
const htmlContent = renderTemplate(template.htmlBody, variables);
const html = wrapInLayout(htmlContent, variables);
const text = renderTemplate(template.textBody, variables);
res.json({ subject, html, text });
});
router.get("/preview/:templateId/render", (req, res) => {
const template = getTemplate(req.params.templateId);
if (!template) {
res.status(404).send("Template not found");
return;
}
const variables = req.query as Record<string, string>;
const htmlContent = renderTemplate(template.htmlBody, variables);
const html = wrapInLayout(htmlContent, variables);
res.setHeader("Content-Type", "text/html");
res.send(html);
});
return router;
}// src/index.ts
import { EmailDatabase } from "./database.js";
import { EmailSender } from "./sender.js";
import { EmailComposer } from "./composer.js";
import { EmailWorker } from "./worker.js";
import { createApi } from "./api.js";
import { createPreviewRouter } from "./preview.js";
const db = new EmailDatabase();
const sender = new EmailSender();
const composer = new EmailComposer(db);
const worker = new EmailWorker(db, sender);
const app = createApi(composer, db);
app.use(createPreviewRouter());
app.listen(3000, () => {
console.log("Email service API on port 3000");
});
worker.start().catch(console.error);# Send a welcome email
curl -X POST http://localhost:3000/send \
-H "Content-Type: application/json" \
-d '{"to": "user@example.com", "templateId": "welcome", "variables": {"name": "Alice", "appName": "MyApp", "dashboardUrl": "https://app.example.com"}}'
# Preview a template in the browser
open "http://localhost:3000/preview/welcome/render?name=Alice&appName=MyApp&dashboardUrl=https://app.example.com"
# Check delivery stats
curl http://localhost:3000/stats