461bf6fd40
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
442 lines
17 KiB
Python
442 lines
17 KiB
Python
"""Provides `partition_email()` function.
|
|
|
|
Suitable for use with `.eml` files, which can be exported from many email clients.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import email
|
|
import email.policy
|
|
import email.utils
|
|
import io
|
|
import os
|
|
from email.message import EmailMessage, MIMEPart
|
|
from functools import cached_property
|
|
from typing import IO, Any, Final, Iterator, cast
|
|
|
|
from dateutil import parser
|
|
|
|
from unstructured.documents.elements import Element, ElementMetadata
|
|
from unstructured.file_utils.model import FileType
|
|
from unstructured.logger import logger
|
|
from unstructured.partition.common import EXPECTED_ATTACHMENT_ERRORS
|
|
from unstructured.partition.common.metadata import get_last_modified_date
|
|
from unstructured.partition.html import partition_html
|
|
from unstructured.partition.text import partition_text
|
|
|
|
VALID_CONTENT_SOURCES: Final[tuple[str, ...]] = ("text/html", "text/plain")
|
|
|
|
|
|
def partition_email(
|
|
filename: str | None = None,
|
|
*,
|
|
file: IO[bytes] | None = None,
|
|
content_source: str = "text/html",
|
|
metadata_filename: str | None = None,
|
|
metadata_last_modified: str | None = None,
|
|
process_attachments: bool = True,
|
|
**kwargs: Any,
|
|
) -> list[Element]:
|
|
"""Partitions an .eml file into document elements.
|
|
|
|
Args:
|
|
filename: str path of the target file.
|
|
file: A file-like object open for reading bytes (not str) e.g. --> open(filename, "rb").
|
|
content_source: The preferred message body. Many emails contain both a plain-text and an
|
|
HTML version of the message body. By default, the HTML version will be used when
|
|
available. Specifying "text/plain" will cause the plain-text version to be preferred.
|
|
When the preferred version is not available, the other version will be used.
|
|
metadata_filename: The file-path to use for metadata purposes. Useful when the target file
|
|
is specified as a file-like object or when `filename` is a temporary file and the
|
|
original file-path is known or a more meaningful file-path is desired.
|
|
metadata_last_modified: The last-modified timestamp to be applied in metadata. Useful when
|
|
a file-like object (which can have no last-modified date) target is used. The
|
|
last-modified metadata is otherwise drawn from the filesystem when a path is provided.
|
|
process_attachments: When True, also partition any attachments in the message after
|
|
partitioning the message body. All document elements appear in the single returned
|
|
element list. The filename of the attachment, when available, is used as the
|
|
`filename` metadata value for elements arising from the attachment.
|
|
|
|
Note that all global keyword arguments such as `unique_element_ids`, `language` and
|
|
`chunking_strategy` can be used and will be passed along to the decorators that implement
|
|
those functions. Further, any keyword arguments applicable to HTML will be passed along to the
|
|
HTML partitioner when processing an HTML message body.
|
|
"""
|
|
ctx = EmailPartitioningContext.load(
|
|
file_path=filename,
|
|
file=file,
|
|
content_source=content_source,
|
|
metadata_file_path=metadata_filename,
|
|
metadata_last_modified=metadata_last_modified,
|
|
process_attachments=process_attachments,
|
|
kwargs=kwargs,
|
|
)
|
|
|
|
return list(_EmailPartitioner.iter_elements(ctx=ctx))
|
|
|
|
|
|
class EmailPartitioningContext:
|
|
"""Encapsulates partitioning option validation, computation, and application of defaults."""
|
|
|
|
def __init__(
|
|
self,
|
|
file_path: str | None = None,
|
|
file: IO[bytes] | None = None,
|
|
content_source: str = "text/html",
|
|
metadata_file_path: str | None = None,
|
|
metadata_last_modified: str | None = None,
|
|
process_attachments: bool = False,
|
|
kwargs: dict[str, Any] = {},
|
|
):
|
|
self._file_path = file_path
|
|
self._file = file
|
|
self._content_source = content_source
|
|
self._metadata_file_path = metadata_file_path
|
|
self._metadata_last_modified = metadata_last_modified
|
|
self._process_attachments = process_attachments
|
|
self._kwargs = kwargs
|
|
|
|
@classmethod
|
|
def load(
|
|
cls,
|
|
file_path: str | None,
|
|
file: IO[bytes] | None,
|
|
content_source: str,
|
|
metadata_file_path: str | None,
|
|
metadata_last_modified: str | None,
|
|
process_attachments: bool,
|
|
kwargs: dict[str, Any],
|
|
) -> EmailPartitioningContext:
|
|
"""Construct and validate an instance."""
|
|
return cls(
|
|
file_path=file_path,
|
|
file=file,
|
|
content_source=content_source,
|
|
metadata_file_path=metadata_file_path,
|
|
metadata_last_modified=metadata_last_modified,
|
|
process_attachments=process_attachments,
|
|
kwargs=kwargs,
|
|
)._validate()
|
|
|
|
@cached_property
|
|
def bcc_addresses(self) -> list[str] | None:
|
|
"""The "blind carbon-copy" Bcc: addresses of the message."""
|
|
bccs = self.msg.get_all("Bcc")
|
|
if not bccs:
|
|
return None
|
|
addrs = email.utils.getaddresses(bccs)
|
|
return [email.utils.formataddr(addr) for addr in addrs]
|
|
|
|
@cached_property
|
|
def body_part(self) -> MIMEPart | None:
|
|
"""The message part containing the actual textual email message.
|
|
|
|
This is as opposed to attachments or "related" parts like an image that appears in the
|
|
message etc.
|
|
"""
|
|
return self.msg.get_body(preferencelist=self.content_type_preference)
|
|
|
|
@cached_property
|
|
def cc_addresses(self) -> list[str] | None:
|
|
"""The "carbon-copy" Cc: addresses of the message."""
|
|
ccs = self.msg.get_all("Cc")
|
|
if not ccs:
|
|
return None
|
|
addrs = email.utils.getaddresses(ccs)
|
|
return [email.utils.formataddr(addr) for addr in addrs]
|
|
|
|
@cached_property
|
|
def content_type_preference(self) -> tuple[str, ...]:
|
|
"""Whether to prefer HTML or plain-text body when message-body has both.
|
|
|
|
The default order of preference is `("html", "plain")`. The order can be switched by
|
|
specifying `"text/plain"` as the `content_source` arg value.
|
|
"""
|
|
return ("plain", "html") if self._content_source == "text/plain" else ("html", "plain")
|
|
|
|
@cached_property
|
|
def email_metadata(self) -> ElementMetadata:
|
|
"""The email-specific metadata fields for this message.
|
|
|
|
Suitable for use with `.metadata.update()` on the base metadata applied to message body
|
|
elements by delegate partitioners for text and HTML.
|
|
"""
|
|
return ElementMetadata(
|
|
bcc_recipient=self.bcc_addresses,
|
|
cc_recipient=self.cc_addresses,
|
|
email_message_id=self.message_id,
|
|
sent_from=[self.from_address] if self.from_address else None,
|
|
sent_to=self.to_addresses,
|
|
subject=self.subject,
|
|
)
|
|
|
|
@cached_property
|
|
def from_address(self) -> str | None:
|
|
"""The address of the message sender."""
|
|
froms = self.msg.get_all("From")
|
|
if not froms:
|
|
# -- this should never occur because the From: header is mandatory per RFC 5322 --
|
|
return None
|
|
addrs = email.utils.getaddresses(froms)
|
|
formatted_addrs = [email.utils.formataddr(addr) for addr in addrs]
|
|
return formatted_addrs[0]
|
|
|
|
@cached_property
|
|
def message_id(self) -> str | None:
|
|
"""The value of the Message-ID: header, when present."""
|
|
raw_id = self.msg.get("Message-ID")
|
|
if not raw_id:
|
|
return None
|
|
return raw_id.strip().strip("<>")
|
|
|
|
@cached_property
|
|
def metadata_file_path(self) -> str | None:
|
|
"""The best available file-path information for this email message.
|
|
|
|
It's value is computed according to these rules, applied in order:
|
|
|
|
- The `metadata_filename` arg value when one was provided to `partition_email()`.
|
|
- The `file_path` value when one was provided.
|
|
- None otherwise.
|
|
|
|
This value is used as the `filename` metadata value for elements produced by partitioning
|
|
the email message (but not those from its attachments).
|
|
"""
|
|
return self._metadata_file_path or self._file_path or None
|
|
|
|
@cached_property
|
|
def metadata_last_modified(self) -> str | None:
|
|
"""The best available last-modified date for this message, as an ISO8601 string.
|
|
|
|
It's value is computed according to these rules, applied in order:
|
|
|
|
- The `metadata_last_modified` arg value when one was provided to `partition_email()`.
|
|
- The date-time in the `Sent:` header of the message, when present.
|
|
- The last-modified date recorded on the filesystem for `file_path` when it was provided.
|
|
- None otherwise.
|
|
|
|
This value is used as the `last_modified` metadata value for all elements produced by
|
|
partitioning this email message, including any attachments.
|
|
"""
|
|
return self._metadata_last_modified or self._sent_date or self._filesystem_last_modified
|
|
|
|
@cached_property
|
|
def msg(self) -> EmailMessage:
|
|
"""The Python stdlib `email.message.EmailMessage` object parsed from the EML file."""
|
|
if self._file_path is not None:
|
|
with open(self._file_path, "rb") as f:
|
|
return cast(
|
|
EmailMessage, email.message_from_binary_file(f, policy=email.policy.default)
|
|
)
|
|
|
|
assert self._file is not None
|
|
|
|
file_bytes = self._file.read()
|
|
|
|
return cast(EmailMessage, email.message_from_bytes(file_bytes, policy=email.policy.default))
|
|
|
|
@cached_property
|
|
def partitioning_kwargs(self) -> dict[str, Any]:
|
|
"""The "extra" keyword arguments received by `partition_email()`.
|
|
|
|
These are passed along to delegate partitioners which extract keyword args like
|
|
`chunking_strategy` etc. in their decorators to control metadata behaviors, etc.
|
|
"""
|
|
return self._kwargs
|
|
|
|
@cached_property
|
|
def process_attachments(self) -> bool:
|
|
"""When True, partition attachments in addition to the email message body.
|
|
|
|
Any attachment having file-format that cannot be partitioned by unstructured is silently
|
|
skipped.
|
|
"""
|
|
return self._process_attachments
|
|
|
|
@cached_property
|
|
def subject(self) -> str | None:
|
|
"""The value of the Subject: header, when present."""
|
|
subject = self.msg.get("Subject")
|
|
if not subject:
|
|
return None
|
|
return subject
|
|
|
|
@cached_property
|
|
def to_addresses(self) -> list[str] | None:
|
|
"""The To: addresses of the message."""
|
|
tos = self.msg.get_all("To")
|
|
if not tos:
|
|
return None
|
|
addrs = email.utils.getaddresses(tos)
|
|
return [email.utils.formataddr(addr) for addr in addrs]
|
|
|
|
@cached_property
|
|
def _filesystem_last_modified(self) -> str | None:
|
|
"""Last-modified retrieved from filesystem when a file-path was provided, None otherwise."""
|
|
return get_last_modified_date(self._file_path) if self._file_path else None
|
|
|
|
@cached_property
|
|
def _sent_date(self) -> str | None:
|
|
"""ISO-8601 str representation of message sent-date, if available."""
|
|
date_str = self.msg.get("Date")
|
|
if not date_str:
|
|
return None
|
|
try:
|
|
sent_date = parser.parse(date_str)
|
|
except (parser.ParserError, TypeError, ValueError):
|
|
return None
|
|
|
|
return sent_date.astimezone(dt.timezone.utc).isoformat(timespec="seconds")
|
|
|
|
def _validate(self) -> EmailPartitioningContext:
|
|
"""Raise on first invalid option, return self otherwise."""
|
|
if not self._file_path and not self._file:
|
|
raise ValueError(
|
|
"no document specified; either a `filename` or `file` argument must be provided."
|
|
)
|
|
|
|
if self._file:
|
|
if not isinstance( # pyright: ignore[reportUnnecessaryIsInstance]
|
|
self._file.read(0), bytes
|
|
):
|
|
raise ValueError("file object must be opened in binary mode")
|
|
self._file.seek(0)
|
|
|
|
if self._content_source not in VALID_CONTENT_SOURCES:
|
|
raise ValueError(
|
|
f"{repr(self._content_source)} is not a valid value for content_source;"
|
|
f" must be one of: {VALID_CONTENT_SOURCES}",
|
|
)
|
|
|
|
return self
|
|
|
|
|
|
class _EmailPartitioner:
|
|
"""Encapsulates the partitioning logic for email documents."""
|
|
|
|
def __init__(self, ctx: EmailPartitioningContext):
|
|
self._ctx = ctx
|
|
|
|
@classmethod
|
|
def iter_elements(cls, ctx: EmailPartitioningContext) -> Iterator[Element]:
|
|
"""Generate the document elements for the email described by `ctx`."""
|
|
return cls(ctx=ctx)._iter_elements()
|
|
|
|
def _iter_elements(self) -> Iterator[Element]:
|
|
"""Generate the document elements for the email described in the partitioning context.
|
|
|
|
This optionally includes elements generated by partitioning any partitionable attachments
|
|
in the message as well.
|
|
"""
|
|
for e in self._iter_email_body_elements():
|
|
e.metadata.update(self._ctx.email_metadata)
|
|
yield e
|
|
|
|
if not self._ctx.process_attachments:
|
|
return
|
|
|
|
for attachment in self._ctx.msg.iter_attachments():
|
|
yield from _AttachmentPartitioner.iter_elements(attachment, self._ctx)
|
|
|
|
def _iter_email_body_elements(self) -> Iterator[Element]:
|
|
"""Generate document elements from the email body."""
|
|
body_part = self._ctx.body_part
|
|
|
|
# -- it's possible to have no body part; that translates to zero elements --
|
|
if body_part is None:
|
|
return
|
|
|
|
content_type = body_part.get_content_type()
|
|
content = body_part.get_content()
|
|
assert isinstance(content, str)
|
|
|
|
if content_type == "text/html":
|
|
yield from partition_html(
|
|
text=content,
|
|
metadata_filename=self._ctx.metadata_file_path,
|
|
metadata_file_type=FileType.EML,
|
|
metadata_last_modified=self._ctx.metadata_last_modified,
|
|
**self._ctx.partitioning_kwargs,
|
|
)
|
|
else:
|
|
yield from partition_text(
|
|
text=content,
|
|
metadata_filename=self._ctx.metadata_file_path,
|
|
metadata_file_type=FileType.EML,
|
|
metadata_last_modified=self._ctx.metadata_last_modified,
|
|
**self._ctx.partitioning_kwargs,
|
|
)
|
|
|
|
|
|
class _AttachmentPartitioner:
|
|
"""Partitions an attachment to a MSG file."""
|
|
|
|
def __init__(self, attachment: EmailMessage, ctx: EmailPartitioningContext):
|
|
self._attachment = attachment
|
|
self._ctx = ctx
|
|
|
|
@classmethod
|
|
def iter_elements(
|
|
cls, attachment: EmailMessage, ctx: EmailPartitioningContext
|
|
) -> Iterator[Element]:
|
|
"""Partition an attachment MIME-part from a MIME email message (.eml file)."""
|
|
return cls(attachment, ctx)._iter_elements()
|
|
|
|
def _iter_elements(self) -> Iterator[Element]:
|
|
"""Partition the byte-stream in the attachment MIME-part into elements.
|
|
|
|
Generates zero elements if the attachment is not partitionable.
|
|
"""
|
|
# -- `auto.partition()` imports this module, so we need to defer the import to here to
|
|
# -- avoid a circular import.
|
|
from unstructured.partition.auto import partition
|
|
|
|
file = io.BytesIO(self._file_bytes)
|
|
|
|
# -- partition the attachment --
|
|
try:
|
|
elements = partition(
|
|
file=file,
|
|
metadata_filename=self._attachment_file_name,
|
|
metadata_last_modified=self._ctx.metadata_last_modified,
|
|
**self._ctx.partitioning_kwargs,
|
|
)
|
|
except BaseException as e:
|
|
if not isinstance(e, EXPECTED_ATTACHMENT_ERRORS):
|
|
raise
|
|
logger.warning(
|
|
"Skipping attachment %s: %s",
|
|
self._attachment_file_name,
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
return
|
|
|
|
for e in elements:
|
|
e.metadata.attached_to_filename = self._attached_to_filename
|
|
yield e
|
|
|
|
@cached_property
|
|
def _attached_to_filename(self) -> str | None:
|
|
"""The file-name (no path) of the message. `None` if not available."""
|
|
file_path = self._ctx.metadata_file_path
|
|
if file_path is None:
|
|
return None
|
|
return os.path.basename(file_path)
|
|
|
|
@cached_property
|
|
def _attachment_file_name(self) -> str | None:
|
|
"""The original name of the attached file, `None` if not present in the MIME part."""
|
|
return self._attachment.get_filename()
|
|
|
|
@cached_property
|
|
def _file_bytes(self) -> bytes:
|
|
"""The bytes of the attached file."""
|
|
content = self._attachment.get_content()
|
|
|
|
if isinstance(content, str):
|
|
return content.encode("utf-8")
|
|
|
|
assert isinstance(content, bytes)
|
|
return content
|