Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/markitdown/src/markitdown/_markitdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
ImageConverter,
AudioConverter,
OutlookMsgConverter,
EmlConverter,
ZipConverter,
EpubConverter,
DocumentIntelligenceConverter,
Expand Down Expand Up @@ -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())

Expand Down
2 changes: 2 additions & 0 deletions packages/markitdown/src/markitdown/converters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -44,6 +45,7 @@
"ImageConverter",
"AudioConverter",
"OutlookMsgConverter",
"EmlConverter",
"ZipConverter",
"DocumentIntelligenceConverter",
"DocumentIntelligenceFileType",
Expand Down
132 changes: 132 additions & 0 deletions packages/markitdown/src/markitdown/converters/_eml_converter.py
Original file line number Diff line number Diff line change
@@ -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"),
)
37 changes: 37 additions & 0 deletions packages/markitdown/tests/test_module_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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()
Expand Down