Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions framework/core/src/Mail/MailFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) => '<p>'.nl2br($this->linkUrls(trim($paragraph)), false).'</p>',
$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 .= '<a href="'.$this->escape($url).'">'.$this->escape($url).'</a>';
$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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\Tests\integration\mail;

use Flarum\Testing\integration\TestCase;
use Illuminate\Contracts\View\Factory;
use PHPUnit\Framework\Attributes\Test;

/**
* The HTML part of an informational email (account activation, email
* confirmation, password reset) is built from a body that was translated
* before it reached the view, so the template receives one plain string.
*
* It used to be printed with `{{ }}`, which meant the address a reader is
* asked to visit arrived as text no mail client would turn into a link, and
* the blank lines separating the paragraphs collapsed, since a newline is not
* a break in HTML.
*
* These tests render the real blade through the real view factory, the way
* SendInformationalEmailJob does.
*/
class InformationalEmailLinksTest extends TestCase
{
private const URL = 'https://example.com/confirm/aBcD1234';

private function render(string $infoContent): string
{
/** @var Factory $view */
$view = $this->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('#<div class="main-content">(.*?)</div>#s', $html);
preg_match('#<div class="main-content">(.*?)</div>#s', $html, $matches);

return trim($matches[1]);
}

#[Test]
public function url_in_an_informational_email_is_a_link(): void
{
$body = $this->body($this->render('Click the following link:'."\n".self::URL));

$this->assertStringContainsString('<a href="'.self::URL.'">'.self::URL.'</a>', $body);
}

#[Test]
public function line_breaks_in_an_informational_email_survive(): void
{
$body = $this->body($this->render("First paragraph.\n\nSecond line.\nThird line."));

// A blank line starts a paragraph, a single newline is a break.
$this->assertStringContainsString('<p>First paragraph.</p>', $body);
$this->assertMatchesRegularExpression('#Second line\.<br>\s*Third line\.#', $body);
}

#[Test]
public function markup_in_an_informational_email_is_not_rendered(): void
{
// The body carries values that were substituted by the translator
// before the view saw them, a display name among them, so nothing in it
// may be treated as markup.
$body = $this->body($this->render('Hello <b>[label](https://evil.example.com)</b> & welcome.'));

// The tags are shown, not applied.
$this->assertStringNotContainsString('<b>', $body);
$this->assertStringContainsString('&lt;b&gt;', $body);
$this->assertStringContainsString('&amp;', $body);

// The address is linked, but the markdown around it is not: the link
// text is the address itself, never the label someone chose for it.
$this->assertStringNotContainsString('>label</a>', $body);
$this->assertStringContainsString('[label](<a href="https://evil.example.com">https://evil.example.com</a>)', $body);
}

#[Test]
public function punctuation_after_a_url_is_left_out_of_the_link(): void
{
$body = $this->body($this->render('Visit '.self::URL.'.'));

$this->assertStringContainsString('<a href="'.self::URL.'">'.self::URL.'</a>.', $body);
}

#[Test]
public function a_bracket_the_url_did_not_open_is_left_out_of_the_link(): void
{
$body = $this->body($this->render('Visit ('.self::URL.') today'));

$this->assertStringContainsString('(<a href="'.self::URL.'">'.self::URL.'</a>)', $body);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<x-mail::html.information>
{{ $infoContent ?? '' }}
{!! $formatter->plainToHtml($infoContent ?? '') !!}
</x-mail::html.information>
Loading