From 89ca841c31a043876ecaae7a2b49fb4eff514a6d Mon Sep 17 00:00:00 2001 From: Matthew Setter Date: Tue, 21 Nov 2023 11:50:59 +1000 Subject: [PATCH 1/8] Add the ability to parse multipart MIME email strings This change adds the ability to instantiate Message objects from multipart MIME email strings. Most of the functionality was already there; there was even a commented out stub that outlined the start of what was required. However, I think because the supporting functionality was lacking in laminas-mime, it was not implemented. Signed-off-by: Matthew Setter --- docs/book/message/intro.md | 82 +++++++++++++ src/Message.php | 14 ++- test/MessageTest.php | 24 ++++ test/_files/mail_with_pdf_attachment.eml | 140 +++++++++++++++++++++++ 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 test/_files/mail_with_pdf_attachment.eml diff --git a/docs/book/message/intro.md b/docs/book/message/intro.md index 8089e267..2467ee59 100644 --- a/docs/book/message/intro.md +++ b/docs/book/message/intro.md @@ -137,6 +137,75 @@ Once your message is shaped to your liking, pass it to a $transport->send($message); ``` +### Create a Message from a raw email string + +You can also create a Message object from a raw email string, one compliant with one or more of the applicable RFCs ([822](https://datatracker.ietf.org/doc/html/rfc822), [2045](https://datatracker.ietf.org/doc/html/rfc2045), [2046](https://datatracker.ietf.org/doc/html/rfc2046), [2047](https://datatracker.ietf.org/doc/html/rfc2047)), by using the static `fromString()` method. + +```php +$rawEmail = << +Subject: test confirmation +To: mailmaster@example.com, mailmaster@example.org, webmaster@example.com, + webmaster@example.org, webmaster@example.jp, mailmaster@example.jp +Message-ID: <05c18622-f2ad-cb77-2ce9-a0bbfc7d7ad0@clear-code.com> +Date: Thu, 15 Aug 2019 14:54:37 +0900 +X-Mozilla-Draft-Info: internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; + attachmentreminder=0; deliveryformat=4 +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:69.0) Gecko/20100101 + Thunderbird/69.0 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="------------26A45336F6C6196BD8BBA2A2" +Content-Language: en-US + +This is a multi-part message in MIME format. +--------------26A45336F6C6196BD8BBA2A2 +Content-Type: text/plain; charset=utf-8; format=flowed +Content-Transfer-Encoding: 7bit + +testtest +testtest +testtest +testtest +testtest +testtest + +--------------26A45336F6C6196BD8BBA2A2 +Content-Type: text/plain; charset=UTF-8; + name="sha1hash.txt" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="sha1hash.txt" + +NzRjOGYwOWRmYTMwZWFjY2ZiMzkyYjEzMjMxNGZjNmI5NzhmMzI1YSAqZmxleC1jb25maXJt +LW1haWwuMS4xMC4wLnhwaQpjY2VlNGI0YWE0N2Y1MTNhYmNlMzQyY2UxZTJlYzJmZDk2MDBl +MzFiICpmbGV4LWNvbmZpcm0tbWFpbC4xLjExLjAueHBpCjA3MWU5ZTM3OGFkMDE3OWJmYWRi +MWJkYzY1MGE0OTQ1NGQyMDRhODMgKmZsZXgtY29uZmlybS1tYWlsLjEuMTIuMC54cGkKOWQ3 +YWExNTM0MThlYThmYmM4YmU3YmE2ZjU0Y2U4YTFjYjdlZTQ2OCAqZmxleC1jb25maXJtLW1h +aWwuMS45LjkueHBpCjgxNjg1NjNjYjI3NmVhNGY5YTJiNjMwYjlhMjA3ZDkwZmIxMTg1NmUg +KmZsZXgtY29uZmlybS1tYWlsLnhwaQo= +--------------26A45336F6C6196BD8BBA2A2 +Content-Type: application/json; + name="manifest.json" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="manifest.json" + +ewogICJtYW5pZmVzdF92ZXJzaW9uIjogMiwKICAiYXBwbGljYXRpb25zIjogewogICAgImdl +Y2tvIjogewogICAgICAiaWQiOiAiZmxleGlibGUtY29uZmlybS1tYWlsQGNsZWFyLWNvZGUu +Y29tIiwKICAgICAgInN0cmljdF9taW5fdmVyc2lvbiI6ICI2OC4wIgogICAgfQogIH0sCiAg +Im5hbWUiOiAiRmxleCBDb25maXJtIE1haWwiLAogICJkZXNjcmlwdGlvbiI6ICJDb25maXJt +IG1haWxhZGRyZXNzIGFuZCBhdHRhY2htZW50cyBiYXNlZCBvbiBmbGV4aWJsZSBydWxlcy4i +LAogICJ2ZXJzaW9uIjogIjIuMCIsCgogICJsZWdhY3kiOiB7CiAgICAidHlwZSI6ICJ4dWwi +LAogICAgIm9wdGlvbnMiOiB7CiAgICAgICJwYWdlIjogImNocm9tZTovL2NvbmZpcm0tbWFp +bC9jb250ZW50L3NldHRpbmcueHVsIiwKICAgICAgIm9wZW5faW5fdGFiIjogdHJ1ZQogICAg +fQogIH0KfQ== +--------------26A45336F6C6196BD8BBA2A2-- +EOF; + +$message = Message::fromString($rawEmail); +``` + ## Configuration Options The `Message` class has no configuration options, and is instead a value object. @@ -426,3 +495,16 @@ toString() : string ``` Serialize to string. + +### fromString + +Instantiates a `Message` object from a raw message string that is compliant with one or more of the applicable RFCs, including: + +- [822](https://datatracker.ietf.org/doc/html/rfc822) +- [2045](https://datatracker.ietf.org/doc/html/rfc2045) +- [2046](https://datatracker.ietf.org/doc/html/rfc2046) +- [2047](https://datatracker.ietf.org/doc/html/rfc2047) + +```php +fromString() : Laminas\Mail\Message +``` \ No newline at end of file diff --git a/src/Message.php b/src/Message.php index f0e74840..bbb87102 100644 --- a/src/Message.php +++ b/src/Message.php @@ -571,9 +571,17 @@ public static function fromString($rawMessage) $headers = null; $content = null; Mime\Decode::splitMessage($rawMessage, $headers, $content, Headers::EOL); - // if ($headers->has('mime-version')) { - // todo - restore body to mime\message - // } + + if ($headers->has('mime-version')) { + $boundary = null; + if ($headers->has('content-type')) { + $contentType = $headers->get('content-type'); + $parameters = $contentType->getParameters(); + $boundary = $parameters['boundary']; + } + $content = Mime\Message::createFromMessage($content, $boundary); + } + $message->setHeaders($headers); $message->setBody($content); return $message; diff --git a/test/MessageTest.php b/test/MessageTest.php index 042e7b85..fe729115 100644 --- a/test/MessageTest.php +++ b/test/MessageTest.php @@ -850,6 +850,30 @@ public function testCanParseMultipartReport(): void $this->assertEquals('multipart/report', $contentType->getType()); } + public function testCanParseMultipartEmail(): void + { + $raw = file_get_contents(__DIR__ . '/_files/mail_with_pdf_attachment.eml'); + $message = Message::fromString($raw); + $this->assertInstanceOf(Message::class, $message); + $this->assertInstanceof(MimeMessage::class, $message->getBody()); + $this->assertTrue($message->getBody()->isMultiPart()); + $parts = $message->getBody()->getParts(); + $this->assertCount(2, $parts); + $partOne = $parts[0]; + $this->assertCount(2, $partOne->getParts()); + $this->assertSame( + "This is a test email with 1 attachment.", + trim($partOne->getParts()[0]->getContent()) + ); + $this->assertSame( + '
This is a test email with 1 attachment.

--


+
', + trim($partOne->getParts()[1]->getRawContent()) + ); + + $attachmentPart = $parts[1]; + } + public function testMailHeaderContainsZeroValue(): void { $message = diff --git a/test/_files/mail_with_pdf_attachment.eml b/test/_files/mail_with_pdf_attachment.eml new file mode 100644 index 00000000..355c228f --- /dev/null +++ b/test/_files/mail_with_pdf_attachment.eml @@ -0,0 +1,140 @@ +Received: by mx0032p1mdw1.sendgrid.net with SMTP id rOkt2xLLKV Tue, 19 Jul 2016 15:06:29 +0000 (UTC) +Received: from mail-it0-f45.google.com (mail-it0-f45.google.com [209.85.214.45]) by mx0032p1mdw1.sendgrid.net (Postfix) with ESMTPS id 26D6080397 for ; Tue, 19 Jul 2016 15:06:22 +0000 (UTC) +Received: by mail-it0-f45.google.com with SMTP id f6so93587860ith.1 for ; Tue, 19 Jul 2016 08:06:22 -0700 (PDT) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendgrid.com; s=ga1; h=mime-version:from:date:message-id:subject:to; bh=UYWCIUKTVXyV9U41l+c9+qOlpoeQGcJkKpyOAatNr3Y=; b=c1I/LcqHEJklmAThWr9Z8NKlTPHUlE/8sDSpK382fJtIQcGdUtczG0pijnUHegrFVt FDr4NehtJDD9KFvXLXboLCtObsu5HTN99ckUCCZTibZseA+J8U3jjCqTdj1fmUage5C7 //Iwi0Ndioonzhm18J7KStap66yZ69ED7UxPk= +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20130820; h=x-gm-message-state:mime-version:from:date:message-id:subject:to; bh=UYWCIUKTVXyV9U41l+c9+qOlpoeQGcJkKpyOAatNr3Y=; b=lgmLXnmmpNcQMckjshsZsa2/8OjFZzntWYSG5XZo0fi32KHLuBLSHuNDFXn0V4ICp1 1xuT2fZCyhBSgNBiWNbjqFspdemzrBjaI1Tgm/Zz8Fv6wW2XdjpoANNQzJxfdhnecPd5 HvZ5P8+KTqjr4tAa9RmLthDc3UqhV9NRnCnhbW/AZaVQLB8eoJus92tD1GeXpBQml5XF m6vPUGrWGZWNugINkRKxIpk+2uECglAjNm4NpZIi9j7N94CxA18RC4NJ59WIsSybtIer hbCgT1Q13rvGEzvnp6FfFQVbE3DOibNqd0bh/EvZCagFVbnenNc/Q+qHtU9KqFlisSOp xh0w== +X-Gm-Message-State: ALyK8tINVaZIP8YCgQbpg5ya8EnqQo76uxkXUPpDnM+kAyAQQzehFU10EgyuAe2fAmWf/muBiFDy0JDU74Eclp1/ +X-Received: by 10.36.76.16 with SMTP id a16mr4479786itb.77.1468940781988; Tue, 19 Jul 2016 08:06:21 -0700 (PDT) +MIME-Version: 1.0 +Received: by 10.107.48.17 with HTTP; Tue, 19 Jul 2016 08:06:21 -0700 (PDT) +From: Sender Name +Date: Tue, 19 Jul 2016 09:06:21 -0600 +Message-ID: +Subject: Hello +To: example@example.comom +Content-Type: multipart/mixed; boundary=001a11447dc881e40f0537fe6d5a + +--001a11447dc881e40f0537fe6d5a +Content-Type: multipart/alternative; boundary=001a11447dc881e40b0537fe6d58 + +--001a11447dc881e40b0537fe6d58 +Content-Type: text/plain; charset=UTF-8 + +This is a test email with 1 attachment. + +--001a11447dc881e40b0537fe6d58 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +
This is a test email with 1 attachment.
<= +div>
--


+ + +--001a11447dc881e40b0537fe6d58-- + +--001a11447dc881e40f0537fe6d5a +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document; + name="DockMcWordface.docx" +Content-Disposition: attachment; filename="DockMcWordface.docx" +Content-Transfer-Encoding: base64 +X-Attachment-Id: f_iqtleujy0 + +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 + +--001a11447dc881e40f0537fe6d5a-- \ No newline at end of file From f2419f4dc5ba99ba1715db43d49ae409b08d9a4a Mon Sep 17 00:00:00 2001 From: Matthew Setter Date: Thu, 23 Nov 2023 14:20:07 +1000 Subject: [PATCH 2/8] Update testCanParseMultipartEmail This change adds assertions for the content of the various parts of message. The intention is to be able to check deeper into a multipart message to confirm that it is as expected. As the underlying test data contains a PDF file, it adds the smalot/pdfparser to simplify working with PDF files. Signed-off-by: Matthew Setter --- composer.json | 1 + composer.lock | 336 +++++++++++++++++++---- test/MessageTest.php | 28 ++ test/_files/mail_with_pdf_attachment.eml | 246 ++++++++++------- 4 files changed, 463 insertions(+), 148 deletions(-) diff --git a/composer.json b/composer.json index 9dc18fcf..711fe1e8 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "laminas/laminas-servicemanager": "^3.22.1", "phpunit/phpunit": "^10.4.2", "psalm/plugin-phpunit": "^0.18.4", + "smalot/pdfparser": "^2.7", "symfony/process": "^6.3.4", "vimeo/psalm": "^5.15" }, diff --git a/composer.lock b/composer.lock index 1c52d3c8..65c3e556 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6590b6b9ba713185120680d6ece476df", + "content-hash": "18e511b298f74d503450bcbde45948cc", "packages": [ { "name": "laminas/laminas-loader", @@ -64,17 +64,11 @@ }, { "name": "laminas/laminas-mime", - "version": "2.12.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-mime.git", - "reference": "08cc544778829b7d68d27a097885bd6e7130135e" - }, + "version": "dev-add-multipart-alternative-support", "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-mime/zipball/08cc544778829b7d68d27a097885bd6e7130135e", - "reference": "08cc544778829b7d68d27a097885bd6e7130135e", - "shasum": "" + "type": "path", + "url": "../laminas-mime", + "reference": "b9b26d9b5c89d49cbafe37bf5887e0f7aed2171c" }, "require": { "laminas/laminas-stdlib": "^2.7 || ^3.0", @@ -97,7 +91,32 @@ "Laminas\\Mime\\": "src/" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "files": [ + "test/TestAsset/Mail/Headers.php" + ], + "psr-4": { + "LaminasTest\\Mime\\": "test/" + } + }, + "scripts": { + "check": [ + "@cs-check", + "@test" + ], + "cs-check": [ + "phpcs" + ], + "cs-fix": [ + "phpcbf" + ], + "test": [ + "phpunit --colors=always" + ], + "test-coverage": [ + "phpunit --colors=always --coverage-clover clover.xml" + ] + }, "license": [ "BSD-3-Clause" ], @@ -108,20 +127,16 @@ "mime" ], "support": { - "chat": "https://laminas.dev/chat", "docs": "https://docs.laminas.dev/laminas-mime/", - "forum": "https://discourse.laminas.dev", "issues": "https://github.com/laminas/laminas-mime/issues", + "source": "https://github.com/laminas/laminas-mime", "rss": "https://github.com/laminas/laminas-mime/releases.atom", - "source": "https://github.com/laminas/laminas-mime" + "chat": "https://laminas.dev/chat", + "forum": "https://discourse.laminas.dev" }, - "funding": [ - { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" - } - ], - "time": "2023-11-02T16:47:19+00:00" + "transport-options": { + "relative": true + } }, { "name": "laminas/laminas-servicemanager", @@ -274,16 +289,16 @@ }, { "name": "laminas/laminas-validator", - "version": "2.42.0", + "version": "2.43.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-validator.git", - "reference": "a5221732b2ff6df59908bbf2eb274ed3688665bc" + "reference": "8f6c2f5753dec64df924a86d18036113f3140f2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-validator/zipball/a5221732b2ff6df59908bbf2eb274ed3688665bc", - "reference": "a5221732b2ff6df59908bbf2eb274ed3688665bc", + "url": "https://api.github.com/repos/laminas/laminas-validator/zipball/8f6c2f5753dec64df924a86d18036113f3140f2b", + "reference": "8f6c2f5753dec64df924a86d18036113f3140f2b", "shasum": "" }, "require": { @@ -354,7 +369,7 @@ "type": "community_bridge" } ], - "time": "2023-11-06T09:13:00+00:00" + "time": "2023-11-20T01:23:15+00:00" }, { "name": "psr/container", @@ -1705,6 +1720,68 @@ ], "time": "2023-05-05T16:22:28+00:00" }, + { + "name": "laminas/laminas-escaper", + "version": "2.13.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/af459883f4018d0f8a0c69c7a209daef3bf973ba", + "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.27.0", + "laminas/laminas-coding-standard": "~2.5.0", + "maglnet/composer-require-checker": "^3.8.0", + "phpunit/phpunit": "^9.6.7", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2023-10-10T08:35:13+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.11.1", @@ -2147,6 +2224,114 @@ }, "time": "2022-10-14T12:47:21+00:00" }, + { + "name": "phpoffice/phpword", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PHPWord.git", + "reference": "90a55955e6a772bb4cd9b1ef6a7e88c8976c2561" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/90a55955e6a772bb4cd9b1ef6a7e88c8976c2561", + "reference": "90a55955e6a772bb4cd9b1ef6a7e88c8976c2561", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-xml": "*", + "laminas/laminas-escaper": ">=2.6", + "php": "^7.1|^8.0" + }, + "require-dev": { + "dompdf/dompdf": "^2.0", + "ext-gd": "*", + "ext-libxml": "*", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.3", + "mpdf/mpdf": "^8.1", + "phpmd/phpmd": "^2.13", + "phpunit/phpunit": ">=7.0", + "symfony/process": "^4.4", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Allows writing PDF", + "ext-gd2": "Allows adding images", + "ext-xmlwriter": "Allows writing OOXML and ODF", + "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template", + "ext-zip": "Allows writing OOXML and ODF" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpWord\\": "src/PhpWord" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Mark Baker" + }, + { + "name": "Gabriel Bull", + "email": "me@gabrielbull.com", + "homepage": "http://gabrielbull.com/" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net/blog/" + }, + { + "name": "Ivan Lanin", + "homepage": "http://ivan.lanin.org" + }, + { + "name": "Roman Syroeshko", + "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" + }, + { + "name": "Antoine de Troostembergh" + } + ], + "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)", + "homepage": "https://phpword.readthedocs.io/", + "keywords": [ + "ISO IEC 29500", + "OOXML", + "Office Open XML", + "OpenDocument", + "OpenXML", + "PhpOffice", + "PhpWord", + "Rich Text Format", + "WordprocessingML", + "doc", + "docx", + "html", + "odf", + "odt", + "office", + "pdf", + "php", + "reader", + "rtf", + "template", + "template processor", + "word", + "writer" + ], + "support": { + "issues": "https://github.com/PHPOffice/PHPWord/issues", + "source": "https://github.com/PHPOffice/PHPWord/tree/1.1.0" + }, + "time": "2023-05-30T07:59:14+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "1.5.1", @@ -2193,16 +2378,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "10.1.7", + "version": "10.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "355324ca4980b8916c18b9db29f3ef484078f26e" + "reference": "84838eed9ded511f61dc3e8b5944a52d9017b297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/355324ca4980b8916c18b9db29f3ef484078f26e", - "reference": "355324ca4980b8916c18b9db29f3ef484078f26e", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/84838eed9ded511f61dc3e8b5944a52d9017b297", + "reference": "84838eed9ded511f61dc3e8b5944a52d9017b297", "shasum": "" }, "require": { @@ -2259,7 +2444,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.7" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.8" }, "funding": [ { @@ -2267,7 +2452,7 @@ "type": "github" } ], - "time": "2023-10-04T15:34:17+00:00" + "time": "2023-11-15T13:31:15+00:00" }, { "name": "phpunit/php-file-iterator", @@ -3699,18 +3884,69 @@ ], "time": "2022-05-25T10:58:12+00:00" }, + { + "name": "smalot/pdfparser", + "version": "v2.7.0", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "eef0263bbaec86d30801d3551ac83f4e1015d4c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/eef0263bbaec86d30801d3551ac83f4e1015d4c3", + "reference": "eef0263bbaec86d30801d3551ac83f4e1015d4c3", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "type": "library", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.7.0" + }, + "time": "2023-08-10T06:11:26+00:00" + }, { "name": "spatie/array-to-xml", - "version": "3.2.1", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/spatie/array-to-xml.git", - "reference": "84a404e5b67dd21466a0ff47d335129d67b94029" + "reference": "96be97e664c87613121d073ea39af4c74e57a7f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/84a404e5b67dd21466a0ff47d335129d67b94029", - "reference": "84a404e5b67dd21466a0ff47d335129d67b94029", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/96be97e664c87613121d073ea39af4c74e57a7f8", + "reference": "96be97e664c87613121d073ea39af4c74e57a7f8", "shasum": "" }, "require": { @@ -3748,7 +3984,7 @@ "xml" ], "support": { - "source": "https://github.com/spatie/array-to-xml/tree/3.2.1" + "source": "https://github.com/spatie/array-to-xml/tree/3.2.2" }, "funding": [ { @@ -3760,7 +3996,7 @@ "type": "github" } ], - "time": "2023-11-08T08:19:46+00:00" + "time": "2023-11-14T14:08:51+00:00" }, { "name": "squizlabs/php_codesniffer", @@ -3911,7 +4147,7 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.3.0", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", @@ -3958,7 +4194,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.3.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.4.0" }, "funding": [ { @@ -4434,16 +4670,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", "shasum": "" }, "require": { @@ -4472,7 +4708,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.1" + "source": "https://github.com/theseer/tokenizer/tree/1.2.2" }, "funding": [ { @@ -4480,7 +4716,7 @@ "type": "github" } ], - "time": "2021-07-28T10:34:58+00:00" + "time": "2023-11-20T00:12:19+00:00" }, { "name": "vimeo/psalm", @@ -4649,7 +4885,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "laminas/laminas-mime": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4660,5 +4898,5 @@ "platform-overrides": { "php": "8.1.99" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/test/MessageTest.php b/test/MessageTest.php index fe729115..6ff1ba94 100644 --- a/test/MessageTest.php +++ b/test/MessageTest.php @@ -14,13 +14,20 @@ use Laminas\Mime\Mime; use Laminas\Mime\Part as MimePart; use PHPUnit\Framework\TestCase; +use Smalot\PdfParser\Parser; use stdClass; use function count; use function date; +use function fclose; use function file_get_contents; +use function fopen; +use function fwrite; use function implode; +use function sprintf; use function substr; +use function sys_get_temp_dir; +use function trim; /** * @group Laminas_Mail @@ -872,6 +879,27 @@ public function testCanParseMultipartEmail(): void ); $attachmentPart = $parts[1]; + + $tempFile = sprintf("%stemp.pdf", sys_get_temp_dir()); + $handle = fopen($tempFile, "w"); + fwrite($handle, $attachmentPart->getRawContent()); + fclose($handle); + + $parser = new Parser(); + $pdf = $parser->parseFile($tempFile); + + $this->assertSame('Here is a document.', $pdf->getText()); + $this->assertSame( + [ + 'Title' => 'test document', + 'Producer' => 'macOS Version 13.6 (Build 22G120) Quartz PDFContext', + 'Creator' => 'Pages', + 'CreationDate' => '2023-11-15T06:37:32+00:00', + 'ModDate' => '2023-11-15T06:37:32+00:00', + 'Pages' => 1, + ], + $pdf->getDetails() + ); } public function testMailHeaderContainsZeroValue(): void diff --git a/test/_files/mail_with_pdf_attachment.eml b/test/_files/mail_with_pdf_attachment.eml index 355c228f..2125196d 100644 --- a/test/_files/mail_with_pdf_attachment.eml +++ b/test/_files/mail_with_pdf_attachment.eml @@ -35,106 +35,154 @@ _signature">
Date: Thu, 23 Nov 2023 14:25:44 +1000 Subject: [PATCH 3/8] Refactor instantiation of mime messages From my understanding of the various specs, primarily RFC 1341, the existing functionality for setting message headers was wrong. Given that, I've made this change, which should bring it into line with the spec, corrects issues with the existing tests, and sets one existing test as skipped (for the time being). Firstly, it removes the functionality that sets headers on the main message from message sub-parts. Secondly, equally as importantly, it sets the required headers, along with their defaults, based on the message's composition. I won't get into detail here, as it's documented in the code and in the updates to the documentation. Finally, the change also cleans up some of the existing functionality as well, setting return types, as they're supported in the package's allowed PHP versions. Signed-off-by: Matthew Setter --- src/Headers.php | 4 +- src/Message.php | 123 ++++++++++++++++++++++++++++++++++--------- test/MessageTest.php | 24 ++++++--- 3 files changed, 119 insertions(+), 32 deletions(-) diff --git a/src/Headers.php b/src/Headers.php index 626fd3a0..92cc7338 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -389,8 +389,8 @@ public function clearHeaders() * Get all headers of a certain name/type * * @param string $name - * @return false|ArrayIterator|HeaderInterface Returns false if there is no headers with $name in this - * contain, an ArrayIterator if the header is a MultipleHeadersInterface instance and finally returns + * @return false|ArrayIterator|HeaderInterface Returns false if there are no headers with $name, + * an ArrayIterator if the header is a MultipleHeadersInterface instance, and finally returns * HeaderInterface for the rest of cases. */ public function get($name) diff --git a/src/Message.php b/src/Message.php index bbb87102..41f4c813 100644 --- a/src/Message.php +++ b/src/Message.php @@ -5,8 +5,10 @@ use ArrayIterator; use Laminas\Mail\Header\Bcc; use Laminas\Mail\Header\Cc; +use Laminas\Mail\Header\ContentTransferEncoding; use Laminas\Mail\Header\ContentType; use Laminas\Mail\Header\From; +use Laminas\Mail\Header\HeaderInterface; use Laminas\Mail\Header\MimeVersion; use Laminas\Mail\Header\ReplyTo; use Laminas\Mail\Header\Sender; @@ -14,7 +16,7 @@ use Laminas\Mime; use Traversable; -use function array_shift; +use function array_filter; use function count; use function date; use function gettype; @@ -23,6 +25,9 @@ use function is_string; use function method_exists; use function sprintf; +use function str_starts_with; + +use const ARRAY_FILTER_USE_BOTH; class Message { @@ -372,9 +377,8 @@ public function getSubject() * * @param null|string|\Laminas\Mime\Message|object $body * @throws Exception\InvalidArgumentException - * @return Message */ - public function setBody($body) + public function setBody($body): Message { if (! is_string($body) && $body !== null) { if (! is_object($body)) { @@ -396,34 +400,105 @@ public function setBody($body) } } } - $this->body = $body; - if (! $this->body instanceof Mime\Message) { - return $this; + /** + * Set the required mime message headers. + */ + if ($body instanceof Mime\Message) { + /** + * Add the mime-version header if the body is mime-compliant, + * and the mime-version header is not already set. + * + * @see https://www.w3.org/Protocols/rfc1341/3_MIME-Version.html + */ + if (! $this->getHeaders()->has('mime-version')) { + $this->headers->addHeader(new MimeVersion()); + } + + /** + * Add a multipart (mixed) content-type header, if the body + * is multipart, and a multipart (mixed) content-type header is not + * already set. + * + * @see https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html + * */ + if ($body->isMultiPart()) { + if (! $this->hasMultipartContentType()) { + $this->headers->addHeader( + (new ContentType()) + ->setType(Mime\Mime::MULTIPART_MIXED) + ->addParameter('boundary', $body->getMime()->boundary()) + ); + } + } + + switch (count($body->getParts())) { + /** + * Set the default headers (content-type and content-transfer-encoding) to their default values. + * + * @see https://www.w3.org/Protocols/rfc1341/7_1_Text.html + * @see https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html + */ + case 0: + $this->headers->addHeader( + (new ContentType()) + ->setType(Mime\Mime::TYPE_TEXT) + ->addParameter('charset', 'us-ascii') + ); + $this->headers->addHeader( + (new ContentTransferEncoding()) + ->setTransferEncoding(Mime\Mime::ENCODING_7BIT) + ); + break; + + /** + * Set the default headers from the sole message part available. + */ + case 1: + $part = $body->getParts()[0]; + $this->headers->addHeader( + (new ContentType()) + ->setType($part->getType()) + ->addParameter('charset', $part->getCharset()) + ); + $this->headers->addHeader( + (new ContentTransferEncoding()) + ->setTransferEncoding($part->getEncoding()) + ); + break; + } } - // Get headers, and set Mime-Version header - $headers = $this->getHeaders(); - $this->getHeaderByName('mime-version', MimeVersion::class); + $this->body = $body; + + return $this; + } + + public function hasMultipartContentType(): bool + { + if (! $this->getHeaders()->has('content-type')) { + return false; + } - // Multipart content headers - if ($this->body->isMultiPart()) { - $mime = $this->body->getMime(); + $contentTypes = $this->getHeaders()->get('content-type'); - /** @var ContentType $header */ - $header = $this->getHeaderByName('content-type', ContentType::class); - $header->setType('multipart/mixed'); - $header->addParameter('boundary', $mime->boundary()); - return $this; + if ($contentTypes instanceof HeaderInterface) { + return str_starts_with($contentTypes->getFieldValue(), 'multipart'); } - // MIME single part headers - $parts = $this->body->getParts(); - if (! empty($parts)) { - $part = array_shift($parts); - $headers->addHeaders($part->getHeadersArray("\r\n")); + if ($contentTypes instanceof ArrayIterator) { + $headers = array_filter( + $contentTypes->getArrayCopy(), + /** @var HeaderInterface $contentType */ + function ($contentType) { + return str_starts_with($contentType->getFieldValue(), 'multipart'); + }, + ARRAY_FILTER_USE_BOTH + ); + return count($headers) !== 0; } - return $this; + + return false; } /** @@ -457,7 +532,7 @@ public function getBodyText() * * @param string $headerName * @param string $headerClass - * @return Header\HeaderInterface|ArrayIterator header instance or collection of headers + * @return HeaderInterface|ArrayIterator header instance or collection of headers */ protected function getHeaderByName($headerName, $headerClass) { diff --git a/test/MessageTest.php b/test/MessageTest.php index 6ff1ba94..8ea28aa0 100644 --- a/test/MessageTest.php +++ b/test/MessageTest.php @@ -526,6 +526,20 @@ public function testMaySetBodyFromMimeMessage(): void $body = new MimeMessage(); $this->message->setBody($body); $this->assertSame($body, $this->message->getBody()); + $headers = $this->message->getHeaders(); + $this->assertTrue($headers->has('content-type')); + $contentTypeHeader = $headers->get('content-type'); + $this->assertSame( + sprintf( + '%s;%scharset="us-ascii"', + Mime::TYPE_TEXT, + Headers::FOLDING + ), + $contentTypeHeader->getFieldValue() + ); + $this->assertTrue($headers->has('content-transfer-encoding')); + $contentTypeHeader = $headers->get('content-transfer-encoding'); + $this->assertSame(Mime::ENCODING_7BIT, $contentTypeHeader->getFieldValue()); } public function testMaySetNullBody(): void @@ -569,10 +583,6 @@ public function testSettingBodyFromSinglePartMimeMessageSetsAppropriateHeaders() $this->assertTrue($headers->has('mime-version')); $header = $headers->get('mime-version'); $this->assertEquals('1.0', $header->getFieldValue()); - - $this->assertTrue($headers->has('content-type')); - $header = $headers->get('content-type'); - $this->assertEquals('text/html', $header->getFieldValue()); } public function testSettingUtf8MailBodyFromSinglePartMimeUtf8MessageSetsAppropriateHeaders(): void @@ -799,6 +809,8 @@ public function testDetectsCRLFInjectionViaSubject(): void public function testHeaderUnfoldingWorksAsExpectedForMultipartMessages(): void { + $this->markTestSkipped("This likely isn't required anymore, as header unfolding is incorrect functionality"); + $text = new MimePart('Test content'); $text->type = Mime::TYPE_TEXT; $text->encoding = Mime::ENCODING_QUOTEDPRINTABLE; @@ -840,10 +852,9 @@ public function testCanParseMultipartReport(): void $raw = file_get_contents(__DIR__ . '/_files/laminas-mail-19.eml'); $message = Message::fromString($raw); $this->assertInstanceOf(Message::class, $message); - $this->assertIsString($message->getBody()); + $this->assertInstanceOf(MimeMessage::class, $message->getBody()); $headers = $message->getHeaders(); - $this->assertCount(8, $headers); $this->assertTrue($headers->has('Date')); $this->assertTrue($headers->has('From')); $this->assertTrue($headers->has('Message-Id')); @@ -854,6 +865,7 @@ public function testCanParseMultipartReport(): void $this->assertTrue($headers->has('Auto-Submitted')); $contentType = $headers->get('Content-Type'); + $this->assertInstanceOf(Header\HeaderInterface::class, $contentType); $this->assertEquals('multipart/report', $contentType->getType()); } From 5bd5f72810578bcf78ed1355868c686a45eb8075 Mon Sep 17 00:00:00 2001 From: Matthew Setter Date: Thu, 23 Nov 2023 15:59:31 +1000 Subject: [PATCH 4/8] Update the documentation to reflect mime message composition changes This commit updates the relevant sections of the documentation to reflect the changes in how mime messages are created. It also does a little bit of cleaning up of the documentation as well. Signed-off-by: Matthew Setter --- docs/book/message/attachments.md | 39 ++++++++++++++++---------------- docs/book/message/intro.md | 14 ++++++++---- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/docs/book/message/attachments.md b/docs/book/message/attachments.md index ec245279..d18225de 100644 --- a/docs/book/message/attachments.md +++ b/docs/book/message/attachments.md @@ -7,8 +7,7 @@ bodies, allowing you to create multipart emails. ## Basic multipart content -The following example creates an email with two parts, HTML content and an -image. +The following example creates an email with two parts, HTML content and an image. ```php use Laminas\Mail\Message; @@ -37,14 +36,11 @@ $contentTypeHeader = $message->getHeaders()->get('Content-Type'); $contentTypeHeader->setType('multipart/related'); ``` -Note that the above code requires us to manually specify the message content -type; laminas-mime does not automatically select the multipart type for us, nor -does laminas-mail populate it by default. - ## multipart/alternative content One of the most common email types sent by web applications is -`multipart/alternative` messages with both text and HTML parts. +`multipart/alternative` messages containing both plain text and HTML parts. +Below, you'll find an example of how to programmatically create one. ```php use Laminas\Mail\Message; @@ -67,22 +63,17 @@ $body->setParts([$text, $html]); $message = new Message(); $message->setBody($body); - -$contentTypeHeader = $message->getHeaders()->get('Content-Type'); -$contentTypeHeader->setType('multipart/alternative'); ``` The only differences from the first example are: - We have text and HTML parts instead of an HTML and image part. -- The `Content-Type` header is now `multipart/alternative`. +- The message's `Content-Type` header is automatically set to [`multipart/mixed`][multipart-content-type]. ## multipart/alternative emails with attachments -Another common task is creating `multipart/alternative` emails where the HTML -content refers to assets attachments (images, CSS, etc.). - -To accomplish this, we need to: +Another common task is creating `multipart/alternative` emails where one of the parts contains assets, such as images, and CSS, etc. +To accomplish this, we need to complete the following steps: - Create a `Laminas\Mime\Part` instance containing our `multipart/alternative` message. @@ -94,6 +85,14 @@ To accomplish this, we need to: The following example creates a MIME message with three parts: text and HTML alternative versions of an email, and an image attachment. +**Note:** The message part order is important for email clients to properly display the correct version of the content. For more information, refer to the quote below, from [section 7.2.3 The Multipart/alternative subtype of RFC 1341][multipart-content-type]: + +> In general, user agents that compose multipart/alternative entities should place the body parts in increasing order of preference, that is, with the preferred format last. For fancy text, the sending user agent should put the plainest format first and the richest format last. Receiving user agents should pick and display the last format they are capable of displaying. In the case where one of the alternatives is itself of type "multipart" and contains unrecognized sub-parts, the user agent may choose either to show that alternative, an earlier alternative, or both. +> +> NOTE: From an implementor's perspective, it might seem more sensible to reverse this ordering, and have the plainest alternative last. However, placing the plainest alternative first is the friendliest possible option when mutlipart/alternative entities are viewed using a non-MIME- compliant mail reader. While this approach does impose some burden on compliant mail readers, interoperability with older mail readers was deemed to be more important in this case. +> +> It may be the case that some user agents, if they can recognize more than one of the formats, will prefer to offer the user the choice of which format to view. This makes sense, for example, if mail includes both a nicely-formatted image version and an easily-edited text version. What is most critical, however, is that the user not automatically be shown multiple versions of the same data. Either the user should be shown the last recognized version or should explicitly be given the choice. + ```php use Laminas\Mail\Message; use Laminas\Mime\Message as MimeMessage; @@ -138,13 +137,15 @@ $contentTypeHeader->setType('multipart/related'); ## Setting custom MIME boundaries -In a multipart message, a MIME boundary for separating the different parts of -the message is normally generated at random. In some cases, however, you might -want to specify the MIME boundary that is used. This can be done by injecting a -new `Laminas\Mime\Mime` instance into the MIME message. +In a multipart message, [a MIME boundary][mime-boundary] for separating the different parts of +the message is normally generated at random, e.g., `000000000000d80dfc060ac6d232` or `Apple-Mail=_CEE98D34-7402-4263-858D-9820B6208C21`. +In some cases, however, you might want to specify the MIME boundary that is used. This can be done by injecting a new `Laminas\Mime\Mime` instance into the MIME message, as in the following example. ```php use Laminas\Mime\Mime; $mimeMessage->setMime(new Mime($customBoundary)); ``` + +[mime-boundary]: https://www.oreilly.com/library/view/programming-internet-email/9780596802585/ch03s04.html +[multipart-content-type]: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html \ No newline at end of file diff --git a/docs/book/message/intro.md b/docs/book/message/intro.md index 2467ee59..e3fb72e6 100644 --- a/docs/book/message/intro.md +++ b/docs/book/message/intro.md @@ -82,10 +82,11 @@ If you wish to set other headers, you can do that as well. $message->getHeaders()->addHeaderLine('X-API-Key', 'FOO-BAR-BAZ-BAT'); ``` -Sometimes you may want to provide HTML content, or multi-part content. To do -that, you'll first create a MIME message object, and then set it as the body of -your mail message object. When you do so, the `Message` class will automatically -set a "MIME-Version" header, as well as an appropriate "Content-Type" header. +Sometimes you may want to provide HTML content, or [multipart][multipart-content] +content. To do that, you'll first create a [MIME][mime-message] message object, +and then set it as the body of your mail message object. When you do so, the `Message` +class will automatically set a "MIME-Version" header, as well as an appropriate +"Content-Type" header. If you are interested in multipart emails or using attachments, read the chapter on [Adding Attachments](attachments.md). @@ -507,4 +508,7 @@ Instantiates a `Message` object from a raw message string that is compliant with ```php fromString() : Laminas\Mail\Message -``` \ No newline at end of file +``` + +[multipart-content]: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html +[mime-message]: https://en.wikipedia.org/wiki/MIME \ No newline at end of file From f283ac4e915c8e05e015f7dac20038687c4fc831 Mon Sep 17 00:00:00 2001 From: Matthew Setter Date: Thu, 23 Nov 2023 16:08:38 +1000 Subject: [PATCH 5/8] Update composer.lock I think that I inadvertantly checked in a composer.lock file which referenced a local fork of laminas-mime that I was working on while patching the library. This change reverts that. Signed-off-by: Matthew Setter --- composer.lock | 254 +++++++------------------------------------------- 1 file changed, 34 insertions(+), 220 deletions(-) diff --git a/composer.lock b/composer.lock index 65c3e556..a4042ca5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "18e511b298f74d503450bcbde45948cc", + "content-hash": "e65dff875e45e39e65c72645d718b615", "packages": [ { "name": "laminas/laminas-loader", @@ -64,11 +64,17 @@ }, { "name": "laminas/laminas-mime", - "version": "dev-add-multipart-alternative-support", + "version": "2.12.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-mime.git", + "reference": "08cc544778829b7d68d27a097885bd6e7130135e" + }, "dist": { - "type": "path", - "url": "../laminas-mime", - "reference": "b9b26d9b5c89d49cbafe37bf5887e0f7aed2171c" + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-mime/zipball/08cc544778829b7d68d27a097885bd6e7130135e", + "reference": "08cc544778829b7d68d27a097885bd6e7130135e", + "shasum": "" }, "require": { "laminas/laminas-stdlib": "^2.7 || ^3.0", @@ -91,32 +97,7 @@ "Laminas\\Mime\\": "src/" } }, - "autoload-dev": { - "files": [ - "test/TestAsset/Mail/Headers.php" - ], - "psr-4": { - "LaminasTest\\Mime\\": "test/" - } - }, - "scripts": { - "check": [ - "@cs-check", - "@test" - ], - "cs-check": [ - "phpcs" - ], - "cs-fix": [ - "phpcbf" - ], - "test": [ - "phpunit --colors=always" - ], - "test-coverage": [ - "phpunit --colors=always --coverage-clover clover.xml" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], @@ -127,16 +108,20 @@ "mime" ], "support": { + "chat": "https://laminas.dev/chat", "docs": "https://docs.laminas.dev/laminas-mime/", + "forum": "https://discourse.laminas.dev", "issues": "https://github.com/laminas/laminas-mime/issues", - "source": "https://github.com/laminas/laminas-mime", "rss": "https://github.com/laminas/laminas-mime/releases.atom", - "chat": "https://laminas.dev/chat", - "forum": "https://discourse.laminas.dev" + "source": "https://github.com/laminas/laminas-mime" }, - "transport-options": { - "relative": true - } + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2023-11-02T16:47:19+00:00" }, { "name": "laminas/laminas-servicemanager", @@ -1720,68 +1705,6 @@ ], "time": "2023-05-05T16:22:28+00:00" }, - { - "name": "laminas/laminas-escaper", - "version": "2.13.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-escaper.git", - "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/af459883f4018d0f8a0c69c7a209daef3bf973ba", - "reference": "af459883f4018d0f8a0c69c7a209daef3bf973ba", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-mbstring": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0" - }, - "conflict": { - "zendframework/zend-escaper": "*" - }, - "require-dev": { - "infection/infection": "^0.27.0", - "laminas/laminas-coding-standard": "~2.5.0", - "maglnet/composer-require-checker": "^3.8.0", - "phpunit/phpunit": "^9.6.7", - "psalm/plugin-phpunit": "^0.18.4", - "vimeo/psalm": "^5.9" - }, - "type": "library", - "autoload": { - "psr-4": { - "Laminas\\Escaper\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", - "homepage": "https://laminas.dev", - "keywords": [ - "escaper", - "laminas" - ], - "support": { - "chat": "https://laminas.dev/chat", - "docs": "https://docs.laminas.dev/laminas-escaper/", - "forum": "https://discourse.laminas.dev", - "issues": "https://github.com/laminas/laminas-escaper/issues", - "rss": "https://github.com/laminas/laminas-escaper/releases.atom", - "source": "https://github.com/laminas/laminas-escaper" - }, - "funding": [ - { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" - } - ], - "time": "2023-10-10T08:35:13+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.11.1", @@ -2224,114 +2147,6 @@ }, "time": "2022-10-14T12:47:21+00:00" }, - { - "name": "phpoffice/phpword", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/PHPOffice/PHPWord.git", - "reference": "90a55955e6a772bb4cd9b1ef6a7e88c8976c2561" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/90a55955e6a772bb4cd9b1ef6a7e88c8976c2561", - "reference": "90a55955e6a772bb4cd9b1ef6a7e88c8976c2561", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-xml": "*", - "laminas/laminas-escaper": ">=2.6", - "php": "^7.1|^8.0" - }, - "require-dev": { - "dompdf/dompdf": "^2.0", - "ext-gd": "*", - "ext-libxml": "*", - "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.3", - "mpdf/mpdf": "^8.1", - "phpmd/phpmd": "^2.13", - "phpunit/phpunit": ">=7.0", - "symfony/process": "^4.4", - "tecnickcom/tcpdf": "^6.5" - }, - "suggest": { - "dompdf/dompdf": "Allows writing PDF", - "ext-gd2": "Allows adding images", - "ext-xmlwriter": "Allows writing OOXML and ODF", - "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template", - "ext-zip": "Allows writing OOXML and ODF" - }, - "type": "library", - "autoload": { - "psr-4": { - "PhpOffice\\PhpWord\\": "src/PhpWord" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "LGPL-3.0" - ], - "authors": [ - { - "name": "Mark Baker" - }, - { - "name": "Gabriel Bull", - "email": "me@gabrielbull.com", - "homepage": "http://gabrielbull.com/" - }, - { - "name": "Franck Lefevre", - "homepage": "https://rootslabs.net/blog/" - }, - { - "name": "Ivan Lanin", - "homepage": "http://ivan.lanin.org" - }, - { - "name": "Roman Syroeshko", - "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" - }, - { - "name": "Antoine de Troostembergh" - } - ], - "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)", - "homepage": "https://phpword.readthedocs.io/", - "keywords": [ - "ISO IEC 29500", - "OOXML", - "Office Open XML", - "OpenDocument", - "OpenXML", - "PhpOffice", - "PhpWord", - "Rich Text Format", - "WordprocessingML", - "doc", - "docx", - "html", - "odf", - "odt", - "office", - "pdf", - "php", - "reader", - "rtf", - "template", - "template processor", - "word", - "writer" - ], - "support": { - "issues": "https://github.com/PHPOffice/PHPWord/issues", - "source": "https://github.com/PHPOffice/PHPWord/tree/1.1.0" - }, - "time": "2023-05-30T07:59:14+00:00" - }, { "name": "phpstan/phpdoc-parser", "version": "1.5.1", @@ -4720,16 +4535,16 @@ }, { "name": "vimeo/psalm", - "version": "5.15.0", + "version": "5.16.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "5c774aca4746caf3d239d9c8cadb9f882ca29352" + "reference": "2897ba636551a8cb61601cc26f6ccfbba6c36591" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/5c774aca4746caf3d239d9c8cadb9f882ca29352", - "reference": "5c774aca4746caf3d239d9c8cadb9f882ca29352", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/2897ba636551a8cb61601cc26f6ccfbba6c36591", + "reference": "2897ba636551a8cb61601cc26f6ccfbba6c36591", "shasum": "" }, "require": { @@ -4754,8 +4569,8 @@ "php": "^7.4 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", "sebastian/diff": "^4.0 || ^5.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", - "symfony/console": "^4.1.6 || ^5.0 || ^6.0", - "symfony/filesystem": "^5.4 || ^6.0" + "symfony/console": "^4.1.6 || ^5.0 || ^6.0 || ^7.0", + "symfony/filesystem": "^5.4 || ^6.0 || ^7.0" }, "conflict": { "nikic/php-parser": "4.17.0" @@ -4777,7 +4592,7 @@ "psalm/plugin-phpunit": "^0.18", "slevomat/coding-standard": "^8.4", "squizlabs/php_codesniffer": "^3.6", - "symfony/process": "^4.4 || ^5.0 || ^6.0" + "symfony/process": "^4.4 || ^5.0 || ^6.0 || ^7.0" }, "suggest": { "ext-curl": "In order to send data to shepherd", @@ -4790,7 +4605,7 @@ "psalm-refactor", "psalter" ], - "type": "library", + "type": "project", "extra": { "branch-alias": { "dev-master": "5.x-dev", @@ -4822,10 +4637,11 @@ "static analysis" ], "support": { + "docs": "https://psalm.dev/docs", "issues": "https://github.com/vimeo/psalm/issues", - "source": "https://github.com/vimeo/psalm/tree/5.15.0" + "source": "https://github.com/vimeo/psalm" }, - "time": "2023-08-20T23:07:30+00:00" + "time": "2023-11-22T20:38:47+00:00" }, { "name": "webimpress/coding-standard", @@ -4885,9 +4701,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "laminas/laminas-mime": 20 - }, + "stability-flags": [], "prefer-stable": false, "prefer-lowest": false, "platform": { From 927391ad43894adf8e53e69577932c39d3297d9e Mon Sep 17 00:00:00 2001 From: Matthew Setter Date: Thu, 30 Nov 2023 13:08:22 +1000 Subject: [PATCH 6/8] Simplify test data After feedback from @mwop, this change simplifies test data for testing the HTML part of an email. It removes SendGrid details, to ensure that the tests don't favour any given provider. Signed-off-by: Matthew Setter --- test/MessageTest.php | 3 +-- test/_files/mail_with_pdf_attachment.eml | 6 +----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/test/MessageTest.php b/test/MessageTest.php index 8ea28aa0..69f83749 100644 --- a/test/MessageTest.php +++ b/test/MessageTest.php @@ -885,8 +885,7 @@ public function testCanParseMultipartEmail(): void trim($partOne->getParts()[0]->getContent()) ); $this->assertSame( - '
This is a test email with 1 attachment.

--


-
', + '
This is a test email with 1 attachment.
', trim($partOne->getParts()[1]->getRawContent()) ); diff --git a/test/_files/mail_with_pdf_attachment.eml b/test/_files/mail_with_pdf_attachment.eml index 2125196d..245cca2f 100644 --- a/test/_files/mail_with_pdf_attachment.eml +++ b/test/_files/mail_with_pdf_attachment.eml @@ -26,11 +26,7 @@ This is a test email with 1 attachment. Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: quoted-printable -
This is a test email with 1 attachment.
<= -div>
--


-
+
This is a test email with 1 attachment.
--001a11447dc881e40b0537fe6d58-- From 33f94d073411ded0aa41cf152614b39a8cac8654 Mon Sep 17 00:00:00 2001 From: Matthew Setter Date: Thu, 30 Nov 2023 13:12:59 +1000 Subject: [PATCH 7/8] Add a series of iterators to simplify working with Message Parts This change adds three custom RecursiveIterator classes with the aim of simplifying working with message parts. - PartsIterator simplifies iterating over a recursive list of parts, as multipart mime email parts can themselves contain parts. - MessagePartFilterIterator is designed to use PartsIterator to filter out parts that are not the contents of a message, such as the plain text or HTML body. - AttachmentPartFilterIterator is designed to use PartsIterator to filter out parts that are not message attachments. Together, these iterators will help provide functionality for quickly retrieving a message's content and attachments, without leaving it to the user. Signed-off-by: Matthew Setter --- src/Iterator/AttachmentPartFilterIterator.php | 35 +++++++++++ src/Iterator/MessagePartFilterIterator.php | 48 +++++++++++++++ src/Iterator/PartsIterator.php | 25 ++++++++ .../AttachmentPartFilterIteratorTest.php | 36 +++++++++++ .../MessagePartFilterIteratorTest.php | 61 +++++++++++++++++++ test/Iterator/PartsIteratorTest.php | 34 +++++++++++ 6 files changed, 239 insertions(+) create mode 100644 src/Iterator/AttachmentPartFilterIterator.php create mode 100644 src/Iterator/MessagePartFilterIterator.php create mode 100644 src/Iterator/PartsIterator.php create mode 100644 test/Iterator/AttachmentPartFilterIteratorTest.php create mode 100644 test/Iterator/MessagePartFilterIteratorTest.php create mode 100644 test/Iterator/PartsIteratorTest.php diff --git a/src/Iterator/AttachmentPartFilterIterator.php b/src/Iterator/AttachmentPartFilterIterator.php new file mode 100644 index 00000000..c6f903a6 --- /dev/null +++ b/src/Iterator/AttachmentPartFilterIterator.php @@ -0,0 +1,35 @@ +hasChildren()) { + return true; + } + + /** @var Part $part */ + $part = $this->current(); + + return str_starts_with((string)$part->getDisposition(), "attachment"); + } + + public function hasChildren(): bool + { + return $this->getInnerIterator()->hasChildren(); + } + + public function getChildren(): AttachmentPartFilterIterator + { + return new self($this->getInnerIterator()->getChildren()); + } +} diff --git a/src/Iterator/MessagePartFilterIterator.php b/src/Iterator/MessagePartFilterIterator.php new file mode 100644 index 00000000..b6925242 --- /dev/null +++ b/src/Iterator/MessagePartFilterIterator.php @@ -0,0 +1,48 @@ +partType = $partType; + } + + public function accept(): bool + { + if ($this->hasChildren()) { + return true; + } + + /** @var Part $part */ + $part = $this->current(); + return str_starts_with($part->getType(), $this->partType); + } + + public function hasChildren(): bool + { + return $this->getInnerIterator()->hasChildren(); + } + + public function getChildren(): MessagePartFilterIterator + { + return new self( + $this->getInnerIterator()->getChildren(), + $this->partType + ); + } +} diff --git a/src/Iterator/PartsIterator.php b/src/Iterator/PartsIterator.php new file mode 100644 index 00000000..88ae38ae --- /dev/null +++ b/src/Iterator/PartsIterator.php @@ -0,0 +1,25 @@ +current(); + return ! empty($part->getParts()); + } + + public function getChildren(): RecursiveArrayIterator + { + /** @var Part $current */ + $current = $this->current(); + return new PartsIterator($current->getParts()); + } +} diff --git a/test/Iterator/AttachmentPartFilterIteratorTest.php b/test/Iterator/AttachmentPartFilterIteratorTest.php new file mode 100644 index 00000000..f8cebf59 --- /dev/null +++ b/test/Iterator/AttachmentPartFilterIteratorTest.php @@ -0,0 +1,36 @@ +getBody()->getParts() + ), + ) + ); + + $this->assertCount(1, $iterator); + } +} diff --git a/test/Iterator/MessagePartFilterIteratorTest.php b/test/Iterator/MessagePartFilterIteratorTest.php new file mode 100644 index 00000000..1534d7f6 --- /dev/null +++ b/test/Iterator/MessagePartFilterIteratorTest.php @@ -0,0 +1,61 @@ +This is a test email with 1 attachment.", + ], + ]; + } + + /** + * @dataProvider partTypeProvider + */ + public function testIteratesSuccessfullyOverPartsData(string $type, string $expectedResult): void + { + $email = file_get_contents( + __DIR__ . '/../_files/mail_with_pdf_attachment.eml' + ); + $message = Message::fromString($email); + + /** @var Part[] $iterator */ + $iterator = new RecursiveIteratorIterator( + new MessagePartFilterIterator( + new PartsIterator($message->getBody()->getParts()), + $type + ) + ); + + $this->assertCount(1, $iterator); + $parts = iterator_to_array($iterator); + /** @var Part $part */ + $part = array_pop($parts); + + $this->assertSame($expectedResult, trim($part->getRawContent())); + } +} diff --git a/test/Iterator/PartsIteratorTest.php b/test/Iterator/PartsIteratorTest.php new file mode 100644 index 00000000..434c0ac7 --- /dev/null +++ b/test/Iterator/PartsIteratorTest.php @@ -0,0 +1,34 @@ +getBody()->getParts(), + ), + RecursiveIteratorIterator::SELF_FIRST + ); + + $this->assertCount(4, $iterator); + } +} From e7d10b0a9428a12e6a57ff9ede84bc25615507b8 Mon Sep 17 00:00:00 2001 From: Matthew Setter Date: Thu, 30 Nov 2023 13:25:07 +1000 Subject: [PATCH 8/8] Add utility functions for retrieving message content and attachments You can iterate over all of the message parts in the calling code and extract the plain text and HTML body, as well as the attachments. However, I thought that this would be something that people would do regularly; so much so such that adding utility functions for doing so might be a great productivity win for the package. The documentation's been updated to reflect these additions so that they're as visible as possible, and readily discoverable. Signed-off-by: Matthew Setter --- docs/book/message/attachments.md | 17 +++++++++ docs/book/message/intro.md | 15 ++++++++ src/Message.php | 47 ++++++++++++++++++++++++ test/MessageTest.php | 40 +++++++++++++++++++- test/_files/mail_with_pdf_attachment.eml | 2 +- 5 files changed, 118 insertions(+), 3 deletions(-) diff --git a/docs/book/message/attachments.md b/docs/book/message/attachments.md index d18225de..ca2cf716 100644 --- a/docs/book/message/attachments.md +++ b/docs/book/message/attachments.md @@ -147,5 +147,22 @@ use Laminas\Mime\Mime; $mimeMessage->setMime(new Mime($customBoundary)); ``` +## Retrieving attachments + +If you have created a multipart message with one or more attachments, whether programmatically +or via the `Message::fromString();` method, you can readily retrieve them by calling the `getAttachments()` method. +It will return an array of `\Laminas\Mime\Part` objects. + +For example: + +```php +// Instantiate a Message object from a .eml file. +$raw = file_get_contents(__DIR__ . '/mail_with_attachments.eml'); +$message = Message::fromString($raw); + +// Retrieve the email's attachments. +$attachments = $message->getAttachments(); +``` + [mime-boundary]: https://www.oreilly.com/library/view/programming-internet-email/9780596802585/ch03s04.html [multipart-content-type]: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html \ No newline at end of file diff --git a/docs/book/message/intro.md b/docs/book/message/intro.md index e3fb72e6..515e7fa8 100644 --- a/docs/book/message/intro.md +++ b/docs/book/message/intro.md @@ -207,6 +207,21 @@ EOF; $message = Message::fromString($rawEmail); ``` +### Retrieve a Message's plain text and HTML body + +Commonly, though not always, an email will contain one or both of a plain text and/or HTMl body. +To retrieve these directly, there are two methods available `getPlainTextBodyPart()` and +`getHtmlBodyPart()`. For example: + +```php +// Instantiate a Message object from a .eml file. +$raw = file_get_contents(__DIR__ . '/mail_with_attachments.eml'); +$message = Message::fromString($raw); + +echo $message->getPlainTextBodyPart(); +echo $message->getHtmlBodyPart(); +``` + ## Configuration Options The `Message` class has no configuration options, and is instead a value object. diff --git a/src/Message.php b/src/Message.php index 41f4c813..c6922cda 100644 --- a/src/Message.php +++ b/src/Message.php @@ -13,16 +13,23 @@ use Laminas\Mail\Header\ReplyTo; use Laminas\Mail\Header\Sender; use Laminas\Mail\Header\To; +use Laminas\Mail\Iterator\AttachmentPartFilterIterator; +use Laminas\Mail\Iterator\MessagePartFilterIterator; +use Laminas\Mail\Iterator\PartsIterator; use Laminas\Mime; +use Laminas\Mime\Part; +use RecursiveIteratorIterator; use Traversable; use function array_filter; +use function array_pop; use function count; use function date; use function gettype; use function is_array; use function is_object; use function is_string; +use function iterator_to_array; use function method_exists; use function sprintf; use function str_starts_with; @@ -118,6 +125,46 @@ public function getHeaders() return $this->headers; } + public function getBodyPart(string $partType): Part + { + /** @var Part[] $iterator */ + $iterator = new RecursiveIteratorIterator( + new MessagePartFilterIterator( + new PartsIterator($this->getBody()->getParts()), + $partType + ) + ); + + $part = iterator_to_array($iterator); + return array_pop($part); + } + + public function getPlainTextBodyPart(): Part + { + return $this->getBodyPart(\Laminas\Mime\Mime::TYPE_TEXT); + } + + public function getHtmlBodyPart(): Part + { + return $this->getBodyPart(\Laminas\Mime\Mime::TYPE_HTML); + } + + /** + * @return Part[] + */ + public function getAttachments(): array + { + /** @var Part[] $iterator */ + $iterator = new RecursiveIteratorIterator( + new AttachmentPartFilterIterator( + new PartsIterator( + $this->getBody()->getParts() + ), + ) + ); + return iterator_to_array($iterator); + } + /** * Set (overwrite) From addresses * diff --git a/test/MessageTest.php b/test/MessageTest.php index 69f83749..b760b29f 100644 --- a/test/MessageTest.php +++ b/test/MessageTest.php @@ -12,11 +12,13 @@ use Laminas\Mail\Message; use Laminas\Mime\Message as MimeMessage; use Laminas\Mime\Mime; +use Laminas\Mime\Part; use Laminas\Mime\Part as MimePart; use PHPUnit\Framework\TestCase; use Smalot\PdfParser\Parser; use stdClass; +use function array_pop; use function count; use function date; use function fclose; @@ -25,6 +27,7 @@ use function fwrite; use function implode; use function sprintf; +use function str_starts_with; use function substr; use function sys_get_temp_dir; use function trim; @@ -888,12 +891,45 @@ public function testCanParseMultipartEmail(): void '
This is a test email with 1 attachment.
', trim($partOne->getParts()[1]->getRawContent()) ); + } + + public function testCanReturnPlainTextAndHTMLMessageBodyIfAvailable() + { + $raw = file_get_contents(__DIR__ . '/_files/mail_with_pdf_attachment.eml'); + $message = Message::fromString($raw); + $this->assertInstanceOf(Message::class, $message); + $this->assertTrue($message->getBody()->isMultiPart()); + $plainTextBody = $message->getPlainTextBodyPart(); + $this->assertSame("This is a test email with 1 attachment.", trim($plainTextBody->getRawContent())); + $htmlBody = $message->getHtmlBodyPart(); + $this->assertSame("
This is a test email with 1 attachment.
", trim($htmlBody->getRawContent())); + } + + public function testReturnsEmptyAttachmentsListWhenEmailHasNoAttachments() + { + $raw = file_get_contents(__DIR__ . '/_files/laminas-mail-19.eml'); + $message = Message::fromString($raw); + $this->assertInstanceOf(Message::class, $message); + $this->assertTrue($message->getBody()->isMultiPart()); + $this->assertEmpty($message->getAttachments()); + } + + public function testCanRetrieveMessageAttachmentsWhenAttachmentsAreAvailable() + { + $raw = file_get_contents(__DIR__ . '/_files/mail_with_pdf_attachment.eml'); + $message = Message::fromString($raw); + $this->assertInstanceOf(Message::class, $message); + $this->assertTrue($message->getBody()->isMultiPart()); - $attachmentPart = $parts[1]; + $attachments = $message->getAttachments(); + $this->assertCount(1, $attachments); + /** @var Part $attachment */ + $attachment = array_pop($attachments); + $this->assertTrue(str_starts_with($attachment->getType(), "application/pdf")); $tempFile = sprintf("%stemp.pdf", sys_get_temp_dir()); $handle = fopen($tempFile, "w"); - fwrite($handle, $attachmentPart->getRawContent()); + fwrite($handle, $attachment->getRawContent()); fclose($handle); $parser = new Parser(); diff --git a/test/_files/mail_with_pdf_attachment.eml b/test/_files/mail_with_pdf_attachment.eml index 245cca2f..89e3ed7a 100644 --- a/test/_files/mail_with_pdf_attachment.eml +++ b/test/_files/mail_with_pdf_attachment.eml @@ -31,7 +31,7 @@ Content-Transfer-Encoding: quoted-printable --001a11447dc881e40b0537fe6d58-- --001a11447dc881e40f0537fe6d5a -Content-Disposition: inline; +Content-Disposition: attachment; filename="test document.pdf" Content-Type: application/pdf; x-mac-hide-extension=yes;