Convert an email to a PDF
Turn an email into a PDF document using PyMuPDF.
This recipe turns an Email into PDF by using the functionality of PyMuPDF Story & DocumentWriter along with standard Python methodology.
1. Install PyMuPDF
%pip install pymupdf2. Import required libraries
We use the built-in Python email parser to read the email content.
"""Convert an .eml file to PDF with the standard-library email parser and PyMuPDF.
What it does:
- Puts a header block (From, To, Cc, Date, Subject) at the top of the page.
- Renders the HTML body, or the plain-text body if there is no HTML part.
- Shows inline images (cid: references) and drops remote images and scripts.
- Embeds the attachments in the PDF as file attachments.
"""
import base64
import html
import re
import sys
from email import policy
from email.parser import BytesParser
from pathlib import Path
import pymupdf3. Setup the paper size and styling for our PDF
Here we define the PDF size with a margin as well as some basic CSS for styling.
PAGE = pymupdf.paper_rect("A4")
MARGIN = 50
BASE_CSS = """
body { font-family: sans-serif; font-size: 10pt; }
table.hdr { width: 100%; border-collapse: collapse; margin-bottom: 12pt; }
table.hdr td { padding: 2pt 4pt; vertical-align: top; }
table.hdr td.k { font-weight: bold; width: 60pt; color: #555; }
hr.sep { margin: 6pt 0 12pt 0; }
pre { font-family: monospace; font-size: 9pt; white-space: pre-wrap; }
img { max-width: 100%; }
"""4. Setup the functions we need
There are 6 methods required for the operation. The first 4 extract headers, body content and inline images from the email as well as providing a clean method to sanitise the data.
4.1 Find header information in the message
The method header_html is responsible for this.
def header_html(msg) -> str:
rows = []
for key in ("From", "To", "Cc", "Date", "Subject"):
val = msg.get(key)
if val:
rows.append(
f'<tr><td class="k">{key}:</td><td>{html.escape(str(val))}</td></tr>'
)
attachments = [p.get_filename() for p in msg.iter_attachments() if p.get_filename()]
if attachments:
names = html.escape(", ".join(attachments))
rows.append(f'<tr><td class="k">Attached:</td><td>{names}</td></tr>')
return f'<table class="hdr">{"".join(rows)}</table><hr class="sep"/>'4.2 Find image information
inline_iumages sources images in the message and includes it as base64 data.
def inline_images(msg) -> dict:
"""Map Content-ID -> data: URI for every image part in the message."""
images = {}
for part in msg.walk():
cid = part.get("Content-ID")
if cid and part.get_content_maintype() == "image":
data = part.get_payload(decode=True)
b64 = base64.b64encode(data).decode("ascii")
images[cid.strip("<>")] = f"data:{part.get_content_type()};base64,{b64}"
return images4.3 Sanitise data from the body and images
clean_html is required for this.
def clean_html(body: str, images: dict) -> str:
# Keep only what is inside <body>, so the email's own <head>/<style> blocks
# don't clash with ours.
m = re.search(r"<body[^>]*>(.*)</body>", body, re.S | re.I)
if m:
body = m.group(1)
# Email microdata can contain large URLs and is not visible in the PDF.
body = re.sub(r"<meta\b[^>]*>", "", body, flags=re.S | re.I)
# Remove scripts.
body = re.sub(r"<script\b.*?</script>", "", body, flags=re.S | re.I)
# Replace cid: references with embedded data URIs.
body = re.sub(
r'src=(["\'])cid:([^"\']+)\1',
lambda m: f'src="{images.get(m.group(2), "")}"',
body,
flags=re.I,
)
# Drop remote images (tracking pixels, hosted logos): nothing is fetched.
body = re.sub(r'<img\b[^>]*src=["\']https?://[^>]*>', "", body, flags=re.I)
return body4.4 Find the main body information
body_html finds the body information in the message, sends it to clean_html and returns the result.
def body_html(msg) -> str:
part = msg.get_body(preferencelist=("html", "plain"))
if part is None:
return "<p><i>(no message body)</i></p>"
content = part.get_content()
if part.get_content_subtype() == "html":
return clean_html(content, inline_images(msg))
return f"<pre>{html.escape(content)}</pre>"5. Render the result
We use the Story & DocumentWriter classes to handle the rendering of the PDF.
def write_story(page_html: str, pdf_path: str, flatten_tables: bool = False) -> bool:
if flatten_tables:
page_html = re.sub(
r"<(/?)(?:table|tr|td|th)\b[^>]*>",
lambda m: "</div>" if m.group(1) else "<div>",
page_html,
flags=re.I,
)
story = pymupdf.Story(html=page_html, user_css=BASE_CSS)
writer = pymupdf.DocumentWriter(pdf_path)
where = PAGE + (MARGIN, MARGIN, -MARGIN, -MARGIN)
more = True
while more:
positions = []
more, _ = story.place(where)
story.element_positions(lambda position: positions.append(position))
if more and not positions:
writer.close()
return False
device = writer.begin_page(PAGE)
story.draw(device)
writer.end_page()
writer.close()
return True6. The main function
eml_to_pdf opens the email and saves it out to the provided path as new PDF file. It calls the functionality of the previous methods header_html, body_html and write_story and finally saves the document file.
def eml_to_pdf(eml_path: str, pdf_path: str) -> None:
with open(eml_path, "rb") as f:
msg = BytesParser(policy=policy.default).parse(f)
page_html = f"<html><body>{header_html(msg)}{body_html(msg)}</body></html>"
# Retry email table layouts as flow blocks if pagination stops advancing.
if not write_story(page_html, pdf_path) and not write_story(
page_html, pdf_path, flatten_tables=True
):
raise RuntimeError("Unable to paginate email body")
# Reopen to add metadata and embed the attachments.
doc = pymupdf.open(pdf_path)
doc.set_metadata({
"title": str(msg.get("Subject", "")),
"author": str(msg.get("From", "")),
})
for part in msg.iter_attachments():
name = part.get_filename()
if name:
doc.embfile_add(name, part.get_payload(decode=True), filename=name)
doc.saveIncr()
doc.close()7. Wrapping it all up
All the functions are in place, let's try it!
dst = "test.pdf"
eml_to_pdf("test.eml", dst)
print(f"wrote {dst}")wrote test.pdf
Discuss This Article with the Community
Have a question, a different approach, or something you built after reading this? Share it on the forum or join the Discord, we'd love to hear from you.