Developer Experience
Semantic tags like mj-button instead of nested tables
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:
<style> blocks and requires inline stylesWriting email HTML that works everywhere requires:
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:
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:
| Template | Purpose |
|---|---|
verification | Email address verification for new brokers |
magic_link | Passwordless login for favorites sync |
lead_assignment | Internal lead routing notifications |
These templates use HomeStar branding and cannot be overridden.
Client-facing emails that brokerages may want to brand:
| Template | Purpose |
|---|---|
lead_notification | New lead alerts to agents |
tour_confirmation | Property tour confirmations to clients |
market_report | Market 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:
{variable} placeholders are replaced with actual valueshtml = 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:
| Variable | Source | Fallback |
|---|---|---|
brokerage_name | Brokerage record | (required in call) |
brokerage_logo_url | Brokerage record | HomeStar 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:
Customizable templates support per-brokerage overrides with cascading resolution.
When rendering a template:
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 Field | Null Behavior |
|---|---|
subject_template | Use system default subject |
mjml_content | Use system default body |
text_content | Use 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:
Not recommended:
MJML compilation happens at save time for performance.
mjml_to_html()compiled_html columncompiled_at timestamp updatedAt send time:
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.htmlCommon warnings:
| Content Type | Typical Size |
|---|---|
| MJML source | 2-5 KB |
| Compiled HTML | 8-15 KB |
| Text fallback | 0.5-1 KB |
| Variable schema | 0.5-1 KB |
| Operation | Typical Time |
|---|---|
| Load from database | 1-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.
mjml-export commandMJML 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:
Every template save records:
updated_by — Email of the editorupdated_at — Timestamp of changecompiled_at — When HTML was regenerated