Python Basics: Parse and Build MIME Messages with email
An email archive contains more than a block of text: headers can repeat, subjects can be encoded, and one message can contain several bodies or attachments. Python’s email package lets a local script inspect that structure without connecting to a mail server. These examples use invented data in memory and never send mail.
Prepare a scratch directory
These standalone examples were tested on Linux with Python 3.12.14. Use Python 3.6 or newer for the EmailMessage interfaces shown here; use a maintained, patched runtime for processing external mail. Use Bash, a text editor, and the standard mktemp, rm, and rmdir utilities. No third-party packages are required. Run without -O so the assertions remain enabled.
Create a fresh directory and keep using the same terminal:
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1Save each named source below as UTF-8. Avoid filenames that shadow standard-library modules.
1. Decode a subject and keep repeated headers
Save as read_message_headers.py:
from email import policy
from email.parser import BytesParser
raw = (
b"From: build@example.invalid\r\n"
b"To: reader@example.invalid\r\n"
b"Subject: =?utf-8?q?Build_caf=C3=A9?=\r\n"
b"Received: by relay-two.example.invalid\r\n"
b"Received: by relay-one.example.invalid\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\nBuild passed.\r\n"
)
message = BytesParser(policy=policy.default).parsebytes(raw)
subject = str(message["Subject"])
received = message.get_all("Received", [])
assert subject == "Build café"
assert len(received) == 2
assert message.get_content().strip() == "Build passed."
print("Subject:", subject)
print("Received headers:", len(received))
print("Body:", message.get_content().strip())Run it:
python3 -B read_message_headers.pyOutput:
Subject: Build café
Received headers: 2
Body: Build passed.Specify policy.default explicitly: the parser’s compatibility default does not provide the same modern object interface. The decoded subject is convenient for display; get_all preserves repeated fields rather than silently reducing them to one dictionary value. These are parsed claims, not proof of who sent the message. See the parser documentation.
2. Round-trip plain-text and HTML alternatives
Save as build_alternative_message.py:
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
message = EmailMessage()
message["From"] = "build@example.invalid"
message["To"] = "reader@example.invalid"
message["Subject"] = "Build summary"
message.set_content("Build passed.\n")
message.add_alternative("<p>Build <strong>passed</strong>.</p>\n", subtype="html")
wire = message.as_bytes(policy=policy.SMTP)
parsed = BytesParser(policy=policy.default).parsebytes(wire)
plain = parsed.get_body(preferencelist=("plain",))
assert plain is not None
types = [part.get_content_type() for part in parsed.iter_parts()]
assert types == ["text/plain", "text/html"]
assert b"\r\n" in wire
print("Container:", parsed.get_content_type())
print("Parts:", ", ".join(types))
print("Preferred body:", plain.get_content().strip())Run it:
python3 -B build_alternative_message.pyOutput:
Container: multipart/alternative
Parts: text/plain, text/html
Preferred body: Build passed.The two parts represent alternatives for the same message, not two attachments. Choosing plain explicitly avoids treating the HTML part as terminal text. policy.SMTP selects CRLF line endings for serialized bytes. Generated MIME boundaries can vary, so the test checks structure after parsing instead of comparing the whole byte string.
3. Read an attachment without saving its filename
Save as inspect_message_attachment.py:
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
message = EmailMessage()
message.set_content("The small report is attached.\n")
payload = b"name,count\nchecks,3\n"
message.add_attachment(
payload,
maintype="application",
subtype="octet-stream",
filename="report.csv",
)
parsed = BytesParser(policy=policy.default).parsebytes(message.as_bytes())
attachments = list(parsed.iter_attachments())
assert len(attachments) == 1
part = attachments[0]
content = part.get_content()
assert content == payload
assert part.get_filename() == "report.csv"
print("Filename:", part.get_filename())
print("Disposition:", part.get_content_disposition())
print("Bytes:", len(content))
print("First row:", content.decode("utf-8").splitlines()[0])Run it:
python3 -B inspect_message_attachment.pyOutput:
Filename: report.csv
Disposition: attachment
Bytes: 20
First row: name,countThe binary content handler returns bytes for this application/octet-stream part. The fixture’s filename is displayed only; nothing is written to that path. iter_attachments examines immediate subparts, so a general archive inspector may also need walk to examine nested structures. The EmailMessage reference describes body selection and traversal.
Keep external messages and attachments size-limited, and treat header values, filenames, declared content types, and HTML bodies as untrusted input. Do not join an attachment filename directly to a destination directory or render message HTML without an appropriate sanitizer. Successful parsing is not sender authentication, malware scanning, or permission to open an attachment. These scripts deliberately stop at local inspection.
Clean up
In the same terminal, remove only the named sources, return to the original directory, and remove the empty scratch directory:
rm -- read_message_headers.py build_alternative_message.py inspect_message_attachment.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirIf rmdir reports remaining files, inspect them instead of deleting unknown contents.
References
Acknowledgements
This post was created with assistance from AI.