From 2df9632b140701f01925bf049f07074cc487db1a Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 16 Nov 2023 20:45:08 +0000 Subject: [PATCH 1/4] Added ability to extract text and html from multipart messages --- src/Service/AbstractMailService.php | 38 +++- .../Service/AbstractMailServiceTest.php | 179 ++++++++++++++++++ 2 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 tests/SlmMailTest/Service/AbstractMailServiceTest.php diff --git a/src/Service/AbstractMailService.php b/src/Service/AbstractMailService.php index 7305e3e..f57f4a9 100644 --- a/src/Service/AbstractMailService.php +++ b/src/Service/AbstractMailService.php @@ -46,11 +46,21 @@ use Laminas\Mime\Message as MimeMessage; use Laminas\Mime\Mime; +use function in_array; + /** * Class AbstractMailService */ abstract class AbstractMailService implements MailServiceInterface { + private const MULTIPART_TYPES = [ + Mime::MULTIPART_ALTERNATIVE, + Mime::MULTIPART_MIXED, + Mime::MULTIPART_RELATED, + Mime::MULTIPART_RELATIVE, + Mime::MULTIPART_REPORT, + ]; + /** * @var HttpClient */ @@ -74,11 +84,23 @@ protected function extractText(Message $message): ?string return null; } - foreach ($body->getParts() as $part) { + return $this->extractTextFromMimeMessage($body); + } + + private function extractTextFromMimeMessage(MimeMessage $message): ?string + { + foreach ($message->getParts() as $part) { if ($part->type === 'text/plain' && $part->disposition !== Mime::DISPOSITION_ATTACHMENT) { return $part->getContent(); } } + foreach ($message->getParts() as $part) { + if (in_array($part->type, self::MULTIPART_TYPES)) { + return $this->extractTextFromMimeMessage( + MimeMessage::createFromMessage($part->getContent(), $part->boundary) + ); + } + } return null; } @@ -98,11 +120,23 @@ protected function extractHtml(Message $message): ?string return null; } - foreach ($body->getParts() as $part) { + return $this->extractHtmlFromMimeMessage($body); + } + + private function extractHtmlFromMimeMessage(MimeMessage $message): ?string + { + foreach ($message->getParts() as $part) { if ($part->type === 'text/html' && $part->disposition !== Mime::DISPOSITION_ATTACHMENT) { return $part->getContent(); } } + foreach ($message->getParts() as $part) { + if (in_array($part->type, self::MULTIPART_TYPES)) { + return $this->extractHtmlFromMimeMessage( + MimeMessage::createFromMessage($part->getContent(), $part->boundary) + ); + } + } return null; } diff --git a/tests/SlmMailTest/Service/AbstractMailServiceTest.php b/tests/SlmMailTest/Service/AbstractMailServiceTest.php new file mode 100644 index 0000000..b511272 --- /dev/null +++ b/tests/SlmMailTest/Service/AbstractMailServiceTest.php @@ -0,0 +1,179 @@ +service = new class () extends AbstractMailService { + public ?string $text = null; + public ?string $html = null; + public array $attachments = []; + + public function send(Message $message) + { + $this->text = $this->extractText($message); + $this->html = $this->extractHtml($message); + $this->attachments = $this->extractAttachments($message); + } + }; + } + + public function testExtractTextFromStringBodyReturnsString(): void + { + $expected = 'Foo'; + $message = new Message(); + $message->setBody($expected); + + $this->service->send($message); + self::assertSame($expected, $this->service->text); + } + + public function testExtractTextFromEmptyBodyReturnsNull(): void + { + $message = new Message(); + + $this->service->send($message); + self::assertNull($this->service->text); + } + + public function testExtractTextFromTwoPartMessageReturnsString(): void + { + $expected = 'Foo'; + $message = new Message(); + $body = new MimeMessage(); + $body->addPart(new Part('')); + $body->addPart( + (new Part($expected)) + ->setType(Mime::TYPE_TEXT) + ); + $message->setBody($body); + + $this->service->send($message); + self::assertSame($expected, $this->service->text); + } + + public function testExtractTextFromTextAttachmentReturnsNull(): void + { + $message = new Message(); + $body = new MimeMessage(); + $body->addPart( + (new Part('Foo')) + ->setType(Mime::TYPE_TEXT) + ->setDisposition(Mime::DISPOSITION_ATTACHMENT) + ); + $message->setBody($body); + + $this->service->send($message); + self::assertNull($this->service->text); + } + + public function testExtractTextFromMultipartMessageReturnsString(): void + { + $expected = 'Foo'; + $message = new Message(); + $body = new MimeMessage(); + $contentPart = new MimeMessage(); + $contentPart->addPart(new Part()); + $contentPart->addPart( + (new Part($expected)) + ->setType(Mime::TYPE_TEXT) + ); + $body->addPart( + (new Part($contentPart->generateMessage())) + ->setType(Mime::MULTIPART_ALTERNATIVE) + ->setBoundary($contentPart->getMime()->boundary()) + ); + $message->setBody($body); + + $this->service->send($message); + self::assertSame($expected, trim($this->service->text)); + } + + public function testExtractHtmlFromStringBodyReturnsNull(): void + { + $message = new Message(); + $message->setBody('Foo'); + + $this->service->send($message); + self::assertNull($this->service->html); + } + + public function testExtractHtmlFromEmptyBodyReturnsNull(): void + { + $message = new Message(); + + $this->service->send($message); + self::assertNull($this->service->html); + } + + public function testExtractHtmlFromTwoPartMessageReturnsString(): void + { + $expected = 'Foo'; + $message = new Message(); + $body = new MimeMessage(); + $body->addPart(new Part('')); + $body->addPart( + (new Part($expected)) + ->setType(Mime::TYPE_HTML) + ); + $message->setBody($body); + + $this->service->send($message); + self::assertSame($expected, $this->service->html); + } + + public function testExtractHtmlFromHtmlAttachmentReturnsNull(): void + { + $message = new Message(); + $body = new MimeMessage(); + $body->addPart( + (new Part('Foo')) + ->setType(Mime::TYPE_HTML) + ->setDisposition(Mime::DISPOSITION_ATTACHMENT) + ); + $message->setBody($body); + + $this->service->send($message); + self::assertNull($this->service->html); + } + + public function testExtractHtmlFromMultipartMessageReturnsString(): void + { + $expected = 'Foo'; + $message = new Message(); + $body = new MimeMessage(); + $contentPart = new MimeMessage(); + $contentPart->addPart(new Part()); + $contentPart->addPart( + (new Part($expected)) + ->setType(Mime::TYPE_HTML) + ); + $body->addPart( + (new Part($contentPart->generateMessage())) + ->setType(Mime::MULTIPART_ALTERNATIVE) + ->setBoundary($contentPart->getMime()->boundary()) + ); + $message->setBody($body); + + $this->service->send($message); + self::assertSame($expected, trim($this->service->html)); + } +} From 18ad01a609524583bb34b8485f2d60e4358809b2 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 16 Nov 2023 21:00:46 +0000 Subject: [PATCH 2/4] Require laminas/laminas-mime:^2.8 so we have access to Mime class constants at lowest deps --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c9f5111..3105e6d 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0", "laminas/laminas-mail": "^2.9", "laminas/laminas-http": "^2.8", - "laminas/laminas-mime": "^2.7", + "laminas/laminas-mime": "^2.8", "laminas/laminas-servicemanager": "^3.11" }, "require-dev": { From 3ea43bd3a8ea38a5002c333b7f9c121fc9fc5986 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 17 Nov 2023 00:37:53 +0000 Subject: [PATCH 3/4] Update README with notes on setting boundary on multipart/alternative parts --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/README.md b/README.md index ee33b5c..62e1306 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,69 @@ $message->setBody($body); > For accessibility purposes, you should *always* provide both a text and HTML version of your mails. +### `multipart/alternative` emails with attachments + +The correct way to compose an email message that contains text, html _and_ attachments is to create a +`multipart/alternative` part containing the text and html parts, followed by one or more parts for the attachments. + +If you have an existing application using `laminas/laminas-mail`, the developers probably followed the +[Laminas Documentation](https://docs.laminas.dev/laminas-mail/message/attachments/#multipartalternative-emails-with-attachments), +in which case the `multipart/alternative` content part may not have any boundary defined. This library **needs** that +boundary to parse out the text and html. + +For existing applications, check that it is set when your code creates the content MIME part: + +```php +$content = new MimeMessage(); +$content->setParts([$text, $html]); +$contentPart = (new MimePart($content->generateMessage())) + ->setBoundary($content->getMime()->boundary()); // <-- THIS IS REQUIRED! +``` + +If you are starting from scratch, here's the example from the Laminas documentation modified to work correctly: + +```php +use Laminas\Mail\Message; +use Laminas\Mime\Message as MimeMessage; +use Laminas\Mime\Mime; +use Laminas\Mime\Part as MimePart; + +$body = new MimeMessage(); + +$text = new MimePart($textContent); +$text->type = Mime::TYPE_TEXT; +$text->charset = 'utf-8'; +$text->encoding = Mime::ENCODING_QUOTEDPRINTABLE; + +$html = new MimePart($htmlMarkup); +$html->type = Mime::TYPE_HTML; +$html->charset = 'utf-8'; +$html->encoding = Mime::ENCODING_QUOTEDPRINTABLE; + +$content = new MimeMessage(); +// This order is important for email clients to properly display the correct version of the content +$content->setParts([$text, $html]); + +$contentPart = (new MimePart($content->generateMessage())) + ->setType(Mime::MULTIPART_ALTERNATIVE) + ->setBoundary($content->getMime()->boundary());; + +$image = new MimePart(fopen($pathToImage, 'r')); +$image->type = 'image/jpeg'; +$image->filename = 'image-file-name.jpg'; +$image->disposition = Mime::DISPOSITION_ATTACHMENT; +$image->encoding = Mime::ENCODING_BASE64; + +$body = new MimeMessage(); +$body->setParts([$contentPart, $image]); + +$message = new Message(); +$message->setBody($body); + +$contentTypeHeader = $message->getHeaders()->get('Content-Type'); +$contentTypeHeader->setType(Mime::MULTIPART_RELATED); +``` + ### How to configure HttpClient with http_options and http_adapter By default the adapter is Laminas\Http\Client\Adapter\Socket but you can override it with other adapter like this in your slm_mail.*.local.php From 394177f03026ecec0e3d7a16311bcc0f210f54e9 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 17 Nov 2023 08:54:16 +0000 Subject: [PATCH 4/4] Link to Laminas documentation for multipart/alternative emails now https://github.com/laminas/laminas-mail/pull/254 is merged --- README.md | 62 +++---------------------------------------------------- 1 file changed, 3 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 62e1306..05e8e7d 100644 --- a/README.md +++ b/README.md @@ -106,65 +106,9 @@ $message->setBody($body); ### `multipart/alternative` emails with attachments The correct way to compose an email message that contains text, html _and_ attachments is to create a -`multipart/alternative` part containing the text and html parts, followed by one or more parts for the attachments. - -If you have an existing application using `laminas/laminas-mail`, the developers probably followed the -[Laminas Documentation](https://docs.laminas.dev/laminas-mail/message/attachments/#multipartalternative-emails-with-attachments), -in which case the `multipart/alternative` content part may not have any boundary defined. This library **needs** that -boundary to parse out the text and html. - -For existing applications, check that it is set when your code creates the content MIME part: - -```php -$content = new MimeMessage(); -$content->setParts([$text, $html]); -$contentPart = (new MimePart($content->generateMessage())) - ->setBoundary($content->getMime()->boundary()); // <-- THIS IS REQUIRED! -``` - -If you are starting from scratch, here's the example from the Laminas documentation modified to work correctly: - -```php -use Laminas\Mail\Message; -use Laminas\Mime\Message as MimeMessage; -use Laminas\Mime\Mime; -use Laminas\Mime\Part as MimePart; - -$body = new MimeMessage(); - -$text = new MimePart($textContent); -$text->type = Mime::TYPE_TEXT; -$text->charset = 'utf-8'; -$text->encoding = Mime::ENCODING_QUOTEDPRINTABLE; - -$html = new MimePart($htmlMarkup); -$html->type = Mime::TYPE_HTML; -$html->charset = 'utf-8'; -$html->encoding = Mime::ENCODING_QUOTEDPRINTABLE; - -$content = new MimeMessage(); -// This order is important for email clients to properly display the correct version of the content -$content->setParts([$text, $html]); - -$contentPart = (new MimePart($content->generateMessage())) - ->setType(Mime::MULTIPART_ALTERNATIVE) - ->setBoundary($content->getMime()->boundary());; - -$image = new MimePart(fopen($pathToImage, 'r')); -$image->type = 'image/jpeg'; -$image->filename = 'image-file-name.jpg'; -$image->disposition = Mime::DISPOSITION_ATTACHMENT; -$image->encoding = Mime::ENCODING_BASE64; - -$body = new MimeMessage(); -$body->setParts([$contentPart, $image]); - -$message = new Message(); -$message->setBody($body); - -$contentTypeHeader = $message->getHeaders()->get('Content-Type'); -$contentTypeHeader->setType(Mime::MULTIPART_RELATED); -``` +`multipart/alternative` part containing the text and html parts, followed by one or more parts for the attachments. See +the [Laminas Documentation](https://docs.laminas.dev/laminas-mail/message/attachments/#multipartalternative-emails-with-attachments) +for a full example. ### How to configure HttpClient with http_options and http_adapter