Skip to content

fix(email): partition an attached email (message/rfc822) instead of raising AssertionError - #4597

Merged
cragwolfe merged 2 commits into
Unstructured-IO:mainfrom
sinnercode228:fix/email-rfc822-attachments
Oct 26, 2026
Merged

cragwolfe merged 2 commits into
Unstructured-IO:mainfrom
sinnercode228:fix/email-rfc822-attachments

Conversation

@sinnercode228

@sinnercode228 sinnercode228 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

partition_email() processes attachments by default. When a message has a
message/rfc822 part, the whole call fails with a bare AssertionError and
the body elements are lost too. Common inputs have such a part:

  • an email forwarded "as attachment" (Outlook, Thunderbird),
  • a delivery-failure bounce (multipart/report with the returned message),
  • a multipart/digest, where every part is message/rfc822.

The repository's own fixture example-docs/eml/mime-multipart-digest.eml
fails this way, through partition() too.

Cause: for a message/rfc822 part, get_content() returns the enclosed
EmailMessage, not str or bytes, so _AttachmentPartitioner._file_bytes
fails its assert isinstance(content, bytes). The assert sits outside the
try/except that skips attachments it cannot partition.

Fix, in unstructured/partition/email.py:

  • _file_bytes serializes an enclosed message with as_bytes().
  • For a message/rfc822 part, content_type="message/rfc822" is added to the
    kwargs of the nested partition() call. Without it the bytes go through
    libmagic, which reports a message as text/plain when its first header is
    e.g. MIME-Version, Message-ID or To and not From/Received/Date.
    The attachment's raw headers then come out as Title elements. Python's
    EmailMessage writes such messages: the forwarded message in the new test
    starts with Content-Type, which libmagic does not recognize either. The
    hint is merged into the kwargs only for these parts, so a content_type=
    kwarg passed to partition_email() directly still reaches the other
    attachments as before instead of colliding with an explicit keyword.

Related:

  • fix: partition_email raises KeyError on a multipart/* attachment #4424 is the sibling fix in the same _file_bytes function (the KeyError
    on a multipart/* attachment). Its comment already lists message/rfc822
    among the types get_content() handles, but that path still hit the assert.
  • Partition_email with process_attachments=True for nested eml #3604 (closed in October 2024 with no fix linked) reported
    process_attachments=True failing on a nested .eml, with a TypeError in
    the implementation of that time. A maintainer asked there for an email that
    reproduces it so a test could guard the behavior. The tests here use two
    such emails: the existing digest fixture and a forwarded message built in
    the test.

Validation

Environment: main at c74e6c4, Python 3.12,
uv sync --locked --group test (no extras; libmagic installed).

New tests in test_unstructured/partition/test_email.py (attachments section):

  • test_partition_email_partitions_the_messages_of_a_multipart_digest: uses
    the existing mime-multipart-digest.eml fixture. It checks the three
    messages' text, their subject metadata and attached_to_filename.
  • test_partition_email_partitions_a_message_forwarded_as_an_attachment: an
    EmailMessage forwarded with add_attachment() whose first header is
    Content-Type. It checks the body, the forwarded text, subject and
    sent_from.

Results:

  • New tests without the fix:
    pytest test_unstructured/partition/test_email.py -k "multipart_digest or forwarded_as_an_attachment"
    gives 2 failed, both AssertionError at unstructured/partition/email.py:485
    (assert isinstance(content, bytes)). The whole file without the fix gives
    2 failed, 77 passed, 1 skipped.
  • New tests with the fix: 2 passed.
  • With only the as_bytes() change and no content_type hint, the digest
    test passes and the forwarded-message test fails with 6 extra Title
    elements (the forwarded message's raw headers). That is why the hint is
    part of the fix.
  • pytest test_unstructured/partition/test_email.py: 79 passed, 1 skipped.
    The skip is the WEBP test that needs unstructured_inference.
  • pytest test_unstructured/file_utils/test_filetype.py test_unstructured/partition/test_msg.py:
    362 passed, 1 failed. The failure is
    test_partition_msg_can_process_attachments, which fails the same way on
    main because the pptx extra is not installed. msg.py has its own
    attachment partitioner and is not changed.
  • Manual check of a bounce (multipart/report with text/plain,
    message/delivery-status and a message/rfc822 part whose first header is
    MIME-Version): returns the bounce text, the delivery-status lines and the
    returned message's body with subject == "Lunch". Before the fix it raised
    AssertionError. A forward of a forward is also partitioned at every level.
  • make check (ruff 0.15.10 from the lint group): All checks passed!,
    355 files already formatted.
  • bash scripts/test-check-new-release-version.sh: Passed 27 version guard tests.
  • VERSION_BASE_REF=upstream/main bash scripts/check-new-release-version.sh:
    PASS: unchanged development version 0.27.35-dev (no tag lookup).
  • No CHANGELOG.md or unstructured/__version__.py changes.

Release note

partition_email(), and partition() on an .eml file, now partition an
attached email (a message forwarded as an attachment, the returned message in
a bounce, each message of a multipart/digest) like any other attachment.
Before, they raised AssertionError. Each element from the attached message
carries that message's own subject, sent_from and other email metadata.

Checklist

  • The PR title is a user-facing release-note entry with a conventional commit prefix.
  • Apply a category label (enhancement, bug, or an appropriate existing label), or ask a maintainer to apply it. A maintainer needs to apply bug.
  • For breaking changes, use ! in the title and explain impact and migration above. (Not a breaking change.)

…aising AssertionError

partition_email() processes attachments by default. For a message/rfc822
part (an email forwarded as an attachment, the returned message in a
bounce, each part of a multipart/digest) get_content() returns the
enclosed message object rather than str or bytes, so
_AttachmentPartitioner._file_bytes failed its assert and the whole call
raised AssertionError, losing the body elements too.

Serialize the enclosed message with as_bytes() and pass
content_type="message/rfc822" for such parts, so the attachment is
partitioned as an email even when its first header is one libmagic does
not recognize (e.g. MIME-Version), instead of emitting its raw headers
as text.

@cragwolfe cragwolfe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT Pro reviewed c9632d5b2981aa909194dde53574bebceaa36704 against c74e6c4c50e58c49778a458745d06c248add2db3.

P2 — Preserve raw binary descendants when serializing the enclosed email (unstructured/partition/email.py:493–494).

content.as_bytes() serializes the entire enclosed MIME tree with the default LF line separator. In Python 3.12, BytesGenerator sends image/application payloads through _writeBody / _handle_text and _write_lines, which normalizes CRLF and bare CR. A forwarded email with an image attachment using Content-Transfer-Encoding: binary therefore loses the original bytes: a PNG signature beginning 89 50 4e 47 0d 0a 1a 0a becomes 89 50 4e 47 0a 1a 0a before the child attachment is partitioned. Base64 attachments do not have this problem.

This is a gap in the newly supported path; the same input previously raised AssertionError. The existing multipart serialization helper has a similar limitation, but that preexisting path is separate from this finding.

Please preserve the decoded binary bytes across the attached-message boundary, for example by processing the parsed child message or safely transfer-encoding binary leaves on a copy before serialization. Switching to CRLF alone cannot preserve arbitrary binary bytes. Add a raw MIME fixture with CRLF, bare CR, bare LF and non-ASCII payload bytes, and assert exact descendant bytes across one and two attached-message levels, with a base64 control. Construct the fixture from raw bytes so the fixture builder does not apply the same lossy serialization.

The MIME hint and its isolation from sibling attachment kwargs look correct. No intentional breaking API change was identified. The two new tests cover the reported assertion and routing issue. No local tests were run for this review. At the CI observation, this head had 47 successful checks and three in progress; this is not a merge-readiness claim. Neither release-managed file is changed.

(authored by codex)

Serializing the enclosed email of a message/rfc822 part with as_bytes() rewrites each CR in a
part's body, alone or in a CRLF, as LF. That is harmless for Base64, which ignores line breaks,
but it corrupts binary data in any other part, for example a PNG image sent with
Content-Transfer-Encoding binary, 8bit or none (7bit).

On a copy of the enclosed email, each non-multipart part that is not in Base64 and whose decoded
bytes contain a CR is now re-encoded in Base64 from those bytes before serializing. Each part then
reaches its partitioner with the same bytes as when the enclosed email is partitioned on its own.
The parsed email is not changed.

A test builds raw MIME bytes with an image attached as binary, without a transfer-encoding header
and in Base64 (the control), one and two attached-message levels deep, and checks the exact bytes
each attachment's partitioner receives.

@cragwolfe cragwolfe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT Pro reviewed the complete final diff at 01c9d4b094dbb36565b5d1c60e42c2e454395715 against c74e6c4c50e58c49778a458745d06c248add2db3, with surrounding source, tests, and the previous review. No concrete introduced correctness defect or breaking compatibility change was found.

The previous P2 is resolved for the attached message/rfc822 path. _serialize_message() walks a copy of the complete enclosed MIME tree and Base64-encodes non-Base64 leaves whose decoded bytes contain carriage returns before serialization. The raw-byte tests exercise binary and missing transfer-encoding headers, Base64 controls, CRLF, bare CR, bare LF, and non-ASCII bytes across one and two attached-message levels. The MIME hint remains isolated from sibling kwargs. The separate, unchanged multipart/* serializer is outside this fix's byte-preservation guarantee.

A useful follow-up test would attach a quoted-printable non-UTF-8 text body containing =0D and soft wrapping, with a no-CR control, and assert the resulting Unicode text. Source inspection preserves the decoded bytes and charset; this suggestion does not identify a current failure.

No local tests were run. At this head, 50 checks succeeded; CI, CodeQL, and Partition Benchmark workflows passed, while Security is action_required. This source approval does not establish integration readiness. Neither release-managed file changed; uv.lock and CHANGELOG.md were excluded from the Pro bundle.

(authored by codex)

@cragwolfe
cragwolfe added this pull request to the merge queue Oct 26, 2026
Merged via the queue into Unstructured-IO:main with commit 631f714 Oct 26, 2026
52 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants