Learning Goals
3 minBy the end of this lesson you can:
- Attach any file with the correct MIME type using
add_attachment. - Send HTML email with a plain-text fallback (
add_alternative). - Guess MIME types automatically with
mimetypes. - Assemble a full report-delivery email: HTML body + attachments.
Warm-Up · The Anatomy of a Rich Email
5 minA modern email is a tree of parts:
multipart/mixed ├── multipart/alternative │ ├── text/plain ← shown if HTML can't render │ └── text/html ← the styled version └── application/... ← each attachment (PDF, xlsx, image)
Good news: EmailMessage builds that tree for you. You set a plain-text body, add_alternative an HTML version, and add_attachment each file — Python arranges the MIME structure correctly. Two rules of courtesy: always include a plain-text fallback (some clients/screen-readers need it), and label each attachment with its real MIME type so it opens properly.
New Concept · Rich Messages
14 minHTML with a plain-text fallback
from email.message import EmailMessage msg = EmailMessage() msg["From"] = "bot@example.com" msg["To"] = "boss@example.com" msg["Subject"] = "Daily report" # 1) set the plain-text version first (the fallback) msg.set_content("Today: 412 orders, total 84,210.50. See the HTML version.") # 2) add the HTML version as an alternative msg.add_alternative("""\ <html> <body style="font-family: sans-serif;"> <h2 style="color:#2563EB;">Daily Report</h2> <p><b>Orders:</b> 412<br><b>Total:</b> 84,210.50</p> </body> </html> """, subtype="html")
Order matters: set the plain text with set_content, then add_alternative(..., subtype="html"). Email clients show the HTML when they can and fall back to the text when they can't.
Attaching a file (with the right type)
import mimetypes from pathlib import Path def attach(msg: EmailMessage, path: str) -> None: p = Path(path) ctype, _ = mimetypes.guess_type(p.name) # e.g. "application/pdf" maintype, subtype = (ctype or "application/octet-stream").split("/", 1) msg.add_attachment( p.read_bytes(), maintype=maintype, subtype=subtype, filename=p.name, ) attach(msg, "report.xlsx") attach(msg, "cover.pdf")
mimetypes.guess_typepicks the type from the extension (.pdf→application/pdf).- Unknown types fall back to
application/octet-stream("generic binary") — always safe. filename=is what the recipient sees and saves.
Inline images (embedded in HTML)
# reference an image by a Content-ID in the HTML, then attach it with that cid msg.add_alternative( '<p>Chart:</p><img src="cid:chart1">', subtype="html") img = msg.get_payload()[-1] # the html part with open("chart.png", "rb") as f: img.add_related(f.read(), maintype="image", subtype="png", cid="chart1")
Inline images use a Content-ID (cid:) so the picture appears inside the message body, not as a separate attachment. Useful for charts in a report email.
Sending is unchanged
# the smtplib send from Lesson 29 works identically — # EmailMessage already contains the whole MIME tree: with smtplib.SMTP(host, 587) as s: s.starttls(); s.login(user, password); s.send_message(msg)
Most servers reject attachments over ~25 MB — for big files, attach a link (or zip first, Lesson 7). Keep HTML simple and inline your styles (email clients strip <style> blocks and ignore external CSS). A missing plain-text part and image-heavy bodies both raise spam scores.
Worked Example · Report Delivery Email
12 minGoal: extend Lesson 29's mailer to send a styled report email with multiple attachments — the final delivery step for the Lesson 22 report generator.
import os, smtplib, mimetypes, logging from email.message import EmailMessage from pathlib import Path from dotenv import load_dotenv load_dotenv() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger("mailer") def send_report(to: list[str], subject: str, summary: dict, attachments: list[str]) -> bool: user = os.environ["SMTP_USER"] msg = EmailMessage() msg["From"] = user msg["To"] = ", ".join(to) msg["Subject"] = subject # plain-text fallback msg.set_content( f"Daily report\n" f"Orders: {summary['rows']}\n" f"Total: {summary['total']:,.2f}\n" f"(See the attached files for detail.)") # styled HTML version msg.add_alternative(f"""\ <html><body style="font-family:Arial,sans-serif;color:#111;"> <h2 style="color:#2563EB;">Daily Report</h2> <table style="border-collapse:collapse;"> <tr><td style="padding:4px 12px;"><b>Orders</b></td> <td style="padding:4px 12px;">{summary['rows']}</td></tr> <tr><td style="padding:4px 12px;"><b>Total</b></td> <td style="padding:4px 12px;">{summary['total']:,.2f}</td></tr> </table> <p style="color:#666;font-size:12px;">Generated automatically.</p> </body></html>""", subtype="html") # attach files with correct MIME types for path in attachments: p = Path(path) if not p.exists(): log.warning("attachment missing, skipping: %s", p) continue ctype, _ = mimetypes.guess_type(p.name) maintype, subtype = (ctype or "application/octet-stream").split("/", 1) msg.add_attachment(p.read_bytes(), maintype=maintype, subtype=subtype, filename=p.name) try: with smtplib.SMTP(os.getenv("SMTP_HOST", "localhost"), int(os.getenv("SMTP_PORT", "587")), timeout=30) as s: if os.getenv("SMTP_PORT", "587") == "587": s.starttls() s.login(user, os.environ["SMTP_PASS"]) s.send_message(msg) log.info("report sent to %d recipient(s), %d attachment(s)", len(to), len(attachments)) return True except smtplib.SMTPException as e: log.error("send failed: %s", e) return False send_report( to=["boss@example.com"], subject="Daily report — 2026-05-28", summary={"rows": 412, "total": 84210.50}, attachments=["report.xlsx", "cover.pdf"], )
INFO report sent to 1 recipient(s), 2 attachment(s)
Read the code
One function now produces a complete deliverable: plain-text fallback, inline-styled HTML table, and both report files attached with correct MIME types (so Excel opens as Excel, PDF as PDF). The missing-attachment guard means a not-yet-generated file logs a warning instead of crashing the send. Chain this to Lesson 22's generator and Lesson 35's scheduler and the report builds and emails itself every morning — the daily report bot of Lesson 40.
Try It Yourself
13 minKeep using the local debug server or Mailtrap from Lesson 29.
Send an email with one attachment (any small file). Confirm the recipient sees it with the right name and that it opens.
Send an email with both a plain-text body and a styled HTML alternative. View it once as HTML and once as text (most clients let you toggle) to confirm both render.
Hint
msg.set_content("Plain version: hello!") msg.add_alternative( "<h1 style='color:teal'>Hello!</h1><p>HTML version.</p>", subtype="html")
Zip a folder (Lesson 7) and attach the single zip, OR attach every file in a folder individually with correct MIME types. Log each attachment's name and size.
Hint
import shutil zip_path = shutil.make_archive("bundle", "zip", "report_folder") attach(msg, zip_path) # one tidy attachment # or: for f in Path("report_folder").iterdir(): attach(msg, f)
Mini-Challenge · The Templated Newsletter
8 minBuild send_newsletter(recipients, items) that renders a list of items (title + link) into an HTML email with a matching plain-text version, and sends a personalised copy to each recipient (their name in the greeting). Send individually so one failure doesn't block the rest.
Show a sample solution
def render_html(name, items): rows = "".join( f'<li><a href="{i["link"]}">{i["title"]}</a></li>' for i in items) return f"<p>Hi {name},</p><ul>{rows}</ul>" def render_text(name, items): lines = "\n".join(f"- {i['title']}: {i['link']}" for i in items) return f"Hi {name},\n\n{lines}\n" def send_newsletter(recipients, items): results = {} for name, email in recipients: msg = EmailMessage() msg["From"] = os.environ["SMTP_USER"] msg["To"] = email msg["Subject"] = "This week's links" msg.set_content(render_text(name, items)) msg.add_alternative(render_html(name, items), subtype="html") results[email] = _send(msg) # reuse your sender return results
Non-negotiables: HTML + text versions, personalised greeting, per-recipient sending with results.
Recap
3 minEmailMessage builds the MIME tree for rich mail: set_content for the plain-text fallback, then add_alternative(..., subtype="html") for a styled version, and add_attachment(bytes, maintype, subtype, filename) per file — with mimetypes.guess_type picking the correct type. Inline images use a cid: reference + add_related. Always include the plain-text part, inline your HTML styles, watch the ~25 MB size limit, and label attachments correctly. The send code from Lesson 29 is unchanged. You now have a complete report-delivery system — ready to schedule.
Vocabulary Card
- add_attachment
- Attaches file bytes with a MIME type and display filename.
- add_alternative
- Adds an HTML version alongside the plain-text body.
- MIME type
- A label like
application/pdftelling clients how to open content. - Content-ID (cid)
- A reference that embeds an image inline within HTML.
Homework
4 minConnect this to Lesson 22: extend the report generator so that, after producing summary.xlsx, cover.pdf, and manifest.json, it emails them as a styled HTML report with all three attached (to the debug server during testing). Add a --email TO flag to report.py that triggers delivery. Verify the email arrives with the correct attachments and a readable HTML body.
Sample · wiring report.py to email
# in report.py's main(), after writing the three files: if args.email: from mailer import send_report # your Lesson 29/30 module send_report( to=[args.email], subject=f"Report — {datetime.now():%Y-%m-%d}", summary={"rows": stats["row_count"], "total": stats["grand_total"]}, attachments=[str(out / "summary.xlsx"), str(out / "cover.pdf"), str(out / "manifest.json")], ) log.info("emailed report to %s", args.email) # add to the argparser: # p.add_argument("--email", help="email the report to this address")
Non-negotiables: --email flag, HTML+text body, all three files attached with correct types, verified delivery.