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