Skip to content

Commit fe1517d

Browse files
committed
fix: partition_email raises KeyError on a multipart/* attachment
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 surface as an "attachment" via iter_attachments() though -- e.g. a PGP/MIME-signed forwarded message (multipart/signed) nested inside a multipart/mixed envelope, or a multipart/mixed forwarded message itself. Resolving such an attachment's bytes in _AttachmentPartitioner._file_bytes raised KeyError('multipart/signed') (or 'multipart/mixed'), crashing the entire partition_email() call instead of processing or skipping just that one attachment. Fixed by falling back to the MIME part's raw serialized bytes (as_bytes()) when its content-type is multipart/*, rather than calling get_content(). This is a root-cause fix at the actual failure point, not a broadening of the shared, deliberately narrow EXPECTED_ATTACHMENT_ERRORS tuple (its own comment: "Intentionally narrow... we do not catch RuntimeError... would otherwise be silently skipped") -- a bare KeyError there would mask unrelated bugs elsewhere in partition(). Fixes Unstructured-IO#3922.
1 parent 114a1d5 commit fe1517d

4 files changed

Lines changed: 57 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
### Fixes
1616

1717
- **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.
18+
- **`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.
1819

1920
## 0.25.1
2021

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
2+
3+
Subject: Test with PGP-signed forwarded message attachment
4+
MIME-Version: 1.0
5+
Content-Type: multipart/mixed; boundary="===============6075316962646898834=="
6+
7+
--===============6075316962646898834==
8+
Content-Type: text/plain; charset="utf-8"
9+
Content-Transfer-Encoding: 7bit
10+
11+
This is the main email body.
12+
13+
--===============6075316962646898834==
14+
Content-Type: multipart/signed
15+
Content-Transfer-Encoding: base64
16+
Content-Disposition: attachment; filename="signed-message.eml"
17+
MIME-Version: 1.0
18+
19+
TUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiBtdWx0aXBhcnQvc2lnbmVkOyBwcm90b2Nv
20+
bD0iYXBwbGljYXRpb24vcGdwLXNpZ25hdHVyZSI7CiBtaWNhbGc9InBncC1zaGEyNTYiOyBib3Vu
21+
ZGFyeT0iPT09PT09PT09PT09PT09MzU2NzcxNDQ4MjMwOTczNzk3MT09IgoKLS09PT09PT09PT09
22+
PT09PT0zNTY3NzE0NDgyMzA5NzM3OTcxPT0KQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFy
23+
c2V0PSJ1dGYtOCIKQ29udGVudC1UcmFuc2Zlci1FbmNvZGluZzogN2JpdApNSU1FLVZlcnNpb246
24+
IDEuMAoKVGhpcyBpcyB0aGUgUEdQLXNpZ25lZCBmb3J3YXJkZWQgbWVzc2FnZSBib2R5LgoKLS09
25+
PT09PT09PT09PT09PT0zNTY3NzE0NDgyMzA5NzM3OTcxPT0KTUlNRS1WZXJzaW9uOiAxLjAKQ29u
26+
dGVudC1UeXBlOiBhcHBsaWNhdGlvbi9wZ3Atc2lnbmF0dXJlCgotLS0tLUJFR0lOIFBHUCBTSUdO
27+
QVRVUkUtLS0tLQpmYWtlZmFrZWZha2UKLS0tLS1FTkQgUEdQIFNJR05BVFVSRS0tLS0tCgotLT09
28+
PT09PT09PT09PT09PTM1Njc3MTQ0ODIzMDk3Mzc5NzE9PS0tCg==
29+
30+
--===============6075316962646898834==--

‎test_unstructured/partition/test_email.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,22 @@ def test_partition_email_silently_skips_attachments_it_cannot_partition():
389389
]
390390

391391

392+
def test_partition_email_does_not_raise_on_multipart_attachment():
393+
"""A multipart/* "attachment" (e.g. a PGP/MIME-signed forwarded message) must not crash.
394+
395+
`email.contentmanager` has no `get_content()` handler for any `multipart/*` content-type, so
396+
without special-casing this in `_AttachmentPartitioner._file_bytes`, resolving such an
397+
attachment's bytes raises `KeyError` -- crashing the whole `partition_email()` call instead
398+
of gracefully processing or skipping just that one attachment. Regression test for #3922.
399+
"""
400+
elements = partition_email(
401+
example_doc_path("eml/mime-attach-multipart-signed.eml"), process_attachments=True
402+
)
403+
404+
# -- No exception; the email body is still partitioned. --
405+
assert elements == [NarrativeText("This is the main email body.")]
406+
407+
392408
# ================================================================================================
393409
# ISOLATED UNIT TESTS
394410
# ================================================================================================

‎unstructured/partition/email.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,16 @@ def _attachment_file_name(self) -> str | None:
432432
@cached_property
433433
def _file_bytes(self) -> bytes:
434434
"""The bytes of the attached file."""
435+
# -- `email.contentmanager` has no `get_content()` handler registered for any
436+
# -- `multipart/*` content-type (handlers exist for text/*, application/*, image/*,
437+
# -- message/rfc822, etc., but multipart sub-parts are normally consumed via
438+
# -- `.iter_parts()`, not `.get_content()`). A multipart sub-part can still appear as an
439+
# -- "attachment" though, e.g. a PGP/MIME-signed forwarded message (`multipart/signed`)
440+
# -- nested inside a `multipart/mixed` envelope. Fall back to the part's raw serialized
441+
# -- bytes in that case rather than letting `get_content()` raise `KeyError`.
442+
if self._attachment.get_content_type().startswith("multipart/"):
443+
return self._attachment.as_bytes()
444+
435445
content = self._attachment.get_content()
436446

437447
if isinstance(content, str):

0 commit comments

Comments
 (0)