Files
olm2eml/olm_xml_to_eml.py
T
2026-07-02 16:26:03 +02:00

307 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""
Convert emails extracted from an OLM file (as XML) to standard .eml format.
Usage:
python olm_xml_to_eml.py /path/to/emails/root /path/to/output_dir
"""
import argparse
import re
import sys
import xml.etree.ElementTree as ET
from email import encoders
from email.mime.base import MIMEBase
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate
from pathlib import Path
EMAILS_ROOT_ATTR = "{http://www.w3.org/XML/1998/namespace}space"
ADDR_ADDRESS = "OPFContactEmailAddressAddress"
ADDR_NAME = "OPFContactEmailAddressName"
ADDR_TYPE = "OPFContactEmailAddressType"
ATTACH_EXT = "OPFAttachmentContentExtension"
ATTACH_SIZE = "OPFAttachmentContentFileSize"
ATTACH_CID = "OPFAttachmentContentID"
ATTACH_TYPE = "OPFAttachmentContentType"
ATTACH_NAME = "OPFAttachmentName"
ATTACH_URL = "OPFAttachmentURL"
def parse_addresses(parent_elem):
addresses = []
if parent_elem is None:
return addresses
for child in parent_elem:
if child.tag == "emailAddress":
addr = child.get(ADDR_ADDRESS, "")
name = child.get(ADDR_NAME, "")
addresses.append((name, addr))
return addresses
def format_address_list(addresses):
parts = []
for name, addr in addresses:
if name and addr:
parts.append(formataddr((name, addr)))
elif addr:
parts.append(addr)
return ", ".join(parts) if parts else ""
def parse_attachment_list(parent_elem):
attachments = []
if parent_elem is None:
return attachments
for child in parent_elem:
if child.tag == "messageAttachment":
att = {
"name": child.get(ATTACH_NAME, "attachment"),
"content_type": child.get(ATTACH_TYPE, "application/octet-stream"),
"extension": child.get(ATTACH_EXT, ""),
"content_id": child.get(ATTACH_CID, ""),
"url": child.get(ATTACH_URL, ""),
"size": child.get(ATTACH_SIZE, "0"),
}
attachments.append(att)
return attachments
def find_email_xmls(root_dir):
xml_files = []
root_path = Path(root_dir)
for path in root_path.rglob("message_*.xml"):
xml_files.append(path)
return sorted(xml_files)
def get_email_element(xml_path):
tree = ET.parse(str(xml_path))
root = tree.getroot()
return root[0]
def text_of(elem):
if elem is None:
return ""
return (elem.text or "").strip()
def convert_email_xml_to_eml(xml_path, emails_root):
email = get_email_element(xml_path)
subject = text_of(email.find("OPFMessageCopySubject"))
from_addrs = parse_addresses(email.find("OPFMessageCopyFromAddresses"))
to_addrs = parse_addresses(email.find("OPFMessageCopyToAddresses"))
cc_addrs = parse_addresses(email.find("OPFMessageCopyCCAddresses"))
reply_to_addrs = parse_addresses(email.find("OPFMessageCopyReplyToAddresses"))
sender_addr = text_of(email.find("OPFMessageCopySenderAddress"))
display_to = text_of(email.find("OPFMessageCopyDisplayTo"))
sent_time = text_of(email.find("OPFMessageCopySentTime"))
received_time = text_of(email.find("OPFMessageCopyReceivedTime"))
mod_time = text_of(email.find("OPFMessageCopyModDate"))
server_time = text_of(email.find("ExchangeServerLastModifiedTime"))
message_id = text_of(email.find("OPFMessageCopyMessageID"))
in_reply_to = text_of(email.find("OPFMessageCopyInReplyTo"))
references = text_of(email.find("OPFMessageCopyReferences"))
thread_topic = text_of(email.find("OPFMessageCopyThreadTopic"))
conversation_id = text_of(email.find("OPFMessageCopyExchangeConversationId"))
html_body = text_of(email.find("OPFMessageCopyHTMLBody"))
body = text_of(email.find("OPFMessageCopyBody"))
attachments = parse_attachment_list(email.find("OPFMessageCopyAttachmentList"))
mime_msg = MIMEMultipart("mixed")
mime_msg["Subject"] = subject
from_str = format_address_list(from_addrs)
if from_str:
mime_msg["From"] = from_str
elif sender_addr:
mime_msg["From"] = sender_addr
else:
mime_msg["From"] = "unknown@unknown.com"
if from_addrs and display_to:
mime_msg["To"] = display_to
else:
to_str = format_address_list(to_addrs)
if to_str:
mime_msg["To"] = to_str
else:
mime_msg["To"] = "undisclosed-recipients:;"
cc_str = format_address_list(cc_addrs)
if cc_str:
mime_msg["Cc"] = cc_str
reply_to_str = format_address_list(reply_to_addrs)
if reply_to_str:
mime_msg["Reply-To"] = reply_to_str
date_str = sent_time or received_time or mod_time or server_time
if date_str:
mime_msg["Date"] = date_str
else:
mime_msg["Date"] = formatdate(localtime=True)
if message_id:
mime_msg["Message-ID"] = message_id
if in_reply_to:
mime_msg["In-Reply-To"] = in_reply_to
if references:
mime_msg["References"] = references
if thread_topic:
mime_msg["Thread-Topic"] = thread_topic
if conversation_id:
mime_msg["Thread-Index"] = conversation_id
mime_msg["MIME-Version"] = "1.0"
has_html = bool(html_body)
has_text = bool(body)
if has_html or has_text:
plain_text = strip_html(body) if body else ""
if has_html:
body_part = MIMEMultipart("alternative")
if plain_text:
body_part.attach(MIMEText(plain_text, "plain", "utf-8"))
body_part.attach(MIMEText(html_body, "html", "utf-8"))
mime_msg.attach(body_part)
elif plain_text:
mime_msg.attach(MIMEText(plain_text, "plain", "utf-8"))
else:
mime_msg.attach(MIMEText("", "plain", "utf-8"))
else:
mime_msg.attach(MIMEText("", "plain", "utf-8"))
for att in attachments:
att_url = att["url"]
if not att_url:
continue
att_path = Path(emails_root) / att_url
if not att_path.exists():
continue
try:
data = att_path.read_bytes()
content_type = att["content_type"] or "application/octet-stream"
if "/" in content_type:
main_type, sub_type = content_type.split("/", 1)
else:
main_type, sub_type = "application", "octet-stream"
if main_type == "image":
part = MIMEImage(data, _subtype=sub_type)
else:
part = MIMEBase(main_type, sub_type)
part.set_payload(data)
encoders.encode_base64(part)
filename = att["name"]
cid = att["content_id"]
if cid:
part.add_header("Content-ID", f"<{cid}>")
part.add_header("Content-Disposition", "inline", filename=filename)
else:
part.add_header("Content-Disposition", "attachment", filename=filename)
mime_msg.attach(part)
except Exception as e:
print(f" Warning: failed to attach {att_url}: {e}", file=sys.stderr)
return mime_msg.as_bytes()
def strip_html(html_text):
text = re.sub(r"<style[^>]*>.*?</style>", "", html_text, flags=re.DOTALL)
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def build_output_path(xml_path, emails_root, output_dir, preserve_structure):
if preserve_structure:
rel = xml_path.parent.relative_to(Path(emails_root).resolve())
out = Path(output_dir) / rel / f"{xml_path.stem}.eml"
else:
out = Path(output_dir) / f"{xml_path.stem}.eml"
out.parent.mkdir(parents=True, exist_ok=True)
return out
def main():
parser = argparse.ArgumentParser(
description="Convert OLM-extracted XML emails to EML format"
)
parser.add_argument("input_dir", help="Root directory of the extracted OLM emails")
parser.add_argument("output_dir", help="Output directory for EML files")
parser.add_argument(
"--preserve-structure",
action="store_true",
help="Preserve folder structure in output (mirrors account/folder hierarchy)",
)
parser.add_argument(
"--overwrite", action="store_true", help="Overwrite existing EML files"
)
args = parser.parse_args()
input_dir = Path(args.input_dir).resolve()
output_dir = Path(args.output_dir).resolve()
if not input_dir.is_dir():
print(f"Error: input directory not found: {input_dir}", file=sys.stderr)
sys.exit(1)
output_dir.mkdir(parents=True, exist_ok=True)
xml_files = find_email_xmls(input_dir)
if not xml_files:
print(f"No message_*.xml files found in {input_dir}", file=sys.stderr)
sys.exit(1)
print(f"Found {len(xml_files)} email XML files")
success = 0
skipped = 0
errors = 0
for xml_path in xml_files:
out_path = build_output_path(
xml_path, input_dir, output_dir, args.preserve_structure
)
if out_path.exists() and not args.overwrite:
skipped += 1
continue
try:
eml_bytes = convert_email_xml_to_eml(xml_path, input_dir)
out_path.write_bytes(eml_bytes)
success += 1
except Exception as e:
print(f"Error converting {xml_path}: {e}", file=sys.stderr)
errors += 1
print(f"\nDone. {success} converted, {skipped} skipped, {errors} errors.")
if __name__ == "__main__":
main()