From 1eceac26c895440853399d000eb5c822b76f87b4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 15 Oct 2024 19:02:33 -0700 Subject: [PATCH] rfctr(email): eml partitioner rewrite (#3694) **Summary** Initial attempts to incrementally refactor `partition_email()` into shape to allow pluggable partitioning quickly became too complex for ready code-review. Prepare separate rewritten module and tests and swap them out whole. **Additional Context** - Uses the modern stdlib `email` module to reliably accomplish several manual decoding steps in the legacy code. - Remove obsolete email-specific element-types which were replaced 18 months or so ago with email-specific metadata fields for things like Cc: addresses, subject, etc. - Remove accepting an email as `text: str` because MIME-email is inherently a binary format which can and often does contain multiple and contradictory character-encodings. - Remove `encoding` parameters as it is now unused. An email file is not a text file and as such does not have a single overall encoding. Character encoding is specified individually for each MIME-part within the message and often varies from one part to another in the same message. - Remove the need for a caller to specify `attachment_partitioner`. There is only one reasonable choice for this which is `auto.partition()`, consistent with the same interface and operation in `partition_msg()`. - Fixes #3671 along the way by silently skipping attachments with a file-type for which there is no partitioner. - Substantially extend the test-suite to cover multiple transport-encoding/charset combinations. --------- Co-authored-by: ryannikolaidis <1208590+ryannikolaidis@users.noreply.github.com> Co-authored-by: scanny --- CHANGELOG.md | 10 + example-docs/eml/empty.eml | 3 + example-docs/eml/mime-attach-mp3.eml | 934 +++++++++++++++ .../eml/mime-different-plain-html.eml | 34 + example-docs/eml/mime-html-only.eml | 14 + example-docs/eml/mime-multi-to-cc-bcc.eml | 10 + example-docs/eml/mime-multipart-digest.eml | 37 + example-docs/eml/mime-no-body.eml | 22 + example-docs/eml/mime-no-subject.eml | 6 + example-docs/eml/mime-no-to.eml | 8 + example-docs/eml/mime-simple.eml | 22 + .../eml/mime-word-encoded-subject.eml | 7 + example-docs/eml/rfc822-no-date.eml | 5 + example-docs/eml/simple-rfc-822.eml | 10 + .../documents/test_email_elements.py | 96 -- test_unstructured/partition/test_auto.py | 16 +- test_unstructured/partition/test_email.py | 1022 ++++++++--------- test_unstructured/partition/test_msg.py | 27 + .../outlook/21be155fb0c95885.eml.json | 38 +- .../outlook/497eba8c81c801c6.eml.json | 38 +- .../outlook/4a16a411f162ebbb.eml.json | 38 +- .../EmailMessage/02sHu00001efErPIAU.eml.json | 2 +- .../EmailMessage/02sHu00001efErQIAU.eml.json | 2 +- unstructured/__version__.py | 2 +- unstructured/documents/email_elements.py | 111 -- unstructured/partition/auto.py | 5 +- unstructured/partition/common/__init__.py | 6 + unstructured/partition/email.py | 811 ++++++------- unstructured/partition/msg.py | 24 +- 29 files changed, 2113 insertions(+), 1247 deletions(-) create mode 100644 example-docs/eml/empty.eml create mode 100644 example-docs/eml/mime-attach-mp3.eml create mode 100644 example-docs/eml/mime-different-plain-html.eml create mode 100644 example-docs/eml/mime-html-only.eml create mode 100644 example-docs/eml/mime-multi-to-cc-bcc.eml create mode 100644 example-docs/eml/mime-multipart-digest.eml create mode 100644 example-docs/eml/mime-no-body.eml create mode 100644 example-docs/eml/mime-no-subject.eml create mode 100644 example-docs/eml/mime-no-to.eml create mode 100644 example-docs/eml/mime-simple.eml create mode 100644 example-docs/eml/mime-word-encoded-subject.eml create mode 100644 example-docs/eml/rfc822-no-date.eml create mode 100644 example-docs/eml/simple-rfc-822.eml delete mode 100644 test_unstructured/documents/test_email_elements.py delete mode 100644 unstructured/documents/email_elements.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ccf02a1..46862fbdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.16.1-dev0 + +### Enhancements + +### Features + +### Fixes + +* **Rewrite of `partition.email` module and tests.** Use modern Python stdlib `email` module interface to parse email messages and attachments. This change shortens and simplifies the code, and makes it more robust and maintainable. Several historical problems were remedied in the process. + ## 0.16.0 ### Enhancements diff --git a/example-docs/eml/empty.eml b/example-docs/eml/empty.eml new file mode 100644 index 000000000..60aa8799b --- /dev/null +++ b/example-docs/eml/empty.eml @@ -0,0 +1,3 @@ + + + diff --git a/example-docs/eml/mime-attach-mp3.eml b/example-docs/eml/mime-attach-mp3.eml new file mode 100644 index 000000000..35495746c --- /dev/null +++ b/example-docs/eml/mime-attach-mp3.eml @@ -0,0 +1,934 @@ +From: sender@example.com +To: recipient@example.com +Subject: Email with MP3 Attachment +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="===============6149310949458043093==" + +--===============6149310949458043093== +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 7bit + +This is an email with an MP3 attachment. + +--===============6149310949458043093== +Content-Type: audio/mpeg +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="sample-3s.mp3" +MIME-Version: 1.0 + +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 +K5iJxNSZIYVnD3h1EXNyePjKmReWtjQjh/NDKNApDtJUqH1EHSSdGlWSMtEytMxWRhqOhEmpsdqM +iaRNUdZYoJOpbJFU6XzyzAtlI+fM0UVo1PWiipI3OrOHGQmamXRWQpDFDtOImxuWLLl5ZeXMqVFF +lJJIomy5z///88dmf/51akxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq +qv/7kmTsAAZ2bVAtamAAAAANIKAAARvV2UrZmQAAAAA0gwAAAKqqqqqqqqqqqqqqqqqqqqqqqqqq +qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq +qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq +qqqqqqqqqqqqqqqqqgCSMqgCgFHWAlKiqDVUciSYu40uXULCx0rRQtTNcqtfcqKqMBsAsaMFhYWs +kFILbDkFoe0zat8M1//rXDcMzeq+qrWzbNfWzVs3yq8ip0RP5U7/Khsqdg0oO/iLlTpUFVA04Gjw +lDYK1UxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVX/+5BkdY/zQTxClzEAAAAADSDgAAEAAAGkAAAAIAAANIAAAARV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV +VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU= + +--===============6149310949458043093==-- diff --git a/example-docs/eml/mime-different-plain-html.eml b/example-docs/eml/mime-different-plain-html.eml new file mode 100644 index 000000000..625de87f2 --- /dev/null +++ b/example-docs/eml/mime-different-plain-html.eml @@ -0,0 +1,34 @@ +From: sender@example.com +To: recipient@example.com +Date: Tue, 01 Oct 2024 12:34:56 -0500 +Subject: Example MIME Email +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="boundary123" + +--boundary123 +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: 7bit + +This is the text/plain part. + +Did you know that the first email was sent by Ray Tomlinson in 1971? He used the "@" symbol to separate the user's name from the computer name, a practice that is still in use today. + +Another interesting fact is that the first known instance of email spam occurred in 1978. A marketing message was sent to 393 recipients on ARPANET, marking the beginning of what we now know as email spam. + +--boundary123 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: 7bit + + + + + Example MIME Email + + +

This is the text/html part.

+

Did you know that the first networked email was sent by Ray Tomlinson in 1971? He used the "@" symbol to separate the user's name from the computer name, a practice that is still in use today.

+

Another interesting fact is that the first known instance of email spam occurred in 1978. A marketing message was sent to 393 recipients on ARPANET, marking the beginning of what we now know as email spam.

+ + + +--boundary123-- diff --git a/example-docs/eml/mime-html-only.eml b/example-docs/eml/mime-html-only.eml new file mode 100644 index 000000000..608770100 --- /dev/null +++ b/example-docs/eml/mime-html-only.eml @@ -0,0 +1,14 @@ +MIME-Version: 1.0 +From: sender@example.com +To: recipient@example.com +Date: Tue, 01 Oct 2024 12:34:56 -0500 +Subject: Example HTML Only MIME Email +Content-Type: text/html; charset="ISO-8859-1" +Content-Transfer-Encoding: base64 + +PHA+VGhpcyBpcyBhIHRleHQvaHRtbCBwYXJ0LjwvcD4KPGRpdiBpZD0iY29udGVudCI+PHA+VGhl +IGZpcnN0IGVtb3RpY29uLCA6KSAsIHdhcyBwcm9wb3NlZCBieSBTY290dCBGYWhsbWFuIGluIDE5 +ODIgdG8gaW5kaWNhdGUganVzdCBvciBzYXJjYXNtIGluIHRleHQgZW1haWxzLjwvcD4KPHA+R21h +aWwgd2FzIGxhdW5jaGVkIGJ5IEdvb2dsZSBpbiAyMDA0IHdpdGggMSBHQiBvZiBmcmVlIHN0b3Jh +Z2UsIHNpZ25pZmljYW50bHkgbW9yZSB0aGFuIHdoYXQgb3RoZXIgc2VydmljZXMgb2ZmZXJlZCBh +dCB0aGUgdGltZS48L3A+PC9kaXY+ diff --git a/example-docs/eml/mime-multi-to-cc-bcc.eml b/example-docs/eml/mime-multi-to-cc-bcc.eml new file mode 100644 index 000000000..1913659e2 --- /dev/null +++ b/example-docs/eml/mime-multi-to-cc-bcc.eml @@ -0,0 +1,10 @@ +From: sender@example.com +To: Bob , Sue +Cc: Tom , Alice +Bcc: John , Mary +Subject: Example Plain-Text MIME Message +Message-ID: <2143658709@example.com> +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +This is a plain-text message. diff --git a/example-docs/eml/mime-multipart-digest.eml b/example-docs/eml/mime-multipart-digest.eml new file mode 100644 index 000000000..c68aa369a --- /dev/null +++ b/example-docs/eml/mime-multipart-digest.eml @@ -0,0 +1,37 @@ +From: alice@example.com +To: bob@example.com +Cc: carol@example.com +Bcc: dave@example.com +Subject: Example Multipart Digest Email +Message-ID: <1234567890@example.com> +MIME-Version: 1.0 +Content-Type: multipart/digest; boundary="boundary123" + +--boundary123 +Content-Type: message/rfc822 + +From: eve@example.com +To: alice@example.com +Subject: First Message + +This is the first message in the digest. + +--boundary123 +Content-Type: message/rfc822 + +From: frank@example.com +To: bob@example.com +Subject: Second Message + +This is the second message in the digest. + +--boundary123 +Content-Type: message/rfc822 + +From: grace@example.com +To: carol@example.com +Subject: Third Message + +This is the third message in the digest. + +--boundary123-- diff --git a/example-docs/eml/mime-no-body.eml b/example-docs/eml/mime-no-body.eml new file mode 100644 index 000000000..c4366ed6c --- /dev/null +++ b/example-docs/eml/mime-no-body.eml @@ -0,0 +1,22 @@ +From: sender@example.com +To: recipient@example.com +Date: Tue, 01 Oct 2024 12:34:56 -0500 +Subject: Image Only Email +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="boundary123" + +--boundary123 +Content-Type: image/jpeg +Content-Disposition: attachment; filename="image.jpg" +Content-Transfer-Encoding: base64 + +/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxISEBAQEhISEBAWFRUVFhUVFRUWFRUWFhUWFhUV +FRUYHSggGBolGxUVITEhJSkrLi4uFx8zODMtNygtLisBCgoKDg0OGhAQGi0fHx8rLS0rLS0rLS0t +LS0rLS0rLS0rLS0rLS0rLS0rLS0rLS0rLS0tLS0rLS0rLS0rLS0rLf/AABEIAMgAyAMBIgACEQED +EQH/xAAbAAEAAgMBAQAAAAAAAAAAAAAABAUCAwYBB//EAD0QAAIBAwMBBgQEBgIDCQAAAAECAwAE +ERIhBTFBBhMiUWFxgZEykaGxFCNCUrHB0fAUM2JygpLwFySTwsL/xAAYAQEBAQEBAAAAAAAAAAAA +AAAABQEDBP/EAB8RAQEBAQEBAQEBAQEAAAAAAAABEQIhEjEEQVFhcf/aAAwDAQACEQMRAD8A+6qK +CiiggqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgq +CiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCooIKgqCiiCo +[Base64 encoded image data continues] +--boundary123-- diff --git a/example-docs/eml/mime-no-subject.eml b/example-docs/eml/mime-no-subject.eml new file mode 100644 index 000000000..ee06ec5e3 --- /dev/null +++ b/example-docs/eml/mime-no-subject.eml @@ -0,0 +1,6 @@ +From: sender@example.com +To: recipient@example.com +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +This is a simple email message without a subject. diff --git a/example-docs/eml/mime-no-to.eml b/example-docs/eml/mime-no-to.eml new file mode 100644 index 000000000..d741d7f95 --- /dev/null +++ b/example-docs/eml/mime-no-to.eml @@ -0,0 +1,8 @@ +From: sender@example.com +Cc: Tom , Alice +Bcc: John , Mary +Subject: Example Plain-Text MIME Message +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +This is a plain-text message. diff --git a/example-docs/eml/mime-simple.eml b/example-docs/eml/mime-simple.eml new file mode 100644 index 000000000..e789b9fe3 --- /dev/null +++ b/example-docs/eml/mime-simple.eml @@ -0,0 +1,22 @@ +From: sender@example.com +To: recipient@example.com +Subject: Example Multipart/Alternative Email +Message-ID: <1234567890@example.com> +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="boundary123" + +--boundary123 +Content-Type: text/plain; charset="UTF-8" + +This is a simple email message. + +--boundary123 +Content-Type: text/html; charset="UTF-8" + + + +

This is a simple email message.

+ + + +--boundary123-- diff --git a/example-docs/eml/mime-word-encoded-subject.eml b/example-docs/eml/mime-word-encoded-subject.eml new file mode 100644 index 000000000..a250826c0 --- /dev/null +++ b/example-docs/eml/mime-word-encoded-subject.eml @@ -0,0 +1,7 @@ +From: sender@example.com +To: recipient@example.com +Subject: =?UTF-8?B?U2ltcGxlIGVtYWlsIHdpdGgg4pi44pi/IFVuaWNvZGUgc3ViamVjdA==?= +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +This is a simple email message with Unicode characters in the subject. diff --git a/example-docs/eml/rfc822-no-date.eml b/example-docs/eml/rfc822-no-date.eml new file mode 100644 index 000000000..d8e2a9f8a --- /dev/null +++ b/example-docs/eml/rfc822-no-date.eml @@ -0,0 +1,5 @@ +From: sender@example.com +To: recipient@example.com +Subject: Example Email Without Date Header + +This is an example email message without a Date header. Note that this is non-standard and may be flagged or corrected by email servers. diff --git a/example-docs/eml/simple-rfc-822.eml b/example-docs/eml/simple-rfc-822.eml new file mode 100644 index 000000000..ec103d2a8 --- /dev/null +++ b/example-docs/eml/simple-rfc-822.eml @@ -0,0 +1,10 @@ +From: sender@example.com +To: recipient@example.com +Date: Tue, 01 Oct 2024 12:34:56 -0500 +Subject: Example RFC 822 Email + +This is an RFC 822 email message. + +An RFC 822 message is characterized by its simple, text-based format, which includes a header and a body. The header contains structured fields such as "From", "To", "Date", and "Subject", each followed by a colon and the corresponding information. The body follows the header, separated by a blank line, and contains the main content of the email. + +The structure ensures compatibility and readability across different email systems and clients, adhering to the standards set by the Internet Engineering Task Force (IETF). diff --git a/test_unstructured/documents/test_email_elements.py b/test_unstructured/documents/test_email_elements.py deleted file mode 100644 index 58989f090..000000000 --- a/test_unstructured/documents/test_email_elements.py +++ /dev/null @@ -1,96 +0,0 @@ -from functools import partial - -import pytest - -from unstructured.cleaners.core import clean_prefix -from unstructured.cleaners.translate import translate_text -from unstructured.documents.email_elements import EmailElement, Name, Subject - - -@pytest.mark.parametrize( - "element", [EmailElement(text=""), Name(text="", name=""), Subject(text="")] -) -def test_EmailElement_autoassigns_a_UUID_then_becomes_an_idempotent_and_deterministic_hash( - element: EmailElement, -): - # -- element self-assigns itself a UUID -- - assert isinstance(element.id, str) - assert len(element.id) == 36 - assert element.id.count("-") == 4 - - expected_hash = "5336294a19f32ff03ef80066fbc3e0f7" - # -- calling `.id_to_hash()` changes the element's id-type to hash -- - assert element.id_to_hash(0) == expected_hash - assert element.id == expected_hash - - # -- `.id_to_hash()` is idempotent -- - assert element.id_to_hash(0) == expected_hash - - -def test_Name_should_assign_a_deterministic_and_an_idempotent_hash(): - element = Name(name="Example", text="hello there!") - expected_hash = "7d191bcecf80c122578c497de5f0dae7" - - assert element._element_id is None, "Element should not have an ID yet" - - # -- calculating hash for the first time -- - assert element.id_to_hash(0) == expected_hash - assert element.id == expected_hash - - # -- `.id_to_hash()` is idempotent -- - assert element.id_to_hash(0) == expected_hash - assert element.id == expected_hash - - -@pytest.mark.parametrize( - "element", - [ - EmailElement(text=""), # -- the default `element_id` is None -- - Name(name="Example", text="hello there!"), # -- the default `element_id` is None -- - Name(name="Example", text="hello there!", element_id=None), - ], -) -def test_EmailElement_assigns_a_UUID_only_once_and_only_at_the_first_id_request( - element: EmailElement, -): - assert element._element_id is None, "Element should not have an ID yet" - - # -- this should generate and assign a fresh UUID -- - id_value = element.id - - # -- check that the UUID is valid -- - assert element._element_id is not None, "Element should already have an ID" - assert isinstance(id_value, str) - assert len(id_value) == 36 - assert id_value.count("-") == 4 - - assert element.id == id_value, "UUID assignment should happen only once" - - -def test_text_element_apply_cleaners(): - name_element = Name(name="[2] Example docs", text="[1] A Textbook on Crocodile Habitats") - - name_element.apply(partial(clean_prefix, pattern=r"\[\d{1,2}\]")) - assert str(name_element) == "Example docs: A Textbook on Crocodile Habitats" - - -def test_name_element_apply_multiple_cleaners(): - cleaners = [ - partial(clean_prefix, pattern=r"\[\d{1,2}\]"), - partial(translate_text, target_lang="ru"), - ] - name_element = Name( - name="[1] A Textbook on Crocodile Habitats", - text="[1] A Textbook on Crocodile Habitats", - ) - name_element.apply(*cleaners) - assert ( - str(name_element) - == "Учебник по крокодильным средам обитания: Учебник по крокодильным средам обитания" - ) - - -def test_apply_raises_if_func_does_not_produce_string(): - name_element = Name(name="Example docs", text="[1] A Textbook on Crocodile Habitats") - with pytest.raises(ValueError): - name_element.apply(lambda s: 1) diff --git a/test_unstructured/partition/test_auto.py b/test_unstructured/partition/test_auto.py index eacdcef8c..8d6670fd3 100644 --- a/test_unstructured/partition/test_auto.py +++ b/test_unstructured/partition/test_auto.py @@ -45,6 +45,7 @@ from unstructured.documents.elements import ( ) from unstructured.file_utils.model import FileType from unstructured.partition.auto import _PartitionerLoader, partition +from unstructured.partition.common import UnsupportedFileFormatError from unstructured.partition.utils.constants import PartitionStrategy from unstructured.staging.base import elements_from_json, elements_to_dicts, elements_to_json @@ -200,14 +201,6 @@ def test_auto_partition_email_from_file(): assert elements == EXPECTED_EMAIL_OUTPUT -def test_auto_partition_eml_add_signature_to_metadata(): - elements = partition(example_doc_path("eml/signed-doc.p7s")) - - assert len(elements) == 1 - assert elements[0].text == "This is a test" - assert elements[0].metadata.signature == "\n" - - # ================================================================================================ # EPUB # ================================================================================================ @@ -911,7 +904,10 @@ def test_auto_partition_raises_with_bad_type(request: FixtureRequest): request, "unstructured.partition.auto.detect_filetype", return_value=FileType.UNK ) - with pytest.raises(ValueError, match="Invalid file made-up.fake. The FileType.UNK file type "): + with pytest.raises( + UnsupportedFileFormatError, + match="Invalid file made-up.fake. The FileType.UNK file type is not supported in partiti", + ): partition(filename="made-up.fake", strategy=PartitionStrategy.HI_RES) detect_filetype_.assert_called_once_with( @@ -1239,7 +1235,7 @@ def test_auto_partition_applies_the_correct_filetype_for_all_filetypes( partition_fn = getattr(module, partition_fn_name) # -- partition the example-doc for this filetype -- - elements = partition_fn(file_path) + elements = partition_fn(file_path, process_attachments=False) assert elements assert all( diff --git a/test_unstructured/partition/test_email.py b/test_unstructured/partition/test_email.py index 2512e5c1f..eb34d499c 100644 --- a/test_unstructured/partition/test_email.py +++ b/test_unstructured/partition/test_email.py @@ -1,51 +1,32 @@ """Test suite for `unstructured.partition.email` module.""" -# pyright: reportPrivateUsage=false - from __future__ import annotations -import datetime -import email -import os -import pathlib +import io import tempfile -from email import policy from email.message import EmailMessage -from typing import cast +from typing import Any import pytest -from pytest_mock import MockFixture from test_unstructured.unit_utils import ( - LogCaptureFixture, + FixtureRequest, + Mock, assert_round_trips_through_JSON, example_doc_path, - parse_optional_datetime, + function_mock, ) from unstructured.chunking.title import chunk_by_title from unstructured.documents.elements import ( - Element, - ElementMetadata, - Image, + CompositeElement, ListItem, NarrativeText, + Table, + TableChunk, Text, Title, ) -from unstructured.documents.email_elements import ( - MetaData, - ReceivedInfo, - Recipient, - Sender, - Subject, -) -from unstructured.partition.email import ( - _convert_to_iso_8601, - _extract_attachment_info, - _partition_email_header, - partition_email, -) -from unstructured.partition.text import partition_text +from unstructured.partition.email import EmailPartitioningContext, partition_email EXPECTED_OUTPUT = [ NarrativeText(text="This is a test email to use for unit tests."), @@ -54,554 +35,226 @@ EXPECTED_OUTPUT = [ ListItem(text="Violets are blue"), ] -IMAGE_EXPECTED_OUTPUT = [ - NarrativeText(text="This is a test email to use for unit tests."), - Title(text="Important points:"), - NarrativeText(text="hello this is our logo."), - Image(text="unstructured_logo.png"), - ListItem(text="Roses are red"), - ListItem(text="Violets are blue"), -] -RECEIVED_HEADER_OUTPUT = [ - ReceivedInfo(name="ABCDEFG-000.ABC.guide", text="00.0.0.00"), - ReceivedInfo(name="ABCDEFG-000.ABC.guide", text="ba23::58b5:2236:45g2:88h2"), - ReceivedInfo( - name="received_datetimetz", - text="2023-02-20 10:03:18+12:00", - datestamp=datetime.datetime( - 2023, - 2, - 20, - 10, - 3, - 18, - tzinfo=datetime.timezone(datetime.timedelta(seconds=43200)), +def test_partition_email_from_filename_can_partition_an_RFC_822_email(): + assert partition_email(example_doc_path("eml/simple-rfc-822.eml")) == [ + NarrativeText("This is an RFC 822 email message."), + NarrativeText( + "An RFC 822 message is characterized by its simple, text-based format, which includes" + ' a header and a body. The header contains structured fields such as "From", "To",' + ' "Date", and "Subject", each followed by a colon and the corresponding information.' + " The body follows the header, separated by a blank line, and contains the main" + " content of the email." ), - ), - MetaData(name="MIME-Version", text="1.0"), - MetaData(name="Date", text="Fri, 16 Dec 2022 17:04:16 -0500"), - Recipient(name="Hello", text="hello@unstructured.io"), - MetaData( - name="Message-ID", - text="CADc-_xaLB2FeVQ7mNsoX+NJb_7hAJhBKa_zet-rtgPGenj0uVw@mail.gmail.com", - ), - Subject(text="Test Email"), - Sender(name="Matthew Robinson", text="mrobinson@unstructured.io"), - Recipient(name="Matthew Robinson", text="mrobinson@unstructured.io"), - Recipient(name="Fake Email", text="fake-email@unstructured.io"), - Recipient(name="test", text="test@unstructured.io"), - MetaData( - name="Content-Type", - text='multipart/alternative; boundary="00000000000095c9b205eff92630"', - ), -] - -HEADER_EXPECTED_OUTPUT = [ - MetaData(name="MIME-Version", text="1.0"), - MetaData(name="Date", text="Fri, 16 Dec 2022 17:04:16 -0500"), - MetaData( - name="Message-ID", - text="CADc-_xaLB2FeVQ7mNsoX+NJb_7hAJhBKa_zet-rtgPGenj0uVw@mail.gmail.com", - ), - Subject(text="Test Email"), - Sender(name="Matthew Robinson", text="mrobinson@unstructured.io"), - Recipient(name="Matthew Robinson", text="mrobinson@unstructured.io"), - MetaData( - name="Content-Type", - text='multipart/alternative; boundary="00000000000095c9b205eff92630"', - ), -] - -ALL_EXPECTED_OUTPUT = HEADER_EXPECTED_OUTPUT + EXPECTED_OUTPUT - -ATTACH_EXPECTED_OUTPUT = [ - {"filename": "fake-attachment.txt", "payload": b"Hey this is a fake attachment!"}, -] + NarrativeText( + "The structure ensures compatibility and readability across different email systems" + " and clients, adhering to the standards set by the Internet Engineering Task Force" + " (IETF)." + ), + ] -def test_partition_email_from_filename(): - elements = partition_email(filename=example_doc_path("eml/fake-email.eml")) - - assert len(elements) > 0 - assert elements == EXPECTED_OUTPUT - for element in elements: - assert element.metadata.filename == "fake-email.eml" - - -def test_partition_email_from_filename_malformed_encoding(): - elements = partition_email(filename=example_doc_path("eml/fake-email-malformed-encoding.eml")) - - assert len(elements) > 0 - assert elements == EXPECTED_OUTPUT - - -@pytest.mark.parametrize( - ("filename", "expected_output"), - [ - ("fake-email-utf-16.eml", EXPECTED_OUTPUT), - ("fake-email-utf-16-be.eml", EXPECTED_OUTPUT), - ("fake-email-utf-16-le.eml", EXPECTED_OUTPUT), - ("fake-email-b64.eml", EXPECTED_OUTPUT), - ("email-no-utf8-2008-07-16.062410.eml", None), - ("email-no-utf8-2014-03-17.111517.eml", None), - ("email-replace-mime-encodings-error-1.eml", None), - ("email-replace-mime-encodings-error-2.eml", None), - ("email-replace-mime-encodings-error-3.eml", None), - ("email-replace-mime-encodings-error-4.eml", None), - ("email-replace-mime-encodings-error-5.eml", None), - ], -) -def test_partition_email_from_filename_default_encoding( - filename: str, expected_output: Element | None -): - elements = partition_email(example_doc_path("eml/" + filename)) - - assert len(elements) > 0 - if expected_output: - assert elements == expected_output - for element in elements: - assert element.metadata.filename == filename - - -def test_partition_email_from_file(): +def test_partition_email_from_file_can_partition_an_email(): with open(example_doc_path("eml/fake-email.eml"), "rb") as f: - elements = partition_email(file=f) - - assert len(elements) > 0 - assert elements == EXPECTED_OUTPUT - for element in elements: - assert element.metadata.filename is None + assert partition_email(file=f) == EXPECTED_OUTPUT -@pytest.mark.parametrize( - ("filename", "expected_output"), - [ - ("fake-email-utf-16.eml", EXPECTED_OUTPUT), - ("fake-email-utf-16-be.eml", EXPECTED_OUTPUT), - ("fake-email-utf-16-le.eml", EXPECTED_OUTPUT), - ("fake-email-b64.eml", EXPECTED_OUTPUT), - ("email-no-utf8-2008-07-16.062410.eml", None), - ("email-no-utf8-2014-03-17.111517.eml", None), - ("email-replace-mime-encodings-error-1.eml", None), - ("email-replace-mime-encodings-error-2.eml", None), - ("email-replace-mime-encodings-error-3.eml", None), - ("email-replace-mime-encodings-error-4.eml", None), - ("email-replace-mime-encodings-error-5.eml", None), - ], -) -def test_partition_email_from_file_default_encoding(filename: str, expected_output: Element | None): - with open(example_doc_path("eml/" + filename), "rb") as f: - elements = partition_email(file=f) +def test_partition_email_from_spooled_temp_file_can_partition_an_email(): + with tempfile.SpooledTemporaryFile() as file: + with open(example_doc_path("eml/fake-email.eml"), "rb") as f: + file.write(f.read()) + file.seek(0) - assert len(elements) > 0 - if expected_output: - assert elements == expected_output - for element in elements: - assert element.metadata.filename is None + assert partition_email(file=file) == EXPECTED_OUTPUT -def test_partition_email_from_file_rb(): - with open(example_doc_path("eml/fake-email.eml"), "rb") as f: - elements = partition_email(file=f) - - assert len(elements) > 0 - assert elements == EXPECTED_OUTPUT - for element in elements: - assert element.metadata.filename is None +def test_partition_email_can_partition_an_HTML_only_email_with_Base64_ISO_8859_1_charset(): + assert partition_email(example_doc_path("eml/mime-html-only.eml")) == [ + NarrativeText("This is a text/html part."), + NarrativeText( + "The first emoticon, :) , was proposed by Scott Fahlman in 1982 to indicate just or" + " sarcasm in text emails." + ), + NarrativeText( + "Gmail was launched by Google in 2004 with 1 GB of free storage, significantly more" + " than what other services offered at the time." + ), + ] -@pytest.mark.parametrize( - ("filename", "expected_output"), - [ - ("fake-email-utf-16.eml", EXPECTED_OUTPUT), - ("fake-email-utf-16-be.eml", EXPECTED_OUTPUT), - ("fake-email-utf-16-le.eml", EXPECTED_OUTPUT), - ("email-no-utf8-2008-07-16.062410.eml", None), - ("email-no-utf8-2014-03-17.111517.eml", None), - ("email-replace-mime-encodings-error-1.eml", None), - ("email-replace-mime-encodings-error-2.eml", None), - ("email-replace-mime-encodings-error-3.eml", None), - ("email-replace-mime-encodings-error-4.eml", None), - ("email-replace-mime-encodings-error-5.eml", None), - ], -) -def test_partition_email_from_file_rb_default_encoding( - filename: str, expected_output: Element | None -): - with open(example_doc_path("eml/" + filename), "rb") as f: - elements = partition_email(file=f) +def test_extract_email_from_text_plain_matches_elements_extracted_from_text_html(): + file_path = example_doc_path("eml/fake-email.eml") - assert len(elements) > 0 - if expected_output: - assert elements == expected_output - for element in elements: - assert element.metadata.filename is None + elements_from_text = partition_email(file_path, content_source="text/plain") + elements_from_html = partition_email(file_path, content_source="text/html") + + assert elements_from_text == EXPECTED_OUTPUT + assert elements_from_html == EXPECTED_OUTPUT + assert elements_from_html == elements_from_text -def test_partition_email_from_spooled_temp_file(): - filename = example_doc_path("eml/family-day.eml") - with open(filename, "rb") as test_file: - spooled_temp_file = tempfile.SpooledTemporaryFile() - spooled_temp_file.write(test_file.read()) - spooled_temp_file.seek(0) - elements = partition_email(file=spooled_temp_file) - assert len(elements) == 9 - assert elements[3].text == "Make sure to RSVP!" - - -def test_partition_email_from_text_file(): - with open(example_doc_path("eml/fake-email.txt"), "rb") as f: - elements = partition_email(file=f, content_source="text/plain") - - assert len(elements) > 0 - assert elements == EXPECTED_OUTPUT - for element in elements: - assert element.metadata.filename is None - - -def test_partition_email_from_text_file_with_headers(): - with open(example_doc_path("eml/fake-email.txt"), "rb") as f: - elements = partition_email(file=f, content_source="text/plain", include_headers=True) - - assert len(elements) > 0 - assert elements == ALL_EXPECTED_OUTPUT - for element in elements: - assert element.metadata.filename is None - - -def test_partition_email_from_text(): - with open(example_doc_path("eml/fake-email.eml")) as f: - text = f.read() - - elements = partition_email(text=text) - - assert len(elements) > 0 - assert elements == EXPECTED_OUTPUT - for element in elements: - assert element.metadata.filename is None - - -def test_partition_email_from_text_work_with_empty_string(): - assert partition_email(text="") == [] - - -def test_partition_email_from_filename_with_embedded_image(): - elements = partition_email( - example_doc_path("eml/fake-email-image-embedded.eml"), content_source="text/plain" - ) - - assert len(elements) > 0 - assert elements == IMAGE_EXPECTED_OUTPUT - for element in elements: - assert element.metadata.filename == "fake-email-image-embedded.eml" - - -def test_partition_email_from_file_with_header(): - with open(example_doc_path("eml/fake-email-header.eml")) as f: - msg = email.message_from_file(f, policy=policy.default) - - msg = cast(EmailMessage, msg) - elements = _partition_email_header(msg) - - assert len(elements) > 0 - assert elements == RECEIVED_HEADER_OUTPUT - all(element.metadata.filename is None for element in elements) - - -def test_extract_email_text_matches_html(): - filename = example_doc_path("eml/fake-email-attachment.eml") - elements_from_text = partition_email(filename, content_source="text/plain") - elements_from_html = partition_email(filename, content_source="text/html") - - assert len(elements_from_text) == len(elements_from_html) - # NOTE(robinson) - checking each individually is necessary because the text/html returns - # HTMLTitle, HTMLNarrativeText, etc - for i, element in enumerate(elements_from_text): - assert element == elements_from_text[i] - assert element.metadata.filename == "fake-email-attachment.eml" - - -def test_extract_base64_email_text_matches_html(): - filename = example_doc_path("eml/fake-email-b64.eml") - elements_from_text = partition_email(filename, content_source="text/plain") - elements_from_html = partition_email(filename, content_source="text/html") - - assert len(elements_from_text) == len(elements_from_html) - for i, element in enumerate(elements_from_text): - assert element == elements_from_text[i] - assert element.metadata.filename == "fake-email-b64.eml" - - -def test_partition_email_processes_fake_email_with_header(): - elements = partition_email(example_doc_path("eml/fake-email-header.eml")) - - assert len(elements) > 0 - assert all(element.metadata.filename == "fake-email-header.eml" for element in elements) - assert all( - element.metadata.bcc_recipient == ["Hello "] for element in elements - ) - assert all( - element.metadata.cc_recipient - == ["Fake Email ", "test@unstructured.io"] - for element in elements - ) - assert all(element.metadata.email_message_id is not None for element in elements) - - -@pytest.mark.parametrize( - (("time", "expected")), - [ - ("Thu, 4 May 2023 02:32:49 +0000", "2023-05-04T02:32:49+00:00"), - ("Thu, 4 May 2023 02:32:49 +0000", "2023-05-04T02:32:49+00:00"), - ("Thu, 4 May 2023 02:32:49 +0000 (UTC)", "2023-05-04T02:32:49+00:00"), - ("Thursday 5/3/2023 02:32:49", None), - ], -) -def test_convert_to_iso_8601(time: str, expected: str | None): - iso_time = _convert_to_iso_8601(time) - - assert iso_time == expected - - -def test_partition_email_still_works_with_no_content(caplog: LogCaptureFixture): - elements = partition_email(example_doc_path("eml/email-no-html-content-1.eml")) - - assert len(elements) == 1 - assert elements[0].text.startswith("Hey there") - assert "text/html was not found. Falling back to text/plain" in caplog.text - - -def test_partition_email_with_json(): +def test_partition_email_round_trips_via_json(): elements = partition_email(example_doc_path("eml/fake-email.eml")) assert_round_trips_through_JSON(elements) -def test_partition_email_with_pgp_encrypted_message(caplog: LogCaptureFixture): - elements = partition_email(example_doc_path("eml/fake-encrypted.eml")) - - assert elements == [] - assert "WARNING" in caplog.text - assert "Encrypted email detected" in caplog.text +# -- transfer-encodings -------------------------------------------------------------------------- -def test_partition_email_inline_content_disposition(): +def test_partition_email_partitions_an_HTML_part_with_Base64_encoded_UTF_8_charset(): + assert partition_email(example_doc_path("eml/fake-email-b64.eml")) == EXPECTED_OUTPUT + + +def test_partition_email_partitions_a_text_plain_part_with_Base64_encoded_windows_1255_charset(): elements = partition_email( - example_doc_path("eml/email-inline-content-disposition.eml"), - process_attachments=True, - attachment_partitioner=partition_text, + example_doc_path("eml/email-no-utf8-2008-07-16.062410.eml"), + content_source="text/plain", ) - assert isinstance(elements[0], Text) - assert isinstance(elements[1], Text) + assert len(elements) == 30 + assert elements[1].text.startswith("אני חושב שזה לא יהיה מקצועי והוגן שאני אראה לך היכן") -def test_add_chunking_strategy_on_partition_email(): - chunk_elements = partition_email( - example_doc_path("eml/fake-email.txt"), chunking_strategy="by_title" +def test_partition_email_partitions_an_html_part_with_quoted_printable_encoded_ISO_8859_1_charset(): + elements = partition_email( + example_doc_path("eml/email-no-utf8-2014-03-17.111517.eml"), + content_source="text/html", + process_attachments=False, ) - elements = partition_email(example_doc_path("eml/fake-email.txt")) - chunks = chunk_by_title(elements) - assert chunk_elements != elements - assert chunk_elements == chunks + assert len(elements) == 1 + assert isinstance(elements[0], Table) + assert elements[0].text.startswith("Slava Gxyzxyz Hi Slava, The password for your Google") -# -- raise error behaviors ----------------------------------------------------------------------- +# -- edge-cases ---------------------------------------------------------------------------------- -def test_partition_msg_raises_with_no_partitioner(): - with pytest.raises(ValueError): - partition_email(example_doc_path("eml/fake-email-attachment.eml"), process_attachments=True) +def test_partition_email_accepts_a_whitespace_only_file(): + """Should produce no elements but should not raise an exception.""" + assert partition_email(example_doc_path("eml/empty.eml")) == [] -def test_partition_email_raises_with_none_specified(): - with pytest.raises(ValueError): +def test_partition_email_can_partition_an_empty_email(): + assert ( + partition_email(example_doc_path("eml/mime-no-body.eml"), process_attachments=False) == [] + ) + + +def test_partition_email_does_not_break_on_an_encrypted_message(): + assert ( + partition_email(example_doc_path("eml/fake-encrypted.eml"), process_attachments=False) == [] + ) + + +def test_partition_email_finds_content_when_it_is_marked_with_content_disposition_inline(): + elements = partition_email( + example_doc_path("eml/email-inline-content-disposition.eml"), process_attachments=False + ) + + assert len(elements) == 1 + e = elements[0] + assert isinstance(e, Text) + assert e.text == "This is a test of inline" + + +def test_partition_email_from_filename_malformed_encoding(): + elements = partition_email(filename=example_doc_path("eml/fake-email-malformed-encoding.eml")) + assert elements == EXPECTED_OUTPUT + + +# -- error behaviors ----------------------------------------------------------------------------- + + +def test_partition_email_raises_when_no_message_source_is_specified(): + with pytest.raises(ValueError, match="no document specified; either a `filename` or `file`"): partition_email() -def test_partition_email_raises_with_too_many_specified(): - with open(example_doc_path("eml/fake-email.eml")) as f: - text = f.read() - - with pytest.raises(ValueError): - partition_email(example_doc_path("eml/fake-email.eml"), text=text) - - def test_partition_email_raises_with_invalid_content_type(): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="'application/json' is not a valid value for content_s"): partition_email(example_doc_path("eml/fake-email.eml"), content_source="application/json") -# -- metadata behaviors -------------------------------------------------------------------------- +# -- .metadata ----------------------------------------------------------------------------------- -def test_partition_email_from_filename_with_metadata_filename(): - elements = partition_email(example_doc_path("eml/fake-email.eml"), metadata_filename="test") +def test_partition_email_augments_message_body_elements_with_email_metadata(): + elements = partition_email(example_doc_path("eml/mime-multi-to-cc-bcc.eml")) - assert len(elements) > 0 - assert all(element.metadata.filename == "test" for element in elements) - - -def test_partition_email_from_filename_has_metadata(): - elements = partition_email(example_doc_path("eml/fake-email.eml")) - parent_id = elements[0].metadata.parent_id - - assert len(elements) > 0 - assert ( - elements[0].metadata.to_dict() - == ElementMetadata( - coordinates=None, - filename=example_doc_path("eml/fake-email.eml"), - last_modified="2022-12-16T17:04:16-05:00", - page_number=None, - url=None, - sent_from=["Matthew Robinson "], - sent_to=["NotMatthew "], - subject="Test Email", - filetype="message/rfc822", - parent_id=parent_id, - languages=["eng"], - email_message_id="CADc-_xaLB2FeVQ7mNsoX+NJb_7hAJhBKa_zet-rtgPGenj0uVw@mail.gmail.com", - ).to_dict() + assert all( + e.metadata.bcc_recipient == ["John ", "Mary "] + for e in elements ) - expected_dt = datetime.datetime.fromisoformat("2022-12-16T17:04:16-05:00") - assert parse_optional_datetime(elements[0].metadata.last_modified) == expected_dt - for element in elements: - assert element.metadata.filename == "fake-email.eml" + assert all( + e.metadata.cc_recipient == ["Tom ", "Alice "] + for e in elements + ) + assert all(e.metadata.email_message_id == "2143658709@example.com" for e in elements) + assert all(e.metadata.sent_from == ["sender@example.com"] for e in elements) + assert all( + e.metadata.sent_to == ["Bob ", "Sue "] for e in elements + ) + assert all(e.metadata.subject == "Example Plain-Text MIME Message" for e in elements) + + +# -- .metadata.filename -------------------------------------------------------------------------- + + +def test_partition_email_from_filename_gets_filename_metadata_from_file_path(): + elements = partition_email(example_doc_path("eml/fake-email.eml")) + + assert all(e.metadata.filename == "fake-email.eml" for e in elements) + assert all(e.metadata.file_directory == example_doc_path("eml") for e in elements) + + +def test_partition_email_from_file_gets_filename_metadata_None(): + with open(example_doc_path("eml/fake-email.eml"), "rb") as f: + elements = partition_email(file=f) + + assert all(e.metadata.filename is None for e in elements) + assert all(e.metadata.file_directory is None for e in elements) + + +def test_partition_email_from_filename_prefers_metadata_filename(): + elements = partition_email( + example_doc_path("eml/fake-email.eml"), metadata_filename="a/b/c.eml" + ) + + assert all(e.metadata.filename == "c.eml" for e in elements) + assert all(e.metadata.file_directory == "a/b" for e in elements) + + +def test_partition_email_from_file_prefers_metadata_filename(): + with open(example_doc_path("eml/fake-email.eml"), "rb") as f: + elements = partition_email(file=f, metadata_filename="d/e/f.eml") + + assert all(e.metadata.filename == "f.eml" for e in elements) + assert all(e.metadata.file_directory == "d/e" for e in elements) # -- .metadata.filetype -------------------------------------------------------------------------- -def test_partition_email_gets_the_EMAIL_mime_type_in_metadata_filetype(): - EMAIL_MIME_TYPE = "message/rfc822" - elements = partition_email(example_doc_path("fake-email.eml")) - assert all(e.metadata.filetype == EMAIL_MIME_TYPE for e in elements), ( - f"Expected all elements to have '{EMAIL_MIME_TYPE}' as their filetype, but got:" +def test_partition_email_gets_the_EML_MIME_type_in_metadata_filetype_for_message_body_elements(): + EML_MIME_TYPE = "message/rfc822" + elements = partition_email(example_doc_path("eml/fake-email.eml")) + assert all(e.metadata.filetype == EML_MIME_TYPE for e in elements), ( + f"Expected all elements to have '{EML_MIME_TYPE}' as their filetype, but got:" f" {repr(elements[0].metadata.filetype)}" ) -# -- .metadata.last_modified --------------------------------------------------------------------- - - -def test_partition_email_metadata_date_from_header(mocker: MockFixture): - mocker.patch("unstructured.partition.email.get_last_modified_date", return_value=None) - - elements = partition_email(example_doc_path("eml/fake-email-attachment.eml")) - - assert elements[0].metadata.last_modified == "2022-12-23T12:08:48-06:00" - - -def test_partition_email_from_file_custom_metadata_date(): - with open(example_doc_path("eml/fake-email-attachment.eml"), "rb") as f: - elements = partition_email(file=f, metadata_last_modified="2020-07-05T09:24:28") - - assert elements[0].metadata.last_modified == "2020-07-05T09:24:28" - - -def test_partition_email_custom_metadata_date(): - elements = partition_email( - example_doc_path("eml/fake-email-attachment.eml"), - metadata_last_modified="2020-07-05T09:24:28", - ) - - assert elements[0].metadata.last_modified == "2020-07-05T09:24:28" - - -# ------------------------------------------------------------------------------------------------ - - -def test_partition_eml_add_signature_to_metadata(): - elements = partition_email(example_doc_path("eml/signed-doc.p7s")) - - assert len(elements) == 1 - assert elements[0].text == "This is a test" - assert elements[0].metadata.signature == "\n" - - -# -- attachment behaviors ------------------------------------------------------------------------ - - -def test_extract_attachment_info(): - with open(example_doc_path("eml/fake-email-attachment.eml")) as f: - msg = email.message_from_file(f, policy=policy.default) - msg = cast(EmailMessage, msg) - attachment_info = _extract_attachment_info(msg) - - assert len(attachment_info) > 0 - assert attachment_info == ATTACH_EXPECTED_OUTPUT - - -def test_partition_email_odd_attachment_filename(): - elements = partition_email( - example_doc_path("eml/email-equals-attachment-filename.eml"), - process_attachments=True, - attachment_partitioner=partition_text, - ) - - assert elements[1].metadata.filename == "odd=file=name.txt" - - -def test_partition_email_can_process_attachments(tmp_path: pathlib.Path): - output_dir = tmp_path / "output" - output_dir.mkdir() - filename = example_doc_path("eml/fake-email-attachment.eml") - with open(filename) as f: - msg = email.message_from_file(f, policy=policy.default) - msg = cast(EmailMessage, msg) - _extract_attachment_info(msg, output_dir=str(output_dir)) - - attachment_filename = os.path.join( - output_dir, - str(ATTACH_EXPECTED_OUTPUT[0]["filename"]), - ) - - mocked_last_modification_date = "0000-00-05T09:24:28" - - attachment_elements = partition_text( - filename=attachment_filename, - metadata_filename=attachment_filename, - metadata_last_modified=mocked_last_modification_date, - ) - expected_metadata = attachment_elements[0].metadata - expected_metadata.file_directory = None - expected_metadata.attached_to_filename = filename - - elements = partition_email( - filename=filename, - attachment_partitioner=partition_text, - process_attachments=True, - metadata_last_modified=mocked_last_modification_date, - ) - - # This test does not need to validate if hierarchy is working - # Patch to nullify parent_id - expected_metadata.parent_id = None - elements[-1].metadata.parent_id = None - - assert [a.name for a in os.scandir(output_dir) if a.is_file()] == ["fake-attachment.txt"] - assert elements[0].text.startswith("Hello!") - for element in elements[:-1]: - assert element.metadata.filename == "fake-email-attachment.eml" - assert element.metadata.subject == "Fake email with attachment" - assert elements[-1].text == "Hey this is a fake attachment!" - assert elements[-1].metadata == expected_metadata - - -# -- language behaviors -------------------------------------------------------------------------- +# -- .metadata.languages ------------------------------------------------------------------------- def test_partition_email_element_metadata_has_languages(): elements = partition_email(example_doc_path("eml/fake-email.eml")) - - assert elements[0].metadata.languages == ["eng"] + assert all(e.metadata.languages == ["eng"] for e in elements) def test_partition_email_respects_languages_arg(): elements = partition_email(example_doc_path("eml/fake-email.eml"), languages=["deu"]) - assert all(element.metadata.languages == ["deu"] for element in elements) @@ -616,3 +269,344 @@ def test_partition_eml_respects_detect_language_per_element(): assert "eng" in langs assert "spa" in langs + + +# -- .metadata.last_modified --------------------------------------------------------------------- + + +def test_partition_email_from_file_path_gets_last_modified_from_Date_header(): + elements = partition_email(example_doc_path("eml/fake-email.eml")) + assert all(e.metadata.last_modified == "2022-12-16T22:04:16+00:00" for e in elements) + + +def test_partition_email_from_file_gets_last_modified_from_Date_header(): + with open(example_doc_path("eml/fake-email.eml"), "rb") as f: + elements = partition_email(file=f) + + assert all(e.metadata.last_modified == "2022-12-16T22:04:16+00:00" for e in elements) + + +def test_partition_email_from_file_path_prefers_metadata_last_modified(): + metadata_last_modified = "2020-07-05T09:24:28" + + elements = partition_email( + example_doc_path("eml/fake-email.eml"), metadata_last_modified=metadata_last_modified + ) + + assert all(e.metadata.last_modified == metadata_last_modified for e in elements) + + +def test_partition_email_from_file_prefers_metadata_last_modified(): + metadata_last_modified = "2020-07-05T09:24:28" + with open(example_doc_path("eml/fake-email.eml"), "rb") as f: + elements = partition_email(file=f, metadata_last_modified=metadata_last_modified) + + assert all(e.metadata.last_modified == metadata_last_modified for e in elements) + + +# -- chunking ------------------------------------------------------------------------------------ + + +def test_partition_email_chunks_when_so_instructed(): + """Note it's actually the delegate partitioners that do the chunking.""" + elements = partition_email(example_doc_path("eml/fake-email.txt")) + chunks = partition_email(example_doc_path("eml/fake-email.txt"), chunking_strategy="by_title") + separately_chunked_chunks = chunk_by_title(elements) + + assert all(isinstance(c, (CompositeElement, Table, TableChunk)) for c in chunks) + assert chunks != elements + assert chunks == separately_chunked_chunks + + +def test_partition_email_chunks_attachments_too(): + chunks = partition_email( + example_doc_path("eml/fake-email-attachment.eml"), + chunking_strategy="by_title", + process_attachments=True, + ) + + assert len(chunks) == 2 + assert all(isinstance(c, CompositeElement) for c in chunks) + attachment_chunk = chunks[-1] + assert attachment_chunk.text == "Hey this is a fake attachment!" + assert attachment_chunk.metadata.filename == "fake-attachment.txt" + assert attachment_chunk.metadata.attached_to_filename == "fake-email-attachment.eml" + assert all(c.metadata.last_modified == "2022-12-23T18:08:48+00:00" for c in chunks) + + +# -- attachments --------------------------------------------------------------------------------- + + +def test_partition_email_also_partitions_attachments_when_so_instructed(): + elements = partition_email( + example_doc_path("eml/email-equals-attachment-filename.eml"), process_attachments=True + ) + + assert elements == [ + NarrativeText("Below is an example of an odd filename"), + Title("Odd filename"), + ] + + +def test_partition_email_can_process_attachments(): + elements = partition_email( + example_doc_path("eml/fake-email-attachment.eml"), process_attachments=True + ) + + assert elements == [ + Title("Hello!"), + NarrativeText("Here's the attachments!"), + NarrativeText("It includes:"), + ListItem("Lots of whitespace"), + ListItem("Little to no content"), + ListItem("and is a quick read"), + Text("Best,"), + Title("Mallori"), + NarrativeText("Hey this is a fake attachment!"), + ] + assert all(e.metadata.last_modified == "2022-12-23T18:08:48+00:00" for e in elements) + attachment_element = elements[-1] + assert attachment_element.text == "Hey this is a fake attachment!" + assert attachment_element.metadata.filename == "fake-attachment.txt" + assert attachment_element.metadata.attached_to_filename == "fake-email-attachment.eml" + + +def test_partition_email_silently_skips_attachments_it_cannot_partition(): + elements = partition_email( + example_doc_path("eml/mime-attach-mp3.eml"), process_attachments=True + ) + + # -- no exception is raised -- + assert elements == [ + # -- the email body is partitioned -- + NarrativeText("This is an email with an MP3 attachment."), + # -- no elements appear for the attachment -- + ] + + +# ================================================================================================ +# ISOLATED UNIT TESTS +# ================================================================================================ + + +class DescribeEmailPartitionerOptions: + """Unit-test suite for `unstructured.partition.email.EmailPartitioningContext` objects.""" + + # -- .load() --------------------------------- + + def it_provides_a_validating_constructor(self, ctx_args: dict[str, Any]): + ctx_args["file_path"] = example_doc_path("eml/fake-email.eml") + + ctx = EmailPartitioningContext.load(**ctx_args) + + assert isinstance(ctx, EmailPartitioningContext) + + def but_it_raises_when_no_source_document_was_specified(self, ctx_args: dict[str, Any]): + with pytest.raises(ValueError, match="no document specified; either a `filename` or `fi"): + EmailPartitioningContext.load(**ctx_args) + + def and_it_raises_when_a_file_open_for_reading_str_is_used(self, ctx_args: dict[str, Any]): + ctx_args["file"] = io.StringIO("abcdefg") + with pytest.raises(ValueError, match="file object must be opened in binary mode"): + EmailPartitioningContext.load(**ctx_args) + + def and_it_raises_when_an_invalid_content_source_is_specified(self, ctx_args: dict[str, Any]): + ctx_args["file_path"] = example_doc_path("eml/fake-email.eml") + ctx_args["content_source"] = "application/json" + + with pytest.raises(ValueError, match="'application/json' is not a valid value for conte"): + EmailPartitioningContext.load(**ctx_args) + + # -- .bcc_addresses -------------------------- + + def it_provides_access_to_the_Bcc_addresses_when_present(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-multi-to-cc-bcc.eml")) + assert ctx.bcc_addresses == ["John ", "Mary "] + + def but_it_returns_None_when_there_are_no_Bcc_addresses(self): + ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml")) + assert ctx.bcc_addresses is None + + # -- .body_part ------------------------------ + + def it_returns_the_html_body_part_when_there_is_one_by_default(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-different-plain-html.eml")) + + body_part = ctx.body_part + + assert isinstance(body_part, EmailMessage) + content = body_part.get_content() + assert isinstance(content, str) + assert content.startswith("") + + def but_it_returns_the_plain_text_body_part_when_there_is_one_when_so_requested(self): + ctx = EmailPartitioningContext( + example_doc_path("eml/mime-different-plain-html.eml"), content_source="text/plain" + ) + + body_part = ctx.body_part + + assert isinstance(body_part, EmailMessage) + content = body_part.get_content() + assert isinstance(content, str) + assert content.startswith("This is the text/plain part.") + + def and_it_returns_None_when_the_email_has_no_body(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-no-body.eml")) + assert ctx.body_part is None + + # -- .cc_addresses --------------------------- + + def it_provides_access_to_the_Cc_addresses_when_present(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-multi-to-cc-bcc.eml")) + assert ctx.cc_addresses == ["Tom ", "Alice "] + + def but_it_returns_None_when_there_are_no_Cc_addresses(self): + ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml")) + assert ctx.cc_addresses is None + + # -- .content_type_preference ---------------- + + @pytest.mark.parametrize( + ("content_source", "expected_value"), + [ + ("text/html", ("html", "plain")), + ("text/plain", ("plain", "html")), + ], + ) + def it_knows_whether_the_caller_prefers_the_HTML_or_plain_text_body( + self, content_source: str, expected_value: tuple[str, ...] + ): + ctx = EmailPartitioningContext(content_source=content_source) + assert ctx.content_type_preference == expected_value + + def and_it_defaults_to_preferring_the_HTML_body(self): + ctx = EmailPartitioningContext() + assert ctx.content_type_preference == ("html", "plain") + + # -- .from ----------------------------------- + + def it_knows_the_From_address_of_the_email(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-simple.eml")) + assert ctx.from_address == "sender@example.com" + + # -- .message_id ----------------------------- + + def it_provides_access_to_the_Message_ID_when_present(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-simple.eml")) + assert ctx.message_id == "1234567890@example.com" + + def but_it_returns_None_when_there_is_no_Message_ID_header(self): + ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml")) + assert ctx.message_id is None + + # -- .metadata_file_path --------------------- + + def it_uses_the_metadata_file_path_arg_value_when_one_was_provided(self): + ctx = EmailPartitioningContext(metadata_file_path="a/b/c.eml") + assert ctx.metadata_file_path == "a/b/c.eml" + + def and_it_uses_the_file_path_arg_value_when_metadata_file_path_was_not_provided(self): + ctx = EmailPartitioningContext(file_path="x/y/z.eml") + assert ctx.metadata_file_path == "x/y/z.eml" + + def and_it_returns_None_when_neither_file_path_was_provided(self): + ctx = EmailPartitioningContext() + assert ctx.metadata_file_path is None + + # -- .metadata_last_modified ----------------- + + def it_uses_the_metadata_last_modified_arg_value_when_one_was_provided(self): + metadata_last_modified = "2023-04-08T12:18:07" + ctx = EmailPartitioningContext(metadata_last_modified=metadata_last_modified) + assert ctx.metadata_last_modified == metadata_last_modified + + def and_it_uses_the_msg_Date_header_date_when_metadata_last_modified_was_not_provided(self): + ctx = EmailPartitioningContext(example_doc_path("eml/simple-rfc-822.eml")) + assert ctx.metadata_last_modified == "2024-10-01T17:34:56+00:00" + + def and_it_falls_back_to_filesystem_last_modified_when_no_Date_header_is_present( + self, get_last_modified_date_: Mock + ): + """Not an expected case as according to RFC 5322, the Date header is required.""" + filesystem_last_modified = "2024-07-09T14:08:17" + get_last_modified_date_.return_value = filesystem_last_modified + + ctx = EmailPartitioningContext(example_doc_path("eml/rfc822-no-date.eml")) + + assert ctx.metadata_last_modified == filesystem_last_modified + + def and_it_returns_None_when_no_last_modified_is_available(self): + with open(example_doc_path("eml/rfc822-no-date.eml"), "rb") as f: + ctx = EmailPartitioningContext(file=f) + assert ctx.metadata_last_modified is None + + # -- .msg ------------------------------------ + + def it_loads_the_email_message_from_the_filesystem_when_a_path_is_provided(self): + ctx = EmailPartitioningContext(file_path=example_doc_path("eml/simple-rfc-822.eml")) + assert isinstance(ctx.msg, EmailMessage) + + def and_it_loads_the_email_message_from_a_file_like_object_when_one_is_provided(self): + with open(example_doc_path("eml/simple-rfc-822.eml"), "rb") as f: + ctx = EmailPartitioningContext(file=f) + assert isinstance(ctx.msg, EmailMessage) + + # -- .partitioning_kwargs -------------------- + + def it_passes_along_the_kwargs_it_received_on_construction(self): + kwargs = {"foo": "bar", "baz": "qux"} + ctx = EmailPartitioningContext(kwargs=kwargs) + + assert ctx.partitioning_kwargs == kwargs + + # -- .process_attachments -------------------- + + @pytest.mark.parametrize("process_attachments", [True, False]) + def it_knows_whether_the_caller_wants_to_also_partition_attachments( + self, process_attachments: bool + ): + ctx = EmailPartitioningContext(process_attachments=process_attachments) + assert ctx.process_attachments == process_attachments + + def but_by_default_it_ignores_attachments(self): + ctx = EmailPartitioningContext() + assert ctx.process_attachments is False + + # -- .subject -------------------------------- + + def it_provides_access_to_the_email_Subject_as_a_string(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-word-encoded-subject.eml")) + assert ctx.subject == "Simple email with ☸☿ Unicode subject" + + def but_it_returns_None_when_there_is_no_Subject_header(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-no-subject.eml")) + assert ctx.subject is None + + # -- .to_addresses --------------------------- + + def it_provides_access_to_the_To_addresses_when_present(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-multi-to-cc-bcc.eml")) + assert ctx.to_addresses == ["Bob ", "Sue "] + + def but_it_returns_None_when_there_are_no_To_addresses(self): + ctx = EmailPartitioningContext(example_doc_path("eml/mime-no-to.eml")) + assert ctx.to_addresses is None + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture() + def ctx_args(self) -> dict[str, Any]: + return { + "file_path": None, + "file": None, + "content_source": "text/html", + "metadata_file_path": None, + "metadata_last_modified": None, + "process_attachments": False, + "kwargs": {}, + } + + @pytest.fixture() + def get_last_modified_date_(self, request: FixtureRequest) -> Mock: + return function_mock(request, "unstructured.partition.email.get_last_modified_date") diff --git a/test_unstructured/partition/test_msg.py b/test_unstructured/partition/test_msg.py index 02fc11b40..43f49a010 100644 --- a/test_unstructured/partition/test_msg.py +++ b/test_unstructured/partition/test_msg.py @@ -14,6 +14,7 @@ from test_unstructured.unit_utils import ( Mock, assert_round_trips_through_JSON, example_doc_path, + function_mock, property_mock, ) from unstructured.chunking.title import chunk_by_title @@ -21,8 +22,10 @@ from unstructured.documents.elements import ( ElementMetadata, ListItem, NarrativeText, + Text, Title, ) +from unstructured.partition.common import UnsupportedFileFormatError from unstructured.partition.msg import MsgPartitionerOptions, partition_msg EXPECTED_MSG_OUTPUT = [ @@ -113,6 +116,9 @@ def test_partition_msg_raises_with_neither(): partition_msg() +# -- attachments --------------------------------------------------------------------------------- + + def test_partition_msg_can_process_attachments(): elements = partition_msg( example_doc_path("fake-email-multiple-attachments.msg"), process_attachments=True @@ -155,6 +161,27 @@ def test_partition_msg_can_process_attachments(): ] +def test_partition_msg_silently_skips_attachments_it_cannot_partition(request: FixtureRequest): + function_mock( + request, "unstructured.partition.auto.partition", side_effect=UnsupportedFileFormatError() + ) + + elements = partition_msg( + example_doc_path("fake-email-multiple-attachments.msg"), process_attachments=True + ) + + # -- no exception is raised -- + assert elements == [ + # -- the email body is partitioned -- + NarrativeText("Here are those documents."), + Text("--"), + Title("Mallori Harrell"), + Title("Unstructured Technologies"), + Title("Data Scientist"), + # -- no elements appear for the attachment(s) -- + ] + + # -- .metadata.filename -------------------------------------------------------------------------- diff --git a/test_unstructured_ingest/expected-structured-output/outlook/21be155fb0c95885.eml.json b/test_unstructured_ingest/expected-structured-output/outlook/21be155fb0c95885.eml.json index 4304e214b..2301c0f2e 100644 --- a/test_unstructured_ingest/expected-structured-output/outlook/21be155fb0c95885.eml.json +++ b/test_unstructured_ingest/expected-structured-output/outlook/21be155fb0c95885.eml.json @@ -1,33 +1,33 @@ [ { - "element_id": "e482ff3e97d6318a4c0e00aea0adf544", + "type": "Title", + "element_id": "df08d0aeb11a34e75766d2d2008d73a6", + "text": "integration test email", "metadata": { - "data_source": { - "date_created": "2023-07-15T15:36:08", - "date_modified": "2023-07-15T15:38:57", - "record_locator": { - "message_id": "AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATaI_sAADZYn-lfnvLSqIcW-YsN8ebAAATaJ9PAAA=", - "user_email": "devops@unstructuredio.onmicrosoft.com" - }, - "url": "https://outlook.office365.com/owa/?ItemID=AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn%2FlfnvLSqIcW%2FYsN8ebAAATaI%2BsAADZYn%2FlfnvLSqIcW%2FYsN8ebAAATaJ9PAAA%3D&exvsurl=1&viewmodel=ReadMessageItem", - "version": "CQAAABYAAADZYn/lfnvLSqIcW/YsN8ebAAATYGBM" - }, - "email_message_id": "CAOvAh-6yWG99vvaoQ5niLgGTgpwe90LGiNPLvx7bAY3ZFyq54w@mail.gmail.com", - "filename": "21be155fb0c95885.eml", - "filetype": "message/rfc822", "languages": [ "eng" ], - "last_modified": "2023-07-15T08:35:51-07:00", + "filename": "21be155fb0c95885.eml", + "filetype": "message/rfc822", + "last_modified": "2023-07-15T15:35:51+00:00", + "email_message_id": "CAOvAh-6yWG99vvaoQ5niLgGTgpwe90LGiNPLvx7bAY3ZFyq54w@mail.gmail.com", "sent_from": [ "David Potter " ], "sent_to": [ "devops@unstructuredio.onmicrosoft.com" ], - "subject": "integration test email 1" - }, - "text": "integration test email", - "type": "Title" + "subject": "integration test email 1", + "data_source": { + "url": "https://graph.microsoft.com/v1.0/users/devops@unstructuredio.onmicrosoft.com/mailFolders/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MAAuAAAAAADc1MfJYetSQ6QZntYrI9k4AQDZYn-lfnvLSqIcW-YsN8ebAAATaI_sAAA=/messages/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATaI_sAADZYn-lfnvLSqIcW-YsN8ebAAATaJ9PAAA=", + "record_locator": { + "message_id": "AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATaI_sAADZYn-lfnvLSqIcW-YsN8ebAAATaJ9PAAA=", + "user_email": "devops@unstructuredio.onmicrosoft.com" + }, + "date_created": "1689435368.0", + "date_modified": "1689435537.0", + "filesize_bytes": 9189 + } + } } ] \ No newline at end of file diff --git a/test_unstructured_ingest/expected-structured-output/outlook/497eba8c81c801c6.eml.json b/test_unstructured_ingest/expected-structured-output/outlook/497eba8c81c801c6.eml.json index bde3500e7..d4beb7d76 100644 --- a/test_unstructured_ingest/expected-structured-output/outlook/497eba8c81c801c6.eml.json +++ b/test_unstructured_ingest/expected-structured-output/outlook/497eba8c81c801c6.eml.json @@ -1,33 +1,33 @@ [ { - "element_id": "4a69e8fcddd4b6eff8488a34ba16b0dd", + "type": "NarrativeText", + "element_id": "e40af23706b4096145f1e4b007719aa5", + "text": "this is a message for the subfolder1_1", "metadata": { - "data_source": { - "date_created": "2023-07-25T01:26:22", - "date_modified": "2023-07-25T01:26:41", - "record_locator": { - "message_id": "AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATzq5tAADZYn-lfnvLSqIcW-YsN8ebAAAZT8XfAAA=", - "user_email": "devops@unstructuredio.onmicrosoft.com" - }, - "url": "https://outlook.office365.com/owa/?ItemID=AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn%2FlfnvLSqIcW%2FYsN8ebAAATzq5tAADZYn%2FlfnvLSqIcW%2FYsN8ebAAAZT8XfAAA%3D&exvsurl=1&viewmodel=ReadMessageItem", - "version": "CQAAABYAAADZYn/lfnvLSqIcW/YsN8ebAAAZRQ8Y" - }, - "email_message_id": "CAL=c59DZsEqq49DgVLQy=6v_WnxmkGfznjOoaGqqJb6VK-Mu=g@mail.gmail.com", - "filename": "497eba8c81c801c6.eml", - "filetype": "message/rfc822", "languages": [ "eng" ], - "last_modified": "2023-07-24T18:25:52-07:00", + "filename": "497eba8c81c801c6.eml", + "filetype": "message/rfc822", + "last_modified": "2023-07-25T01:25:52+00:00", + "email_message_id": "CAL=c59DZsEqq49DgVLQy=6v_WnxmkGfznjOoaGqqJb6VK-Mu=g@mail.gmail.com", "sent_from": [ "Ryan Nikolaidis " ], "sent_to": [ "devops@unstructuredio.onmicrosoft.com" ], - "subject": "subfolder1_1" - }, - "text": "this is a message for the subfolder1_1", - "type": "NarrativeText" + "subject": "subfolder1_1", + "data_source": { + "url": "https://graph.microsoft.com/v1.0/users/devops@unstructuredio.onmicrosoft.com/mailFolders/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MAAuAAAAAADc1MfJYetSQ6QZntYrI9k4AQDZYn-lfnvLSqIcW-YsN8ebAAATaI_sAAA=/childFolders/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MAAuAAAAAADc1MfJYetSQ6QZntYrI9k4AQDZYn-lfnvLSqIcW-YsN8ebAAATzq5sAAA=/childFolders/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MAAuAAAAAADc1MfJYetSQ6QZntYrI9k4AQDZYn-lfnvLSqIcW-YsN8ebAAATzq5tAAA=/messages/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATzq5tAADZYn-lfnvLSqIcW-YsN8ebAAAZT8XfAAA=", + "record_locator": { + "message_id": "AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATzq5tAADZYn-lfnvLSqIcW-YsN8ebAAAZT8XfAAA=", + "user_email": "devops@unstructuredio.onmicrosoft.com" + }, + "date_created": "1690248382.0", + "date_modified": "1690248401.0", + "filesize_bytes": 9207 + } + } } ] \ No newline at end of file diff --git a/test_unstructured_ingest/expected-structured-output/outlook/4a16a411f162ebbb.eml.json b/test_unstructured_ingest/expected-structured-output/outlook/4a16a411f162ebbb.eml.json index 0bbcb8fd8..8906138fd 100644 --- a/test_unstructured_ingest/expected-structured-output/outlook/4a16a411f162ebbb.eml.json +++ b/test_unstructured_ingest/expected-structured-output/outlook/4a16a411f162ebbb.eml.json @@ -1,33 +1,33 @@ [ { - "element_id": "4df3eedf1b6f98566fc40a132b48205f", + "type": "NarrativeText", + "element_id": "8488a63070421b09a14ad6078c2cec2a", + "text": "this is a message for the subfolder", "metadata": { - "data_source": { - "date_created": "2023-07-10T03:39:04", - "date_modified": "2023-07-15T22:36:12", - "record_locator": { - "message_id": "AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATzq5sAADZYn-lfnvLSqIcW-YsN8ebAAATzrolAAA=", - "user_email": "devops@unstructuredio.onmicrosoft.com" - }, - "url": "https://outlook.office365.com/owa/?ItemID=AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn%2FlfnvLSqIcW%2FYsN8ebAAATzq5sAADZYn%2FlfnvLSqIcW%2FYsN8ebAAATzrolAAA%3D&exvsurl=1&viewmodel=ReadMessageItem", - "version": "CQAAABYAAADZYn/lfnvLSqIcW/YsN8ebAAATxicu" - }, - "email_message_id": "CAOvAh-7KVeFHwtX20KVL=S4WgpWN91YzK11td4_W0Pv3cJ4jLQ@mail.gmail.com", - "filename": "4a16a411f162ebbb.eml", - "filetype": "message/rfc822", "languages": [ "eng" ], - "last_modified": "2023-07-09T20:38:47-07:00", + "filename": "4a16a411f162ebbb.eml", + "filetype": "message/rfc822", + "last_modified": "2023-07-10T03:38:47+00:00", + "email_message_id": "CAOvAh-7KVeFHwtX20KVL=S4WgpWN91YzK11td4_W0Pv3cJ4jLQ@mail.gmail.com", "sent_from": [ "David Potter " ], "sent_to": [ "devops@unstructuredio.onmicrosoft.com" ], - "subject": "message for subfolder" - }, - "text": "this is a message for the subfolder", - "type": "NarrativeText" + "subject": "message for subfolder", + "data_source": { + "url": "https://graph.microsoft.com/v1.0/users/devops@unstructuredio.onmicrosoft.com/mailFolders/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MAAuAAAAAADc1MfJYetSQ6QZntYrI9k4AQDZYn-lfnvLSqIcW-YsN8ebAAATaI_sAAA=/childFolders/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MAAuAAAAAADc1MfJYetSQ6QZntYrI9k4AQDZYn-lfnvLSqIcW-YsN8ebAAATzq5sAAA=/messages/AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATzq5sAADZYn-lfnvLSqIcW-YsN8ebAAATzrolAAA=", + "record_locator": { + "message_id": "AAMkAGE2MmEwNzFlLWVjYzAtNDNhZS04ZGM1LTFjYmMzZDhiMmI0MABGAAAAAADc1MfJYetSQ6QZntYrI9k4BwDZYn-lfnvLSqIcW-YsN8ebAAATzq5sAADZYn-lfnvLSqIcW-YsN8ebAAATzrolAAA=", + "user_email": "devops@unstructuredio.onmicrosoft.com" + }, + "date_created": "1688960344.0", + "date_modified": "1689460572.0", + "filesize_bytes": 9254 + } + } } ] \ No newline at end of file diff --git a/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErPIAU.eml.json b/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErPIAU.eml.json index afbddc098..d2aafacb8 100644 --- a/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErPIAU.eml.json +++ b/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErPIAU.eml.json @@ -1,7 +1,7 @@ [ { "type": "NarrativeText", - "element_id": "191e99ff4061730e85d9300183b4ccbe", + "element_id": "4196fe41da19e8657761ecffcafd3d2f", "text": "Jane. This is a test of sending you an email from Salesforce! _____________________________________________________________________ Powered by Salesforce http://www.salesforce.com/", "metadata": { "languages": [ diff --git a/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErQIAU.eml.json b/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErQIAU.eml.json index 9fa968280..44ea5eb7f 100644 --- a/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErQIAU.eml.json +++ b/test_unstructured_ingest/expected-structured-output/salesforce/EmailMessage/02sHu00001efErQIAU.eml.json @@ -1,7 +1,7 @@ [ { "type": "NarrativeText", - "element_id": "f7d72e773a4c72747c88d8ea6e5d012a", + "element_id": "6f168cd430b41fc0d66a3691ef3caa0f", "text": "Hey Sean. Testing email parsing here. Type: email Just testing the email system _____________________________________________________________________ Powered by Salesforce http://www.salesforce.com/", "metadata": { "languages": [ diff --git a/unstructured/__version__.py b/unstructured/__version__.py index 65162b438..d0af64910 100644 --- a/unstructured/__version__.py +++ b/unstructured/__version__.py @@ -1 +1 @@ -__version__ = "0.16.0" # pragma: no cover +__version__ = "0.16.1-dev0" # pragma: no cover diff --git a/unstructured/documents/email_elements.py b/unstructured/documents/email_elements.py deleted file mode 100644 index e38bbe435..000000000 --- a/unstructured/documents/email_elements.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -from abc import ABC -from datetime import datetime -from typing import Callable, Optional - -from unstructured.documents.elements import Text - - -class NoDatestamp(ABC): - """Class to indicate that an element do not have a datetime stamp.""" - - -class EmailElement(Text): - """An email element is a section of the email.""" - - -class Name(EmailElement): - """Base element for capturing free text from within document.""" - - category = "Uncategorized" - - def __init__( - self, - name: str, - text: str, - datestamp: datetime | NoDatestamp = NoDatestamp(), - element_id: Optional[str] = None, - ): - self.name: str = name - - super().__init__(text=text, element_id=element_id) - - if isinstance(datestamp, datetime): - self.datestamp: datetime = datestamp - - def has_datestamp(self): - return "self.datestamp" in globals() - - def __str__(self): - return f"{self.name}: {self.text}" - - def __eq__(self, other) -> bool: - if self.has_datestamp(): - return ( - self.name == other.name - and self.text == other.text - and self.datestamp == other.datestamp - ) - return self.name == other.name and self.text == other.text - - def apply(self, *cleaners: Callable): - """Applies a cleaning brick to the text element. The function that's passed in - should take a string as input and produce a string as output.""" - cleaned_text = self.text - cleaned_name = self.name - - for cleaner in cleaners: - cleaned_text = cleaner(cleaned_text) - cleaned_name = cleaner(cleaned_name) - - if not isinstance(cleaned_text, str) or not isinstance(cleaned_name, str): - raise ValueError("Cleaner produced a non-string output.") - - self.text = cleaned_text - self.name = cleaned_name - - -class BodyText(list[Text]): - """BodyText is an element consisting of multiple, well-formulated sentences. This - excludes elements such titles, headers, footers, and captions. It is the body of an email.""" - - category = "BodyText" - - -class Recipient(Name): - """A text element for capturing the recipient information of an email""" - - category = "Recipient" - - -class Sender(Name): - """A text element for capturing the sender information of an email""" - - category = "Sender" - - -class Subject(EmailElement): - """A text element for capturing the subject information of an email""" - - category = "Subject" - - -class MetaData(Name): - """A text element for capturing header meta data of an email - (miscellaneous data in the email).""" - - category = "MetaData" - - -class ReceivedInfo(Name): - """A text element for capturing header information of an email (e.g. IP addresses, etc).""" - - category = "ReceivedInfo" - - -class Attachment(Name): - """A text element for capturing the attachment name in an email (e.g. documents, - images, etc).""" - - category = "Attachment" diff --git a/unstructured/partition/auto.py b/unstructured/partition/auto.py index b5e95f2e8..7f3bb5e5b 100644 --- a/unstructured/partition/auto.py +++ b/unstructured/partition/auto.py @@ -13,6 +13,7 @@ from unstructured.documents.elements import DataSourceMetadata, Element from unstructured.file_utils.filetype import detect_filetype, is_json_processable from unstructured.file_utils.model import FileType from unstructured.logger import logger +from unstructured.partition.common import UnsupportedFileFormatError from unstructured.partition.common.common import exactly_one from unstructured.partition.common.lang import check_language_args from unstructured.partition.utils.constants import PartitionStrategy @@ -442,7 +443,9 @@ def partition( elements = [] else: msg = "Invalid file" if not filename else f"Invalid file {filename}" - raise ValueError(f"{msg}. The {file_type} file type is not supported in partition.") + raise UnsupportedFileFormatError( + f"{msg}. The {file_type} file type is not supported in partition." + ) for element in elements: element.metadata.url = url diff --git a/unstructured/partition/common/__init__.py b/unstructured/partition/common/__init__.py index e69de29bb..04e20f9ef 100644 --- a/unstructured/partition/common/__init__.py +++ b/unstructured/partition/common/__init__.py @@ -0,0 +1,6 @@ +class UnsupportedFileFormatError(Exception): + """File-type is not supported for this operation. + + For example, when receiving a file for auto-partitioning where its file-formatt cannot be + identified or there is no partitioner available for that file-format. + """ diff --git a/unstructured/partition/email.py b/unstructured/partition/email.py index 787387c5e..d194e22d2 100644 --- a/unstructured/partition/email.py +++ b/unstructured/partition/email.py @@ -1,510 +1,429 @@ +"""Provides `partition_email()` function. + +Suitable for use with `.eml` files, which can be exported from many email clients. +""" + from __future__ import annotations -import datetime +import datetime as dt import email +import email.policy +import email.utils +import io import os -import re -from email import policy -from email.headerregistry import AddressHeader -from email.message import EmailMessage -from functools import partial -from tempfile import TemporaryDirectory -from typing import IO, Any, Callable, Final, Type, cast +from email.message import EmailMessage, MIMEPart +from typing import IO, Any, Final, Iterator, cast -from unstructured.cleaners.core import clean_extra_whitespace, replace_mime_encodings -from unstructured.cleaners.extract import ( - extract_datetimetz, - extract_ip_address, - extract_ip_address_name, - extract_mapi_id, -) -from unstructured.documents.elements import ( - Element, - ElementMetadata, - Image, - NarrativeText, - Text, - Title, -) -from unstructured.documents.email_elements import ( - MetaData, - ReceivedInfo, - Recipient, - Sender, - Subject, -) -from unstructured.file_utils.encoding import ( - COMMON_ENCODINGS, - format_encoding_str, - read_txt_file, - validate_encoding, -) +from unstructured.documents.elements import Element, ElementMetadata from unstructured.file_utils.model import FileType -from unstructured.logger import logger -from unstructured.nlp.patterns import EMAIL_DATETIMETZ_PATTERN_RE -from unstructured.partition.common.common import convert_to_bytes, exactly_one +from unstructured.partition.common import UnsupportedFileFormatError 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 lazyproperty -VALID_CONTENT_SOURCES: Final[list[str]] = ["text/html", "text/plain"] -DETECTION_ORIGIN: str = "email" +VALID_CONTENT_SOURCES: Final[tuple[str, ...]] = ("text/html", "text/plain") def partition_email( filename: str | None = None, *, file: IO[bytes] | None = None, - encoding: str | None = None, - text: str | None = None, content_source: str = "text/html", - include_headers: bool = False, metadata_filename: str | None = None, metadata_last_modified: str | None = None, - process_attachments: bool = False, - attachment_partitioner: Callable[..., list[Element]] | None = None, + process_attachments: bool = True, **kwargs: Any, ) -> list[Element]: - """Partitions an .eml documents into its constituent elements. + """Partitions an .eml file into document elements. - Parameters - ---------- - filename - A string defining the target filename path. - file - A file-like object using "r" mode --> open(filename, "r"). - encoding - The encoding method used to decode the input bytes when drawn from `filename` or `file`. - Defaults to "utf-8". - text - The string representation of the .eml document. - content_source - default: "text/html" - other: "text/plain" - 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. - attachment_partitioner - The partitioning function to use to process attachments. + 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. """ - if content_source not in VALID_CONTENT_SOURCES: - raise ValueError( - f"{content_source} is not a valid value for content_source. " - f"Valid content sources are: {VALID_CONTENT_SOURCES}", - ) - - if text is not None and text.strip() == "" and not file and not filename: - return [] - - # Verify that only one of the arguments was provided - exactly_one(filename=filename, file=file, text=text) - detected_encoding = "utf-8" - if filename is not None: - extracted_encoding, msg = _parse_email(filename=filename) - if extracted_encoding: - detected_encoding = extracted_encoding - else: - detected_encoding, file_text = read_txt_file( - filename=filename, - encoding=encoding, - ) - msg = email.message_from_string(file_text, policy=policy.default) - elif file is not None: - extracted_encoding, msg = _parse_email(file=file) - if extracted_encoding: - detected_encoding = extracted_encoding - else: - detected_encoding, file_text = read_txt_file(file=file, encoding=encoding) - msg = email.message_from_string(file_text, policy=policy.default) - elif text is not None: - _text: str = str(text) - msg = email.message_from_string(_text, policy=policy.default) - else: - return [] - if not encoding: - encoding = detected_encoding - msg = cast(EmailMessage, msg) - - is_encrypted = False - content_map: dict[str, str] = {} - for part in msg.walk(): - # NOTE(robinson) - content dispostiion is None for the content of the email itself. - # Other dispositions include "attachment" for attachments - if part.get_content_disposition() is not None: - continue - content_type = part.get_content_type() - - # 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 - if content_type.endswith("encrypted"): - is_encrypted = True - - # NOTE(andymli) - we can determine if text is base64 encoded via the - # content-transfer-encoding property of a part - # https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html - if ( - part.get_content_maintype() == "text" - and part.get("content-transfer-encoding", None) == "base64" - ): - try: - content_map[content_type] = part.get_payload(decode=True).decode( # type: ignore - encoding - ) - except (UnicodeDecodeError, UnicodeError): - content_map[content_type] = part.get_payload() # type: ignore - else: - content_map[content_type] = part.get_payload() # type: ignore - - content = None - if content_source in content_map: - content = content_map.get(content_source) - # NOTE(robinson) - If the chosen content source is not available and there is - # another valid content source, fall back to the other valid source - else: - for _content_source in VALID_CONTENT_SOURCES: - content = content_map.get(_content_source, "") - if content: - logger.warning( - f"{content_source} was not found. Falling back to {_content_source}." - ) - break - - elements: list[Element] = [] - - if is_encrypted: - logger.warning( - "Encrypted email detected. Partition function will return an empty list.", - ) - - elif not content: - pass - - elif content_source == "text/html": - # NOTE(robinson) - In the .eml files, the HTML content gets stored in a format that - # looks like the following, resulting in extraneous "=" characters in the output if - # you don't clean it up - #
    = - #
  • Item 1
  • = - #
  • Item 2
  • = - #
- - content = content.replace("=\n", "").replace("=\r\n", "") - elements = partition_html( - text=content, - metadata_filename=metadata_filename, - metadata_file_type=FileType.EML, - detection_origin="email", - **kwargs, - ) - for element in elements: - if isinstance(element, Text): - _replace_mime_encodings = partial( - replace_mime_encodings, - encoding=encoding, - ) - try: - element.apply(_replace_mime_encodings) - except (UnicodeDecodeError, UnicodeError): - # If decoding fails, try decoding through common encodings - common_encodings: list[str] = [] - for x in COMMON_ENCODINGS: - _x = format_encoding_str(x) - if _x != encoding: - common_encodings.append(_x) - - for enc in common_encodings: - try: - _replace_mime_encodings = partial( - replace_mime_encodings, - encoding=enc, - ) - element.apply(_replace_mime_encodings) - break - except (UnicodeDecodeError, UnicodeError): - continue - elif content_source == "text/plain": - elements = partition_text( - text=content, - encoding=encoding, - metadata_file_type=FileType.EML, - detection_origin="email", - **kwargs, - ) - else: - raise ValueError( - f"Invalid content source: {content_source}. " - f"Valid content sources are: {VALID_CONTENT_SOURCES}", - ) - - for idx, element in enumerate(elements): - indices = _has_embedded_image(element) - if (isinstance(element, (NarrativeText, Title))) and indices: - image_info, clean_element = _find_embedded_image(element, indices) - elements[idx] = clean_element - elements.insert(idx + 1, image_info) - - header: list[Element] = [] - if include_headers: - header = _partition_email_header(msg) - all_elements = header + elements - - last_modified = get_last_modified_date(filename) if filename else None - - metadata = _build_email_metadata( - msg, - filename=metadata_filename or filename, + ctx = EmailPartitioningContext.load( + file_path=filename, + file=file, + content_source=content_source, + metadata_file_path=metadata_filename, metadata_last_modified=metadata_last_modified, - last_modification_date=last_modified, + process_attachments=process_attachments, + kwargs=kwargs, ) - for element in all_elements: - element.metadata.update(metadata) - if process_attachments: - with TemporaryDirectory() as tmpdir: - _extract_attachment_info(msg, tmpdir) - attached_files = os.listdir(tmpdir) - for attached_file in attached_files: - attached_filename = os.path.join(tmpdir, attached_file) - if attachment_partitioner is None: - raise ValueError( - "Specify the attachment_partitioner kwarg to process attachments.", - ) - attached_elements = attachment_partitioner( - filename=attached_filename, metadata_last_modified=metadata_last_modified - ) - for element in attached_elements: - element.metadata.filename = attached_file - element.metadata.file_directory = None - element.metadata.attached_to_filename = metadata_filename or filename - all_elements.append(element) - - return all_elements + return list(_EmailPartitioner.iter_elements(ctx=ctx)) -# ================================================================================================ -# HELPER FUNCTIONS -# ================================================================================================ +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 -def _build_email_metadata( - msg: EmailMessage, - filename: str | None, - metadata_last_modified: str | None = None, - last_modification_date: str | None = None, -) -> ElementMetadata: - """Creates an ElementMetadata object from the header information in the email.""" - signature = _find_signature(msg) + @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() - header_dict = dict(msg.raw_items()) - email_date = header_dict.get("Date") + @lazyproperty + 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] - def parse_recipients(header_value: str | None) -> list[str] | None: - if header_value is not None: - return [recipient.strip() for recipient in header_value.split(",")] - return None + @lazyproperty + def body_part(self) -> MIMEPart | None: + """The message part containing the actual textual email message. - if email_date is not None: - email_date = _convert_to_iso_8601(email_date) + 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) - email_message_id = header_dict.get("Message-ID") - if email_message_id: - email_message_id = _strip_angle_brackets(email_message_id) + @lazyproperty + 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] - element_metadata = ElementMetadata( - bcc_recipient=parse_recipients(header_dict.get("Bcc")), - cc_recipient=parse_recipients(header_dict.get("Cc")), - email_message_id=email_message_id, - sent_to=parse_recipients(header_dict.get("To")), - sent_from=parse_recipients(header_dict.get("From")), - subject=msg.get("Subject"), - signature=signature, - last_modified=metadata_last_modified or email_date or last_modification_date, - filename=filename, - ) - element_metadata.detection_origin = DETECTION_ORIGIN - return element_metadata + @lazyproperty + 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") -def _convert_to_iso_8601(time: str) -> str | None: - """Converts the datetime from the email output to ISO-8601 format.""" - cleaned_time = clean_extra_whitespace(time) - regex_match = EMAIL_DATETIMETZ_PATTERN_RE.search(cleaned_time) - if regex_match is None: - logger.warning( - f"{time} did not match RFC-2822 format. Unable to extract the time.", + @lazyproperty + 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, ) - return None - start, end = regex_match.span() - dt_string = cleaned_time[start:end] - datetime_object = datetime.datetime.strptime(dt_string, "%a, %d %b %Y %H:%M:%S %z") - return datetime_object.isoformat() + @lazyproperty + 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] + @lazyproperty + 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("<>") -def _extract_attachment_info( - message: EmailMessage, - output_dir: str | None = None, -) -> list[dict[str, str]]: - list_attachments: list[Any] = [] + @lazyproperty + def metadata_file_path(self) -> str | None: + """The best available file-path information for this email message. - for part in message.walk(): - if "content-disposition" in part: - cdisp = part["content-disposition"].split(";") - cdisp = [clean_extra_whitespace(item) for item in cdisp] + It's value is computed according to these rules, applied in order: - attachment_info: dict[str, Any] = {} - for item in cdisp: - if item.lower() in ("attachment", "inline"): - continue - key, value = item.split("=", 1) - key = clean_extra_whitespace(key.replace('"', "")) - value = clean_extra_whitespace(value.replace('"', "")) - attachment_info[clean_extra_whitespace(key)] = clean_extra_whitespace( - value, + - 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 + + @lazyproperty + 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 + + @lazyproperty + 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) ) - attachment_info["payload"] = part.get_payload(decode=True) - list_attachments.append(attachment_info) - if output_dir: - for idx, attachment in enumerate(list_attachments): - if "filename" in attachment: - filename = output_dir + "/" + attachment["filename"] - with open(filename, "wb") as f: - # Note(harrell) mypy wants to just us `w` when opening the file but this - # causes an error since the payloads are bytes not str - f.write(attachment["payload"]) - else: - filename = os.path.join(output_dir, f"attachment_{idx}") - with open(filename, "wb") as f: - list_attachments[idx]["filename"] = os.path.basename(filename) - f.write(attachment["payload"]) + assert self._file is not None - return list_attachments + file_bytes = self._file.read() + return cast(EmailMessage, email.message_from_bytes(file_bytes, policy=email.policy.default)) -def _find_embedded_image( - element: NarrativeText | Title, indices: re.Match[str] -) -> tuple[Element, Element]: - start, end = indices.start(), indices.end() + @lazyproperty + def partitioning_kwargs(self) -> dict[str, Any]: + """The "extra" keyword arguments received by `partition_email()`. - image_raw_info = element.text[start:end] - image_info = clean_extra_whitespace(image_raw_info.split(":")[1]) - element.text = element.text.replace("[image: " + image_info[:-1] + "]", "") - return Image(text=image_info[:-1], detection_origin="email"), element + 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 + @lazyproperty + def process_attachments(self) -> bool: + """When True, partition attachments in addition to the email message body. -def _find_signature(msg: EmailMessage) -> str | None: - """Extracts the signature from an email message, if it's available.""" - payload: Any = msg.get_payload() - if not isinstance(payload, list): - return None + Any attachment having file-format that cannot be partitioned by unstructured is silently + skipped. + """ + return self._process_attachments - payload = cast(list[EmailMessage], payload) - for item in payload: - if item.get_content_type().endswith("signature"): - return item.get_payload() + @lazyproperty + 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 - return None + @lazyproperty + 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] + @lazyproperty + 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 -def _has_embedded_image(element: Element): - PATTERN = re.compile(r"\[image: .+\]") - return PATTERN.search(element.text) + @lazyproperty + 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 + sent_date = email.utils.parsedate_to_datetime(date_str) + return sent_date.astimezone(dt.timezone.utc).isoformat(timespec="seconds") - -def _parse_email( - filename: str | None = None, file: IO[bytes] | None = None -) -> tuple[str | None, EmailMessage]: - if filename is not None: - with open(filename, "rb") as f: - msg = email.message_from_binary_file(f, policy=policy.default) - elif file is not None: - f_bytes = convert_to_bytes(file) - msg = email.message_from_bytes(f_bytes, policy=policy.default) - else: - raise ValueError("Either 'filename' or 'file' must be provided.") - - encoding = None - charsets = msg.get_charsets() or [] - for charset in charsets: - if charset and charset.strip() and validate_encoding(charset): - encoding = charset - break - - formatted_encoding = format_encoding_str(encoding) if encoding else None - msg = cast(EmailMessage, msg) - return formatted_encoding, msg - - -def _parse_received_data(data: str) -> list[Element]: - ip_address_names = extract_ip_address_name(data) - ip_addresses = extract_ip_address(data) - mapi_id = extract_mapi_id(data) - datetimetz = extract_datetimetz(data) - - elements: list[Element] = [] - if ip_address_names and ip_addresses: - for name, ip in zip(ip_address_names, ip_addresses): - elements.append(ReceivedInfo(name=name, text=ip)) - if mapi_id: - elements.append(ReceivedInfo(name="mapi_id", text=mapi_id[0])) - if datetimetz: - elements.append( - ReceivedInfo( - name="received_datetimetz", - text=str(datetimetz), - datestamp=datetimetz, - ), - ) - return elements - - -def _partition_email_header(msg: EmailMessage) -> list[Element]: - def append_address_header_elements(header: AddressHeader, element_type: Type[Element]): - for addr in header.addresses: - elements.append( - element_type( - name=addr.display_name or addr.username, # type: ignore - text=addr.addr_spec, # type: ignore - ) + 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." ) - elements: list[Element] = [] + 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) - for msg_field, msg_value in msg.items(): - if msg_field in {"To", "Bcc", "Cc"}: - append_address_header_elements(msg_value, Recipient) - elif msg_field == "From": - append_address_header_elements(msg_value, Sender) - elif msg_field == "Subject": - elements.append(Subject(text=msg_value)) - elif msg_field == "Received": - elements += _parse_received_data(msg_value) - elif msg_field == "Message-ID": - elements.append(MetaData(name=msg_field, text=_strip_angle_brackets(str(msg_value)))) + 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: - elements.append(MetaData(name=msg_field, text=msg_value)) - - return elements + 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, + ) -def _strip_angle_brackets(data: str) -> str: - """Remove angle brackets from the beginning and end of the string if they exist. +class _AttachmentPartitioner: + """Partitions an attachment to a MSG file.""" - Returns: - str: The string with surrounding angle brackets removed. + def __init__(self, attachment: EmailMessage, ctx: EmailPartitioningContext): + self._attachment = attachment + self._ctx = ctx - Example: - >>> _strip_angle_brackets("") - 'example' - >>> _strip_angle_brackets("test>") - 'another>test' - >>> _strip_angle_brackets("<>") - '' - """ - return re.sub(r"^<|>$", "", data) + @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 UnsupportedFileFormatError: + # -- indicates `auto.partition()` has no partitioner for this file-format; + # -- silently skip the attachment + return + + for e in elements: + e.metadata.attached_to_filename = self._attached_to_filename + yield e + + @lazyproperty + 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) + + @lazyproperty + 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() + + @lazyproperty + 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 diff --git a/unstructured/partition/msg.py b/unstructured/partition/msg.py index 5ef4c9e09..7c43f4667 100644 --- a/unstructured/partition/msg.py +++ b/unstructured/partition/msg.py @@ -11,6 +11,7 @@ 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 UnsupportedFileFormatError from unstructured.partition.common.metadata import get_last_modified_date from unstructured.partition.html import partition_html from unstructured.partition.text import partition_text @@ -23,7 +24,7 @@ def partition_msg( file: Optional[IO[bytes]] = None, metadata_filename: Optional[str] = None, metadata_last_modified: Optional[str] = None, - process_attachments: bool = False, + process_attachments: bool = True, **kwargs: Any, ) -> list[Element]: """Partitions a MSFT Outlook .msg file @@ -259,14 +260,19 @@ class _AttachmentPartitioner: f.write(self._file_bytes) # -- partition the attachment -- - for element in partition( - detached_file_path, - metadata_filename=self._attachment_file_name, - metadata_last_modified=self._attachment_last_modified, - **self._opts.partitioning_kwargs, - ): - element.metadata.attached_to_filename = self._opts.metadata_file_path - yield element + try: + elements = partition( + detached_file_path, + metadata_filename=self._attachment_file_name, + metadata_last_modified=self._attachment_last_modified, + **self._opts.partitioning_kwargs, + ) + except UnsupportedFileFormatError: + return + + for e in elements: + e.metadata.attached_to_filename = self._opts.metadata_file_path + yield e @lazyproperty def _attachment_file_name(self) -> str: