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 @@
{/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();