diff --git a/framework/core/src/Mail/MailFormatter.php b/framework/core/src/Mail/MailFormatter.php index 09dbe99d5f..4b1c95bbd1 100644 --- a/framework/core/src/Mail/MailFormatter.php +++ b/framework/core/src/Mail/MailFormatter.php @@ -34,6 +34,76 @@ public function convert(?string $content): string return SafeSubstitution::restore($this->formatter->convert($content)); } + /** + * Turn plain text an email view was handed into HTML: escaped, with its + * line breaks kept and its URLs made clickable. + * + * Informational emails (account activation, email confirmation, password + * reset) do not build their body in a view. It arrives already translated, + * so its parameters, a display name among them, are part of the string by + * the time a template sees it, and no markers are left for + * {@see SafeSubstitution} to put back. Rendering such a body with + * `convert()` would therefore put those values in front of the parser, + * which is the thing the mail translator exists to prevent. The content is + * escaped instead, and only URLs are linked: the visible text of every link + * produced here is the address it points at. + */ + public function plainToHtml(?string $content): string + { + if (! $content) { + return ''; + } + + $paragraphs = array_filter( + preg_split('/\R{2,}/', trim($content)) ?: [], + fn (string $paragraph) => trim($paragraph) !== '' + ); + + return implode("\n", array_map( + fn (string $paragraph) => '
'.nl2br($this->linkUrls(trim($paragraph)), false).'
', + $paragraphs + )); + } + + /** + * Escape a run of plain text, wrapping every URL in it in an anchor. + * + * Escaping happens as the text is assembled rather than up front, so that + * an address containing `&` is escaped once (for the attribute and for the + * link text) instead of the entity being fed back through the matcher. + */ + private function linkUrls(string $text): string + { + $html = ''; + $offset = 0; + + preg_match_all('~\bhttps?://[^\s<>"]+~', $text, $matches, PREG_OFFSET_CAPTURE); + + foreach ($matches[0] as [$match, $position]) { + // Sentence punctuation after an address is not part of it. + $url = rtrim($match, '.,:;!?'); + + // Neither is a closing bracket that was never opened inside it, + // as in "(https://example.com/page)". + while (str_ends_with($url, ')') && substr_count($url, ')') > substr_count($url, '(')) { + $url = substr($url, 0, -1); + } + + $html .= $this->escape(substr($text, $offset, $position - $offset)); + $html .= ''.$this->escape($url).''; + $html .= $this->escape(substr($match, strlen($url))); + + $offset = $position + strlen($match); + } + + return $html.$this->escape(substr($text, $offset)); + } + + private function escape(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + } + /** * Anything else an email view might reach for goes to the real formatter. * diff --git a/framework/core/tests/integration/mail/InformationalEmailLinksTest.php b/framework/core/tests/integration/mail/InformationalEmailLinksTest.php new file mode 100644 index 0000000000..3b4560e0f8 --- /dev/null +++ b/framework/core/tests/integration/mail/InformationalEmailLinksTest.php @@ -0,0 +1,114 @@ +app()->getContainer()->make(Factory::class); + + // The data SendInformationalEmailJob shares before rendering. + $view->share([ + 'forumTitle' => 'Test Forum', + 'userEmail' => 'recipient@example.com', + 'title' => null, + 'username' => 'Recipient', + ]); + + return $view->make('mail::html.information.generic', compact('infoContent'))->render(); + } + + /** + * The `main-content` div is the body itself. The footer and the header hold + * links of their own, so matching the whole document would not tell us + * anything about the body. + */ + private function body(string $html): string + { + $this->assertMatchesRegularExpression('#First paragraph.
', $body); + $this->assertMatchesRegularExpression('#Second line\.