Email an Excel Report with an HTML Summary Body
An emailed report that says "please find attached" makes every recipient open a spreadsheet to learn one number. Putting the headline figures in the body answers most people's question before they leave their inbox — and the ones who need detail still have the workbook. This guide builds that email: a multipart message with a plain-text part, an HTML body carrying a styled summary table, an embedded chart, and the .xlsx attached. It extends Emailing Excel Reports with smtplib.
Prerequisites
pip install pandas xlsxwriter matplotlib
Everything else is standard library. Credentials come from the environment:
export SMTP_HOST=smtp.example.com
export SMTP_PORT=587
export SMTP_USER=reporting@example.com
export SMTP_PASSWORD='...'
Some figures to report:
import pandas as pd
summary = pd.DataFrame({
"region": ["North", "South", "West", "East"],
"revenue": [5150.00, 4268.50, 3511.25, 2980.10],
"target": [4500.00, 4500.00, 3000.00, 3500.00],
})
summary["variance"] = summary["revenue"] / summary["target"] - 1
Step 1 — Write the plain-text version first
Starting with text keeps you honest about what actually matters — if a figure does not earn a line here, it does not belong in the HTML either:
def text_body(summary, period="August 2026"):
"""The message as plain text. Written first, deliberately."""
total = summary["revenue"].sum()
target = summary["target"].sum()
variance = total / target - 1
lines = [
f"Regional revenue — {period}",
"",
f"Total revenue {total:>12,.2f}",
f"Target {target:>12,.2f}",
f"Variance {variance:>11.1%}",
"",
"By region:",
]
for row in summary.itertuples(index=False):
lines.append(
f" {row.region:<8} {row.revenue:>10,.2f} ({row.variance:+.1%})"
)
lines += ["", "The full detail is attached."]
return "\n".join(lines)
Step 2 — Build the HTML with inline styles
Mail clients strip stylesheets, ignore most modern CSS, and in several cases still lay out with tables. Every style has to be inline, and the layout has to be a table:
import html
CELL = "padding:8px 12px;border-bottom:1px solid #cdd5e6;font-size:14px;"
HEAD = ("padding:8px 12px;background-color:#4338ca;color:#ffffff;"
"font-size:13px;font-weight:bold;text-align:left;")
def html_body(summary, period="August 2026", chart_cid=None):
"""The message as HTML. Every style inline; tables for layout."""
total = summary["revenue"].sum()
target = summary["target"].sum()
variance = total / target - 1
tone = "#0b6157" if variance >= 0 else "#be185d"
rows = []
for row in summary.itertuples(index=False):
colour = "#0b6157" if row.variance >= 0 else "#be185d"
rows.append(
f'<tr>'
f'<td style="{CELL}">{html.escape(str(row.region))}</td>'
f'<td style="{CELL}text-align:right;">{row.revenue:,.2f}</td>'
f'<td style="{CELL}text-align:right;color:{colour};'
f'font-weight:bold;">{row.variance:+.1%}</td>'
f'</tr>'
)
chart = (
f'<img src="cid:{chart_cid}" alt="Revenue by region" '
f'style="display:block;width:100%;max-width:520px;height:auto;'
f'margin:20px 0;border:1px solid #cdd5e6;border-radius:6px;">'
if chart_cid else ""
)
return f"""\
<!doctype html>
<html><body style="margin:0;padding:0;background-color:#f4f7ff;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:#f4f7ff;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0"
style="width:560px;max-width:100%;background-color:#ffffff;
border-radius:10px;padding:24px;
font-family:Arial,Helvetica,sans-serif;color:#172033;">
<tr><td>
<p style="margin:0 0 4px;font-size:19px;font-weight:bold;color:#4338ca;">
Regional revenue</p>
<p style="margin:0 0 20px;font-size:13px;color:#5b6780;">
{html.escape(period)}</p>
<p style="margin:0 0 20px;font-size:15px;">
Total <strong>{total:,.2f}</strong> against a target of
{target:,.2f} —
<strong style="color:{tone};">{variance:+.1%}</strong>.
</p>
{chart}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="border-collapse:collapse;">
<tr>
<th style="{HEAD}">Region</th>
<th style="{HEAD}text-align:right;">Revenue</th>
<th style="{HEAD}text-align:right;">vs target</th>
</tr>
{''.join(rows)}
</table>
<p style="margin:20px 0 0;font-size:13px;color:#5b6780;">
The full detail is attached.</p>
</td></tr>
</table>
</td></tr></table>
</body></html>"""
Three constraints worth internalising. Escape anything from the data — a region called Smith & Co breaks the markup otherwise, and html.escape costs nothing. Fix the outer table's width in pixels, because percentage widths render unpredictably across clients. And avoid flexbox, grid and background images: several widely used clients support none of them, and the fallback is usually a broken layout rather than a plain one.
Step 3 — Assemble the message
EmailMessage builds the multipart structure for you, provided the calls happen in the right order:
import mimetypes
from email.message import EmailMessage
from email.utils import formataddr, make_msgid
from pathlib import Path
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
def build_message(summary, attachment, sender, recipients,
period="August 2026", chart_path=None):
"""A multipart email with a text part, an HTML part and attachments."""
msg = EmailMessage()
msg["From"] = formataddr(("Reporting", sender))
msg["To"] = ", ".join(recipients)
msg["Subject"] = f"Regional revenue — {period}"
# 1. Plain text becomes the message body.
msg.set_content(text_body(summary, period))
# 2. A Content-ID for the chart, if there is one.
cid = None
if chart_path:
cid = make_msgid(domain="reports.example.com")
# 3. HTML is added as an alternative to the text.
msg.add_alternative(
html_body(summary, period, chart_cid=cid.strip("<>") if cid else None),
subtype="html",
)
# 4. The chart is related to the HTML part, not to the message.
if chart_path:
html_part = msg.get_payload()[1]
html_part.add_related(
Path(chart_path).read_bytes(),
maintype="image", subtype="png", cid=cid,
filename=Path(chart_path).name,
)
# 5. The workbook attaches to the message itself.
data = Path(attachment).read_bytes()
msg.add_attachment(data, maintype="application",
subtype=XLSX.split("/", 1)[1],
filename=Path(attachment).name)
return msg
The order is not cosmetic. set_content before add_alternative produces a multipart/alternative; the other way round leaves the text as an attachment. And add_related must be called on the HTML part, not the message — a cid: reference only resolves within the multipart/related container holding that HTML.
The cid.strip("<>") is easy to miss: make_msgid returns an angle-bracketed identifier for the header, while the src="cid:..." attribute uses the bare form.
Step 4 — Send it
import os
import smtplib
def send(msg, host=None, port=None, user=None, password=None):
host = host or os.environ["SMTP_HOST"]
port = int(port or os.environ.get("SMTP_PORT", 587))
user = user or os.environ["SMTP_USER"]
password = password or os.environ["SMTP_PASSWORD"]
with smtplib.SMTP(host, port, timeout=30) as smtp:
smtp.starttls()
smtp.login(user, password)
smtp.send_message(msg)
return True
Preview before sending — reviewing the rendered HTML in a browser catches layout problems far faster than sending yourself test messages:
from pathlib import Path
Path("preview.html").write_text(html_body(summary), encoding="utf-8")
print("open preview.html in a browser")
A browser preview is optimistic, because browsers support far more CSS than mail clients do. Treat it as a first check, then send one real test to the clients your audience actually uses.
Step 5 — Keep the message small
Mail servers reject large messages, and base64 encoding inflates every attachment by about a third.
from pathlib import Path
MAX_ENCODED_MB = 20
def check_size(*paths, limit_mb=MAX_ENCODED_MB):
"""Refuse to build a message whose attachments will be rejected."""
raw = sum(Path(p).stat().st_size for p in paths)
encoded_mb = raw * 4 / 3 / 1024 / 1024
if encoded_mb > limit_mb:
raise ValueError(
f"attachments encode to about {encoded_mb:.1f} MB, over the "
f"{limit_mb} MB budget — publish the workbook and send a link."
)
return encoded_mb
When the workbook is large, publish it and link instead — the body still carries the figures, so the email is complete on its own. That is the pattern described in publishing Excel reports to cloud storage, and the summary body is what makes a link-only email useful rather than an extra click.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| CSS ignored in Outlook | Stylesheets stripped | Put every style inline. |
| Layout collapses | Flexbox or grid used | Lay out with tables. |
| Text part arrives as an attachment | add_alternative called first | set_content first, then add_alternative. |
| Embedded image does not show | add_related on the message | Call it on the HTML part. |
| Broken image icon | Angle brackets left in the cid: | Strip < and > in the src. |
| Markup broken by a value | Data not escaped | html.escape every interpolated value. |
| Message rejected as too large | Base64 inflates by a third | Link the workbook instead. |
| Flagged as spam | HTML-only message | Always include a text part. |
Performance and scale notes
Building the message is trivial; the costs are the SMTP connection and the encoding.
Reuse one connection for a batch. Opening and authenticating a session per recipient is the single biggest waste in a fan-out job:
import os
import smtplib
def send_batch(messages):
"""One connection, many messages."""
with smtplib.SMTP(os.environ["SMTP_HOST"],
int(os.environ.get("SMTP_PORT", 587)), timeout=30) as smtp:
smtp.starttls()
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
for msg in messages:
smtp.send_message(msg)
Build the attachment bytes once. Where forty regional emails share one workbook, read it once and reuse the bytes rather than re-reading per message.
Respect the provider's rate limit. Most services cap messages per minute and per day, and exceeding it gets an account throttled or suspended. Pace the loop and back off on a SMTPResponseException, as in retrying a failed Excel report job — and never retry an authentication failure, which will fail identically every time.
One design note that outweighs all of the above: for a report going to more than a handful of people, personalising the body while linking a single published workbook scales far better than attaching a per-recipient file. The body is cheap to build, the link costs nothing, and the storage holds one copy instead of forty.
Conclusion
An email whose body carries the headline figures answers most recipients' question without them opening anything. Write the plain-text version first — it keeps the summary honest and keeps the message out of spam filters — then add the HTML as an alternative with every style inline and tables for layout, because mail clients strip stylesheets and ignore modern CSS. Escape the data, embed a chart with a Content-ID related to the HTML part, and attach the workbook for the detail. Then check the encoded size, and when the workbook is large, publish it and send a link instead.
Frequently asked questions
Why do I need a plain-text part as well as HTML?
Some clients and most automated processors read the text part, and a message with only HTML is more likely to be treated as spam. EmailMessage handles both: set the text content first, then add the HTML as an alternative.
Why is my CSS ignored in Outlook?
Mail clients strip stylesheets and many CSS properties. Put every style inline on the element with a style attribute, use tables for layout rather than flexbox or grid, and stick to background-color, color, padding, border and font properties.
How do I render a DataFrame as an HTML table?DataFrame.to_html gives you the markup, but its classes are useless in email. Generate the rows yourself with inline styles, or post-process to_html output to inject the styles into each tag.
Should the email carry the numbers or just a link? Both. Put the three or four figures a reader checks in the body so the email answers the question on a phone, and attach or link the workbook for anyone who needs the detail.
How do I embed a chart image in the body?
Attach it with a Content-ID and reference it from an img tag with a cid: URL. Many clients block remote images by default, so an embedded CID image is far more reliable than a hosted one.
Related
- Up to the parent: Emailing Excel Reports with smtplib — the connection and attachment basics.
- Attach Multiple Excel Files to One Email in Python — sending several workbooks at once.
- Send an Excel Report to Multiple Recipients with Python — addressing and Bcc handling.
- Publishing Excel Reports to Cloud Storage — linking rather than attaching a large workbook.
- Embed a matplotlib Chart in an Excel Report — producing the chart image the body embeds.