diff --git a/sources/Application/TwigBase/Twig/Extension.php b/sources/Application/TwigBase/Twig/Extension.php index 2c5ebeffa..18721f9af 100644 --- a/sources/Application/TwigBase/Twig/Extension.php +++ b/sources/Application/TwigBase/Twig/Extension.php @@ -153,8 +153,12 @@ class Extension return twig_array_filter($oTwigEnv, $array, $arrow); }, ['needs_environment' => true]); - // @since 3.3.0 N°8579 - // Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter + /** + * Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter + * Usage in twig: {% apply spaceless %}some html{% endapply %} + * + * @since 3.2.3 3.3.0 N°8579 + */ $aFilters[] = new TwigFilter('spaceless', function (?string $content) { return trim(preg_replace('/>\s+<', $content ?? '')); }, ['is_safe' => ['html']]); diff --git a/sources/Core/Email/EmailSymfony.php b/sources/Core/Email/EmailSymfony.php index e397345c4..fbb012034 100644 --- a/sources/Core/Email/EmailSymfony.php +++ b/sources/Core/Email/EmailSymfony.php @@ -29,7 +29,9 @@ use Symfony\Component\CssSelector\Exception\ParseException; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Transport; use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; 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; @@ -183,18 +185,7 @@ class EMailSymfony extends Email $sDsn = sprintf('smtp://%s%s@%s%s', $sDsnUser, $sDsnPassword, $sDsnPort, $sEncQuery); } - $oTransport = Transport::fromDsn($sDsn); - - // Handle peer verification - $oStream = $oTransport->getStream(); - $aOptions = $oStream->getStreamOptions(); - if (!$bVerifyPeer && array_key_exists('ssl', $aOptions)) { - // Disable verification - $aOptions['ssl']['verify_peer'] = false; - $aOptions['ssl']['verify_peer_name'] = false; - $aOptions['ssl']['allow_self_signed'] = true; - } - $oStream->setStreamOptions($aOptions); + $oTransport = $this->CreateSmtpTransport($sDsn, $bVerifyPeer); $oMailer = new Mailer($oTransport); break; @@ -260,6 +251,36 @@ class EMailSymfony extends Email } } + /** + * Build and configure an SMTP transport from a DSN string. + * + * Extracted from {@see SendSynchronous} to make SSL option handling independently testable. + * When $bVerifyPeer is false, the ssl stream context options must be written unconditionally: + * with STARTTLS the connection starts unencrypted, so the 'ssl' key is absent from the stream + * options at construction time and only used later when stream_socket_enable_crypto() is called. + * + * @param string $sDsn Full Symfony Mailer DSN (smtp:// or smtps://) + * @param bool $bVerifyPeer Whether to verify the peer SSL certificate + * + * @return \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport + */ + protected function CreateSmtpTransport(string $sDsn, bool $bVerifyPeer): EsmtpTransport + { + /** @var EsmtpTransport $oTransport */ + $oTransport = Transport::fromDsn($sDsn); + + $oStream = $oTransport->getStream(); + $aOptions = $oStream->getStreamOptions(); + if (!$bVerifyPeer) { + $aOptions['ssl']['verify_peer'] = false; + $aOptions['ssl']['verify_peer_name'] = false; + $aOptions['ssl']['allow_self_signed'] = true; + } + $oStream->setStreamOptions($aOptions); + + return $oTransport; + } + /** * Reprocess the body of the message (if it is an HTML message) * to replace the URL of images based on attachments by a link @@ -416,13 +437,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..2cefa1874 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,12 @@ 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