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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
### Fixes

- **Reject an empty `tokenizer` when chunking by `max_tokens`**: `""` is not `None`, so it slipped past the "tokenizer is required" check while still leaving the chunkers without a token counter — the window was then silently measured in characters, making `max_tokens=20` mean 20 characters. It now raises the same `ValueError` as omitting `tokenizer` altogether.
- **`partition_email()` no longer crashes on a `multipart/*` attachment**: a multipart sub-part surfaced as an "attachment" (e.g. a PGP/MIME-signed forwarded message) previously raised an uncaught `KeyError` from `email.contentmanager`, which has no `get_content()` handler for any `multipart/*` content-type. `_AttachmentPartitioner._file_bytes` now falls back to the part's raw serialized bytes for this case instead.
- **`partition_email()` no longer drops a `multipart/*` attachment's own body**: serializing such an attachment verbatim left its own `Content-Disposition: attachment` header intact, so re-parsing it as the root of a new message made `EmailMessage.get_body()` skip it entirely instead of finding its body part. The attachment's raw bytes are now serialized from a copy with that header stripped.

## 0.25.1

Expand Down
36 changes: 36 additions & 0 deletions example-docs/eml/mime-attach-multipart-signed.eml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
From: sender@example.com
To: recipient@example.com
Subject: Test with PGP-signed forwarded message attachment
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="===============6075316962646898834=="

--===============6075316962646898834==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit

This is the main email body.

--===============6075316962646898834==
Content-Type: multipart/signed; protocol="application/pgp-signature";
micalg="pgp-sha256"; boundary="===============3567714482309737971=="
Content-Disposition: attachment; filename="signed-message.eml"
MIME-Version: 1.0

--===============3567714482309737971==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0

This is the signed forwarded message body.

--===============3567714482309737971==
MIME-Version: 1.0
Content-Type: application/pgp-signature

-----BEGIN PGP SIGNATURE-----
fakefakefake
-----END PGP SIGNATURE-----

--===============3567714482309737971==--

--===============6075316962646898834==--
26 changes: 26 additions & 0 deletions test_unstructured/partition/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,32 @@ def test_partition_email_silently_skips_attachments_it_cannot_partition():
]


def test_partition_email_does_not_raise_on_multipart_attachment():
"""A multipart/* "attachment" (e.g. a PGP/MIME-signed forwarded message) must not crash.

`email.contentmanager` has no `get_content()` handler for any `multipart/*` content-type, so
without special-casing this in `_AttachmentPartitioner._file_bytes`, resolving such an
attachment's bytes raises `KeyError` -- crashing the whole `partition_email()` call instead
of gracefully processing or skipping just that one attachment. Regression test for #3922.

The fixture's attachment is itself a genuine boundary-bearing `multipart/signed` MIME part
(a PGP-signed forwarded message), so this also guards a second, subtler bug: serializing
that part verbatim (with its own `Content-Disposition: attachment` header intact) and
re-parsing it as a new root message made `EmailMessage.get_body()` skip it entirely, i.e.
the attachment partitioned to zero elements instead of surfacing its signed body text.
"""
elements = partition_email(
example_doc_path("eml/mime-attach-multipart-signed.eml"), process_attachments=True
)

# -- No exception; the outer email body is partitioned ... --
assert elements[0] == NarrativeText("This is the main email body.")
# -- ... and the signed attachment's own body is also partitioned, not silently dropped. --
assert elements[1] == NarrativeText("This is the signed forwarded message body.")
assert elements[1].metadata.filename == "signed-message.eml"
assert elements[1].metadata.attached_to_filename == "mime-attach-multipart-signed.eml"


# ================================================================================================
# ISOLATED UNIT TESTS
# ================================================================================================
Expand Down
27 changes: 27 additions & 0 deletions unstructured/partition/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import copy
import datetime as dt
import email
import email.policy
Expand Down Expand Up @@ -432,10 +433,36 @@ def _attachment_file_name(self) -> str | None:
@cached_property
def _file_bytes(self) -> bytes:
"""The bytes of the attached file."""
# -- `email.contentmanager` has no `get_content()` handler registered for any
# -- `multipart/*` content-type (handlers exist for text/*, application/*, image/*,
# -- message/rfc822, etc., but multipart sub-parts are normally consumed via
# -- `.iter_parts()`, not `.get_content()`). A multipart sub-part can still appear as an
# -- "attachment" though, e.g. a PGP/MIME-signed forwarded message (`multipart/signed`)
# -- nested inside a `multipart/mixed` envelope. Fall back to the part's raw serialized
# -- bytes in that case rather than letting `get_content()` raise `KeyError`.
if self._attachment.get_content_type().startswith("multipart/"):
return self._serialized_multipart_bytes

content = self._attachment.get_content()

if isinstance(content, str):
return content.encode("utf-8")

assert isinstance(content, bytes)
return content

@cached_property
def _serialized_multipart_bytes(self) -> bytes:
"""Raw serialized bytes of a `multipart/*` attachment, re-parseable as a document.

`EmailMessage.get_body()` skips any candidate part -- including a `multipart/*` root
message -- whose own `Content-Disposition` is "attachment". This MIME part carries
exactly that header (that's how it surfaced as an attachment via `iter_attachments()`
in the first place), so serializing it verbatim and re-parsing it as the root of a new
message would make `partition_email()` find no body at all. Strip that header on a
*copy* before serializing -- `self._attachment` itself is left untouched since
`_attachment_file_name` still needs its original `Content-Disposition` filename param.
"""
part = copy.deepcopy(self._attachment)
del part["Content-Disposition"]
return part.as_bytes()