Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:33:56 +08:00

324 lines
12 KiB
Python

from __future__ import annotations
import os
import re
import tempfile
from functools import cached_property
from typing import IO, Any, Iterator, Optional
from oxmsg import Message
from oxmsg.attachment import Attachment
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
from unstructured.utils import is_temp_file_path
def partition_msg(
filename: Optional[str] = None,
*,
file: Optional[IO[bytes]] = None,
metadata_filename: Optional[str] = None,
metadata_last_modified: Optional[str] = None,
process_attachments: bool = True,
**kwargs: Any,
) -> list[Element]:
"""Partitions a MSFT Outlook .msg file
Parameters
----------
filename
A string defining the target filename path.
file
A file-like object using "rb" mode --> open(filename, "rb").
metadata_filename
The filename to use for the metadata.
metadata_last_modified
The last modified date for the document.
process_attachments
If True, partition_email will process email attachments in addition to
processing the content of the email itself.
"""
opts = MsgPartitionerOptions(
file=file,
file_path=filename,
metadata_file_path=metadata_filename,
metadata_last_modified=metadata_last_modified,
partition_attachments=process_attachments,
kwargs=kwargs,
)
return list(_MsgPartitioner.iter_message_elements(opts))
class MsgPartitionerOptions:
"""Encapsulates partitioning option validation, computation, and application of defaults."""
def __init__(
self,
*,
file: IO[bytes] | None,
file_path: str | None,
metadata_file_path: str | None,
metadata_last_modified: str | None,
partition_attachments: bool,
kwargs: dict[str, Any],
):
self._file = file
self._file_path = file_path
self._metadata_file_path = metadata_file_path
self._metadata_last_modified = metadata_last_modified
self._partition_attachments = partition_attachments
self._kwargs = kwargs
@cached_property
def extra_msg_metadata(self) -> ElementMetadata:
"""ElementMetadata suitable for use on an element formed from message content.
These are only the metadata fields specific to email messages. The remaining metadata
fields produced by the delegate partitioner are used as produced.
None of these metadata fields change based on the element, so we just compute it once.
"""
msg = self.msg
sent_from = [s.strip() for s in sender.split(",")] if (sender := msg.sender) else None
sent_to = [r.email_address for r in msg.recipients] or None
bcc_recipient = (
[c.strip() for c in bcc.split(",")] if (bcc := msg.message_headers.get("Bcc")) else None
)
cc_recipient = (
[c.strip() for c in cc.split(",")] if (cc := msg.message_headers.get("Cc")) else None
)
if email_message_id := msg.message_headers.get("Message-Id"):
email_message_id = re.sub(r"^<|>$", "", email_message_id) # Strip angle brackets
element_metadata = ElementMetadata(
bcc_recipient=bcc_recipient,
cc_recipient=cc_recipient,
email_message_id=email_message_id,
sent_from=sent_from,
sent_to=sent_to,
subject=msg.subject or None,
)
element_metadata.detection_origin = "msg"
return element_metadata
@cached_property
def is_encrypted(self) -> bool:
"""True when message is encrypted."""
# NOTE(robinson) - Per RFC 2015, the content type for emails with PGP encrypted content
# is multipart/encrypted (ref: https://www.ietf.org/rfc/rfc2015.txt)
# NOTE(scanny) - pretty sure we're going to want to dig deeper to discover messages that are
# encrypted with something other than PGP.
# - might be able to distinguish based on PID_MESSAGE_CLASS = 'IPM.Note.Signed'
# - Content-Type header might include "application/pkcs7-mime" for Microsoft S/MIME
# encryption.
return "encrypted" in self.msg.message_headers.get("Content-Type", "")
@cached_property
def metadata_file_path(self) -> str | None:
"""Best available path for MSG file.
The value is the caller supplied `metadata_filename` if present, falling back to the
source file-path if that was provided, otherwise `None`.
"""
return self._metadata_file_path or self._file_path
@cached_property
def metadata_last_modified(self) -> str | None:
"""Caller override for `.metadata.last_modified` to be applied to all elements."""
email_date = sent_date.isoformat() if (sent_date := self.msg.sent_date) else None
return self._metadata_last_modified or email_date or self._last_modified
@cached_property
def msg(self) -> Message:
"""The `oxmsg.Message` object loaded from file or filename."""
return Message.load(self._msg_file)
@cached_property
def partition_attachments(self) -> bool:
"""True when message attachments should also be partitioned."""
return self._partition_attachments
@cached_property
def partitioning_kwargs(self) -> dict[str, Any]:
"""The "extra" keyword arguments received by `partition_msg()`.
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 _last_modified(self) -> str | None:
"""The best last-modified date available from source-file, None if not available."""
if not self._file_path or is_temp_file_path(self._file_path):
return None
return get_last_modified_date(self._file_path)
@cached_property
def _msg_file(self) -> str | IO[bytes]:
"""The source for the bytes of the message, either a file-path or a file-like object."""
if file_path := self._file_path:
return file_path
if file := self._file:
return file
raise ValueError("one of `file` or `filename` arguments must be provided")
class _MsgPartitioner:
"""Partitions Outlook email message (MSG) files."""
def __init__(self, opts: MsgPartitionerOptions):
self._opts = opts
@classmethod
def iter_message_elements(cls, opts: MsgPartitionerOptions) -> Iterator[Element]:
"""Partition MS Outlook email messages (.msg files) into elements."""
if opts.is_encrypted:
logger.warning("Encrypted email detected. Partitioner will return an empty list.")
return
yield from cls(opts)._iter_message_elements()
def _iter_message_elements(self) -> Iterator[Element]:
"""Partition MS Outlook email messages (.msg files) into elements."""
yield from self._iter_message_body_elements()
if not self._opts.partition_attachments:
return
for attachment in self._attachments:
yield from _AttachmentPartitioner.iter_elements(attachment, self._opts)
@cached_property
def _attachments(self) -> tuple[Attachment, ...]:
"""The `oxmsg.attachment.Attachment` objects for this message."""
return tuple(self._opts.msg.attachments)
def _iter_message_body_elements(self) -> Iterator[Element]:
"""Partition the message body (but not the attachments)."""
msg = self._opts.msg
if html_body := msg.html_body:
elements = partition_html(
text=html_body,
metadata_filename=self._opts.metadata_file_path,
metadata_file_type=FileType.MSG,
metadata_last_modified=self._opts.metadata_last_modified,
**self._opts.partitioning_kwargs,
)
elif msg.body:
elements = partition_text(
text=msg.body,
metadata_filename=self._opts.metadata_file_path,
metadata_file_type=FileType.MSG,
metadata_last_modified=self._opts.metadata_last_modified,
**self._opts.partitioning_kwargs,
)
else:
elements: list[Element] = []
# -- augment the element metadata with email-specific values --
email_specific_metadata = self._opts.extra_msg_metadata
for e in elements:
e.metadata.update(email_specific_metadata)
yield e
class _AttachmentPartitioner:
"""Partitions an attachment to a MSG file."""
def __init__(self, attachment: Attachment, opts: MsgPartitionerOptions):
self._attachment = attachment
self._opts = opts
@classmethod
def iter_elements(
cls, attachment: Attachment, opts: MsgPartitionerOptions
) -> Iterator[Element]:
"""Partition an `oxmsg.attachment.Attachment` from an Outlook email message (.msg file)."""
return cls(attachment, opts)._iter_elements()
def _iter_elements(self) -> Iterator[Element]:
"""Partition the file in an `oxmsg.attachment.Attachment` into elements."""
from unstructured.partition.auto import partition
with tempfile.TemporaryDirectory() as tmp_dir_path:
# -- save attachment as file in this temporary directory --
detached_file_path = os.path.join(tmp_dir_path, self._attachment_file_name)
with open(detached_file_path, "wb") as f:
f.write(self._file_bytes)
# -- partition the attachment --
try:
elements = partition(
detached_file_path,
metadata_filename=self._attachment_file_name,
metadata_last_modified=self._attachment_last_modified,
**self._opts.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._opts.metadata_file_path
yield e
@cached_property
def _attachment_file_name(self) -> str:
"""The original name of the attached file, no path.
This value is 'unknown' if it is not present in the MSG file (not expected).
The filename is sanitized to prevent path traversal attacks.
"""
raw_filename = self._attachment.file_name or "unknown"
# Sanitize the filename to prevent path traversal attacks
# Remove any path components for both Unix and Windows paths
# Use both separators to handle cross-platform attacks
safe_filename = os.path.basename(raw_filename.replace("\\", "/"))
# Remove null bytes and other control characters
safe_filename = safe_filename.replace("\0", "")
# If the filename becomes empty after sanitization, use a default
if not safe_filename or safe_filename in (".", ".."):
safe_filename = "unknown"
return safe_filename
@cached_property
def _attachment_last_modified(self) -> str | None:
"""ISO8601 string timestamp of attachment last-modified date.
This value generally available on the attachment and will be the most reliable last-modifed
time. There are fallbacks for when it is not present, ultimately `None` if we have no way
of telling.
"""
if last_modified := self._attachment.last_modified:
return last_modified.isoformat()
return self._opts.metadata_last_modified
@cached_property
def _file_bytes(self) -> bytes:
"""The bytes of the attached file."""
return self._attachment.file_bytes or b""