From 4cc19b64b36476d1e03ab2abf0829cc47d2f9ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Zral=C3=BD?= Date: Wed, 11 Feb 2026 12:10:55 +0100 Subject: [PATCH 1/4] fixed html preview rendering --- src/MailPanel.body.latte | 22 +----- src/MailPanel.latte | 3 +- src/MailPanel.php | 106 +++++++++++++++++++++++---- tests/MailPanelTest.phpt | 152 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 37 deletions(-) create mode 100644 tests/MailPanelTest.phpt diff --git a/src/MailPanel.body.latte b/src/MailPanel.body.latte index b075cb3..5e65711 100644 --- a/src/MailPanel.body.latte +++ b/src/MailPanel.body.latte @@ -1,22 +1,2 @@ {* Little magic here. Create iframe and then render message into it (needed because HTML messages) *} -{if $message->getHtmlBody() !== ''} - {$message->getHtmlBody()|noescape} -{else} - - - - {$message|plainText} -{/if} +{$message|previewHtml|noescape} diff --git a/src/MailPanel.latte b/src/MailPanel.latte index e39d82f..158fef0 100644 --- a/src/MailPanel.latte +++ b/src/MailPanel.latte @@ -108,8 +108,7 @@ - {capture $htmlPreview}{include MailPanel.body.latte, message => $message}{/capture} - + {/foreach} diff --git a/src/MailPanel.php b/src/MailPanel.php index f652d08..8f54e38 100644 --- a/src/MailPanel.php +++ b/src/MailPanel.php @@ -12,6 +12,7 @@ use Nette; use Nette\Http; use Nette\Mail\Mailer; +use Nette\Mail\Message; use Nette\Mail\MimePart; use Nette\Utils\Strings; use Tracy\Debugger; @@ -43,6 +44,9 @@ class MailPanel implements IBarPanel /** @var Latte\Engine|NULL */ private $latte; + /** @var \ReflectionProperty|NULL */ + private $mimePartPartsProperty; + public function __construct(?string $tempDir, Http\IRequest $request, Mailer $mailer, int $messagesLimit = self::DEFAULT_COUNT) { @@ -135,22 +139,33 @@ private function getLatte(): Latte\Engine }); $this->latte->addFilter('plainText', function (MimePart $part) { - $ref = new \ReflectionProperty('Nette\Mail\MimePart', 'parts'); - - $queue = [$part]; - for ($i = 0; $i < count($queue); $i++) { - /** @var MimePart $subPart */ - foreach ($ref->getValue($queue[$i]) as $subPart) { - $contentType = $subPart->getHeader('Content-Type'); - if (Strings::startsWith($contentType, 'text/plain') && $subPart->getHeader('Content-Transfer-Encoding') !== 'base64') { // Take first available plain text - return $subPart->getBody(); - } elseif (Strings::startsWith($contentType, 'multipart/alternative')) { - $queue[] = $subPart; - } - } + $plainText = $this->findBodyByContentType($part, 'text/plain'); + if ($plainText !== null) { + return $plainText; + } + + return $this->decodeBody($part); + }); + + $this->latte->addFilter('htmlBody', function (MimePart $part): string { + if ($part instanceof Message && $part->getHtmlBody() !== '') { + return $part->getHtmlBody(); } - return $part->getBody(); + return $this->findBodyByContentType($part, 'text/html') ?? ''; + }); + + $this->latte->addFilter('previewHtml', function (MimePart $part): string { + $htmlBody = $this->extractHtmlBody($part); + if ($htmlBody !== '') { + return $htmlBody; + } + + $plainText = $this->findBodyByContentType($part, 'text/plain') ?? $this->decodeBody($part); + return '' + . '' + . '' + . '' . htmlspecialchars($plainText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; }); } @@ -158,6 +173,69 @@ private function getLatte(): Latte\Engine } + private function extractHtmlBody(MimePart $part): string + { + if ($part instanceof Message && $part->getHtmlBody() !== '') { + return $part->getHtmlBody(); + } + + return $this->findBodyByContentType($part, 'text/html') ?? ''; + } + + + private function findBodyByContentType(MimePart $part, string $contentTypePrefix): ?string + { + $queue = [$part]; + for ($i = 0; $i < count($queue); $i++) { + $currentPart = $queue[$i]; + $contentType = $currentPart->getHeader('Content-Type'); + if (is_string($contentType) && Strings::startsWith(strtolower($contentType), $contentTypePrefix)) { + return $this->decodeBody($currentPart); + } + + /** @var MimePart $subPart */ + foreach ($this->getMimePartParts($currentPart) as $subPart) { + $queue[] = $subPart; + } + } + + return null; + } + + + /** + * @return MimePart[] + */ + private function getMimePartParts(MimePart $part): array + { + if ($this->mimePartPartsProperty === null) { + $this->mimePartPartsProperty = new \ReflectionProperty(MimePart::class, 'parts'); + $this->mimePartPartsProperty->setAccessible(true); + } + + $parts = $this->mimePartPartsProperty->getValue($part); + return is_array($parts) ? $parts : []; + } + + + private function decodeBody(MimePart $part): string + { + $body = $part->getBody(); + $transferEncoding = strtolower((string) $part->getHeader('Content-Transfer-Encoding')); + + if ($transferEncoding === MimePart::EncodingQuotedPrintable) { + return quoted_printable_decode($body); + } + + if ($transferEncoding === MimePart::EncodingBase64) { + $decodedBody = base64_decode($body, true); + return is_string($decodedBody) ? $decodedBody : $body; + } + + return $body; + } + + private function tryHandleRequest(): void { if (Debugger::$productionMode !== false) { diff --git a/tests/MailPanelTest.phpt b/tests/MailPanelTest.phpt new file mode 100644 index 0000000..452c096 --- /dev/null +++ b/tests/MailPanelTest.phpt @@ -0,0 +1,152 @@ +setContentType('text/html', 'UTF-8') + ->setBody('

Hello from HTML

'); + + $output = $this->renderMessageBody($message); + + Assert::contains('

Hello from HTML

', $output); + Assert::notContains('<h1>Hello from HTML</h1>', $output); + } + + + public function testPanelStoresEscapedHtmlPreviewInDataAttribute(): void + { + $message = (new Message()) + ->setSubject('Panel preview') + ->setHtmlBody('

Hello from HTML

'); + + $panel = $this->createPanel(new ArrayPersistentMailer(['message-id' => $message])); + $output = $panel->getPanel(); + + Assert::contains('data-content="<h1>Hello from HTML</h1>"', $output); + } + + + private function renderMessageBody(Message $message): string + { + $panel = $this->createPanel(new NullPersistentMailer()); + + $ref = new ReflectionMethod(MailPanel::class, 'getLatte'); + $ref->setAccessible(true); + $latte = $ref->invoke($panel); + + return $latte->renderToString(__DIR__ . '/../src/MailPanel.body.latte', ['message' => $message]); + } + + + private function createPanel(IPersistentMailer $mailer): MailPanel + { + return new MailPanel( + TEMP_DIR . '/latte', + new Request(new UrlScript('http://localhost/index.php')), + $mailer, + ); + } +} + + +class NullPersistentMailer implements IPersistentMailer +{ + public function send(Message $message): void + { + } + + + public function getMessageCount(): int + { + return 0; + } + + + public function getMessage(string $messageId): Message + { + throw new RuntimeException('No messages available.'); + } + + + public function getMessages(int $limit): array + { + return []; + } + + + public function deleteOne(string $messageId): void + { + } + + + public function deleteAll(): void + { + } +} + + +class ArrayPersistentMailer implements IPersistentMailer +{ + /** + * @param Message[] $messages + */ + public function __construct( + private array $messages, + ) { + } + + + public function send(Message $message): void + { + } + + + public function getMessageCount(): int + { + return count($this->messages); + } + + + public function getMessage(string $messageId): Message + { + return $this->messages[$messageId]; + } + + + public function getMessages(int $limit): array + { + return array_slice($this->messages, 0, $limit, true); + } + + + public function deleteOne(string $messageId): void + { + } + + + public function deleteAll(): void + { + } +} + +(new MailPanelTest)->run(); From 10f0f88ba02048ba59955ca80c618a583dd8b5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Zral=C3=BD?= Date: Thu, 12 Feb 2026 11:37:59 +0100 Subject: [PATCH 2/4] fixed php 8.5 reflection warning --- src/MailPanel.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/MailPanel.php b/src/MailPanel.php index 8f54e38..a6b4f1a 100644 --- a/src/MailPanel.php +++ b/src/MailPanel.php @@ -147,14 +147,6 @@ private function getLatte(): Latte\Engine return $this->decodeBody($part); }); - $this->latte->addFilter('htmlBody', function (MimePart $part): string { - if ($part instanceof Message && $part->getHtmlBody() !== '') { - return $part->getHtmlBody(); - } - - return $this->findBodyByContentType($part, 'text/html') ?? ''; - }); - $this->latte->addFilter('previewHtml', function (MimePart $part): string { $htmlBody = $this->extractHtmlBody($part); if ($htmlBody !== '') { @@ -210,7 +202,9 @@ private function getMimePartParts(MimePart $part): array { if ($this->mimePartPartsProperty === null) { $this->mimePartPartsProperty = new \ReflectionProperty(MimePart::class, 'parts'); - $this->mimePartPartsProperty->setAccessible(true); + if (PHP_VERSION_ID < 80100) { + $this->mimePartPartsProperty->setAccessible(true); + } } $parts = $this->mimePartPartsProperty->getValue($part); From 7575200b5f770704f72e8ad0f85f87be2624d721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Zral=C3=BD?= Date: Thu, 9 Apr 2026 14:50:24 +0200 Subject: [PATCH 3/4] fixed html preview loading --- src/MailPanel.body.latte | 2 +- src/MailPanel.latte | 16 ++++++--------- src/MailPanel.php | 44 ++++++++++++++++++++++++++++++++++++++-- tests/MailPanelTest.phpt | 5 ++--- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/MailPanel.body.latte b/src/MailPanel.body.latte index 5e65711..0bc62f0 100644 --- a/src/MailPanel.body.latte +++ b/src/MailPanel.body.latte @@ -1,2 +1,2 @@ -{* Little magic here. Create iframe and then render message into it (needed because HTML messages) *} +{* Render a standalone HTML preview document or fragment. *} {$message|previewHtml|noescape} diff --git a/src/MailPanel.latte b/src/MailPanel.latte index 158fef0..68c10cd 100644 --- a/src/MailPanel.latte +++ b/src/MailPanel.latte @@ -108,7 +108,7 @@ - + {/foreach} @@ -129,22 +129,18 @@ var iframe = document.createElement('iframe'); preview.appendChild(iframe); - iframe.contentWindow.document.write(preview.dataset.content); - iframe.contentWindow.document.close(); - delete preview.dataset.content; - - var baseTag = iframe.contentWindow.document.createElement('base'); - baseTag.target = '_parent'; - iframe.contentWindow.document.body.appendChild(baseTag); - var fixHeight = function (ev) { + var iframeDocument = iframe.contentWindow.document; + var iframeBody = iframeDocument.body || iframeDocument.documentElement; iframe.style.height = 0; - iframe.style.height = iframe.contentWindow.document.body.scrollHeight + 'px'; + iframe.style.height = iframeBody.scrollHeight + 'px'; iframe.contentWindow.removeEventListener(ev.type, fixHeight); }; iframe.contentWindow.addEventListener('load', fixHeight); iframe.contentWindow.addEventListener('resize', fixHeight); + iframe.src = preview.dataset.src; + delete preview.dataset.src; actions.removeEventListener('tracy-toggle', initHtmlPreview); actions.removeEventListener('click', initHtmlPreview); }; diff --git a/src/MailPanel.php b/src/MailPanel.php index a6b4f1a..f184eba 100644 --- a/src/MailPanel.php +++ b/src/MailPanel.php @@ -240,7 +240,10 @@ private function tryHandleRequest(): void $messageId = $this->request->getQuery('nextras-mail-panel-message-id'); $attachmentId = $this->request->getQuery('nextras-mail-panel-attachment-id'); - if ($action === 'detail' && is_string($messageId)) { + if ($action === 'preview' && is_string($messageId)) { + $this->handlePreview($messageId); + + } elseif ($action === 'detail' && is_string($messageId)) { $this->handleDetail($messageId); } elseif ($action === 'source' && is_string($messageId)) { @@ -264,7 +267,18 @@ private function handleDetail(string $messageId): void $message = $this->mailer->getMessage($messageId); header('Content-Type: text/html'); - $this->getLatte()->render(__DIR__ . '/MailPanel.body.latte', ['message' => $message]); + echo $this->renderMessagePreview($message); + exit; + } + + + private function handlePreview(string $messageId): void + { + assert($this->mailer !== null); + $message = $this->mailer->getMessage($messageId); + + header('Content-Type: text/html'); + echo $this->addParentBaseTarget($this->renderMessagePreview($message)); exit; } @@ -280,6 +294,32 @@ private function handleSource(string $messageId): void } + private function renderMessagePreview(MimePart $message): string + { + return $this->getLatte()->renderToString(__DIR__ . '/MailPanel.body.latte', ['message' => $message]); + } + + + private function addParentBaseTarget(string $html): string + { + $baseTag = ''; + + if (preg_match('~]*)?>~i', $html, $match, PREG_OFFSET_CAPTURE) === 1) { + $headTag = $match[0][0]; + $position = $match[0][1] + strlen($headTag); + return substr($html, 0, $position) . $baseTag . substr($html, $position); + } + + if (preg_match('~]*)?>~i', $html, $match, PREG_OFFSET_CAPTURE) === 1) { + $htmlTag = $match[0][0]; + $position = $match[0][1] + strlen($htmlTag); + return substr($html, 0, $position) . '' . $baseTag . '' . substr($html, $position); + } + + return '' . $baseTag . '' . $html . ''; + } + + private function handleAttachment(string $messageId, int $attachmentId): void { assert($this->mailer !== null); diff --git a/tests/MailPanelTest.phpt b/tests/MailPanelTest.phpt index 452c096..2ce31ee 100644 --- a/tests/MailPanelTest.phpt +++ b/tests/MailPanelTest.phpt @@ -33,7 +33,7 @@ class MailPanelTest extends TestCase } - public function testPanelStoresEscapedHtmlPreviewInDataAttribute(): void + public function testPanelStoresPreviewEndpointInDataAttribute(): void { $message = (new Message()) ->setSubject('Panel preview') @@ -42,10 +42,9 @@ class MailPanelTest extends TestCase $panel = $this->createPanel(new ArrayPersistentMailer(['message-id' => $message])); $output = $panel->getPanel(); - Assert::contains('data-content="<h1>Hello from HTML</h1>"', $output); + Assert::contains('data-src="index.php?nextras-mail-panel-action=preview&nextras-mail-panel-message-id=message-id"', $output); } - private function renderMessageBody(Message $message): string { $panel = $this->createPanel(new NullPersistentMailer()); From 2339ef6136ddb9f8351fa0689057cff9cb329ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Zral=C3=BD?= Date: Thu, 9 Apr 2026 16:29:50 +0200 Subject: [PATCH 4/4] fixed base tag handling --- src/MailPanel.php | 4 ++++ tests/MailPanelTest.phpt | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/MailPanel.php b/src/MailPanel.php index f184eba..a701f0b 100644 --- a/src/MailPanel.php +++ b/src/MailPanel.php @@ -304,6 +304,10 @@ private function addParentBaseTarget(string $html): string { $baseTag = ''; + if (preg_match('~]*)?>~i', $html) === 1) { + return $html; + } + if (preg_match('~]*)?>~i', $html, $match, PREG_OFFSET_CAPTURE) === 1) { $headTag = $match[0][0]; $position = $match[0][1] + strlen($headTag); diff --git a/tests/MailPanelTest.phpt b/tests/MailPanelTest.phpt index 2ce31ee..f4a710d 100644 --- a/tests/MailPanelTest.phpt +++ b/tests/MailPanelTest.phpt @@ -45,6 +45,19 @@ class MailPanelTest extends TestCase Assert::contains('data-src="index.php?nextras-mail-panel-action=preview&nextras-mail-panel-message-id=message-id"', $output); } + + public function testPreviewPreservesExistingBaseTag(): void + { + $message = (new Message()) + ->setSubject('Panel preview') + ->setHtmlBody(''); + + $output = $this->renderPreviewHtml($message); + + Assert::contains('', $output); + Assert::notContains('', $output); + } + private function renderMessageBody(Message $message): string { $panel = $this->createPanel(new NullPersistentMailer()); @@ -57,6 +70,19 @@ class MailPanelTest extends TestCase } + private function renderPreviewHtml(Message $message): string + { + $panel = $this->createPanel(new NullPersistentMailer()); + + $renderMessagePreview = new ReflectionMethod(MailPanel::class, 'renderMessagePreview'); + $renderMessagePreview->setAccessible(true); + $addParentBaseTarget = new ReflectionMethod(MailPanel::class, 'addParentBaseTarget'); + $addParentBaseTarget->setAccessible(true); + + return $addParentBaseTarget->invoke($panel, $renderMessagePreview->invoke($panel, $message)); + } + + private function createPanel(IPersistentMailer $mailer): MailPanel { return new MailPanel(