Personalise Report Emails with Jinja2 Templates
The first version of a report email is usually an f-string: f"Hi {name}, attached is the {month} report." It works until the message needs a table of the recipient's figures, a paragraph that only appears when something is overdue, an HTML version with styling and a plain-text version that says the same thing — and a manager who wants to change the wording without editing Python. At that point the wording belongs in templates. Jinja2 is the standard Python template engine: templates are plain files with placeholders, loops and conditions, and the Python code only prepares the data for each recipient. This page builds personalised report emails for a list of branch managers with Jinja2, covering the details that matter for email: escaping, failing loudly on a missing field, number formatting, a shared layout, and checking the rendered messages before they go out. It belongs to Emailing Excel Reports with smtplib.
Prerequisites
pip install jinja2 pandas openpyxl
The data is one row per branch with the manager's name and email, revenue against target and overdue invoices. The emails are built with the standard library's email.message.EmailMessage; sending them is the same as for any other message and is covered in Send Each Recipient Their Own Excel Report with Python.
Step 1 — Put the templates in files
from pathlib import Path
tpl = Path("templates")
tpl.mkdir(exist_ok=True)
(tpl / "subject.txt").write_text("{{ branch }}: {{ month }} results — {{ revenue | money }} ({{ vs_target | pct }} vs target)")
(tpl / "base.html").write_text("""<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#222">
{% block content %}{% endblock %}
<p style="color:#888;font-size:12px">Sent automatically by the finance reporting job. Reply to this email with questions.</p>
</body></html>""")
(tpl / "results.html").write_text("""{% extends "base.html" %}
{% block content %}
<p>Hi {{ first_name }},</p>
<p>Here are the {{ month }} results for <b>{{ branch }}</b>, prepared for {{ manager }}.</p>
<table style="border-collapse:collapse">
<tr><td style="padding:4px 12px">Revenue</td><td style="padding:4px 12px;text-align:right">{{ revenue | money }}</td></tr>
<tr><td style="padding:4px 12px">Target</td><td style="padding:4px 12px;text-align:right">{{ target | money }}</td></tr>
<tr><td style="padding:4px 12px">Against target</td>
<td style="padding:4px 12px;text-align:right;color:{{ '#1a7f37' if vs_target >= 0 else '#c62828' }}">{{ vs_target | pct }}</td></tr>
</table>
{% if overdue_count %}
<p><b>{{ overdue_count }} invoice{{ 's' if overdue_count != 1 }}</b> totalling {{ overdue_amount | money }}
{{ 'are' if overdue_count != 1 else 'is' }} overdue. The list is on the <i>Overdue</i> sheet of the attached workbook.</p>
{% else %}
<p>No invoices are overdue — thank you.</p>
{% endif %}
{% endblock %}""")
(tpl / "results.txt").write_text("""Hi {{ first_name }},
Here are the {{ month }} results for {{ branch }}.
Revenue {{ "%12s" | format(revenue | money) }}
Target {{ "%12s" | format(target | money) }}
Against target {{ "%12s" | format(vs_target | pct) }}
{% if overdue_count %}
{{ overdue_count }} invoice{{ 's' if overdue_count != 1 }} totalling {{ overdue_amount | money }} {{ 'are' if overdue_count != 1 else 'is' }} overdue.
{% else %}
No invoices are overdue - thank you.
{% endif %}
-- Sent automatically by the finance reporting job.
""")
print(sorted(p.name for p in tpl.iterdir()))
There are four templates: the subject line, a shared HTML layout, the HTML body that extends it, and a plain-text body. Each template holds only wording and layout — no calculations — so whoever owns the message can edit it without touching the script. The HTML template uses {% extends %} and {% block %} so that the greeting, footer and styling live in one place when there are several kinds of report email. The plain-text template exists because every HTML email should also carry a text part: some mail clients and many notification previews show only the text, and spam filters treat HTML-only messages with suspicion. Writing it as its own template, rather than stripping tags from the HTML, gives a version that reads properly.
Step 2 — An environment that escapes, fails loudly and formats numbers
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
env = Environment(
loader=FileSystemLoader("templates"),
autoescape=select_autoescape(enabled_extensions=("html",), default_for_string=False),
undefined=StrictUndefined,
trim_blocks=True, lstrip_blocks=True,
)
env.filters["money"] = lambda x: f"£{x:,.2f}"
env.filters["pct"] = lambda x: f"{x:+.1%}"
Three settings make Jinja2 safe for this job. Autoescaping for .html templates turns <, > and & in the data into HTML entities, so a manager name like Smith & Sons .txt templates, where entities would show up literally. StrictUndefined makes any placeholder without a value raise an error; the default is to render it as an empty string, which produces emails that say "Hi ," and nobody notices until a recipient replies. trim_blocks and lstrip_blocks remove the blank lines that {% if %} tags would otherwise leave in the text version. The two filters keep number formatting in one place, so every email shows money and percentages the same way.
Step 3 — Build one context per recipient and render
from email.message import EmailMessage
from email.utils import formataddr
import pandas as pd
results = pd.read_excel("branch_results.xlsx")
month = "September 2026"
def context(row):
return {"branch": row["Branch"], "manager": row["Manager"], "first_name": row["Manager"].split()[0], "month": month,
"revenue": row["Revenue"], "target": row["Target"], "vs_target": row["Revenue"] / row["Target"] - 1,
"overdue_count": int(row["Overdue invoices"]), "overdue_amount": row["Overdue amount"]}
def build(row):
ctx = context(row)
msg = EmailMessage()
msg["Subject"] = env.get_template("subject.txt").render(ctx).strip()
msg["From"] = formataddr(("Finance Reporting", "reports@example.com"))
msg["To"] = formataddr((row["Manager"], row["Email"]))
msg.set_content(env.get_template("results.txt").render(ctx))
msg.add_alternative(env.get_template("results.html").render(ctx), subtype="html")
return msg
messages = [build(row) for _, row in results.iterrows()]
for m in messages:
print(m["Subject"])
All the logic — which numbers, calculated how — lives in context(). It turns a spreadsheet row into a plain dictionary of exactly what the templates need, with names that make sense in the template rather than the column headings of the source. Keeping calculations out of the templates matters: a template that computes revenue / target - 1 itself is hard to test and easy to break when someone edits the wording. The subject is rendered from a template too and stripped, because a stray newline in a subject header makes the message invalid. The workbook attachment is added exactly as in any other report email, with msg.add_attachment(...).
Step 4 — Check the rendered messages before sending
from pathlib import Path
out = Path("preview")
out.mkdir(exist_ok=True)
for row, m in zip(results.itertuples(), messages):
(out / f"{row.Branch}.eml").write_bytes(bytes(m))
html = m.get_body(preferencelist=("html",)).get_content()
line = next(l for l in html.splitlines() if "prepared for" in l)
print(f"{row.Branch:8}", line.split("prepared for ")[1])
print(messages[1].get_body(preferencelist=("plain",)).get_content())
Writing each message to an .eml file gives a complete preview: double-clicking one opens it in the mail client exactly as the recipient will see it, with both parts and any attachments. Reviewing the previews on the first run of a new template, or after a wording change, catches the problems that tests miss — a paragraph that reads awkwardly, a figure in the wrong place, a table that looks wrong in Outlook. The printed lines show that the Cardiff contact name, Smith & Sons Smith & Sons <Agency> — displayed correctly by the mail client rather than swallowed as an unknown tag — while Tom O'Neill's apostrophe is encoded harmlessly; and the text body of the Bristol message shows the "nothing overdue" branch of the condition.
Step 5 — Catch a missing field before the run sends anything
from jinja2 import UndefinedError
try:
env.get_template("results.html").render({k: v for k, v in context(results.iloc[0]).items() if k != "first_name"})
except UndefinedError as e:
print("render failed:", e)
With StrictUndefined, a template that refers to a value the context does not provide fails with a clear message naming it. Because every message is rendered before any is sent — the messages list in step 3 is complete before the first send_message — a mistake in one recipient's data or a renamed placeholder in a template stops the whole run, instead of sending half the emails correctly and the rest with gaps. Pair this with a log of what was sent so a rerun after a fix does not send duplicates.
Who edits what
The point of templates is to divide the work cleanly. The person who owns the message — often in finance or operations rather than development — edits the .txt and .html files: the greeting, the wording, which figures appear and in what order. The developer owns context() and the filters, which decide what values exist and how they are calculated and formatted. A useful convention is a short comment at the top of each template listing the variables available to it, so editors know what they can use without reading the Python. Keep the templates under version control alongside the script, so a change of wording is reviewed and can be undone like any other change.
Common pitfalls
- HTML templates without autoescape. Data with
&or<breaks the layout; enable it for.html. - Silent blanks. The default undefined renders as nothing; use
StrictUndefined. - Calculations in templates. Prepare values in Python and pass them in the context.
- HTML-only emails. Add a plain-text part rendered from its own template.
- Newlines in the subject. Strip the rendered subject before setting the header.
Conclusion
Jinja2 separates what a report email says from how its data is prepared. Put the subject, text and HTML bodies in template files with a shared layout, create the environment with autoescape for HTML, StrictUndefined and filters for money and percentages, build one context dictionary per recipient, render every message before sending any, and preview them as .eml files the first time a template changes.
Frequently asked questions
How do I use Jinja2 for email templates in Python?
Create an Environment with a FileSystemLoader, render the subject, text and HTML templates with a context dictionary, and put the results into an EmailMessage.
How do I make Jinja2 fail when a variable is missing?
Pass undefined=StrictUndefined to the Environment; rendering then raises UndefinedError instead of inserting an empty string.
Should autoescape be on for email templates?
Yes for HTML templates, so that characters like & and < in the data are displayed safely; leave it off for plain-text templates.
How do I format currency in a Jinja2 template?
Register a filter in Python, such as env.filters["money"], and use it in the template as {{ revenue | money }}.
Related
- Up one level: Emailing Excel Reports with smtplib — sending reports by email.
- Email an Excel Report with an HTML Summary Body — HTML email styling.
- Generate an HTML Report from Excel Data with Jinja2 — Jinja2 for web reports.
- Test Report Emails with a Local SMTP Server — test before sending.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toDownload Excel Attachments from an Email Inbox with Python
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toEmail an Excel Report with an HTML Summary Body
- How-toEmbed a Chart Image in the Body of a Report Email