diff --git a/src/MailPanel.body.latte b/src/MailPanel.body.latte index b075cb3..0bc62f0 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} +{* Render a standalone HTML preview document or fragment. *} +{$message|previewHtml|noescape} diff --git a/src/MailPanel.latte b/src/MailPanel.latte index e39d82f..68c10cd 100644 --- a/src/MailPanel.latte +++ b/src/MailPanel.latte @@ -108,8 +108,7 @@ - {capture $htmlPreview}{include MailPanel.body.latte, message => $message}{/capture} - + {/foreach} @@ -130,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 f652d08..a701f0b 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,25 @@ 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('previewHtml', function (MimePart $part): string { + $htmlBody = $this->extractHtmlBody($part); + if ($htmlBody !== '') { + return $htmlBody; } - return $part->getBody(); + $plainText = $this->findBodyByContentType($part, 'text/plain') ?? $this->decodeBody($part); + return '' + . '' + . '' + . '' . htmlspecialchars($plainText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; }); } @@ -158,6 +165,71 @@ 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'); + if (PHP_VERSION_ID < 80100) { + $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) { @@ -168,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)) { @@ -192,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; } @@ -208,6 +294,36 @@ 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) === 1) { + return $html; + } + + 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 new file mode 100644 index 0000000..f4a710d --- /dev/null +++ b/tests/MailPanelTest.phpt @@ -0,0 +1,177 @@ +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 testPanelStoresPreviewEndpointInDataAttribute(): 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-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()); + + $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 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( + 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();