From a3baf5ee3073ac2f263040ed92b8d25746e7289a Mon Sep 17 00:00:00 2001 From: Sai Teja Bandaru Date: Sat, 11 Jul 2026 01:58:50 +0200 Subject: [PATCH] feat(eml): add local EmlConverter supporting header extraction, body parsing, and recursive attachment conversion --- .../markitdown/src/markitdown/_markitdown.py | 2 + .../src/markitdown/converters/__init__.py | 2 + .../markitdown/converters/_eml_converter.py | 132 ++++++++++++++++++ packages/markitdown/tests/test_module_misc.py | 37 +++++ 4 files changed, 173 insertions(+) create mode 100644 packages/markitdown/src/markitdown/converters/_eml_converter.py diff --git a/packages/markitdown/src/markitdown/_markitdown.py b/packages/markitdown/src/markitdown/_markitdown.py index f6aa4df0e..8af6df31a 100644 --- a/packages/markitdown/src/markitdown/_markitdown.py +++ b/packages/markitdown/src/markitdown/_markitdown.py @@ -35,6 +35,7 @@ ImageConverter, AudioConverter, OutlookMsgConverter, + EmlConverter, ZipConverter, EpubConverter, DocumentIntelligenceConverter, @@ -201,6 +202,7 @@ def enable_builtins(self, **kwargs) -> None: self.register_converter(IpynbConverter()) self.register_converter(PdfConverter()) self.register_converter(OutlookMsgConverter()) + self.register_converter(EmlConverter(markitdown=self)) self.register_converter(EpubConverter()) self.register_converter(CsvConverter()) diff --git a/packages/markitdown/src/markitdown/converters/__init__.py b/packages/markitdown/src/markitdown/converters/__init__.py index 77f8b1acd..2da358888 100644 --- a/packages/markitdown/src/markitdown/converters/__init__.py +++ b/packages/markitdown/src/markitdown/converters/__init__.py @@ -16,6 +16,7 @@ from ._image_converter import ImageConverter from ._audio_converter import AudioConverter from ._outlook_msg_converter import OutlookMsgConverter +from ._eml_converter import EmlConverter from ._zip_converter import ZipConverter from ._doc_intel_converter import ( DocumentIntelligenceConverter, @@ -44,6 +45,7 @@ "ImageConverter", "AudioConverter", "OutlookMsgConverter", + "EmlConverter", "ZipConverter", "DocumentIntelligenceConverter", "DocumentIntelligenceFileType", diff --git a/packages/markitdown/src/markitdown/converters/_eml_converter.py b/packages/markitdown/src/markitdown/converters/_eml_converter.py new file mode 100644 index 000000000..4f12de78b --- /dev/null +++ b/packages/markitdown/src/markitdown/converters/_eml_converter.py @@ -0,0 +1,132 @@ +import email +import email.policy +import io +import os +from typing import BinaryIO, Any, TYPE_CHECKING + +from .._base_converter import DocumentConverter, DocumentConverterResult +from .._stream_info import StreamInfo +from .._exceptions import UnsupportedFormatException, FileConversionException + +if TYPE_CHECKING: + from .._markitdown import MarkItDown + +ACCEPTED_MIME_TYPE_PREFIXES = [ + "message/rfc822", +] + +ACCEPTED_FILE_EXTENSIONS = [".eml"] + + +class EmlConverter(DocumentConverter): + """Converts .eml files to markdown by extracting headers, body content, and recursively converting attachments. + """ + + def __init__( + self, + *, + markitdown: "MarkItDown", + ): + super().__init__() + self._markitdown = markitdown + + def accepts( + self, + file_stream: BinaryIO, + stream_info: StreamInfo, + **kwargs: Any, + ) -> bool: + mimetype = (stream_info.mimetype or "").lower() + extension = (stream_info.extension or "").lower() + + if extension in ACCEPTED_FILE_EXTENSIONS: + return True + + for prefix in ACCEPTED_MIME_TYPE_PREFIXES: + if mimetype.startswith(prefix): + return True + + return False + + def convert( + self, + file_stream: BinaryIO, + stream_info: StreamInfo, + **kwargs: Any, + ) -> DocumentConverterResult: + # Parse email bytes using default policy + msg = email.message_from_bytes(file_stream.read(), policy=email.policy.default) + + # Get headers + headers = {} + for h in ["From", "To", "Subject", "Date"]: + headers[h] = msg.get(h) + + md_content = "# Email Message\n\n" + for key, value in headers.items(): + if value: + md_content += f"**{key}:** {value}\n" + + md_content += "\n## Content\n\n" + + # Extract email body + body_text = "" + body_html = "" + + for part in msg.walk(): + content_type = part.get_content_type() + # Skip attachments, handle separately + if part.is_multipart() or part.get_filename(): + continue + try: + content = part.get_content() + if content_type == "text/plain": + body_text += content + "\n" + elif content_type == "text/html": + body_html += content + "\n" + except Exception: + pass + + if body_html: + html_stream = io.BytesIO(body_html.encode("utf-8")) + html_stream_info = StreamInfo(mimetype="text/html", extension=".html") + try: + res = self._markitdown.convert_stream(html_stream, html_stream_info) + md_content += res.markdown + "\n\n" + except Exception: + if body_text: + md_content += body_text + "\n\n" + elif body_text: + md_content += body_text + "\n\n" + + # Handle attachments + for part in msg.iter_attachments(): + filename = part.get_filename() + if not filename: + continue + payload = part.get_payload(decode=True) + if not payload: + continue + + att_stream = io.BytesIO(payload) + att_stream_info = StreamInfo( + extension=os.path.splitext(filename)[1], + filename=filename, + ) + try: + result = self._markitdown.convert_stream( + stream=att_stream, + stream_info=att_stream_info, + ) + if result is not None: + md_content += f"## Attachment: {filename}\n\n" + md_content += result.markdown + "\n\n" + except (UnsupportedFormatException, FileConversionException): + pass + except Exception: + pass + + return DocumentConverterResult( + markdown=md_content.strip(), + title=headers.get("Subject"), + ) diff --git a/packages/markitdown/tests/test_module_misc.py b/packages/markitdown/tests/test_module_misc.py index 4d62e4919..b685a7ba9 100644 --- a/packages/markitdown/tests/test_module_misc.py +++ b/packages/markitdown/tests/test_module_misc.py @@ -532,6 +532,42 @@ def test_markitdown_llm() -> None: validate_strings(result, PPTX_TEST_STRINGS) +def test_eml_converter() -> None: + from email.message import EmailMessage + + # Create a dummy email message + msg = EmailMessage() + msg["Subject"] = "Test Subject" + msg["From"] = "sender@example.com" + msg["To"] = "receiver@example.com" + msg["Date"] = "Fri, 10 Jul 2026 12:00:00 +0000" + msg.set_content("This is the main email content.") + + # Add an attachment + attachment_content = "This is the attachment text." + msg.add_attachment( + attachment_content.encode("utf-8"), + maintype="text", + subtype="plain", + filename="hello.txt" + ) + + # Convert EML stream using MarkItDown + eml_bytes = msg.as_bytes() + markitdown = MarkItDown() + + stream = io.BytesIO(eml_bytes) + stream_info = StreamInfo(mimetype="message/rfc822", extension=".eml") + + result = markitdown.convert_stream(stream, stream_info=stream_info) + + # Assert on output + assert "Test Subject" in result.title + assert "**From:** sender@example.com" in result.markdown + assert "**To:** receiver@example.com" in result.markdown + assert "This is the main email content." in result.markdown + assert "## Attachment: hello.txt" in result.markdown + assert "This is the attachment text." in result.markdown if __name__ == "__main__": """Runs this file's tests from the command line.""" for test in [ @@ -547,6 +583,7 @@ def test_markitdown_llm() -> None: test_markitdown_exiftool, test_markitdown_llm_parameters, test_markitdown_llm, + test_eml_converter, ]: print(f"Running {test.__name__}...", end="") test()