Skip to content

[2.x] fix: make URLs in informational emails clickable - #5038

Open
karl-bullock wants to merge 1 commit into
flarum:2.xfrom
karl-bullock:fix-email-links-clickable
Open

[2.x] fix: make URLs in informational emails clickable#5038
karl-bullock wants to merge 1 commit into
flarum:2.xfrom
karl-bullock:fix-email-links-clickable

Conversation

@karl-bullock

Copy link
Copy Markdown
Member

Reported at https://discuss.flarum.org/d/39814-urls-in-emails-should-be-clickable.

The problem

The HTML part of every informational email (account activation, email confirmation, password reset, the admin test mail) comes from views/email/html/information/generic.blade.php, which printed the body with {{ }}:

<x-mail::html.information>
    {{ $infoContent ?? '' }}
</x-mail::html.information>

Two things follow. The address the reader is asked to visit arrives as text, so no mail client makes it a link and the reader has to select and copy it by hand. And the blank lines between the paragraphs vanish, because a newline is not a break in HTML, so the message renders as one run-on block.

That is what an activation email looks like on 2.x today, taken from a real forum with mail_driver=log:

<div class="main-content">
Someone (hopefully you!) has signed up to Flarum Forum with this email address.

If this was you, simply click the following link and your account will be activated:
http://localhost:8080/confirm/xQ8MfVQ32II0YKUqEMsbq8qwUzlO2Wq2fI0O5yYC

If you did not sign up, please ignore this email.
</div>

Why not {!! $formatter->convert(...) !!}

That is the obvious one-line fix, it is what the footer of the same component does, and it does linkify. It is not safe here.

An informational body is translated before it ever reaches a view: AccountActivationMailerTrait::sendConfirmationEmail() and the other three callers build it with the ordinary translator and hand SendInformationalEmailJob a finished string. Its parameters, the recipient's display name among them, are part of that string by the time the template sees it, and no SafeSubstitution markers are left for MailFormatter to put back. Passing it to convert() would therefore put those values in front of the parser, which is the thing MailTranslator was added to prevent: with flarum/markdown enabled, a display name of [Click here](https://example.com) picks its own link text and destination.

The fix

MailFormatter::plainToHtml(), for content that reaches a view as plain text. It escapes, keeps the line breaks, and wraps URLs in anchors. The visible text of every link it produces is the address that link points at, so nothing in a body can name its own link text, and nothing in it is parsed as markup.

Trailing sentence punctuation and a closing bracket the address never opened are left outside the link, so (https://example.com/page). links what a reader means by it.

Only the HTML view changes. The plain-text part already carried real newlines and a bare URL, which is right for plain text.

Tests

tests/integration/mail/InformationalEmailLinksTest renders the real blade through the real view factory, the way SendInformationalEmailJob does: the URL is a link, the paragraphs and line breaks survive, markup in the body is escaped rather than rendered, and the two punctuation cases. Putting {{ $infoContent }} back fails all five.

The full flarum/core integration suite and PHPStan are green locally.

The HTML part of an informational email (account activation, email
confirmation, password reset, the admin test mail) printed its body with
`{{ }}`, so the address the reader is asked to visit arrived as text that
no mail client turns into a link, and the blank lines between its
paragraphs collapsed, since a newline is not a break in HTML.

The body is translated before it reaches the view, so its parameters, the
recipient's display name among them, are already substituted into the
string and carry no `SafeSubstitution` markers. Rendering it with
`convert()` would put those values in front of the parser, which is what
`MailTranslator` exists to prevent, so add `MailFormatter::plainToHtml()`
instead: it escapes the content, keeps its line breaks, and links URLs
with the address itself as the link text.
@karl-bullock
karl-bullock requested a review from a team as a code owner September 8, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant