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); +});