Guide
Automating Reporting WorkflowsDeep dive

Email an Excel Report with an HTML Summary Body

Put the headline numbers in the email itself — build an HTML body with a plain-text fallback, render a DataFrame as a styled table, and attach the workbook for the detail.

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.

How the parts of a report email fit together The outer message is multipart mixed. Inside it sits a multipart alternative containing two renderings of the same content: a plain-text version for clients and processors that cannot show HTML, and an HTML version with the styled summary table. Alongside the alternative sit the attachments — the xlsx workbook, and a chart image carrying a Content-ID so the HTML can reference it inline. Clients pick the richest alternative they support. multipart/mixed — the whole message multipart/alternative text/plain the same figures, no markup text/html styled summary table, inline CSS the workbook regional-2026-08.xlsx spreadsheetml attachment chart.png Content-ID: <trend> referenced as cid:trend

Prerequisites

Bash
pip install pandas xlsxwriter matplotlib

Everything else is standard library. Credentials come from the environment:

Bash
export SMTP_HOST=smtp.example.com
export SMTP_PORT=587
export SMTP_USER=reporting@example.com
export SMTP_PASSWORD='...'

Some figures to report:

Python
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:

Python
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:

Python
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} &mdash;
      <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:

Python
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

What survives in a mail client, and what does not Two columns. Reliable across clients: inline style attributes, table-based layout, and the basic properties of colour, background colour, padding, border and font. Unreliable or stripped entirely: style blocks and external stylesheets, flexbox and grid layout, CSS positioning, and background images. Designing for the reliable column produces an email that looks the same everywhere rather than one that looks best in a browser preview. reliable everywhere inline style attributes table-based layout color, background-color, padding border, font-size, font-weight design for this column stripped or ignored style blocks and stylesheets flexbox and grid position and float tricks background images a browser preview will not warn you
Python
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:

Python
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.

Base64 encoding inflates every attachment by a third Two message compositions against a typical twenty-five megabyte limit. Attaching a twelve megabyte workbook plus a chart produces roughly sixteen megabytes on the wire once base64 encoding is applied, which is close enough to the limit that some gateways reject it. Attaching only a small chart image and linking the workbook keeps the message under one megabyte and never bounces. size on the wire, against a typical 25 MB limit attach the workbook 12 MB file → ~16 MB encoded link it instead under 1 MB — the chart and the body only 25 MB limit the limit applies to the encoded size, and the receiving server's limit may be lower than yours
Python
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

SymptomCauseFix
CSS ignored in OutlookStylesheets strippedPut every style inline.
Layout collapsesFlexbox or grid usedLay out with tables.
Text part arrives as an attachmentadd_alternative called firstset_content first, then add_alternative.
Embedded image does not showadd_related on the messageCall it on the HTML part.
Broken image iconAngle brackets left in the cid:Strip < and > in the src.
Markup broken by a valueData not escapedhtml.escape every interpolated value.
Message rejected as too largeBase64 inflates by a thirdLink the workbook instead.
Flagged as spamHTML-only messageAlways 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:

Python
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.