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