capabilities->has(Capability::EmailTransportConfigure), 404); $provider = MailProviderSettings::current()->provider; if (! $provider->isOAuth()) { return back()->with('error', __('The selected mail provider does not use a connected mailbox.')); } $connection = MailOAuthConnection::for($provider); if (! $connection->configured()) { return back()->with('error', __('Enter and save the application (client) ID and secret first.')); } $state = Str::random(40); $request->session()->put(self::STATE, $state); $request->session()->put(self::PROVIDER, $provider->value); // Inertia::location(), not redirect()->away(): the button posts // through Inertia's XHR, and a plain 302 to another origin makes // the XHR follow it into a CORS wall — the consent screen never // appears and the page just reloads. The 409/X-Inertia-Location // handshake turns it into a real top-level navigation (and falls // back to an ordinary redirect for a non-Inertia request). return Inertia::location( $this->brokers->for($provider)->authorizeUrl($connection, $state, route('system-settings.email.oauth.callback')), ); } /** The provider sent the admin's browser back with a code (or a refusal). */ public function callback(Request $request): RedirectResponse { abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404); $expectedState = $request->session()->pull(self::STATE); $startedProvider = $request->session()->pull(self::PROVIDER); $provider = MailProviderSettings::current()->provider; // Nobody started this exchange from here — or the provider was // switched mid-flight, in which case the code belongs to a // configuration that no longer exists. if (! is_string($expectedState) || $startedProvider !== $provider->value || ! $provider->isOAuth()) { return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.')); } $state = $request->query('state'); if (! is_string($state) || ! hash_equals($expectedState, $state)) { return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.')); } // The admin clicked "Cancel" on the consent screen, or the // provider refused. Their description is safe to show — this is // an authenticated administrator on their own settings page. $error = $request->query('error'); if (is_string($error) && $error !== '') { $description = $request->query('error_description'); return redirect()->route('system-settings.email.edit') ->with('error', __('The mailbox was not connected: :reason', [ 'reason' => is_string($description) && $description !== '' ? $description : $error, ])); } $code = $request->query('code'); if (! is_string($code) || $code === '') { return redirect()->route('system-settings.email.edit')->with('error', __('That connection attempt could not be completed. Please try again.')); } $connection = MailOAuthConnection::for($provider); try { $this->brokers->for($provider)->exchange($connection, $code, route('system-settings.email.oauth.callback')); } catch (MailOAuthException $e) { return redirect()->route('system-settings.email.edit') ->with('error', __('The mailbox was not connected: :reason', ['reason' => $e->getMessage()])); } $this->activateConnection(); $this->activity->log(Action::SettingsUpdated, context: ['section' => 'email', 'action' => 'mailbox_connected']); return redirect()->route('system-settings.email.edit') ->with('success', __(':account connected. Outgoing email now sends as this mailbox.', [ 'account' => (string) $connection->account_email, ])); } /** * Drop the tokens; keep the app registration, so reconnecting is one * click through the consent screen rather than a form refill. */ public function disconnect(): RedirectResponse { abort_unless($this->capabilities->has(Capability::EmailTransportConfigure), 404); $provider = MailProviderSettings::current()->provider; if (! $provider->isOAuth()) { return back()->with('error', __('The selected mail provider does not use a connected mailbox.')); } $connection = MailOAuthConnection::for($provider); $connection->fill([ 'access_token' => null, 'refresh_token' => null, 'token_expires_at' => null, 'account_email' => null, ]); $connection->clearFailure(); $connection->save(); $this->activateConnection(); $this->activity->log(Action::SettingsUpdated, context: ['section' => 'email', 'action' => 'mailbox_disconnected']); return back()->with('success', __('Mailbox disconnected. Outgoing email is paused until one is connected again.')); } /** * The same three steps EmailSettingsController::update() ends with, * for the same reason: this request must already see the new * transport, and the long-running queue worker must not keep sending * (or failing) with the old one. */ private function activateConnection(): void { $this->mailConfig->flush(); $this->mailConfig->apply(); Artisan::call('queue:restart'); } }