#!/usr/bin/env python3
# PDFVerge EML / MSG to PDF
# Copyright (C) 2026 WebScare (Pvt.) Limited. Licensed under the GNU AGPL-3.0,
# like the rest of PDFVerge's published converters. Source: https://pdfverge.com/source/
"""
email2pdf.py INPUT.(eml|emlx|msg) OUTPUT.pdf [--size letter|a4] [--attach on|off]
[--tz Area/City] [--cpu-secs N]
Lays an email out as a PDF with WeasyPrint: a header block (subject, from, to,
cc, date, attachments), then the message itself as the sender formatted it
(HTML) or as plain text. Inline images (cid:) are shown; remote images and
anything else outside the email are never fetched, so tracking pixels can't
fire and nothing on the server can be read. Optionally the attachments are
embedded inside the PDF as file attachments.
Result contract (one JSON line on stdout)
exit 0 {"ok": true, "pages": N, "attachments": A, "embedded": E, "blocked": B, "format": "eml|msg", "body": "html|text"}
exit 2 {"ok": false, "error": ""}
exit 3 {"ok": false, "error": "...", "internal": ""}
"""
import argparse
import html as H
import json
import logging
import os
import re
import sys
import time
from datetime import datetime, timezone
_RESULT_FD = os.dup(1)
_devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(_devnull, 1)
logging.disable(logging.CRITICAL)
PAGE = {"a4": "A4", "letter": "letter"}
MAX_ATTACH_EMBED = 40 * 1024 * 1024
def say(obj, code):
os.write(_RESULT_FD, (json.dumps(obj) + "\n").encode())
os._exit(code)
def fail(msg, code=2, internal=None):
out = {"ok": False, "error": msg}
if internal:
out["internal"] = str(internal)[:2000]
say(out, code)
def limit_resources(mem_mb, cpu_secs):
try:
import resource
if mem_mb:
b = mem_mb * 1024 * 1024
resource.setrlimit(resource.RLIMIT_AS, (b, b))
resource.setrlimit(resource.RLIMIT_CPU, (cpu_secs, cpu_secs + 5))
except Exception:
pass
def human(n):
if n is None:
return ""
if n < 1024:
return "%d B" % n
if n < 1048576:
return "%.0f KB" % (n / 1024)
return "%.1f MB" % (n / 1048576)
def clean_name(name, fallback):
name = re.sub(r"[\x00-\x1f\\/]+", "_", str(name or "")).strip(" .")
return (name or fallback)[:150]
def decode_bytes(b, hint=None):
if isinstance(b, str):
return b
for cs in [hint, "utf-8", "cp1252"]:
if not cs:
continue
try:
return b.decode(cs)
except (LookupError, UnicodeDecodeError):
continue
return b.decode("utf-8", "replace")
# ---------------------------------------------------------------- EML
def parse_eml(raw):
from email import policy
from email.parser import BytesParser
if re.match(rb"^\s*\d+\s*\n", raw): # Apple Mail .emlx: byte count, message, plist
first, rest = raw.split(b"\n", 1)
try:
raw = rest[:int(first.strip())]
except ValueError:
raw = rest
msg = BytesParser(policy=policy.default).parsebytes(raw)
if not (msg.get("from") or msg.get("subject") or msg.get("date") or msg.get("to")):
fail("This file doesn't look like an email. Choose a .eml file saved from your email program.")
def hdr(name):
try:
v = msg.get(name)
return str(v) if v is not None else ""
except Exception:
return ""
date = None
try:
d = msg.get("date")
date = d.datetime if d is not None and getattr(d, "datetime", None) else None
except Exception:
date = None
html_part = text_part = None
atts = []
def content(part):
try:
c = part.get_content()
return c if isinstance(c, str) else decode_bytes(c, part.get_content_charset())
except Exception:
try:
return decode_bytes(part.get_payload(decode=True) or b"", part.get_content_charset())
except Exception:
return ""
def walk(part):
nonlocal html_part, text_part
ctype = part.get_content_type()
disp = (part.get_content_disposition() or "").lower()
if ctype == "message/rfc822":
inner = part.get_payload()
inner = inner[0] if isinstance(inner, list) and inner else inner
subj = ""
try:
subj = str(inner.get("subject") or "")
except Exception:
pass
data = inner.as_bytes() if hasattr(inner, "as_bytes") else b""
atts.append({"name": clean_name(part.get_filename() or (subj or "Forwarded message") + ".eml", "message.eml"),
"data": data, "size": len(data), "ctype": "message/rfc822", "cid": None})
return
if part.is_multipart():
for p in part.iter_parts():
walk(p)
return
if disp != "attachment" and ctype == "text/html" and html_part is None:
html_part = part
return
if disp != "attachment" and ctype == "text/plain" and text_part is None and not part.get_filename():
text_part = part
return
try:
data = part.get_payload(decode=True) or b""
except Exception:
data = b""
cid = (part.get("content-id") or "").strip().strip("<>") or None
name = part.get_filename()
if not name:
ext = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "text/calendar": ".ics",
"application/pdf": ".pdf"}.get(ctype, "")
name = ("image" if ctype.startswith("image/") else "attachment") + ext
atts.append({"name": clean_name(name, "attachment"), "data": data, "size": len(data), "ctype": ctype, "cid": cid})
walk(msg)
return {
"subject": hdr("subject"), "from": hdr("from"), "to": hdr("to"), "cc": hdr("cc"),
"date": date, "html": content(html_part) if html_part is not None else None,
"text": content(text_part) if text_part is not None else None, "attachments": atts,
}
# ---------------------------------------------------------------- MSG
def parse_msg(path):
try:
import extract_msg
except Exception as e:
fail("MSG conversion is not available right now.", 3, "extract_msg import: " + repr(e))
try:
m = extract_msg.openMsg(path, delayAttachments=False)
except Exception as e:
fail("This file could not be read as an Outlook .msg email. It may be damaged, or it may be a different kind of .msg file.", 2)
html = None
try:
hb = m.htmlBody
if hb:
html = decode_bytes(hb)
except Exception:
html = None
text = None
try:
text = m.body
except Exception:
text = None
date = None
try:
date = m.date
if isinstance(date, datetime) and date.tzinfo is None:
date = date.replace(tzinfo=timezone.utc)
except Exception:
date = None
atts = []
for a in getattr(m, "attachments", []) or []:
try:
data = a.data
except Exception:
data = None
name = getattr(a, "longFilename", None) or getattr(a, "shortFilename", None) or getattr(a, "name", None)
if data is not None and not isinstance(data, (bytes, bytearray)):
# an attached email (embedded .msg): keep its subject as the name
subj = getattr(data, "subject", None) or "Attached message"
name = name or (subj + ".msg")
blob = None
try:
import io
buf = io.BytesIO()
data.export(buf)
blob = buf.getvalue()
except Exception:
blob = None
atts.append({"name": clean_name(name, "message.msg"), "data": blob, "size": len(blob) if blob else None,
"ctype": "application/vnd.ms-outlook", "cid": None})
continue
cid = getattr(a, "cid", None) or getattr(a, "contentId", None)
data = bytes(data or b"")
atts.append({"name": clean_name(name, "attachment"), "data": data, "size": len(data),
"ctype": getattr(a, "mimetype", None) or "application/octet-stream",
"cid": (str(cid).strip("<>") if cid else None)})
def s(v):
return str(v or "").strip()
# extract_msg separates several recipients with "; " already
return {"subject": s(getattr(m, "subject", "")), "from": s(getattr(m, "sender", "")), "to": s(getattr(m, "to", "")),
"cc": s(getattr(m, "cc", "")), "date": date, "html": html, "text": text, "attachments": atts}
# ---------------------------------------------------------------- layout
CSS = """
@page { size: %(size)s; margin: 14mm 13mm 16mm;
@bottom-left { content: "%(foot)s"; font: 8pt 'DejaVu Sans', sans-serif; color: #7a847f; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); font: 8pt 'DejaVu Sans', sans-serif; color: #7a847f; } }
html { font-size: 10.5pt; }
body { font-family: Arial, 'Liberation Sans', 'DejaVu Sans', 'Noto Sans', sans-serif; }
.pv-hdr { font-family: 'DejaVu Sans', 'Noto Sans', sans-serif !important; color: #10231c !important; margin: 0 0 14pt !important;
padding: 0 0 10pt !important; border-bottom: 1.2pt solid #c9d3cf !important; text-align: left !important; }
.pv-hdr h1 { font: bold 15pt/1.3 'DejaVu Sans', sans-serif !important; margin: 0 0 7pt !important; color: #10231c !important; }
.pv-row { font-size: 9pt !important; line-height: 1.45 !important; margin: 0 !important; color: #33453e !important; }
.pv-row b { display: inline-block; width: 26mm; color: #5d6f67 !important; font-weight: bold; }
.pv-row span { word-break: break-word; }
.pv-note { font-size: 8pt !important; color: #7a847f !important; margin: 5pt 0 0 !important; }
.pv-att { margin: 4pt 0 0 !important; padding: 0 !important; list-style: none !important; font-size: 8.5pt !important; }
.pv-att li { margin: 0 !important; padding: 0 !important; }
.pv-text { font: 9.5pt/1.5 'DejaVu Sans Mono', monospace; white-space: pre-wrap; word-wrap: break-word; margin: 0; }
.pv-body img { max-width: 100%% !important; height: auto !important; }
.pv-body { overflow-wrap: break-word; }
.pv-body table { max-width: 100%% !important; }
.pv-body pre { white-space: pre-wrap; }
"""
def fmt_date(d, tzname):
if not isinstance(d, datetime):
return ""
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(tzname) if tzname else None
except Exception:
tz = None
try:
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
d = d.astimezone(tz or timezone.utc)
return d.strftime("%a, %b %d, %Y, %I:%M %p ").replace(" 0", " ", 1) + (d.tzname() or "UTC")
except Exception:
return str(d)
def linkify(escaped):
return re.sub(r"(https?://[^\s<>&\"]+)", r'\1', escaped)
def split_html(src):
"""Return (head_inner, body_inner) of an email's HTML, scripts removed."""
src = re.sub(r"(?is)<(script|iframe|object|frameset|noscript|template)\b.*?\1\s*>", "", src)
src = re.sub(r"(?is)<(script|iframe|object|embed|frame|base|link)\b[^>]*>", "", src)
src = re.sub(r"(?is)?form\b[^>]*>|]*http-equiv[^>]*>", "", src)
head = ""
m = re.search(r"(?is)]*>(.*?)", src)
if m:
head = "".join(re.findall(r"(?is)", m.group(1)))
# Outlook/Word HTML: named pages (@page WordSection1, div { page: ... }) force a page break
# before the message and override our paper size, so drop them
head = re.sub(r"(?is)@page[^{]*\{[^}]*\}", "", head)
head = re.sub(r"(?i)(?]*>(.*)
]*>(.*)", src)
body = m.group(1) if m else re.sub(r"(?is)]*>.*?|?html[^>]*>|]*>", "", src)
return head, body
def main():
ap = argparse.ArgumentParser()
ap.add_argument("input")
ap.add_argument("output")
ap.add_argument("--size", default="letter")
ap.add_argument("--attach", default="off")
ap.add_argument("--tz", default="")
ap.add_argument("--cpu-secs", type=int, default=90)
ap.add_argument("--max-mb", type=int, default=1500)
a = ap.parse_args()
limit_resources(a.max_mb, a.cpu_secs)
t0 = time.time()
ext = os.path.splitext(a.input)[1].lower()
with open(a.input, "rb") as fh:
head8 = fh.read(8)
is_ole = head8 == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
if ext == ".msg" or is_ole:
if not is_ole:
fail("This .msg file is not an Outlook message. If it is an email saved in another format, rename it to .eml and use EML to PDF.")
e = parse_msg(a.input)
fmt = "msg"
else:
with open(a.input, "rb") as fh:
raw = fh.read()
e = parse_eml(raw)
fmt = "eml"
atts = e["attachments"]
html_src = e["html"]
used_cids = set()
if html_src:
used_cids = set(c.strip().lower() for c in re.findall(r"(?i)cid:([^\"'\s)>]+)", html_src))
by_cid = {}
for at in atts:
if at.get("cid"):
by_cid[at["cid"].lower()] = at
for at in atts:
at["inline"] = bool(at.get("cid") and at["cid"].lower() in used_cids and (at["ctype"] or "").startswith("image/"))
listed = [at for at in atts if not at["inline"]]
rows = []
def row(label, val):
if val:
rows.append('%s%s
' % (label, H.escape(val)))
row("From", e["from"])
row("To", e["to"])
row("Cc", e["cc"])
row("Date", fmt_date(e["date"], a.tz))
att_html = ""
if listed:
att_html = ('Attachments%d
' % len(listed) +
"".join("- %s%s
" % (H.escape(x["name"]), (" (%s)" % human(x["size"])) if x["size"] is not None else "")
for x in listed) + "
")
subject = e["subject"] or "(no subject)"
class Blocked:
n = 0
import warnings
warnings.filterwarnings("ignore")
try:
from weasyprint import HTML, CSS as WCSS, Attachment
from weasyprint.urls import URLFetcher, URLFetcherResponse
except Exception as ex:
fail("Email conversion is not available right now.", 3, "weasyprint import: " + repr(ex))
class MailFetcher(URLFetcher):
"""Only images inside the email (cid:) and data: URIs. Never the network or the disk."""
def fetch(self, url, headers=None):
low = url.lower()
if low.startswith("cid:"):
from urllib.parse import unquote
at = by_cid.get(unquote(url[4:]).strip("<>").lower())
if not at or not at.get("data"):
raise ValueError("missing cid")
return URLFetcherResponse(url, bytes(at["data"]), {"Content-Type": at["ctype"] or "application/octet-stream"})
if low.startswith("data:"):
return super().fetch(url, headers)
if low.startswith("http:") or low.startswith("https:") or low.startswith("//"):
Blocked.n += 1
raise ValueError("blocked")
fetcher = MailFetcher(allowed_protocols={"data"}, allow_redirects=False, timeout=2)
if html_src:
mail_head, mail_body = split_html(html_src)
body_html = '%s
' % mail_body
mode = "html"
else:
txt = e["text"] or ""
mail_head = ""
body_html = '%s
' % linkify(H.escape(txt.replace("\r\n", "\n")))
mode = "text"
foot = (subject[:80] + ("…" if len(subject) > 80 else "")).replace("\\", "\\\\").replace('"', '\\"')
doc_head = ('%s%s'
% (H.escape(subject), mail_head))
hdr_block = '%s
%s%s%%s' % (H.escape(subject), "".join(rows), att_html)
page_css = WCSS(string=CSS % {"size": PAGE.get(a.size, "letter"), "foot": foot})
def render(note):
return HTML(string=doc_head + (hdr_block % note) + body_html + "",
url_fetcher=fetcher, base_url="cid:/", encoding="utf-8").render(
stylesheets=[page_css], optimize_images=True, presentational_hints=True)
try:
doc = render("")
if Blocked.n: # say so in the PDF, then lay it out again with the note
n = Blocked.n
doc = render('%d remote image%s not loaded. PDFVerge never downloads images from the internet, so tracking pixels in the email could not report that it was opened.
'
% (n, " was" if n == 1 else "s were"))
Blocked.n = n
if time.time() - t0 > a.cpu_secs:
raise TimeoutError()
embedded = 0
pdf_atts = []
if a.attach == "on":
total = 0
for at in listed:
if at.get("data") and total + len(at["data"]) <= MAX_ATTACH_EMBED:
total += len(at["data"])
pdf_atts.append(Attachment(string=bytes(at["data"]), name=at["name"], description=at["name"]))
embedded = len(pdf_atts)
doc.metadata.title = subject
doc.write_pdf(a.output, attachments=pdf_atts or None)
except MemoryError:
fail("This email is too large to convert.")
except TimeoutError:
fail("This email took too long to lay out. Try converting it without attachments.")
except Exception as ex:
fail("This email could not be converted.", 3, repr(ex))
say({"ok": True, "pages": len(doc.pages), "attachments": len(listed), "embedded": embedded, "blocked": Blocked.n,
"format": fmt, "body": mode, "seconds": round(time.time() - t0, 2)}, 0)
if __name__ == "__main__":
main()