diff --git a/README.md b/README.md index ee33b5c..05e8e7d 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,13 @@ $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. 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 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 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": { 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)); + } +}