From ee502b9f76fe9442e32260c0c4997d0c78131d57 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 18 Aug 2025 14:54:08 +0200
Subject: [PATCH] feat(email): implement email change request and verification
process
- Added functionality for users to request an email change, including generating a verification code and setting an expiration time.
- Implemented methods in the User model to handle email change requests, code validation, and confirmation.
- Created a new job to update the user's email in Stripe after confirmation.
- Introduced rate limiting for email change requests and verification attempts to prevent abuse.
- Added a new notification for email change verification.
- Updated the profile component to manage email change requests and verification UI.
---
app/Jobs/CleanupInstanceStuffsJob.php | 13 ++
app/Jobs/UpdateStripeCustomerEmailJob.php | 133 ++++++++++++
app/Livewire/Profile/Index.php | 189 ++++++++++++++++++
app/Models/User.php | 74 +++++++
.../Channels/TransactionalEmailChannel.php | 7 +-
.../EmailChangeVerification.php | 43 ++++
bootstrap/helpers/subscriptions.php | 19 ++
config/constants.php | 4 +
...add_email_change_fields_to_users_table.php | 30 +++
.../views/components/forms/input.blade.php | 6 +-
.../email-change-verification.blade.php | 11 +
.../views/livewire/profile/index.blade.php | 43 +++-
12 files changed, 568 insertions(+), 4 deletions(-)
create mode 100644 app/Jobs/UpdateStripeCustomerEmailJob.php
create mode 100644 app/Notifications/TransactionalEmails/EmailChangeVerification.php
create mode 100644 database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php
create mode 100644 resources/views/emails/email-change-verification.blade.php
diff --git a/app/Jobs/CleanupInstanceStuffsJob.php b/app/Jobs/CleanupInstanceStuffsJob.php
index 60ae58489..011c58639 100644
--- a/app/Jobs/CleanupInstanceStuffsJob.php
+++ b/app/Jobs/CleanupInstanceStuffsJob.php
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Models\TeamInvitation;
+use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
@@ -30,6 +31,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
{
try {
$this->cleanupInvitationLink();
+ $this->cleanupExpiredEmailChangeRequests();
} catch (\Throwable $e) {
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
}
@@ -42,4 +44,15 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
$item->isValid();
}
}
+
+ private function cleanupExpiredEmailChangeRequests()
+ {
+ User::whereNotNull('email_change_code_expires_at')
+ ->where('email_change_code_expires_at', '<', now())
+ ->update([
+ 'pending_email' => null,
+ 'email_change_code' => null,
+ 'email_change_code_expires_at' => null,
+ ]);
+ }
}
diff --git a/app/Jobs/UpdateStripeCustomerEmailJob.php b/app/Jobs/UpdateStripeCustomerEmailJob.php
new file mode 100644
index 000000000..2e86c14a0
--- /dev/null
+++ b/app/Jobs/UpdateStripeCustomerEmailJob.php
@@ -0,0 +1,133 @@
+onQueue('high');
+ }
+
+ public function handle(): void
+ {
+ try {
+ if (! isCloud() || ! $this->team->subscription) {
+ Log::info('Skipping Stripe email update - not cloud or no subscription', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ ]);
+
+ return;
+ }
+
+ // Check if the user changing email is a team owner
+ $isOwner = $this->team->members()
+ ->wherePivot('role', 'owner')
+ ->where('users.id', $this->userId)
+ ->exists();
+
+ if (! $isOwner) {
+ Log::info('Skipping Stripe email update - user is not team owner', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ ]);
+
+ return;
+ }
+
+ // Get current Stripe customer email to verify it matches the user's old email
+ $stripe_customer_id = data_get($this->team, 'subscription.stripe_customer_id');
+ if (! $stripe_customer_id) {
+ Log::info('Skipping Stripe email update - no Stripe customer ID', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ ]);
+
+ return;
+ }
+
+ Stripe::setApiKey(config('subscription.stripe_api_key'));
+
+ try {
+ $stripeCustomer = \Stripe\Customer::retrieve($stripe_customer_id);
+ $currentStripeEmail = $stripeCustomer->email;
+
+ // Only update if the current Stripe email matches the user's old email
+ if (strtolower($currentStripeEmail) !== strtolower($this->oldEmail)) {
+ Log::info('Skipping Stripe email update - Stripe customer email does not match user old email', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ 'stripe_email' => $currentStripeEmail,
+ 'user_old_email' => $this->oldEmail,
+ ]);
+
+ return;
+ }
+
+ // Update Stripe customer email
+ \Stripe\Customer::update($stripe_customer_id, ['email' => $this->newEmail]);
+
+ } catch (\Exception $e) {
+ Log::error('Failed to retrieve or update Stripe customer', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ 'stripe_customer_id' => $stripe_customer_id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ throw $e;
+ }
+
+ Log::info('Successfully updated Stripe customer email', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ 'old_email' => $this->oldEmail,
+ 'new_email' => $this->newEmail,
+ ]);
+ } catch (\Exception $e) {
+ Log::error('Failed to update Stripe customer email', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ 'old_email' => $this->oldEmail,
+ 'new_email' => $this->newEmail,
+ 'error' => $e->getMessage(),
+ 'attempt' => $this->attempts(),
+ ]);
+
+ // Re-throw to trigger retry
+ throw $e;
+ }
+ }
+
+ public function failed(\Throwable $exception): void
+ {
+ Log::error('Permanently failed to update Stripe customer email after all retries', [
+ 'team_id' => $this->team->id,
+ 'user_id' => $this->userId,
+ 'old_email' => $this->oldEmail,
+ 'new_email' => $this->newEmail,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php
index 788802353..a6b4dbe9e 100644
--- a/app/Livewire/Profile/Index.php
+++ b/app/Livewire/Profile/Index.php
@@ -4,6 +4,7 @@ namespace App\Livewire\Profile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -23,11 +24,25 @@ class Index extends Component
#[Validate('required')]
public string $name;
+ public string $new_email = '';
+
+ public string $email_verification_code = '';
+
+ public bool $show_email_change = false;
+
+ public bool $show_verification = false;
+
public function mount()
{
$this->userId = Auth::id();
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
+
+ // Check if there's a pending email change
+ if (Auth::user()->hasEmailChangeRequest()) {
+ $this->new_email = Auth::user()->pending_email;
+ $this->show_verification = true;
+ }
}
public function submit()
@@ -46,6 +61,180 @@ class Index extends Component
}
}
+ public function requestEmailChange()
+ {
+ try {
+ // For self-hosted, check if email is enabled
+ if (! isCloud()) {
+ $settings = instanceSettings();
+ if (! $settings->smtp_enabled && ! $settings->resend_enabled) {
+ $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.');
+
+ return;
+ }
+ }
+
+ $this->validate([
+ 'new_email' => ['required', 'email', 'unique:users,email'],
+ ]);
+
+ // Skip rate limiting in development mode
+ if (! isDev()) {
+ // Rate limit by current user's email (1 request per 2 minutes)
+ $userEmailKey = 'email-change:user:'.Auth::id();
+ if (! RateLimiter::attempt($userEmailKey, 1, function () {}, 120)) {
+ $seconds = RateLimiter::availableIn($userEmailKey);
+ $this->dispatch('error', 'Too many requests. Please wait '.$seconds.' seconds before trying again.');
+
+ return;
+ }
+
+ // Rate limit by new email address (3 requests per hour per email)
+ $newEmailKey = 'email-change:email:'.md5(strtolower($this->new_email));
+ if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) {
+ $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.');
+
+ return;
+ }
+
+ // Additional rate limit by IP address (5 requests per hour)
+ $ipKey = 'email-change:ip:'.request()->ip();
+ if (! RateLimiter::attempt($ipKey, 5, function () {}, 3600)) {
+ $this->dispatch('error', 'Too many requests from your IP address. Please try again later.');
+
+ return;
+ }
+ }
+
+ Auth::user()->requestEmailChange($this->new_email);
+
+ $this->show_email_change = false;
+ $this->show_verification = true;
+
+ $this->dispatch('success', 'Verification code sent to '.$this->new_email);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function verifyEmailChange()
+ {
+ try {
+ $this->validate([
+ 'email_verification_code' => ['required', 'string', 'size:6'],
+ ]);
+
+ // Skip rate limiting in development mode
+ if (! isDev()) {
+ // Rate limit verification attempts (5 attempts per 10 minutes)
+ $verifyKey = 'email-verify:user:'.Auth::id();
+ if (! RateLimiter::attempt($verifyKey, 5, function () {}, 600)) {
+ $seconds = RateLimiter::availableIn($verifyKey);
+ $minutes = ceil($seconds / 60);
+ $this->dispatch('error', 'Too many verification attempts. Please wait '.$minutes.' minutes before trying again.');
+
+ // If too many failed attempts, clear the email change request for security
+ if (RateLimiter::attempts($verifyKey) >= 10) {
+ Auth::user()->clearEmailChangeRequest();
+ $this->new_email = '';
+ $this->email_verification_code = '';
+ $this->show_verification = false;
+ $this->dispatch('error', 'Email change request cancelled due to too many failed attempts. Please start over.');
+ }
+
+ return;
+ }
+ }
+
+ if (! Auth::user()->isEmailChangeCodeValid($this->email_verification_code)) {
+ $this->dispatch('error', 'Invalid or expired verification code.');
+
+ return;
+ }
+
+ if (Auth::user()->confirmEmailChange($this->email_verification_code)) {
+ // Clear rate limiters on successful verification (only in production)
+ if (! isDev()) {
+ $verifyKey = 'email-verify:user:'.Auth::id();
+ RateLimiter::clear($verifyKey);
+ }
+
+ $this->email = Auth::user()->email;
+ $this->new_email = '';
+ $this->email_verification_code = '';
+ $this->show_verification = false;
+
+ $this->dispatch('success', 'Email address updated successfully.');
+ } else {
+ $this->dispatch('error', 'Failed to update email address.');
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function resendVerificationCode()
+ {
+ try {
+ // Check if there's a pending request
+ if (! Auth::user()->hasEmailChangeRequest()) {
+ $this->dispatch('error', 'No pending email change request.');
+
+ return;
+ }
+
+ // Check if enough time has passed (at least half of the expiry time)
+ $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
+ $halfExpiryMinutes = $expiryMinutes / 2;
+ $codeExpiry = Auth::user()->email_change_code_expires_at;
+ $timeSinceCreated = $codeExpiry->subMinutes($expiryMinutes)->diffInMinutes(now());
+
+ if ($timeSinceCreated < $halfExpiryMinutes) {
+ $minutesToWait = ceil($halfExpiryMinutes - $timeSinceCreated);
+ $this->dispatch('error', 'Please wait '.$minutesToWait.' more minutes before requesting a new code.');
+
+ return;
+ }
+
+ $pendingEmail = Auth::user()->pending_email;
+
+ // Skip rate limiting in development mode
+ if (! isDev()) {
+ // Rate limit by email address
+ $newEmailKey = 'email-change:email:'.md5(strtolower($pendingEmail));
+ if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) {
+ $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.');
+
+ return;
+ }
+ }
+
+ // Generate and send new code
+ Auth::user()->requestEmailChange($pendingEmail);
+
+ $this->dispatch('success', 'New verification code sent to '.$pendingEmail);
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function cancelEmailChange()
+ {
+ Auth::user()->clearEmailChangeRequest();
+ $this->new_email = '';
+ $this->email_verification_code = '';
+ $this->show_email_change = false;
+ $this->show_verification = false;
+
+ $this->dispatch('success', 'Email change request cancelled.');
+ }
+
+ public function showEmailChangeForm()
+ {
+ $this->show_email_change = true;
+ $this->new_email = '';
+ }
+
public function resetPassword()
{
try {
diff --git a/app/Models/User.php b/app/Models/User.php
index 3c5a220f8..48651d292 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -53,6 +53,7 @@ class User extends Authenticatable implements SendsEmail
'email_verified_at' => 'datetime',
'force_password_reset' => 'boolean',
'show_boarding' => 'boolean',
+ 'email_change_code_expires_at' => 'datetime',
];
protected static function boot()
@@ -320,4 +321,77 @@ class User extends Authenticatable implements SendsEmail
return data_get($user, 'pivot.role');
}
+
+ public function requestEmailChange(string $newEmail): void
+ {
+ // Generate 6-digit code
+ $code = sprintf('%06d', mt_rand(0, 999999));
+
+ // Set expiration using config value
+ $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
+ $expiresAt = Carbon::now()->addMinutes($expiryMinutes);
+
+ $this->update([
+ 'pending_email' => $newEmail,
+ 'email_change_code' => $code,
+ 'email_change_code_expires_at' => $expiresAt,
+ ]);
+
+ // Send verification email to new address
+ $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt));
+ }
+
+ public function isEmailChangeCodeValid(string $code): bool
+ {
+ return $this->email_change_code === $code
+ && $this->email_change_code_expires_at
+ && Carbon::now()->lessThan($this->email_change_code_expires_at);
+ }
+
+ public function confirmEmailChange(string $code): bool
+ {
+ if (! $this->isEmailChangeCodeValid($code)) {
+ return false;
+ }
+
+ $oldEmail = $this->email;
+ $newEmail = $this->pending_email;
+
+ // Update email and clear change request fields
+ $this->update([
+ 'email' => $newEmail,
+ 'pending_email' => null,
+ 'email_change_code' => null,
+ 'email_change_code_expires_at' => null,
+ ]);
+
+ // For cloud users, dispatch job to update Stripe customer email asynchronously
+ if (isCloud() && $this->currentTeam()->subscription) {
+ dispatch(new \App\Jobs\UpdateStripeCustomerEmailJob(
+ $this->currentTeam(),
+ $this->id,
+ $newEmail,
+ $oldEmail
+ ));
+ }
+
+ return true;
+ }
+
+ public function clearEmailChangeRequest(): void
+ {
+ $this->update([
+ 'pending_email' => null,
+ 'email_change_code' => null,
+ 'email_change_code_expires_at' => null,
+ ]);
+ }
+
+ public function hasEmailChangeRequest(): bool
+ {
+ return ! is_null($this->pending_email)
+ && ! is_null($this->email_change_code)
+ && $this->email_change_code_expires_at
+ && Carbon::now()->lessThan($this->email_change_code_expires_at);
+ }
}
diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php
index 114d1f6ed..8ab74a60b 100644
--- a/app/Notifications/Channels/TransactionalEmailChannel.php
+++ b/app/Notifications/Channels/TransactionalEmailChannel.php
@@ -16,7 +16,12 @@ class TransactionalEmailChannel
if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) {
return;
}
- $email = $notifiable->email;
+
+ // Check if notification has a custom recipient (for email changes)
+ $email = property_exists($notification, 'newEmail') && $notification->newEmail
+ ? $notification->newEmail
+ : $notifiable->email;
+
if (! $email) {
return;
}
diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php
new file mode 100644
index 000000000..ea8462366
--- /dev/null
+++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php
@@ -0,0 +1,43 @@
+onQueue('high');
+ }
+
+ public function toMail(): MailMessage
+ {
+ // Use the configured expiry minutes value
+ $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10);
+
+ $mail = new MailMessage;
+ $mail->subject('Coolify: Verify Your New Email Address');
+ $mail->view('emails.email-change-verification', [
+ 'newEmail' => $this->newEmail,
+ 'verificationCode' => $this->verificationCode,
+ 'expiryMinutes' => $expiryMinutes,
+ ]);
+
+ return $mail;
+ }
+}
diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php
index 510516a2f..48c3a62c3 100644
--- a/bootstrap/helpers/subscriptions.php
+++ b/bootstrap/helpers/subscriptions.php
@@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts()
'livewire/update',
];
}
+
+function updateStripeCustomerEmail(Team $team, string $newEmail): void
+{
+ if (! isStripe()) {
+ return;
+ }
+
+ $stripe_customer_id = data_get($team, 'subscription.stripe_customer_id');
+ if (! $stripe_customer_id) {
+ return;
+ }
+
+ Stripe::setApiKey(config('subscription.stripe_api_key'));
+
+ \Stripe\Customer::update(
+ $stripe_customer_id,
+ ['email' => $newEmail]
+ );
+}
diff --git a/config/constants.php b/config/constants.php
index ecb2a85c5..bbd442654 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -70,6 +70,10 @@ return [
],
],
+ 'email_change' => [
+ 'verification_code_expiry_minutes' => 10,
+ ],
+
'sentry' => [
'sentry_dsn' => env('SENTRY_DSN'),
],
diff --git a/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php
new file mode 100644
index 000000000..9cefe2c09
--- /dev/null
+++ b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php
@@ -0,0 +1,30 @@
+string('pending_email')->nullable()->after('email');
+ $table->string('email_change_code', 6)->nullable()->after('pending_email');
+ $table->timestamp('email_change_code_expires_at')->nullable()->after('email_change_code');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn(['pending_email', 'email_change_code', 'email_change_code_expires_at']);
+ });
+ }
+};
diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php
index 799a5f110..858f5ac1c 100644
--- a/resources/views/components/forms/input.blade.php
+++ b/resources/views/components/forms/input.blade.php
@@ -32,7 +32,8 @@
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
- aria-placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif>
+ aria-placeholder="{{ $attributes->get('placeholder') }}"
+ @if ($autofocus) x-ref="autofocusInput" @endif>
@else
@@ -45,7 +46,8 @@
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}"
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}"
- placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif>
+ placeholder="{{ $attributes->get('placeholder') }}"
+ @if ($autofocus) x-ref="autofocusInput" @endif>
@endif
@if (!$label && $helper)