Convert an email to a PDF

Turn an email into a PDF document using PyMuPDF.

October 21, 20264 min readIntermediateEmailPDFPyMuPDF

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 pymupdf

2. 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 pymupdf

3. 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 images

4.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 body

4.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 True

6. 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}")
Output
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.

PyMuPDF Logo

© 2026 Artifex Software Inc. All rights reserved.