Building a Robust Mail Template System
TL;DR
Use MJML for reliable responsive layout and Pug for concise templating/partials. Render server-side with safe variables only, keep assets in /public, and version your templates.
Prerequisites
- Node.js 18+ and a mail provider (SMTP or API)
mjmlandpuginstalled- Public logo and brand assets in
/public
Quick start
npm i mjml pug
# or
pnpm add mjml pugCreate views/invite.pug, add header/footer partials, then render with the Node snippet below.
A high-level overview of setting up a reusable, maintainable email templating system with strong privacy practices. This article contains no personally identifiable information (PII) or private data.
Dmytro Vavreniuk
Product Engineer
This post describes architecture, patterns, and guidelines at a conceptual level. It avoids any sensitive details and uses generic names and examples only.
Why MJML + Pug over Raw Handlebars
- Layout safety: MJML compiles to responsive HTML that works across clients (Outlook, Gmail, Apple Mail), reducing brittle table markup you would handcraft with Handlebars.
- Abstractions: Components like
mj-section,mj-column, andmj-textencode email best practices, whereas Handlebars leaves all HTML/CSS correctness to you. - Templating ergonomics: Pug enables includes, mixins, and concise syntax for partials and layouts; Handlebars is verbose for nested tables and partial composition.
- Maintainability: Clear separation of structure (MJML), templating (Pug), and data (server variables) makes large template systems easier to evolve than pure Handlebars HTML.
- Security & hygiene: Using variables only for links and copy while relying on MJML components limits inline, unsafe HTML manipulation typical in Handlebars-only templates.
Goals of a Mail Template System
- Reusable components for headers, footers, and content blocks
- Consistent visual language across transactional and marketing emails
- Localization-ready copy and flexible variables for dynamic content
- Accessible markup that renders well across major clients
Recommended Structure
templates/
components/
Button
Container
Header
Footer
layouts/
BaseLayout
MarketingLayout
TransactionalLayout
messages/
WelcomeEmail
PasswordReset
BillingNoticePrivacy & Data Hygiene
- Do not hardcode any PII or secrets in templates or examples
- Use placeholder variables (e.g.,
{{customer_name}}) and mock data during development - Sanitize logs; avoid capturing email contents that include sensitive data
- Review assets to ensure no embedded PII in images or links
Deployment Tips
- Version templates and track changes with semantic commits
- Use environment variables for external URLs and assets
- Provide a migration guide when changing template variables
Example: MJML + Pug Email (invite.pug excerpt)
mjml(lang="en" dir="ltr")
include templates/head
mj-body(width="540px" background-color="#dddcdc")
include templates/header
mj-section(css-class="section" padding-top="16px")
mj-column
mj-text(padding="16px 0")
h1(style="font-weight: 700; font-size: 22px; text-align: center; margin: 0")
| Welcome onboard!
mj-section(css-class="section")
mj-column
mj-text(font-size="16px" align="center" padding="0")
| You have been invited to a project.
mj-button(href=acceptUrl background-color="#007bff" color="#ffffff" font-size="16px" padding="16px 0" border-radius="4px" align="center")
| Accept Invitation
include templates/footerReusable partials (include templates/head, header, footer) and safe, variable-driven logic (e.g. acceptUrl is a server-assigned link, not PII).
Example: Node.js Mail Rendering (without PII)
const pug = require('pug')
const mjml2html = require('mjml')
// Compile with mock/placeholder data only
const html = mjml2html(
pug.renderFile('views/invite.pug', {
acceptUrl: 'https://example.com/invite/accept?token=1234',
year: new Date().getFullYear(),
// ...other generic parameters, NO private info
})
)
// Use html.html for email delivery
This pipeline uses only placeholder/mock variable values and never stores or renders PII in source, templates, or logs.
Example: Microservice Server (TypeScript)
import express, { Request, Response } from 'express'
import path from 'path'
import mjml2html from 'mjml'
import pug from 'pug'
const app = express()
// Configure Pug views directory (e.g., views/*.pug)
app.set('views', path.join(process.cwd(), 'views'))
app.set('view engine', 'pug')
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'OK' })
})
// GET /invite?url=https://example.com/accept
app.get('/invite', (req: Request, res: Response) => {
const { url } = req.query as { url?: string }
res.render(
'invite.pug',
{ acceptUrl: url, year: new Date().getFullYear() },
(err: Error | null, html?: string) => {
if (err) return res.status(500).send(String(err))
const { html: emailHtml, errors } = mjml2html(html as string)
if (errors?.length) return res.status(500).json(errors)
res.type('html').send(emailHtml)
}
)
})
const port = Number(process.env.PORT || 3000)
app.listen(port, '0.0.0.0', () => {
console.log(`mail microservice listening at http://localhost:${port}`)
})Expose one route per message type and render MJML compiled from Pug. Accept only safe parameters (e.g., links), validate inputs, and keep all assets public.
Example: Header partial (templates/header.pug)
mj-section(padding="16px" background-color="transparent")
mj-section(css-class="section")
mj-column
mj-image(
padding="0"
src="/logo.png"
alt="logo"
href="/"
width="155px"
)
mj-text(padding="0" font-size="16px" align="center" color="#888888")
| Example Inc.Branding-only content. Replace URLs, alt text, and copy with your public brand assets. No private data.
Example: Footer partial (templates/footer.pug)
mj-section(css-class="section")
mj-column
mj-text(font-size="12px" align="center" color="#888888" padding="32px 0 16px 0")
| If you did not expect this message, you can safely ignore it.
br
br
| © #{year} Example Inc. All rights reserved.
mj-section(padding="16px" background-color="transparent")Uses year as a safe, generic variable. Avoid personal data or identifiers in footer content.
Rendered Preview (Example)
This is an on-page preview that approximates the final email rendering (no PII).
Example Inc.
Welcome onboard!
You have been invited to a project. Click the button below to accept your invitation.
If you did not expect this message, you can safely ignore it.
© 2025 Example Inc. All rights reserved.