From 9d839f7b5a73d25ec8ed0bbf79747108f46d01c0 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Sun, 13 Sep 2026 15:19:12 +0930
Subject: [PATCH] Fix missing email verification dispatch on settings email
change
---
.../Controllers/Settings/HomeSettings.php | 22 +++
resources/views/settings/email.blade.php | 7 +-
routes/web.php | 1 +
tests/Feature/Settings/EmailUpdateTest.php | 149 ++++++++++++++++++
4 files changed, 178 insertions(+), 1 deletion(-)
diff --git a/app/Http/Controllers/Settings/HomeSettings.php b/app/Http/Controllers/Settings/HomeSettings.php
index 487fcd6c0..530499e99 100644
--- a/app/Http/Controllers/Settings/HomeSettings.php
+++ b/app/Http/Controllers/Settings/HomeSettings.php
@@ -8,6 +8,7 @@ use App\Models\EmailVerification;
use App\Models\Media;
use App\Models\User;
use App\Services\AccountService;
+use App\Services\EmailVerificationService;
use App\Services\PronounService;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\PrettyNumber;
@@ -211,6 +212,10 @@ trait HomeSettings
$user->save();
$profile->save();
+ if ($validate && is_null($user->email_verified_at)) {
+ EmailVerificationService::send($user);
+ }
+
return redirect('/settings/email')->with('status', 'Email successfully updated!');
} else {
return redirect('/settings/email');
@@ -218,6 +223,23 @@ trait HomeSettings
}
+ public function emailVerificationResend(Request $request)
+ {
+ $user = $request->user();
+
+ if (! is_null($user->email_verified_at)) {
+ return redirect('/settings/email');
+ }
+
+ if (! EmailVerificationService::send($user)) {
+ return redirect('/settings/email')->withErrors([
+ 'email' => __('A verification email was sent a moment ago. Check your inbox, then try again in a minute.'),
+ ]);
+ }
+
+ return redirect('/settings/email')->with('status', __('Verification email sent to').' '.$user->email);
+ }
+
public function avatar()
{
return view('settings.avatar');
diff --git a/resources/views/settings/email.blade.php b/resources/views/settings/email.blade.php
index d748bc827..886cf167f 100644
--- a/resources/views/settings/email.blade.php
+++ b/resources/views/settings/email.blade.php
@@ -23,7 +23,7 @@
@if(Auth::user()->email_verified_at)
{{__('settings.email.verified')}} {{Auth::user()->email_verified_at->diffForHumans()}}
@else
- {{__('settings.email.unverified')}} {{__('settings.email.you_need_to')}} {{__('settings.email.verify_your_email')}}.
+ {{__('settings.email.unverified')}} {{__('settings.email.you_need_to')}} .
@endif
@@ -33,4 +33,9 @@
+@unless(Auth::user()->email_verified_at)
+
+@endunless
@endsection
diff --git a/routes/web.php b/routes/web.php
index 26a26aeb2..000558adc 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -296,6 +296,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['localization'])->grou
Route::post('password', [SettingsController::class, 'passwordUpdate'])->middleware('dangerzone');
Route::get('email', [SettingsController::class, 'email'])->name('settings.email')->middleware('dangerzone');
Route::post('email', [SettingsController::class, 'emailUpdate'])->middleware('dangerzone');
+ Route::post('email/resend', [SettingsController::class, 'emailVerificationResend'])->name('settings.email.resend')->middleware(['dangerzone', 'throttle:3,10']);
Route::get('notifications', [SettingsController::class, 'notifications'])->name('settings.notifications');
Route::get('privacy', [SettingsController::class, 'privacy'])->name('settings.privacy');
Route::post('privacy', [SettingsController::class, 'privacyStore']);
diff --git a/tests/Feature/Settings/EmailUpdateTest.php b/tests/Feature/Settings/EmailUpdateTest.php
index ac9e924f1..82c9e9c91 100644
--- a/tests/Feature/Settings/EmailUpdateTest.php
+++ b/tests/Feature/Settings/EmailUpdateTest.php
@@ -1,10 +1,23 @@
time()];
+}
+
/*
|--------------------------------------------------------------------------
| POST /settings/email
@@ -54,3 +67,139 @@ it('rejects an email already used by another account', function () {
expect($user->fresh()->email)->not->toBe('taken@example.com');
});
+
+/*
+|--------------------------------------------------------------------------
+| Email verification on change (enforce_email_verification=true)
+|--------------------------------------------------------------------------
+|
+| Changing email nulls email_verified_at for non-admins. The removed per-request
+| /i/verify-email gate used to let them resend verification; that route is gone,
+| so the change itself must now dispatch a verification email and the settings
+| page must expose an in-app resend path.
+|
+*/
+
+it('dispatches a verification email when a non-admin changes email and verification is enforced', function () {
+ config(['pixelfed.enforce_email_verification' => true]);
+ Mail::fake();
+
+ $user = User::factory()->create();
+ $user->refresh();
+
+ $newEmail = 'changed.'.uniqid().'@example.com';
+
+ $this->actingAs($user)
+ ->withSession(confirmedSession())
+ ->post('/settings/email', ['email' => $newEmail])
+ ->assertSessionHasNoErrors();
+
+ $fresh = $user->fresh();
+
+ expect($fresh->email)->toBe($newEmail)
+ ->and($fresh->email_verified_at)->toBeNull();
+
+ expect(
+ EmailVerification::where('user_id', $user->id)->where('email', $newEmail)->exists()
+ )->toBeTrue();
+
+ Mail::assertSent(ConfirmEmail::class);
+});
+
+it('auto-verifies an admin email change without dispatching verification mail', function () {
+ config(['pixelfed.enforce_email_verification' => true]);
+ Mail::fake();
+
+ $user = User::factory()->admin()->create();
+ $user->refresh();
+
+ $newEmail = 'admin.changed.'.uniqid().'@example.com';
+
+ $this->actingAs($user)
+ ->withSession(confirmedSession())
+ ->post('/settings/email', ['email' => $newEmail])
+ ->assertSessionHasNoErrors();
+
+ expect($user->fresh()->email_verified_at)->not->toBeNull();
+
+ Mail::assertNothingSent();
+});
+
+it('does not touch verification state when enforcement is disabled', function () {
+ config(['pixelfed.enforce_email_verification' => false]);
+ Mail::fake();
+
+ $user = User::factory()->create();
+ $user->refresh();
+
+ $newEmail = 'noenforce.'.uniqid().'@example.com';
+
+ $this->actingAs($user)
+ ->withSession(confirmedSession())
+ ->post('/settings/email', ['email' => $newEmail])
+ ->assertSessionHasNoErrors();
+
+ expect($user->fresh()->email_verified_at)->not->toBeNull();
+
+ Mail::assertNothingSent();
+});
+
+it('resends a verification email for an authenticated unverified user', function () {
+ Mail::fake();
+
+ $user = User::factory()->unverified()->create();
+ $user->refresh();
+
+ $this->actingAs($user)
+ ->withSession(confirmedSession())
+ ->withoutMiddleware(ThrottleRequests::class)
+ ->post(route('settings.email.resend'))
+ ->assertRedirect('/settings/email')
+ ->assertSessionHas('status');
+
+ expect(
+ EmailVerification::where('user_id', $user->id)->where('email', $user->email)->exists()
+ )->toBeTrue();
+
+ Mail::assertSent(ConfirmEmail::class);
+});
+
+it('does not resend verification for an already-verified user', function () {
+ Mail::fake();
+
+ $user = User::factory()->create();
+ $user->refresh();
+
+ $this->actingAs($user)
+ ->withSession(confirmedSession())
+ ->withoutMiddleware(ThrottleRequests::class)
+ ->post(route('settings.email.resend'))
+ ->assertRedirect('/settings/email');
+
+ Mail::assertNothingSent();
+});
+
+it('rejects a resend inside the service cooldown window with an error', function () {
+ Mail::fake();
+
+ $user = User::factory()->unverified()->create();
+ $user->refresh();
+
+ // Simulate a verification link that was just sent for the current address,
+ // putting the account inside the resend cooldown window.
+ $seed = new EmailVerification;
+ $seed->user_id = $user->id;
+ $seed->email = $user->email;
+ $seed->user_token = (string) Str::uuid().'seed';
+ $seed->random_token = Str::random(64);
+ $seed->save();
+
+ $this->actingAs($user)
+ ->withSession(confirmedSession())
+ ->withoutMiddleware(ThrottleRequests::class)
+ ->post(route('settings.email.resend'))
+ ->assertRedirect('/settings/email')
+ ->assertSessionHasErrors('email');
+
+ Mail::assertNothingSent();
+});