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.
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 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
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).
Example: Node.js Mail Rendering
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.
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.
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.
© 2026 Example Inc. All rights reserved.