#!/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.*?", "", src) src = re.sub(r"(?is)<(script|iframe|object|embed|frame|base|link)\b[^>]*>", "", src) src = re.sub(r"(?is)]*>|]*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) or re.search(r"(?is)]*>(.*)", src) body = m.group(1) if m else re.sub(r"(?is)]*>.*?|]*>|]*>", "", 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()