flowchart TD A["multipart/mixed"] --> B["multipart/alternative"] A --> C["text/csv -- data.csv"] B --> D["text/plain -- the fallback"] B --> E["text/html -- the rich version"] D -.->|"a text-only client shows this"| F["readable everywhere"] E -.->|"a normal client shows this"| G["the client picks the LAST part it can render"] C --> H["Content-Disposition: attachment"]
HTML email + attachment
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path
def send_html_with_attachment(to: str, subject: str, html: str, attachment: Path):
user = os.environ["SMTP_USER"]
password = os.environ["SMTP_PASS"]
msg = EmailMessage()
msg["From"] = user
msg["To"] = to
msg["Subject"] = subject
msg.set_content("Your email client does not support HTML.")
msg.add_alternative(html, subtype="html")
data = attachment.read_bytes()
msg.add_attachment(
data,
maintype="application",
subtype="octet-stream",
filename=attachment.name,
)
with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
smtp.starttls()
smtp.login(user, password)
smtp.send_message(msg)
# send_html_with_attachment("to@example.com", "Report", "<h1>Hi</h1>", Path("report.pdf"))Building the message step by step
The nesting diagram above corresponds to three calls, and the order you make them in matters.
set_content(text)creates the plain-text body. This is the fallback.add_alternative(html, subtype="html")adds the HTML version next to it. The message becomesmultipart/alternative.add_attachment(...)wraps everything inmultipart/mixedand adds the file.
If you call add_attachment before add_alternative, the tree is built differently and some clients show the attachment instead of the body. Follow the order in the example.
Choosing the attachment type
The example sends every file as application/octet-stream, which works but tells the recipient's client nothing about the file. Guess the type from the file name and fall back to octet-stream:
import mimetypes
from pathlib import Path
def mime_for(path: Path) -> tuple[str, str]:
kind, _ = mimetypes.guess_type(path.name)
if kind is None:
return "application", "octet-stream"
maintype, subtype = kind.split("/", 1)
return maintype, subtype
print(mime_for(Path("report.pdf"))) # application, pdf
print(mime_for(Path("photo.png"))) # image, png
print(mime_for(Path("notes.xyz"))) # application, octet-streamThen call msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=path.name).
Writing the HTML
Email clients render HTML much less well than browsers. Outlook uses a Word-based engine. So keep the HTML simple.
- Use inline
styleattributes. Many clients ignore<style>blocks and external stylesheets. - Use tables for layout, not flexbox or grid.
- Avoid JavaScript, forms, and web fonts. They are stripped or blocked.
- Put real text in the plain-text part, ideally the same content, not a note saying "your client does not support HTML". Screen readers and spam filters read it.
- Escape any user data you insert into the HTML with
html.escape, or a name like<b>will change the layout.
To show an image inside the message, either link to a hosted image (many clients block remote images until the reader allows them) or embed it with add_related on the HTML part and refer to it with a cid: URL.
Size and safety
Most providers reject messages over about 25 MB, and encoding adds roughly a third. Link to large files instead. Attachments are how malware spreads, so do not attach executables or scripts, and expect some scripts and archives to be blocked by the recipient's server. Read the credentials from environment variables, as in the previous lesson, and test by sending to yourself.
๐งช Try It Yourself
Exercise 1 โ Inspect the Message Structure
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Check your understanding
Which call order builds a correct HTML email with a plain-text fallback and an attachment?
The plain-text part must exist first, the HTML alternative comes next, and the attachment wraps them. A different order can hide the body.
Show answer
B โ set_content, then add_alternative, then add_attachment โ The plain-text part must exist first, the HTML alternative comes next, and the attachment wraps them. A different order can hide the body.
Why put real content in the plain-text part instead of a note saying HTML is not supported?
The plain-text part is what some readers and filters actually use, so it should carry the same information.
Show answer
A โ Screen readers, text-only clients and spam filters read it โ The plain-text part is what some readers and filters actually use, so it should carry the same information.
What should you do with user-supplied text that you insert into the HTML body?
Unescaped text such as <b> or & changes the markup. Escaping keeps the layout intact and prevents injected HTML.
Show answer
B โ Escape it, for example with html.escape โ Unescaped text such as <b> or & changes the markup. Escaping keeps the layout intact and prevents injected HTML.