diff --git a/sources/Core/Email/EmailSymfony.php b/sources/Core/Email/EmailSymfony.php index e397345c4..1d29aec20 100644 --- a/sources/Core/Email/EmailSymfony.php +++ b/sources/Core/Email/EmailSymfony.php @@ -30,6 +30,7 @@ use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Transport; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Mime\Email as SymfonyEmail; +use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\Multipart\RelatedPart; use Symfony\Component\Mime\Part\Multipart\MixedPart; @@ -416,13 +417,13 @@ class EMailSymfony extends Email $this->m_aData['body'] = ['body' => $sBody, 'mimeType' => $sMimeType]; - $oTextPart = new TextPart(strip_tags($sBody), 'utf-8', 'plain', 'base64'); - // Embed inline images and store them in attachments (so BuildSymfonyMessageFromInternal can pick them) if ($sPrimaryMimeType === 'text/html') { $aAdditionalParts = $this->EmbedInlineImages($sBody); + $oTextPart = new TextPart((new DefaultHtmlToTextConverter())->convert($sBody, 'utf-8'), 'utf-8', 'plain', 'base64'); $oHtmlPart = new TextPart($sBody, 'utf-8', 'html', 'base64'); - $oAlternativePart = new AlternativePart($oHtmlPart, $oTextPart); + // It's important de order parts from least prefered to most prefered as per RFC 2046 {@see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4} + $oAlternativePart = new AlternativePart($oTextPart, $oHtmlPart); // Default root part is the HTML body $oRootPart = $oAlternativePart; diff --git a/tests/php-unit-tests/unitary-tests/sources/core/Email/EmailSymfonyTest.php b/tests/php-unit-tests/unitary-tests/sources/core/Email/EmailSymfonyTest.php index 40594eccb..1a289798d 100644 --- a/tests/php-unit-tests/unitary-tests/sources/core/Email/EmailSymfonyTest.php +++ b/tests/php-unit-tests/unitary-tests/sources/core/Email/EmailSymfonyTest.php @@ -1,6 +1,11 @@ assertSame($sExpectedBody, $sActualBody); } + + /** + * Returns the parts of the AlternativePart produced by SetBody() for an HTML email. + * + * Handles both the simple case (AlternativePart at root) and the inline-images case + * where the root is a RelatedPart whose first child is the AlternativePart. + * + * @return AbstractPart[] + */ + private function GetAlternativePartsFromHtmlEmail(EMailSymfony $oEmail): array + { + $oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage'); + $oBody = $oSymfonyMessage->getBody(); + + // With inline images the root is a RelatedPart; the AlternativePart is its first child. + if ($oBody instanceof RelatedPart) { + $oBody = $oBody->getParts()[0]; + } + + $this->assertInstanceOf(AlternativePart::class, $oBody, 'Body should be a multipart/alternative for HTML emails'); + + return $oBody->getParts(); + } + + /** + * RFC 2046 §5.1.4: parts in multipart/alternative must be ordered from least to most preferred. + * Email clients display the last part they support, so text/plain must come first and text/html last. + * + * @see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4 + * @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody() + * @since N°9574 + */ + public function testSetBodyAlternativePartOrderForHtmlEmailIsPlainThenHtml(): void + { + $oEmail = new EMailSymfony(); + $oEmail->SetBody('
Hello there!
', 'text/html'); + + [$oFirstPart, $oSecondPart] = $this->GetAlternativePartsFromHtmlEmail($oEmail); + + $this->assertSame('plain', $oFirstPart->getMediaSubtype(), 'First part must be text/plain (least preferred per RFC 2046)'); + $this->assertSame('html', $oSecondPart->getMediaSubtype(), 'Last part must be text/html (most preferred per RFC 2046)'); + } + + /** + * @dataProvider provideSetBodyPlainTextDoesNotContainCss + * + * @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody() + * @since N°9574 + */ + public function testSetBodyPlainTextDoesNotContainCss(string $sHtml, ?string $sCustomStyles): void + { + $oEmail = new EMailSymfony(); + $oEmail->SetBody($sHtml, 'text/html', $sCustomStyles); + + // We locate the plain text part by subtype to be order-agnostic and isolate this assertion from the order bug. + $aParts = $this->GetAlternativePartsFromHtmlEmail($oEmail); + $oPlainPart = null; + foreach ($aParts as $oPart) { + if ($oPart instanceof TextPart && $oPart->getMediaSubtype() === 'plain') { + $oPlainPart = $oPart; + break; + } + } + $this->assertNotNull($oPlainPart, 'No text/plain part found in the message'); + + $sPlainText = $oPlainPart->getBody(); + + $this->assertStringNotContainsString('Hello there!