Skip to content

Email Template Architecture

Understanding how the email template system processes MJML, handles variable substitution, and supports per-brokerage customization.


Email clients are notoriously inconsistent in rendering HTML. What works in Gmail breaks in Outlook. What looks perfect on mobile fails on desktop webmail.

Common email HTML challenges:

  • Outlook ignores CSS margins and requires MSO conditionals
  • Gmail strips <style> blocks and requires inline styles
  • Yahoo Mail has unpredictable rendering quirks
  • Mobile clients need responsive width handling

Writing email HTML that works everywhere requires:

  • Table-based layouts (not CSS Grid or Flexbox)
  • Inline CSS styles on every element
  • Microsoft Office conditionals for Outlook
  • Careful responsive breakpoints

MJML (Mailjet Markup Language) is a framework that compiles to email-safe HTML. You write semantic components; MJML generates the complex underlying code.

<mjml>
<mj-body>
<mj-section background-color="#f8fafc">
<mj-column>
<mj-text font-size="16px">
Hello {client_name}!
</mj-text>
<mj-button href="{action_url}">
View Details
</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>

This compiles to ~200 lines of email-safe HTML with:

  • Nested tables for layout
  • Inline styles on every element
  • MSO conditionals for Outlook
  • Responsive width calculations

Developer Experience

Semantic tags like mj-button instead of nested tables

Consistency

Same output renders identically across email clients

Responsive

Built-in mobile breakpoints and stacking

Maintainable

Changes to source are easy to understand and review


Templates are categorized by who can edit them:

System-critical emails that should be consistent across all brokerages:

TemplatePurpose
verificationEmail address verification for new brokers
magic_linkPasswordless login for favorites sync
lead_assignmentInternal lead routing notifications

These templates use HomeStar branding and cannot be overridden.

Client-facing emails that brokerages may want to brand:

TemplatePurpose
lead_notificationNew lead alerts to agents
tour_confirmationProperty tour confirmations to clients
market_reportMarket report delivery

Brokerages can override these with custom subject lines, content, and styling while maintaining the variable contracts.


Templates use {variable_name} placeholders that are replaced at send time.

When an email is sent:

  1. Template content is loaded (brokerage override or system default)
  2. Pre-compiled HTML is retrieved (or MJML is compiled on-the-fly)
  3. All {variable} placeholders are replaced with actual values
  4. Final HTML is sent via email provider
html = render_template(
"tour_confirmation",
brokerage_id=5,
client_name="Sarah Johnson",
property_address="123 Oak Street",
tour_date="Saturday, January 25",
tour_time="2:00 PM",
agent_name="Mike Chen"
)

When brokerage_id is provided, two variables are automatically injected:

VariableSourceFallback
brokerage_nameBrokerage record(required in call)
brokerage_logo_urlBrokerage recordHomeStar logo SVG

This ensures consistent branding without requiring every caller to look up brokerage details.

Each template defines a schema documenting its variables:

{
"client_name": {
"type": "string",
"required": true,
"description": "Recipient's display name"
},
"tour_date": {
"type": "string",
"required": true,
"description": "Formatted tour date"
},
"agent_phone_display": {
"type": "string",
"required": false,
"description": "Agent phone with HTML formatting"
}
}

The editor uses this schema to:

  • Show variable chips with descriptions
  • Highlight required variables in amber
  • Provide autocomplete in the subject field

Customizable templates support per-brokerage overrides with cascading resolution.

When rendering a template:

  1. Brokerage Override — Check for active override for this brokerage
  2. System Default — Use the platform-wide template from database
  3. File Fallback — Load from filesystem (legacy support during migration)
def get_template_content(slug, brokerage_id=None, db=None):
# 1. Try brokerage override first
if brokerage_id:
override = db.query(BrokerageEmailTemplate)...
if override and override.is_active:
return override.get_effective_content()
# 2. Try system default from database
template = db.query(EmailTemplate)...
if template:
return template
# 3. File-based fallback
return load_from_file(slug)

Brokerage overrides can customize only specific fields:

Override FieldNull Behavior
subject_templateUse system default subject
mjml_contentUse system default body
text_contentUse system default plain text

A brokerage might override just the subject line while keeping the standard body, or customize the entire email.

Good candidates for override:

  • Adding brokerage taglines to subject lines
  • Customizing color schemes to match brand
  • Including brokerage-specific contact information
  • Modifying call-to-action button text

Not recommended:

  • Removing required information (unsubscribe links, etc.)
  • Changing the fundamental purpose of the email
  • Breaking variable contracts

MJML compilation happens at save time for performance.

  1. Admin edits MJML in visual or code editor
  2. Admin clicks Save
  3. Server compiles MJML → HTML using mjml_to_html()
  4. Compiled HTML stored in compiled_html column
  5. compiled_at timestamp updated

At send time:

  • Pre-compiled HTML is loaded directly (no compilation)
  • Variable substitution is simple string replacement
  • No MJML parsing overhead

If compiled_html is missing (legacy or error), compilation happens on-the-fly with a warning logged.

MJML is forgiving but can produce warnings:

result = mjml_to_html(mjml_content)
if result.errors:
# Log warnings but proceed
logger.warning(f"MJML warnings: {result.errors}")
html = result.html

Common warnings:

  • Deprecated attributes
  • Missing recommended attributes
  • Non-standard tag usage

Content TypeTypical Size
MJML source2-5 KB
Compiled HTML8-15 KB
Text fallback0.5-1 KB
Variable schema0.5-1 KB
OperationTypical Time
Load from database1-5 ms
Variable substitution< 1 ms
MJML compilation (fallback)50-200 ms

Pre-compilation eliminates the 50-200ms MJML overhead at send time.


The frontend uses GrapesJS with the MJML plugin:

GrapesJS and its MJML plugin are loaded dynamically when a user first opens the visual editor:

if (!grapesjs) {
const [gjsModule, mjmlModule] = await Promise.all([
import('grapesjs'),
import('grapesjs-mjml'),
]);
grapesjs = gjsModule.default;
grapesjsMjml = mjmlModule.default;
}

This reduces initial bundle size by ~500KB.

  • Loading: MJML is parsed into GrapesJS components
  • Editing: Changes update the internal component model
  • Saving: Components are exported back to MJML via mjml-export command

MJML Native

First-class MJML support via official plugin

Drag & Drop

Visual block-based editing for non-developers

Extensible

Custom blocks, panels, and commands possible

Code Access

Switch to raw MJML for power users


Variables are substituted with simple string replacement. To prevent XSS:

  • Email HTML is rendered in iframes (preview)
  • Email clients sanitize HTML on their end
  • Don’t allow arbitrary HTML in variable values
  • Platform templates: Admin-only editing
  • Customizable templates: Future broker editing via role check
  • All templates: Read-only for non-admin users

Every template save records:

  • updated_by — Email of the editor
  • updated_at — Timestamp of change
  • compiled_at — When HTML was regenerated