Email
Templates
Best Practices

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)
  • mjml and pug installed
  • Public logo and brand assets in /public

Quick start

npm i mjml pug
# or
pnpm add mjml pug

Create 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.

6 min read
Dmytro Vavreniuk

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, and mj-text encode 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
    BillingNotice

Privacy & 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/footer

Reusable 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).

Logo

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.