From 7cac280b830bece4352515ccb056586553e69110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Harnes?= Date: Mon, 4 May 2026 16:33:38 +0200 Subject: [PATCH] =?UTF-8?q?N=C2=B09574=20-=20Fix=20CKEditor=20CSS=20displa?= =?UTF-8?q?yed=20as=20part=20of=20the=20email=20message=20in=20Gmail=20(#8?= =?UTF-8?q?98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(email): generate plain text before inlining HTML CSS * N°9574 - Add unit tests * Apply suggestions from code review Co-authored-by: Molkobain --------- Co-authored-by: Molkobain --- sources/Core/Email/EmailSymfony.php | 7 +- .../sources/core/Email/EmailSymfonyTest.php | 163 ++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) 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!

', 'text/html'); + + $oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage'); + $oBody = $oSymfonyMessage->getBody(); + + // Root must be a RelatedPart when inline images are present + $this->assertInstanceOf(RelatedPart::class, $oBody, 'Root part must be multipart/related when inline images are present'); + + // The AlternativePart must be the first child of the RelatedPart + $aRelatedParts = $oBody->getParts(); + $this->assertInstanceOf(AlternativePart::class, $aRelatedParts[0], 'First child of RelatedPart must be the AlternativePart'); + + // Order and CSS checks are delegated to the shared helper, which now handles RelatedPart + [$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)'); + } + + public function provideSetBodyPlainTextDoesNotContainCss(): array + { + $sCustomStyles = 'p { color: blue; font-size: 14px; }'; + + return [ + '

Hello there!

', + null, + ], + '

Hello there!

', + $sCustomStyles, + ], + 'custom styles only, no