A brand-matched HTML email shell. Single-file Python module. Works in every major email client without an MJML build step.
Three things keep tripping me up when building transactional emails:
- Outlook does not understand
<div>for layout. You need<table>for anything more complex than a single column. MJML solves this with a build step. I wanted a no-build-step alternative. - Most email clients strip
<style>blocks. Or they leave the block intact but ignore the rules. You need inline styles on every element that matters, and a top-level<style>only as a progressive enhancement for the clients that DO honour it. - Plain-text fallback is non-optional for deliverability. Resend, SES, Postmark all want a
textfield alongsidehtml. Most teams either skip it or write it twice. This module derives a clean plain-text version from the HTML automatically.
The result is a single module — email_styles.py — that exposes a small set of component functions (wrap_email, paragraph, button, data_table, callout, etc.) you compose into a real email. No build step, no Jinja, no third-party deps. Inline styles on every element. Table-based layout for Outlook compatibility. Mobile responsive via a media query that the supporting clients honour and the others gracefully ignore.
- Single-column 600px layout (responsive on mobile, collapses to full-width below 600px)
- Brand tokens (colors, font stack, spacing) as module constants — change them once, all components update
- Components:
wrap_email,paragraph,heading,button,secondary_button,callout,data_table,divider,small_text,eyebrow,hero_title - Hidden preheader (the inbox-preview line)
- Plain-text fallback via
text_fallback(html_str)— strips tags, decodes entities, collapses whitespace, handles<style>and<head>blocks cleanly - Outlook-specific:
mso-padding-alton buttons,bgcolorattribute alongsidestyle="background" - Apple Mail dark-mode hint via
prefers-color-schememedia query
from email_styles import (
wrap_email, eyebrow, hero_title, paragraph, data_table,
button, secondary_button, callout, text_fallback,
)
html = wrap_email(
preheader="Your monthly summary is ready.",
body_blocks=[
eyebrow("Monthly summary"),
hero_title("April was your best month yet."),
paragraph("Hi Alice,\n\nHere are the numbers."),
data_table([
("Total revenue", "£12,400"),
("Active customers", "47"),
("Net new this month", "+8"),
("Estimated MRR", "£8,800"),
], accent_last=True),
paragraph("If you want to dig in, the dashboard has the full breakdown."),
button("Open the dashboard", "https://example.com/dashboard"),
secondary_button("Forward to a colleague", "mailto:?subject=April%20summary"),
callout("Heads up - we're shipping a new export feature next week. Reply to this email if you'd like early access."),
paragraph("Sal"),
],
)
# Send via Resend, SES, Postmark, whatever
text = text_fallback(html)
# resend.Emails.send({"from": "...", "to": [...], "html": html, "text": text, ...})wrap_email is the outer shell. Everything else returns a string of HTML — usually a single <tr> row in the inner 600px-wide layout table — and you pass a list of those strings to wrap_email as body_blocks. The order in the list is the order in the email.
This means you can build dynamic emails by computing body_blocks at runtime. Conditional sections, data-driven rows, all just list comprehensions.
body_blocks = [hero_title("Hi Alice"), paragraph("Here are your stats.")]
if metrics["new_customers"] > 0:
body_blocks.append(callout(f"You added {metrics['new_customers']} customers this week."))
body_blocks.append(button("Open dashboard", dashboard_url))- No JSX-like nesting. Every component is a flat string of HTML.
- No build step. Run
pythonand you have email. - No CLI. The module is the API.
- No template engine. Python f-strings + a small set of helper functions.
What this costs you:
- You can't trivially share templates with a designer who doesn't know Python. They'd be editing Python f-strings.
- Heavily-styled or unusual layouts (multi-column, complex hero images) need custom component functions you'd add to the module.
For 80% of transactional email use cases (welcome, magic-link, receipt, summary, autoresponder), the trade is worth it.
email_styles.py— the module (~300 lines, no dependencies)demo.py— generates 4 example emails topreviews/so you can open them in a browserpreviews/*.html— generated example emails (commit them so visitors can preview without running anything)
python demo.py
open previews/welcome.html
open previews/monthly-summary.html
open previews/playback.html
open previews/cancellation.htmlMIT. Take it, fork it, change the brand colors. If you find a rendering bug in a major client (Gmail web, iOS Mail, Outlook 2019+) please open an issue.