From 351da21e8d03ceb69174e2b6c9d318ac4a82d940 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Thu, 27 Aug 2026 15:29:25 -0300 Subject: [PATCH] Stop the text half of an email printing its link twice in brackets Laravel's notification view writes the subcopy URL as [$url]($url). The HTML half parses that into an anchor; the text half parses nothing, so it arrives as literal brackets around a duplicated address. With the button line above it the URL appeared three times in one message. It reads as broken, and it reads broken in a specific direction: a long opaque token, the recipient's address in the query string, and a duplicated link in brackets is the shape of a phishing template. On a password reset, which is often the first mail an installation ever sends somebody, from a domain with no reputation yet. Fixed the way every other component in that message already handles the same split -- one name, two files, Laravel picks per half. Which meant publishing the framework's view for a one-line change, so there is a note in it saying to re-copy on upgrade. Seen in a real reset mail, not in a test. --- CHANGELOG.md | 4 ++ .../vendor/mail/html/action-url.blade.php | 8 +++ .../vendor/mail/text/action-url.blade.php | 9 +++ .../vendor/notifications/email.blade.php | 72 +++++++++++++++++++ .../Notifications/MailActionUrlTest.php | 65 +++++++++++++++++ 5 files changed, 158 insertions(+) create mode 100644 resources/views/vendor/mail/html/action-url.blade.php create mode 100644 resources/views/vendor/mail/text/action-url.blade.php create mode 100644 resources/views/vendor/notifications/email.blade.php create mode 100644 tests/Feature/Notifications/MailActionUrlTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 273ff157..4af0c891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ Anything under **Upgrade notes** is something you have to do, not something we d This section collects changes as they land; the release process turns it into a numbered entry when a version is cut. +**Fixed** + +- The plain-text version of an email no longer shows the link twice, wrapped in brackets. + ## 2.2.0 — 27 August 2026 A big release. Most of it closes holes in who can see what. The rest is a handful of new things. diff --git a/resources/views/vendor/mail/html/action-url.blade.php b/resources/views/vendor/mail/html/action-url.blade.php new file mode 100644 index 00000000..6e9b43a9 --- /dev/null +++ b/resources/views/vendor/mail/html/action-url.blade.php @@ -0,0 +1,8 @@ +{{-- The action URL, spelled out for somebody who cannot click the button. + + Paired with text/action-url.blade.php. Laravel resolves `mail::` + components against html/ or text/ depending on which half of the + message it is building, which is the whole reason this is a component + rather than a line in the view: the two halves need different things + from the same URL, and only one of them understands markdown. --}} +[{{ $url }}]({{ $url }}) diff --git a/resources/views/vendor/mail/text/action-url.blade.php b/resources/views/vendor/mail/text/action-url.blade.php new file mode 100644 index 00000000..9eded107 --- /dev/null +++ b/resources/views/vendor/mail/text/action-url.blade.php @@ -0,0 +1,9 @@ +{{-- The plain-text half of html/action-url.blade.php. + + Just the URL. Laravel's own view writes `[$url]($url)` here, which is + correct for the HTML half and wrong for this one: nothing parses + markdown in a text/plain body, so it arrives as literal brackets with + the address duplicated inside them — the shape a phishing template + has. Seen in a real password reset, which is often the first mail an + installation ever sends somebody. --}} +{{ $url }} diff --git a/resources/views/vendor/notifications/email.blade.php b/resources/views/vendor/notifications/email.blade.php new file mode 100644 index 00000000..ea1237d2 --- /dev/null +++ b/resources/views/vendor/notifications/email.blade.php @@ -0,0 +1,72 @@ +{{-- Laravel's notification email view, published so the subcopy can spell + the action URL out differently in each half of the message. + + Upstream writes `[$url]($url)` there. That is right for the HTML half + and wrong for the text one, where nothing parses markdown: it arrives + as literal brackets around a duplicated address, which is what a + badly-built phishing mail looks like — on a password reset, often the + first mail an installation ever sends anybody. The x-mail::action-url + component resolves to a different file per half, which is how every + other component in this message already handles the same problem. + + This is a copy of a framework view, so it does not follow Laravel + forward on its own. If an upgrade changes the notification layout, + re-copy it and re-apply the one-line change below. --}} + +{{-- Greeting --}} +@if (! empty($greeting)) +# {{ $greeting }} +@else +@if ($level === 'error') +# @lang('Whoops!') +@else +# @lang('Hello!') +@endif +@endif + +{{-- Intro Lines --}} +@foreach ($introLines as $line) +{{ $line }} + +@endforeach + +{{-- Action Button --}} +@isset($actionText) + $level, + default => 'primary', + }; +?> + +{{ $actionText }} + +@endisset + +{{-- Outro Lines --}} +@foreach ($outroLines as $line) +{{ $line }} + +@endforeach + +{{-- Salutation --}} +@if (! empty($salutation)) +{{ $salutation }} +@else +@lang('Regards,')
+{{ config('app.name') }} +@endif + +{{-- Subcopy --}} +@isset($actionText) + +@lang( + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". + 'into your web browser:', + [ + 'actionText' => $actionText, + ] +) + +@endisset +
diff --git a/tests/Feature/Notifications/MailActionUrlTest.php b/tests/Feature/Notifications/MailActionUrlTest.php new file mode 100644 index 00000000..cf67fc18 --- /dev/null +++ b/tests/Feature/Notifications/MailActionUrlTest.php @@ -0,0 +1,65 @@ +create(); +}); + +/** The two halves Laravel builds for one markdown notification. */ +function renderResetMail(): array +{ + $user = User::factory()->create(); + $mail = (new ResetPasswordNotification(str_repeat('a', 64)))->toMail($user); + $mail->viewData['actionText'] = $mail->actionText; + + $markdown = app(Markdown::class); + $view = $mail->markdown ?: 'notifications::email'; + $data = array_merge($mail->toArray(), $mail->viewData); + + return [ + 'text' => (string) $markdown->renderText($view, $data), + 'html' => (string) $markdown->render($view, $data), + 'url' => $mail->actionUrl, + ]; +} + +it('spells the action URL out plainly in the text half', function () { + ['text' => $text, 'url' => $url] = renderResetMail(); + + expect($text)->toContain($url) + ->and($text)->not->toContain('](') + ->and($text)->not->toContain('['.$url); +}); + +it('still links the action URL in the html half', function () { + ['html' => $html, 'url' => $url] = renderResetMail(); + + // Twice: the button itself, and the subcopy that repeats it. + expect(substr_count($html, 'href="'.e($url).'"'))->toBe(2) + // The markdown must have been parsed, not passed through. + ->and($html)->not->toContain('['.e($url).']'); +}); + +it('does not repeat the URL more than the two places that need it', function () { + ['text' => $text, 'url' => $url] = renderResetMail(); + + // Once after the button label, once in the subcopy. A third meant the + // markdown link had been left in place. + expect(substr_count($text, $url))->toBe(2); +});