From 933eaa2ba4c23584ed57e838aa086e07bc99d2ff Mon Sep 17 00:00:00 2001 From: denkfabrik-li <274324701+denkfabrik-li@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:34:55 +0200 Subject: [PATCH] Send mail through Microsoft Graph as an admin-connected mailbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Microsoft 365 (OAuth)" to the Email settings provider dropdown. Selecting it swaps the SMTP form for an app registration (client id, secret, optional tenant) and a "Connect mailbox" flow: the admin signs into the mailbox the installation should send as, and outgoing email goes through Graph sendMail as that mailbox — no password, no app password, no SMTP AUTH, which Microsoft is winding down. Delegated flow on purpose: it needs no admin consent and works for work/school and personal accounts alike. Its one weakness — a grant can die silently behind a password reset or a Conditional Access change — is answered by a daily scheduled refresh that keeps the token alive and, on a dead grant, warns the settings admins once in-app and on the settings page instead of letting mail stop quietly. Tokens and the client secret live encrypted in their own row and are read fresh at send time, never through the boot-config cache. The stored SMTP transport survives a provider switch untouched. --- .../Http/Controllers/EmailOAuthController.php | 188 +++++++ .../Controllers/EmailSettingsController.php | 146 +++++- .../SchedulerMonitoringController.php | 1 + .../Console/RefreshMailOAuthTokensCommand.php | 91 ++++ app/Modules/Platform/Mail/MailOAuthBroker.php | 47 ++ .../Platform/Mail/MailOAuthBrokers.php | 27 + .../Platform/Mail/MailOAuthConnection.php | 88 ++++ .../Platform/Mail/MailOAuthException.php | 26 + .../Platform/Mail/MicrosoftGraphTransport.php | 85 ++++ .../Platform/Mail/MicrosoftMailBroker.php | 217 ++++++++ .../Platform/PlatformServiceProvider.php | 22 +- .../Platform/Settings/MailConfigApplier.php | 46 +- .../Platform/Settings/MailProvider.php | 57 ++- config/mail.php | 8 + ...00_create_mail_oauth_connections_table.php | 42 ++ resources/js/pages/system/settings/email.tsx | 273 +++++++--- routes/console.php | 1 + routes/settings.php | 13 + tests/Feature/Platform/MailOAuthTest.php | 473 ++++++++++++++++++ .../Platform/SchedulerMonitoringTest.php | 2 +- 20 files changed, 1749 insertions(+), 104 deletions(-) create mode 100644 app/Modules/Platform/Http/Controllers/EmailOAuthController.php create mode 100644 app/Modules/Platform/Mail/Console/RefreshMailOAuthTokensCommand.php create mode 100644 app/Modules/Platform/Mail/MailOAuthBroker.php create mode 100644 app/Modules/Platform/Mail/MailOAuthBrokers.php create mode 100644 app/Modules/Platform/Mail/MailOAuthConnection.php create mode 100644 app/Modules/Platform/Mail/MailOAuthException.php create mode 100644 app/Modules/Platform/Mail/MicrosoftGraphTransport.php create mode 100644 app/Modules/Platform/Mail/MicrosoftMailBroker.php create mode 100644 database/migrations/2026_08_23_090000_create_mail_oauth_connections_table.php create mode 100644 tests/Feature/Platform/MailOAuthTest.php diff --git a/app/Modules/Platform/Http/Controllers/EmailOAuthController.php b/app/Modules/Platform/Http/Controllers/EmailOAuthController.php new file mode 100644 index 00000000..218e9edf --- /dev/null +++ b/app/Modules/Platform/Http/Controllers/EmailOAuthController.php @@ -0,0 +1,188 @@ +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, + 'last_error' => null, + ])->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'); + } +} diff --git a/app/Modules/Platform/Http/Controllers/EmailSettingsController.php b/app/Modules/Platform/Http/Controllers/EmailSettingsController.php index 6a91cfd7..efa512aa 100644 --- a/app/Modules/Platform/Http/Controllers/EmailSettingsController.php +++ b/app/Modules/Platform/Http/Controllers/EmailSettingsController.php @@ -9,6 +9,7 @@ use App\Modules\Audit\Action; use App\Modules\Audit\ActivityLogger; use App\Modules\Platform\Capabilities\Capability; use App\Modules\Platform\Capabilities\CapabilityRegistry; +use App\Modules\Platform\Mail\MailOAuthConnection; use App\Modules\Platform\Notifications\TestEmailNotification; use App\Modules\Platform\Settings\MailConfigApplier; use App\Modules\Platform\Settings\MailProvider; @@ -66,7 +67,27 @@ class EmailSettingsController extends Controller 'label' => $provider->label(), 'host' => $provider->defaultHost(), 'port' => $provider->defaultPort(), + 'oauth' => $provider->isOAuth(), + 'needs_tenant' => $provider->needsTenant(), ], MailProvider::cases()), + // Keyed by provider so the form can switch providers without + // a round-trip; tokens and the secret never leave the server + // — only "is one stored" and the connection's health. + 'mail_oauth_connections' => collect(MailProvider::cases()) + ->filter(fn (MailProvider $provider): bool => $provider->isOAuth()) + ->mapWithKeys(function (MailProvider $provider): array { + $connection = MailOAuthConnection::for($provider); + + return [$provider->value => [ + 'client_id' => $connection->client_id ?? '', + 'has_client_secret' => $connection->client_secret !== null && $connection->client_secret !== '', + 'tenant_id' => $connection->tenant_id ?? '', + 'connected' => $connection->usable(), + 'account_email' => $connection->account_email, + 'last_refreshed_at' => $connection->last_refreshed_at?->toIso8601String(), + 'last_error' => $connection->last_error, + ]]; + }), 'test_result' => $request->session()->get('mail_test_result'), ]); } @@ -79,23 +100,43 @@ class EmailSettingsController extends Controller { $canConfigureTransport = $this->capabilities->has(Capability::EmailTransportConfigure); + // Peeked at before validation because it decides which rule set + // the rest of the transport fields get; an unknown value falls + // through to the SMTP rules, whose `provider` rule then rejects + // it with the proper validation error. + $requestedProvider = $canConfigureTransport + ? MailProvider::tryFrom((string) $request->input('provider')) + : null; + $wantsOAuth = $requestedProvider?->isOAuth() ?? false; + $rules = [ 'email_notifications_enabled' => ['required', 'boolean'], 'admin_notification_emails' => ['required', 'array', 'min:1'], 'admin_notification_emails.*' => ['email', 'max:255'], - 'from_address' => ['required', 'email', 'max:255'], + // With an OAuth provider the sender is the connected mailbox, + // not a form field — the form doesn't submit one. + 'from_address' => [$wantsOAuth ? 'nullable' : 'required', 'email', 'max:255'], 'from_name' => ['required', 'string', 'max:255'], ]; if ($canConfigureTransport) { - $rules += [ - 'provider' => ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))], - 'host' => ['required', 'string', 'max:255'], - 'port' => ['required', 'integer', 'between:1,65535'], - 'username' => ['nullable', 'string', 'max:255'], - 'password' => ['nullable', 'string', 'max:255'], - 'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])], - ]; + $rules['provider'] = ['required', Rule::in(array_map(fn (MailProvider $p): string => $p->value, MailProvider::cases()))]; + + if ($wantsOAuth) { + $rules += [ + 'client_id' => ['required', 'string', 'max:255'], + 'client_secret' => ['nullable', 'string', 'max:255'], + 'tenant_id' => ['nullable', 'string', 'max:255'], + ]; + } else { + $rules += [ + 'host' => ['required', 'string', 'max:255'], + 'port' => ['required', 'integer', 'between:1,65535'], + 'username' => ['nullable', 'string', 'max:255'], + 'password' => ['nullable', 'string', 'max:255'], + 'encryption' => ['required', Rule::in(['none', 'tls', 'ssl'])], + ]; + } } $validated = $request->validate($rules); @@ -109,23 +150,69 @@ class EmailSettingsController extends Controller // Transport fields are simply never read from the request when the // capability is absent — a hand-crafted PATCH can't smuggle a // custom relay into a cloud install through this endpoint either. - if ($canConfigureTransport) { - $mailProvider->fill([ - 'provider' => $validated['provider'], - 'host' => $validated['host'], - 'port' => $validated['port'], - 'username' => $validated['username'] ?? null, - 'encryption' => $validated['encryption'], - ]); + if ($canConfigureTransport && $requestedProvider !== null) { + $mailProvider->provider = $requestedProvider; - // A blank password keeps whatever is already stored — the field - // is never round-tripped to the browser (only `has_password` is). - if (is_string($validated['password'] ?? null) && $validated['password'] !== '') { - $mailProvider->password = $validated['password']; + if ($wantsOAuth) { + // The SMTP columns keep their values — switching to an + // OAuth provider and back must lose nothing. + $connection = MailOAuthConnection::for($requestedProvider); + + // A different app registration invalidates tokens minted + // by the old one (the next refresh would present the new + // client_id against them and die) — drop them now so the + // page honestly shows "not connected" instead of a + // connection that fails on first send. + $clientIdChanged = $connection->client_id !== null + && $connection->client_id !== '' + && $connection->client_id !== $validated['client_id']; + + $connection->client_id = $validated['client_id']; + $connection->tenant_id = ($validated['tenant_id'] ?? null) !== null && trim((string) $validated['tenant_id']) !== '' + ? trim((string) $validated['tenant_id']) + : null; + + // A blank secret keeps whatever is already stored, like + // the SMTP password below (only `has_client_secret` is + // ever round-tripped to the browser). + if (is_string($validated['client_secret'] ?? null) && $validated['client_secret'] !== '') { + $connection->client_secret = $validated['client_secret']; + } + + if ($clientIdChanged) { + $connection->fill([ + 'access_token' => null, + 'refresh_token' => null, + 'token_expires_at' => null, + 'account_email' => null, + 'last_error' => null, + ]); + } + + $connection->save(); + } else { + $mailProvider->fill([ + 'host' => $validated['host'], + 'port' => $validated['port'], + 'username' => $validated['username'] ?? null, + 'encryption' => $validated['encryption'], + ]); + + // A blank password keeps whatever is already stored — the field + // is never round-tripped to the browser (only `has_password` is). + if (is_string($validated['password'] ?? null) && $validated['password'] !== '') { + $mailProvider->password = $validated['password']; + } } } - $mailProvider->from_address = $validated['from_address']; + // Absent while an OAuth provider is selected (the connected + // mailbox is the sender) — the stored value survives for a later + // switch back to SMTP. + if (is_string($validated['from_address'] ?? null) && $validated['from_address'] !== '') { + $mailProvider->from_address = $validated['from_address']; + } + $mailProvider->from_name = $validated['from_name']; $mailProvider->save(); @@ -157,9 +244,18 @@ class EmailSettingsController extends Controller 'recipient' => ['required', 'email', 'max:255'], ]); - $host = config('mail.mailers.smtp.host'); - $port = config('mail.mailers.smtp.port'); - $hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : ''); + // What "via" means depends on the active transport: host:port + // only describes SMTP; an OAuth mailer is best named by its + // mailer key (e.g. "microsoft-graph"). + $mailer = config('mail.default'); + + if ($mailer === 'smtp') { + $host = config('mail.mailers.smtp.host'); + $port = config('mail.mailers.smtp.port'); + $hostPort = (is_string($host) ? $host : '').':'.(is_scalar($port) ? (string) $port : ''); + } else { + $hostPort = is_string($mailer) ? $mailer : ''; + } // Which of the two this is has to travel with the message rather // than be inferred from its text: the frontend colours the result, diff --git a/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php index 9eccf7de..d603092a 100644 --- a/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php +++ b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php @@ -61,6 +61,7 @@ class SchedulerMonitoringController extends Controller 'projectsend:purge-api-request-logs' => (string) __('Purge API request logs'), 'projectsend:purge-failed-jobs' => (string) __('Purge failed jobs'), 'projectsend:purge-notifications' => (string) __('Purge read notifications'), + 'projectsend:refresh-mail-oauth-tokens' => (string) __('Refresh mail OAuth tokens'), ]; } diff --git a/app/Modules/Platform/Mail/Console/RefreshMailOAuthTokensCommand.php b/app/Modules/Platform/Mail/Console/RefreshMailOAuthTokensCommand.php new file mode 100644 index 00000000..91976c5b --- /dev/null +++ b/app/Modules/Platform/Mail/Console/RefreshMailOAuthTokensCommand.php @@ -0,0 +1,91 @@ +get()->filter( + fn (MailOAuthConnection $connection): bool => $connection->usable(), + ); + + if ($connections->isEmpty()) { + $this->info('No connected OAuth mailboxes; nothing to refresh.'); + + return self::SUCCESS; + } + + foreach ($connections as $connection) { + $hadError = $connection->last_error !== null; + + try { + $brokers->for($connection->provider)->refresh($connection); + + $this->info("Refreshed {$connection->provider->value} ({$connection->account_email})."); + + // Back from the dead (an admin fixed things upstream + // without reconnecting): the applier may have been + // resolving "not ready" and must see the recovery. + if ($hadError) { + $mailConfig->flush(); + } + } catch (MailOAuthException $e) { + $this->error("Could not refresh {$connection->provider->value}: {$e->getMessage()}"); + + if (! $e->needsReconnect) { + continue; + } + + // Only on the transition into the broken state — the + // notification would otherwise repeat daily for as long + // as nobody reconnects, and a nagging alert trains + // people to ignore the one that matters. + if (! $hadError) { + $recipients = array_values(User::query()->where('type', UserType::Staff)->get() + ->filter(fn (User $staff): bool => $permissions->allows($staff, Permission::EditSettings)) + ->all()); + + $notifier->send('mail_oauth_connection_broken', $recipients, data: [ + 'provider' => $connection->provider->label(), + 'account' => (string) $connection->account_email, + ]); + } + + $mailConfig->flush(); + } + } + + return self::SUCCESS; + } +} diff --git a/app/Modules/Platform/Mail/MailOAuthBroker.php b/app/Modules/Platform/Mail/MailOAuthBroker.php new file mode 100644 index 00000000..3958d2bc --- /dev/null +++ b/app/Modules/Platform/Mail/MailOAuthBroker.php @@ -0,0 +1,47 @@ + app(MicrosoftMailBroker::class), + default => throw new InvalidArgumentException("{$provider->value} is not an OAuth mail provider."), + }; + } +} diff --git a/app/Modules/Platform/Mail/MailOAuthConnection.php b/app/Modules/Platform/Mail/MailOAuthConnection.php new file mode 100644 index 00000000..ba7ddb3c --- /dev/null +++ b/app/Modules/Platform/Mail/MailOAuthConnection.php @@ -0,0 +1,88 @@ + MailProvider::class, + 'client_secret' => 'encrypted', + 'access_token' => 'encrypted', + 'refresh_token' => 'encrypted', + 'token_expires_at' => 'datetime', + 'last_refreshed_at' => 'datetime', + ]; + } + + public static function for(MailProvider $provider): self + { + return static::query()->firstOrNew(['provider' => $provider->value]); + } + + /** + * Whether the connect flow can be started: the admin has entered the + * app registration, even if no mailbox is connected yet. + */ + public function configured(): bool + { + return $this->filled('client_id') && $this->filled('client_secret'); + } + + /** + * Whether transports can send through this connection. A half-torn + * state (configured but never connected, or tokens cleared by a + * disconnect) behaves as "not usable" rather than failing inside a + * queued job — the same rule SocialSettings::usable() follows. + */ + public function usable(): bool + { + return $this->configured() && $this->filled('refresh_token'); + } + + private function filled(string $attribute): bool + { + $value = $this->getAttribute($attribute); + + return is_string($value) && trim($value) !== ''; + } +} diff --git a/app/Modules/Platform/Mail/MailOAuthException.php b/app/Modules/Platform/Mail/MailOAuthException.php new file mode 100644 index 00000000..2e67fc6b --- /dev/null +++ b/app/Modules/Platform/Mail/MailOAuthException.php @@ -0,0 +1,26 @@ +usable()) { + throw new TransportException('Microsoft 365 is selected as the mail provider, but no mailbox is connected.'); + } + + try { + $token = $this->broker->freshAccessToken($connection); + } catch (MailOAuthException $e) { + throw new TransportException('Could not get a Microsoft 365 access token: '.$e->getMessage(), 0, $e); + } + + $response = Http::withToken($token) + ->withBody(base64_encode($message->toString()), 'text/plain') + ->post('https://graph.microsoft.com/v1.0/me/sendMail'); + + // Graph acknowledges an accepted submission with 202 and an empty + // body; anything else is a refusal worth the admin's attention + // (SendAsDenied when the From header isn't the connected mailbox, + // ErrorMessageSubmissionBlocked, throttling). + if ($response->status() !== 202) { + $code = $response->json('error.code'); + $detail = $response->json('error.message'); + + // The code carries the diagnosis ("ErrorQuotaExceeded", + // "ErrorSendAsDenied"); Graph's message text is often generic + // to the point of useless, so both go into the exception. + throw new TransportException( + 'Microsoft Graph refused the message (HTTP '.$response->status() + .(is_string($code) && $code !== '' ? ', '.$code : '').')' + .(is_string($detail) && $detail !== '' ? ': '.$detail : '.'), + ); + } + } + + public function __toString(): string + { + return 'microsoft-graph'; + } +} diff --git a/app/Modules/Platform/Mail/MicrosoftMailBroker.php b/app/Modules/Platform/Mail/MicrosoftMailBroker.php new file mode 100644 index 00000000..fad5488e --- /dev/null +++ b/app/Modules/Platform/Mail/MicrosoftMailBroker.php @@ -0,0 +1,217 @@ +tenant($connection).'/oauth2/v2.0/authorize?'.http_build_query([ + 'client_id' => (string) $connection->client_id, + 'response_type' => 'code', + 'redirect_uri' => $redirectUri, + 'response_mode' => 'query', + 'scope' => self::SCOPE, + 'state' => $state, + 'prompt' => 'select_account', + ]); + } + + public function exchange(MailOAuthConnection $connection, string $code, string $redirectUri): void + { + $response = Http::asForm()->post($this->tokenEndpoint($connection), [ + 'client_id' => (string) $connection->client_id, + 'client_secret' => (string) $connection->client_secret, + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'scope' => self::SCOPE, + ]); + + if ($response->failed()) { + throw $this->failure($response); + } + + $this->storeTokens($connection, $response); + } + + public function refresh(MailOAuthConnection $connection): void + { + $refreshToken = $connection->refresh_token; + + if (! is_string($refreshToken) || $refreshToken === '') { + throw new MailOAuthException('No mailbox is connected.', needsReconnect: true); + } + + $response = Http::asForm()->post($this->tokenEndpoint($connection), [ + 'client_id' => (string) $connection->client_id, + 'client_secret' => (string) $connection->client_secret, + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'scope' => self::SCOPE, + ]); + + if ($response->failed()) { + $failure = $this->failure($response); + + // Only a dead grant is worth alarming the admin over; a + // transient endpoint problem heals on the next attempt and + // must not paint the settings page red in the meantime. + if ($failure->needsReconnect) { + $connection->last_error = $failure->getMessage(); + $connection->save(); + } + + throw $failure; + } + + $this->storeTokens($connection, $response); + } + + public function freshAccessToken(MailOAuthConnection $connection): string + { + $token = $connection->access_token; + $expiresAt = $connection->token_expires_at; + + if (is_string($token) && $token !== '' && $expiresAt !== null && $expiresAt->gt(now()->addSeconds(self::EXPIRY_MARGIN_SECONDS))) { + return $token; + } + + $this->refresh($connection); + + return (string) $connection->access_token; + } + + private function tenant(MailOAuthConnection $connection): string + { + $tenant = $connection->tenant_id; + + return is_string($tenant) && trim($tenant) !== '' ? trim($tenant) : 'common'; + } + + private function tokenEndpoint(MailOAuthConnection $connection): string + { + return 'https://login.microsoftonline.com/'.$this->tenant($connection).'/oauth2/v2.0/token'; + } + + private function storeTokens(MailOAuthConnection $connection, Response $response): void + { + $accessToken = $response->json('access_token'); + $expiresIn = $response->json('expires_in'); + + if (! is_string($accessToken) || $accessToken === '') { + throw new MailOAuthException('The token response did not include an access token.'); + } + + $connection->access_token = $accessToken; + $connection->token_expires_at = now()->addSeconds(is_numeric($expiresIn) ? (int) $expiresIn : 3600); + + // Microsoft rotates the refresh token on every use; a response + // without one (some resource-scoped edge cases) keeps the old. + $newRefreshToken = $response->json('refresh_token'); + if (is_string($newRefreshToken) && $newRefreshToken !== '') { + $connection->refresh_token = $newRefreshToken; + } + + $email = $this->emailFromIdToken($response->json('id_token')); + if ($email !== null) { + $connection->account_email = $email; + } + + $connection->last_refreshed_at = now(); + $connection->last_error = null; + $connection->save(); + } + + /** + * The signed-in mailbox's address, read from the id_token's claims. + * + * Deliberately without signature verification: this token arrived in + * the token endpoint's own TLS response — not from the browser — and + * feeds a display/From value, not an authentication decision. That + * is the trade that lets sending work with Mail.Send alone. + */ + private function emailFromIdToken(mixed $idToken): ?string + { + if (! is_string($idToken) || substr_count($idToken, '.') !== 2) { + return null; + } + + [, $payload] = explode('.', $idToken); + + $decoded = base64_decode(strtr($payload, '-_', '+/'), true); + + if ($decoded === false) { + return null; + } + + $claims = json_decode($decoded, true); + + if (! is_array($claims)) { + return null; + } + + foreach (['preferred_username', 'email'] as $claim) { + $value = $claims[$claim] ?? null; + + if (is_string($value) && str_contains($value, '@')) { + return $value; + } + } + + return null; + } + + private function failure(Response $response): MailOAuthException + { + $error = $response->json('error'); + $description = $response->json('error_description'); + + $message = is_string($description) && $description !== '' + ? $description + : (is_string($error) && $error !== '' ? $error : 'The Microsoft token endpoint answered HTTP '.$response->status().'.'); + + // invalid_grant covers everything that kills a delegated grant: + // revoked consent, password reset, Conditional Access changes, an + // expired refresh token. invalid_client means the app + // registration itself (secret expired?) — also unfixable by retry. + $needsReconnect = in_array($error, ['invalid_grant', 'invalid_client'], true); + + return new MailOAuthException($message, needsReconnect: $needsReconnect); + } +} diff --git a/app/Modules/Platform/PlatformServiceProvider.php b/app/Modules/Platform/PlatformServiceProvider.php index 5681e186..33645b2b 100644 --- a/app/Modules/Platform/PlatformServiceProvider.php +++ b/app/Modules/Platform/PlatformServiceProvider.php @@ -13,8 +13,11 @@ use App\Modules\Platform\Captcha\Console\DisableCaptchaCommand; use App\Modules\Platform\Captcha\Console\TestCaptchaCommand; use App\Modules\Platform\Localization\LocaleRegistry; use App\Modules\Platform\Localization\TimezoneRegistry; +use App\Modules\Platform\Mail\Console\RefreshMailOAuthTokensCommand; +use App\Modules\Platform\Mail\MicrosoftGraphTransport; use App\Modules\Platform\News\Console\FetchNewsCommand; use App\Modules\Platform\Notifications\ThemedMailChannel; +use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand; use App\Modules\Platform\Scheduling\RecordsScheduledTaskRuns; use App\Modules\Platform\Settings\ExternalStorageConfigApplier; use App\Modules\Platform\Settings\MailConfigApplier; @@ -22,13 +25,13 @@ use App\Modules\Platform\Settings\Settings; use App\Modules\Platform\Theming\Console\GenerateThemePreviewDataCommand; use App\Modules\Platform\Theming\EmailThemeRegistry; use App\Modules\Platform\Theming\PublicThemeRegistry; -use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand; use App\Modules\Platform\Updates\Console\CheckForUpdatesCommand; use App\Modules\Platform\Updates\Console\UpdateCommand; use Illuminate\Console\Events\ScheduledTaskFailed; use Illuminate\Console\Events\ScheduledTaskFinished; use Illuminate\Notifications\Channels\MailChannel; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\ServiceProvider; class PlatformServiceProvider extends ServiceProvider @@ -78,12 +81,18 @@ class PlatformServiceProvider extends ServiceProvider DisableCaptchaCommand::class, TestCaptchaCommand::class, PurgeFailedJobsCommand::class, + RefreshMailOAuthTokensCommand::class, ]); } } public function boot(): void { + // Registered before apply() below can select it as the default + // mailer. The closure resolves lazily on first send, so booting + // never pays for a transport nobody uses. + Mail::extend('microsoft-graph', fn (): MicrosoftGraphTransport => $this->app->make(MicrosoftGraphTransport::class)); + // Every process boot (a web request, or a freshly (re)started // queue worker) picks up the admin-configured mail provider, if // any — a no-op until the Email settings page is actually saved. @@ -117,6 +126,17 @@ class PlatformServiceProvider extends ServiceProvider url: fn (array $data) => route('dashboard'), )); + // In-app only, like update_available — deliberately not mail: + // this fires precisely when outgoing mail is broken, so an email + // companion would either vanish into the dead transport or (on + // the scheduled check) fail the very job reporting the problem. + $this->app->make(NotificationTypeRegistry::class)->register(new NotificationTypeDefinition( + key: 'mail_oauth_connection_broken', + label: 'The connected mailbox can no longer send email', + template: 'The :provider mailbox connection (:account) stopped working and needs to be reconnected', + url: fn (array $data) => route('system-settings.email.edit'), + )); + // Core's free themes — available in every edition, gated by // nothing. A genuinely edition-exclusive theme would instead // register into these same singletons from a private package's diff --git a/app/Modules/Platform/Settings/MailConfigApplier.php b/app/Modules/Platform/Settings/MailConfigApplier.php index 89fc9783..2b2f8cef 100644 --- a/app/Modules/Platform/Settings/MailConfigApplier.php +++ b/app/Modules/Platform/Settings/MailConfigApplier.php @@ -6,6 +6,7 @@ namespace App\Modules\Platform\Settings; use App\Modules\Platform\Capabilities\Capability; use App\Modules\Platform\Capabilities\CapabilityRegistry; +use App\Modules\Platform\Mail\MailOAuthConnection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Schema; @@ -36,7 +37,12 @@ class MailConfigApplier // rememberForever value under the old key would otherwise crash every // boot with "Undefined array key" (PlatformServiceProvider::boot() // calls apply() unconditionally). Bump again on any future shape change. - private const CACHE_KEY = 'platform.mail_provider_settings.v2'; + // v3: OAuth provider fields added. Note what is deliberately NOT in + // the cached shape: tokens. Transports read those fresh from the + // connection row at send time — only readiness and the connected + // address are cheap enough to be worth caching, and neither is a + // credential. + private const CACHE_KEY = 'platform.mail_provider_settings.v3'; public function __construct( private readonly CapabilityRegistry $capabilities, @@ -46,7 +52,17 @@ class MailConfigApplier { $resolved = $this->resolve(); - if ($resolved['transport_configured'] && $this->capabilities->has(Capability::EmailTransportConfigure)) { + if ($resolved['oauth_mailer'] !== null && $resolved['oauth_ready'] && $this->capabilities->has(Capability::EmailTransportConfigure)) { + Config::set('mail.default', $resolved['oauth_mailer']); + + // Delegated Graph/Gmail can only send as the mailbox that + // consented, so the From address is pinned to it — a stored + // from_address from an earlier SMTP setup must not survive + // into a mode where the vendor would reject it (SendAsDenied). + if ($resolved['oauth_account'] !== null) { + Config::set('mail.from.address', $resolved['oauth_account']); + } + } elseif ($resolved['transport_configured'] && $this->capabilities->has(Capability::EmailTransportConfigure)) { Config::set('mail.default', 'smtp'); Config::set('mail.mailers.smtp.host', $resolved['host']); Config::set('mail.mailers.smtp.port', $resolved['port']); @@ -55,7 +71,7 @@ class MailConfigApplier Config::set('mail.mailers.smtp.encryption', $resolved['encryption']); } - if ($resolved['from_address'] !== null) { + if ($resolved['from_address'] !== null && ($resolved['oauth_mailer'] === null || ! $resolved['oauth_ready'])) { Config::set('mail.from.address', $resolved['from_address']); } @@ -70,7 +86,7 @@ class MailConfigApplier } /** - * @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, password: string|null, encryption: string|null, from_address: string|null, from_name: string|null} + * @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, password: string|null, encryption: string|null, from_address: string|null, from_name: string|null, oauth_mailer: string|null, oauth_ready: bool, oauth_account: string|null} */ private function resolve(): array { @@ -78,6 +94,7 @@ class MailConfigApplier 'transport_configured' => false, 'host' => null, 'port' => null, 'username' => null, 'password' => null, 'encryption' => null, 'from_address' => null, 'from_name' => null, + 'oauth_mailer' => null, 'oauth_ready' => false, 'oauth_account' => null, ]; // Through BootSettingsCache, not Cache directly: this runs on every @@ -92,8 +109,24 @@ class MailConfigApplier $settings = MailProviderSettings::current(); $hasHost = $settings->host !== null && $settings->host !== ''; + $oauthMailer = null; + $oauthReady = false; + $oauthAccount = null; + + // The table guard covers an install mid-upgrade, where this + // migration has not run yet but the settings row already + // names an OAuth provider (it can't, but a guard beats a + // boot-killing query on the ordering assumption). + if ($settings->provider->isOAuth() && Schema::hasTable('mail_oauth_connections')) { + $connection = MailOAuthConnection::for($settings->provider); + + $oauthMailer = $settings->provider->oauthMailer(); + $oauthReady = $connection->usable(); + $oauthAccount = $connection->account_email; + } + return [ - 'transport_configured' => $hasHost, + 'transport_configured' => $hasHost && ! $settings->provider->isOAuth(), 'host' => $settings->host, 'port' => $settings->port, 'username' => $settings->username, @@ -101,6 +134,9 @@ class MailConfigApplier 'encryption' => $settings->encryption === 'none' ? null : $settings->encryption, 'from_address' => $settings->from_address, 'from_name' => $settings->from_name, + 'oauth_mailer' => $oauthMailer, + 'oauth_ready' => $oauthReady, + 'oauth_account' => $oauthAccount, ]; }, $blank); } diff --git a/app/Modules/Platform/Settings/MailProvider.php b/app/Modules/Platform/Settings/MailProvider.php index c2fc8bd1..2082475c 100644 --- a/app/Modules/Platform/Settings/MailProvider.php +++ b/app/Modules/Platform/Settings/MailProvider.php @@ -5,10 +5,17 @@ declare(strict_types=1); namespace App\Modules\Platform\Settings; /** - * A preset picker over the single generic SMTP transport — every provider - * here supports SMTP relay, so selecting one just pre-fills the well-known - * host/port; the app always sends via Laravel's "smtp" mailer regardless - * of which preset was picked. Custom covers anything else ("etc"). + * The choices in the Email settings "Provider" dropdown. + * + * Two kinds share the one list, distinguished by isOAuth(): the SMTP + * presets (every one of them supports SMTP relay, so selecting one just + * pre-fills the well-known host/port — the app sends via Laravel's "smtp" + * mailer regardless of which was picked; Custom covers anything else), + * and the OAuth API providers, which switch the transport itself to a + * dedicated mailer that talks the vendor's HTTP API with tokens from + * MailOAuthConnection instead of a password. One dropdown rather than a + * separate screen because "where does outgoing email go" should have + * exactly one answer. */ enum MailProvider: string { @@ -17,6 +24,7 @@ enum MailProvider: string case Mailgun = 'mailgun'; case Postmark = 'postmark'; case AmazonSes = 'ses'; + case Microsoft365 = 'microsoft365'; public function label(): string { @@ -26,13 +34,14 @@ enum MailProvider: string self::Mailgun => 'Mailgun', self::Postmark => 'Postmark', self::AmazonSes => 'Amazon SES', + self::Microsoft365 => 'Microsoft 365 (OAuth)', }; } public function defaultHost(): ?string { return match ($this) { - self::Custom => null, + self::Custom, self::Microsoft365 => null, self::SendGrid => 'smtp.sendgrid.net', self::Mailgun => 'smtp.mailgun.org', self::Postmark => 'smtp.postmarkapp.com', @@ -43,8 +52,44 @@ enum MailProvider: string public function defaultPort(): ?int { return match ($this) { - self::Custom => null, + self::Custom, self::Microsoft365 => null, self::SendGrid, self::Mailgun, self::Postmark, self::AmazonSes => 587, }; } + + /** + * Whether this provider sends through a vendor HTTP API authorized by + * an admin-connected mailbox (MailOAuthConnection) rather than through + * the generic SMTP transport. + */ + public function isOAuth(): bool + { + return $this === self::Microsoft365; + } + + /** + * The custom Laravel mailer this provider sends through — the name + * registered via Mail::extend() and declared in config/mail.php. + */ + public function oauthMailer(): ?string + { + return match ($this) { + self::Microsoft365 => 'microsoft-graph', + default => null, + }; + } + + /** + * Whether the connect flow needs a directory/tenant to build its + * endpoints. Microsoft's authorize/token URLs are tenant-scoped; + * blank falls back to 'common', which admits work/school accounts of + * any tenant plus personal accounts — the inclusive default for this + * app's audience. Unlike social login's tenant pinning this is not a + * security control: the flow is started by an administrator and the + * resulting token can only send as the one mailbox that consented. + */ + public function needsTenant(): bool + { + return $this === self::Microsoft365; + } } diff --git a/config/mail.php b/config/mail.php index 76e7d0b5..0d263c06 100644 --- a/config/mail.php +++ b/config/mail.php @@ -70,6 +70,14 @@ return [ 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), ], + // Custom transport (registered in PlatformServiceProvider); its + // credentials live in the mail_oauth_connections row, not here — + // selected as the default by MailConfigApplier when the admin has + // connected a Microsoft 365 mailbox. + 'microsoft-graph' => [ + 'transport' => 'microsoft-graph', + ], + 'log' => [ 'transport' => 'log', 'channel' => env('MAIL_LOG_CHANNEL'), diff --git a/database/migrations/2026_08_23_090000_create_mail_oauth_connections_table.php b/database/migrations/2026_08_23_090000_create_mail_oauth_connections_table.php new file mode 100644 index 00000000..eebdde07 --- /dev/null +++ b/database/migrations/2026_08_23_090000_create_mail_oauth_connections_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('provider')->unique(); + $table->string('client_id')->nullable(); + $table->text('client_secret')->nullable(); + $table->string('tenant_id')->nullable(); + $table->string('account_email')->nullable(); + $table->text('access_token')->nullable(); + $table->text('refresh_token')->nullable(); + $table->timestamp('token_expires_at')->nullable(); + $table->timestamp('last_refreshed_at')->nullable(); + // The last refresh/send failure that means "reconnect me", kept + // until a successful refresh or reconnect clears it — what the + // settings page's warning and the admin notification read. + $table->text('last_error')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mail_oauth_connections'); + } +}; diff --git a/resources/js/pages/system/settings/email.tsx b/resources/js/pages/system/settings/email.tsx index c2bb56cf..8ae76a9a 100644 --- a/resources/js/pages/system/settings/email.tsx +++ b/resources/js/pages/system/settings/email.tsx @@ -7,6 +7,7 @@ import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { SavedIndicator } from '@/components/save-button'; import { TestResultAlert } from '@/components/test-result-alert'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; @@ -21,6 +22,8 @@ interface MailProviderPreset { label: string; host: string | null; port: number | null; + oauth: boolean; + needs_tenant: boolean; } interface MailProviderProps { @@ -34,15 +37,26 @@ interface MailProviderProps { from_name: string; } +interface MailOAuthConnectionProps { + client_id: string; + has_client_secret: boolean; + tenant_id: string; + connected: boolean; + account_email: string | null; + last_refreshed_at: string | null; + last_error: string | null; +} + interface EmailSettingsProps { email_notifications_enabled: boolean; admin_notification_emails: string[]; mail_provider: MailProviderProps; mail_provider_presets: MailProviderPreset[]; + mail_oauth_connections: Record; test_result: { ok: boolean; message: string } | null; } -type Tab = 'general' | 'smtp' | 'test'; +type Tab = 'general' | 'sending' | 'test'; const FORM_ID = 'email-settings-form'; @@ -51,14 +65,16 @@ export default function EmailSettings({ admin_notification_emails, mail_provider, mail_provider_presets, + mail_oauth_connections, test_result, }: EmailSettingsProps) { const { t } = useTranslation(); const { auth } = usePage().props; const canConfigureTransport = useCapability('email.transport.configure'); - const tabs: Tab[] = canConfigureTransport ? ['general', 'smtp', 'test'] : ['general']; + const tabs: Tab[] = canConfigureTransport ? ['general', 'sending', 'test'] : ['general']; const [tab, setTab] = useState('general'); const [sendingTest, setSendingTest] = useState(false); + const [connecting, setConnecting] = useState(false); const [testRecipient, setTestRecipient] = useState(auth.user.email); const [newRecipient, setNewRecipient] = useState(''); @@ -67,6 +83,8 @@ export default function EmailSettings({ { title: t('Email'), href: '/system/settings/email' }, ]; + const initialConnection = mail_oauth_connections[mail_provider.provider]; + const { data, setData, patch, processing, recentlySuccessful, errors } = useForm({ email_notifications_enabled: email_notifications_enabled, admin_notification_emails: admin_notification_emails, @@ -78,8 +96,23 @@ export default function EmailSettings({ encryption: mail_provider.encryption, from_address: mail_provider.from_address, from_name: mail_provider.from_name, + client_id: initialConnection?.client_id ?? '', + client_secret: '', + tenant_id: initialConnection?.tenant_id ?? '', }); + const selectedPreset = mail_provider_presets.find((p) => p.value === data.provider); + const isOAuth = selectedPreset?.oauth ?? false; + const connection = mail_oauth_connections[data.provider]; + + // Connecting sends the browser to the provider with whatever is in + // the database — a registration typed into the form but not yet + // saved would silently not be the one used, so the button waits. + const oauthDirty = + connection !== undefined && + (data.client_id !== connection.client_id || data.client_secret !== '' || data.tenant_id !== connection.tenant_id); + const canConnect = connection !== undefined && connection.client_id !== '' && connection.has_client_secret && !oauthDirty; + const addRecipient = () => { const email = newRecipient.trim(); if (email === '' || data.admin_notification_emails.includes(email)) return; @@ -96,11 +129,15 @@ export default function EmailSettings({ const selectPreset = (value: string) => { const preset = mail_provider_presets.find((p) => p.value === value); + const presetConnection = mail_oauth_connections[value]; setData((current) => ({ ...current, provider: value, host: preset?.host ?? current.host, port: preset?.port ?? current.port, + client_id: presetConnection?.client_id ?? '', + client_secret: '', + tenant_id: presetConnection?.tenant_id ?? '', })); }; @@ -108,10 +145,22 @@ export default function EmailSettings({ e.preventDefault(); patch(route('system-settings.email.update'), { preserveScroll: true, - onSuccess: () => setData('password', ''), + onSuccess: () => { + setData('password', ''); + setData('client_secret', ''); + }, }); }; + const connect = () => { + setConnecting(true); + router.post(route('system-settings.email.oauth.connect'), {}, { onFinish: () => setConnecting(false) }); + }; + + const disconnect = () => { + router.delete(route('system-settings.email.oauth.disconnect'), { preserveScroll: true }); + }; + const sendTest: FormEventHandler = (e) => { e.preventDefault(); setSendingTest(true); @@ -137,7 +186,7 @@ export default function EmailSettings({ onClick={() => setTab(tabKey)} className={`border-b-2 px-3 py-2 text-sm ${tab === tabKey ? 'border-primary text-foreground font-medium' : 'text-muted-foreground border-transparent'}`} > - {tabKey === 'general' ? t('General') : tabKey === 'smtp' ? t('SMTP configuration') : t('Test')} + {tabKey === 'general' ? t('General') : tabKey === 'sending' ? t('Sending') : t('Test')} ))} @@ -240,7 +289,7 @@ export default function EmailSettings({ )} - {tab === 'smtp' && canConfigureTransport && ( + {tab === 'sending' && canConfigureTransport && (

{t('Where outgoing email is actually sent from.')}

@@ -260,70 +309,162 @@ export default function EmailSettings({
-
-
- - setData('host', e.target.value)} /> - -
-
- - setData('port', Number(e.target.value))} /> - -
-
+ {isOAuth && ( + <> +

+ {t( + 'Sends through the provider’s API as a mailbox you connect below — no password or app password. Register an application with the provider, enter its credentials here, save, then connect the mailbox.', + )} +

-
-
- - setData('username', e.target.value)} /> - -
-
- - setData('password', e.target.value)} - /> - -
-
+
+ + setData('client_id', e.target.value)} + /> + +
-
- - - -
+
+
+ + setData('client_secret', e.target.value)} + /> + +
+ {selectedPreset?.needs_tenant && ( +
+ + setData('tenant_id', e.target.value)} + /> + +
+ )} +
-
-
- - setData('from_address', e.target.value)} - /> - -
-
- - setData('from_name', e.target.value)} /> - -
-
+
+ + setData('from_name', e.target.value)} /> + +
+ + {connection?.last_error != null && ( + + {t('The connection stopped working and needs to be reconnected')} + {connection.last_error} + + )} + + {connection?.connected && connection.last_error == null && ( + + + {t('Connected as :email', { email: connection.account_email ?? '' })} + + + {t('Outgoing email sends as this mailbox. Its address is the sender address.')} + + + )} + +
+ + {connection?.connected && ( + + )} +
+ {!canConnect && ( +

+ {oauthDirty + ? t('Save your changes first, then connect the mailbox.') + : t('Enter and save the application (client) ID and secret first.')} +

+ )} + + )} + + {!isOAuth && ( + <> +
+
+ + setData('host', e.target.value)} /> + +
+
+ + setData('port', Number(e.target.value))} /> + +
+
+ +
+
+ + setData('username', e.target.value)} /> + +
+
+ + setData('password', e.target.value)} + /> + +
+
+ +
+ + + +
+ +
+
+ + setData('from_address', e.target.value)} + /> + +
+
+ + setData('from_name', e.target.value)} /> + +
+
+ + )} )} diff --git a/routes/console.php b/routes/console.php index 6a285819..d3141b31 100644 --- a/routes/console.php +++ b/routes/console.php @@ -18,3 +18,4 @@ Schedule::command('projectsend:purge-orphan-files')->daily(); Schedule::command('projectsend:purge-api-request-logs')->daily(); Schedule::command('projectsend:purge-failed-jobs')->daily(); Schedule::command('projectsend:purge-notifications')->daily(); +Schedule::command('projectsend:refresh-mail-oauth-tokens')->daily(); diff --git a/routes/settings.php b/routes/settings.php index 3e8792ca..1d3e2e7f 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -16,6 +16,7 @@ use App\Modules\Identity\Http\Controllers\TwoFactorEnrollmentController; use App\Modules\Notifications\Http\Controllers\NotificationPreferencesController; use App\Modules\Platform\Http\Controllers\AboutController; use App\Modules\Platform\Http\Controllers\CaptchaSettingsController; +use App\Modules\Platform\Http\Controllers\EmailOAuthController; use App\Modules\Platform\Http\Controllers\EmailSettingsController; use App\Modules\Platform\Http\Controllers\EmailTemplatesController; use App\Modules\Platform\Http\Controllers\ExternalStorageSettingsController; @@ -156,6 +157,18 @@ Route::middleware('auth')->group(function () { Route::get('system/settings/email', [EmailSettingsController::class, 'edit'])->name('system-settings.email.edit'); Route::patch('system/settings/email', [EmailSettingsController::class, 'update'])->name('system-settings.email.update'); Route::post('system/settings/email/test', [EmailSettingsController::class, 'sendTest'])->name('system-settings.email.test'); + // The OAuth mailbox behind the Microsoft 365 provider. Its own + // throttle buckets like every action route here; the callback is + // a GET because it is the provider redirecting the admin's + // browser back, session and all. + Route::post('system/settings/email/oauth/connect', [EmailOAuthController::class, 'connect']) + ->middleware('throttle:20,1,mail-oauth-connect') + ->name('system-settings.email.oauth.connect'); + Route::get('system/settings/email/oauth/callback', [EmailOAuthController::class, 'callback']) + ->middleware('throttle:20,1,mail-oauth-callback') + ->name('system-settings.email.oauth.callback'); + Route::delete('system/settings/email/oauth', [EmailOAuthController::class, 'disconnect']) + ->name('system-settings.email.oauth.disconnect'); // Deliberately outside any capability: group. LDAP is an // administrator's setting, available in every edition, not an // edition difference. diff --git a/tests/Feature/Platform/MailOAuthTest.php b/tests/Feature/Platform/MailOAuthTest.php new file mode 100644 index 00000000..eb786da9 --- /dev/null +++ b/tests/Feature/Platform/MailOAuthTest.php @@ -0,0 +1,473 @@ +admin = User::factory()->create(); +}); + +/** An id_token whose payload names the connected mailbox — signature irrelevant, it is never verified. */ +function fakeIdToken(string $email = 'portal@example.test'): string +{ + $encode = fn (array $claims): string => rtrim(strtr(base64_encode((string) json_encode($claims)), '+/', '-_'), '='); + + return $encode(['alg' => 'none']).'.'.$encode(['preferred_username' => $email]).'.sig'; +} + +/** A token endpoint success, as Microsoft shapes it. */ +function fakeTokenResponse(array $overrides = []): array +{ + return array_merge([ + 'access_token' => 'access-token-1', + 'refresh_token' => 'refresh-token-1', + 'expires_in' => 3600, + 'id_token' => fakeIdToken(), + ], $overrides); +} + +/** The Microsoft 365 provider selected and its mailbox connected, ready to send. */ +function connectMicrosoftMailbox(): MailOAuthConnection +{ + MailProviderSettings::current()->fill(['provider' => 'microsoft365', 'from_name' => 'Portal'])->save(); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + $connection->fill([ + 'client_id' => 'client-id-1', + 'client_secret' => 'client-secret-1', + 'account_email' => 'portal@example.test', + 'access_token' => 'access-token-1', + 'refresh_token' => 'refresh-token-1', + 'token_expires_at' => now()->addHour(), + ])->save(); + + app(MailConfigApplier::class)->flush(); + app(MailConfigApplier::class)->apply(); + + return $connection; +} + +test('the client secret and both tokens are encrypted at rest', function () { + connectMicrosoftMailbox(); + + $raw = DB::table('mail_oauth_connections')->first(); + assert($raw !== null); + + expect($raw->client_secret)->not->toBe('client-secret-1') + ->and($raw->access_token)->not->toBe('access-token-1') + ->and($raw->refresh_token)->not->toBe('refresh-token-1'); + + $reloaded = MailOAuthConnection::for(MailProvider::Microsoft365); + + expect($reloaded->client_secret)->toBe('client-secret-1') + ->and($reloaded->access_token)->toBe('access-token-1') + ->and($reloaded->refresh_token)->toBe('refresh-token-1'); +}); + +test('saving the form with the Microsoft 365 provider stores the app registration and skips the SMTP rules', function () { + $this->actingAs($this->admin); + + $this->patch('/system/settings/email', [ + 'email_notifications_enabled' => true, + 'admin_notification_emails' => ['admin@example.com'], + 'provider' => 'microsoft365', + 'client_id' => 'client-id-1', + 'client_secret' => 'client-secret-1', + 'tenant_id' => '', + 'from_name' => 'Portal', + ])->assertRedirect()->assertSessionDoesntHaveErrors(); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + + expect(MailProviderSettings::current()->provider)->toBe(MailProvider::Microsoft365) + ->and($connection->client_id)->toBe('client-id-1') + ->and($connection->client_secret)->toBe('client-secret-1') + ->and($connection->tenant_id)->toBeNull(); +}); + +test('saving with an OAuth provider keeps the stored SMTP transport for a later switch back', function () { + MailProviderSettings::current()->fill([ + 'host' => 'smtp.example.test', + 'port' => 587, + 'username' => 'mailer', + 'password' => 'secret', + 'from_address' => 'hello@example.com', + ])->save(); + + $this->actingAs($this->admin); + + $this->patch('/system/settings/email', [ + 'email_notifications_enabled' => true, + 'admin_notification_emails' => ['admin@example.com'], + 'provider' => 'microsoft365', + 'client_id' => 'client-id-1', + 'client_secret' => 'client-secret-1', + 'from_name' => 'Portal', + ])->assertRedirect()->assertSessionDoesntHaveErrors(); + + $settings = MailProviderSettings::current(); + + expect($settings->host)->toBe('smtp.example.test') + ->and($settings->username)->toBe('mailer') + ->and($settings->password)->toBe('secret') + ->and($settings->from_address)->toBe('hello@example.com'); +}); + +test('a blank client secret keeps the stored one, and a changed client id drops the tokens', function () { + connectMicrosoftMailbox(); + $this->actingAs($this->admin); + + $payload = fn (array $overrides): array => array_merge([ + 'email_notifications_enabled' => true, + 'admin_notification_emails' => ['admin@example.com'], + 'provider' => 'microsoft365', + 'client_id' => 'client-id-1', + 'client_secret' => '', + 'from_name' => 'Portal', + ], $overrides); + + // Same client id, blank secret: nothing lost. + $this->patch('/system/settings/email', $payload([]))->assertRedirect()->assertSessionDoesntHaveErrors(); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + expect($connection->client_secret)->toBe('client-secret-1') + ->and($connection->refresh_token)->toBe('refresh-token-1'); + + // New client id: the old app's tokens are dead weight and go. + $this->patch('/system/settings/email', $payload(['client_id' => 'client-id-2']))->assertRedirect(); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + expect($connection->client_id)->toBe('client-id-2') + ->and($connection->refresh_token)->toBeNull() + ->and($connection->access_token)->toBeNull() + ->and($connection->account_email)->toBeNull(); +}); + +test('connect refuses until an app registration is saved', function () { + MailProviderSettings::current()->fill(['provider' => 'microsoft365'])->save(); + $this->actingAs($this->admin); + + $this->from('/system/settings/email')->post('/system/settings/email/oauth/connect') + ->assertRedirect('/system/settings/email') + ->assertSessionHas('error'); +}); + +test('connect sends the admin to the tenant-scoped consent URL with a state marker', function () { + MailProviderSettings::current()->fill(['provider' => 'microsoft365'])->save(); + MailOAuthConnection::for(MailProvider::Microsoft365) + ->fill(['client_id' => 'client-id-1', 'client_secret' => 'client-secret-1', 'tenant_id' => 'tenant-1']) + ->save(); + + $this->actingAs($this->admin); + + $response = $this->post('/system/settings/email/oauth/connect'); + + $response->assertRedirect(); + $location = $response->headers->get('Location'); + assert(is_string($location)); + + expect($location)->toStartWith('https://login.microsoftonline.com/tenant-1/oauth2/v2.0/authorize?') + ->and($location)->toContain('client_id=client-id-1') + ->and($location)->toContain(urlencode(route('system-settings.email.oauth.callback'))) + ->and($location)->toContain('Mail.Send'); + + $state = session('mail_oauth.state'); + expect($state)->toBeString()->and($location)->toContain('state='.$state); +}); + +test('an empty tenant falls back to common', function () { + MailProviderSettings::current()->fill(['provider' => 'microsoft365'])->save(); + MailOAuthConnection::for(MailProvider::Microsoft365) + ->fill(['client_id' => 'client-id-1', 'client_secret' => 'client-secret-1']) + ->save(); + + $this->actingAs($this->admin); + + $location = $this->post('/system/settings/email/oauth/connect')->headers->get('Location'); + assert(is_string($location)); + + expect($location)->toStartWith('https://login.microsoftonline.com/common/'); +}); + +test('the callback refuses a state nobody here issued', function () { + MailProviderSettings::current()->fill(['provider' => 'microsoft365'])->save(); + $this->actingAs($this->admin); + + $this->withSession(['mail_oauth.state' => 'expected', 'mail_oauth.provider' => 'microsoft365']) + ->get('/system/settings/email/oauth/callback?state=forged&code=abc') + ->assertRedirect(route('system-settings.email.edit')) + ->assertSessionHas('error'); + + expect(MailOAuthConnection::for(MailProvider::Microsoft365)->refresh_token)->toBeNull(); +}); + +test('the callback surfaces a consent-screen refusal instead of a generic failure', function () { + MailProviderSettings::current()->fill(['provider' => 'microsoft365'])->save(); + $this->actingAs($this->admin); + + $response = $this->withSession(['mail_oauth.state' => 'state-1', 'mail_oauth.provider' => 'microsoft365']) + ->get('/system/settings/email/oauth/callback?state=state-1&error=access_denied&error_description=The+user+cancelled'); + + $response->assertRedirect(route('system-settings.email.edit')); + + expect(session('error'))->toContain('The user cancelled'); +}); + +test('a successful callback stores tokens, reads the mailbox from the id_token, and activates the transport', function () { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(fakeTokenResponse()), + ]); + + MailProviderSettings::current()->fill(['provider' => 'microsoft365', 'from_name' => 'Portal'])->save(); + MailOAuthConnection::for(MailProvider::Microsoft365) + ->fill(['client_id' => 'client-id-1', 'client_secret' => 'client-secret-1']) + ->save(); + + $this->actingAs($this->admin); + + $this->withSession(['mail_oauth.state' => 'state-1', 'mail_oauth.provider' => 'microsoft365']) + ->get('/system/settings/email/oauth/callback?state=state-1&code=auth-code-1') + ->assertRedirect(route('system-settings.email.edit')) + ->assertSessionHas('success'); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + + expect($connection->access_token)->toBe('access-token-1') + ->and($connection->refresh_token)->toBe('refresh-token-1') + ->and($connection->account_email)->toBe('portal@example.test') + ->and($connection->last_error)->toBeNull(); + + Http::assertSent(function ($request): bool { + return str_starts_with($request->url(), 'https://login.microsoftonline.com/common/oauth2/v2.0/token') + && $request['grant_type'] === 'authorization_code' + && $request['code'] === 'auth-code-1'; + }); + + // The same request already sees the new transport (flush + apply + // ran), with the From pinned to the connected mailbox. + expect(config('mail.default'))->toBe('microsoft-graph') + ->and(config('mail.from.address'))->toBe('portal@example.test'); +}); + +test('the applier leaves the transport alone while the OAuth provider is selected but no mailbox is connected', function () { + $originalDefault = config('mail.default'); + + MailProviderSettings::current()->fill(['provider' => 'microsoft365'])->save(); + MailOAuthConnection::for(MailProvider::Microsoft365) + ->fill(['client_id' => 'client-id-1', 'client_secret' => 'client-secret-1']) + ->save(); + + app(MailConfigApplier::class)->flush(); + app(MailConfigApplier::class)->apply(); + + expect(config('mail.default'))->toBe($originalDefault); +}); + +test('a stored SMTP host does not hijack the transport while an OAuth provider is selected', function () { + connectMicrosoftMailbox(); + + MailProviderSettings::current()->fill(['host' => 'smtp.example.test'])->save(); + + app(MailConfigApplier::class)->flush(); + app(MailConfigApplier::class)->apply(); + + expect(config('mail.default'))->toBe('microsoft-graph'); +}); + +test('cloud edition never activates an OAuth transport, even with a usable connection stored', function () { + config()->set('projectsend.edition', Edition::Cloud); + + $originalDefault = config('mail.default'); + + connectMicrosoftMailbox(); + + expect(config('mail.default'))->toBe($originalDefault); +}); + +test('the connect and disconnect routes are community-only', function () { + config()->set('projectsend.edition', Edition::Cloud); + $this->actingAs($this->admin); + + $this->post('/system/settings/email/oauth/connect')->assertNotFound(); + $this->get('/system/settings/email/oauth/callback')->assertNotFound(); + $this->delete('/system/settings/email/oauth')->assertNotFound(); +}); + +test('sending posts the rendered message to Graph as base64 MIME with the fresh access token', function () { + Http::fake([ + 'graph.microsoft.com/*' => Http::response(null, 202), + ]); + + connectMicrosoftMailbox(); + + Mail::mailer('microsoft-graph')->raw('Hello from the portal', function ($message) { + $message->to('client@example.com')->subject('A test subject'); + }); + + Http::assertSent(function ($request): bool { + if ($request->url() !== 'https://graph.microsoft.com/v1.0/me/sendMail') { + return false; + } + + $mime = base64_decode($request->body(), true); + + return $request->hasHeader('Authorization', 'Bearer access-token-1') + && is_string($mime) + && str_contains($mime, 'A test subject') + && str_contains($mime, 'client@example.com'); + }); +}); + +test('an expired access token is refreshed (and the rotated refresh token kept) before sending', function () { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(fakeTokenResponse([ + 'access_token' => 'access-token-2', + 'refresh_token' => 'refresh-token-2', + ])), + 'graph.microsoft.com/*' => Http::response(null, 202), + ]); + + $connection = connectMicrosoftMailbox(); + $connection->fill(['token_expires_at' => now()->subMinute()])->save(); + + Mail::mailer('microsoft-graph')->raw('Hello', function ($message) { + $message->to('client@example.com')->subject('Refresh path'); + }); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + + expect($connection->access_token)->toBe('access-token-2') + ->and($connection->refresh_token)->toBe('refresh-token-2'); + + Http::assertSent(fn ($request): bool => str_contains($request->url(), '/token') && $request['grant_type'] === 'refresh_token'); + Http::assertSent(fn ($request): bool => $request->url() === 'https://graph.microsoft.com/v1.0/me/sendMail' + && $request->hasHeader('Authorization', 'Bearer access-token-2')); +}); + +test('a Graph refusal surfaces as a send failure, not a silent success', function () { + Http::fake([ + 'graph.microsoft.com/*' => Http::response(['error' => ['code' => 'ErrorSendAsDenied', 'message' => 'Not allowed to send as this user']], 403), + ]); + + connectMicrosoftMailbox(); + + expect(fn () => Mail::mailer('microsoft-graph')->raw('Hello', function ($message) { + $message->to('client@example.com')->subject('Refused'); + }))->toThrow(TransportException::class, 'Not allowed to send as this user'); +}); + +test('the scheduled refresh keeps a healthy connection fresh', function () { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(fakeTokenResponse([ + 'access_token' => 'access-token-2', + 'refresh_token' => 'refresh-token-2', + ])), + ]); + + connectMicrosoftMailbox(); + + Artisan::call('projectsend:refresh-mail-oauth-tokens'); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + + expect($connection->access_token)->toBe('access-token-2') + ->and($connection->refresh_token)->toBe('refresh-token-2') + ->and($connection->last_error)->toBeNull() + ->and($connection->last_refreshed_at)->not->toBeNull(); +}); + +test('a dead grant records the error and notifies settings admins exactly once', function () { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response([ + 'error' => 'invalid_grant', + 'error_description' => 'AADSTS50173: The provided grant has expired.', + ], 400), + ]); + + connectMicrosoftMailbox(); + + // Somebody without the settings permission must not be alarmed. + $bystander = staffWithPermissions([]); + + Artisan::call('projectsend:refresh-mail-oauth-tokens'); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + expect($connection->last_error)->toContain('AADSTS50173'); + + $notifications = InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->get(); + expect($notifications)->toHaveCount(1) + ->and($notifications->first()?->user_id)->toBe($this->admin->id) + ->and($notifications->first()?->user_id)->not->toBe($bystander->id); + + // The broken state is already known — the next run must not nag. + Artisan::call('projectsend:refresh-mail-oauth-tokens'); + + expect(InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->count())->toBe(1); +}); + +test('a transient token endpoint failure neither flags the connection nor notifies anyone', function () { + Http::fake([ + 'login.microsoftonline.com/*' => Http::response(['error' => 'temporarily_unavailable'], 503), + ]); + + connectMicrosoftMailbox(); + + Artisan::call('projectsend:refresh-mail-oauth-tokens'); + + expect(MailOAuthConnection::for(MailProvider::Microsoft365)->last_error)->toBeNull() + ->and(InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->count())->toBe(0); +}); + +test('disconnect drops the tokens but keeps the app registration', function () { + connectMicrosoftMailbox(); + $this->actingAs($this->admin); + + $this->from('/system/settings/email')->delete('/system/settings/email/oauth') + ->assertRedirect('/system/settings/email') + ->assertSessionHas('success'); + + $connection = MailOAuthConnection::for(MailProvider::Microsoft365); + + expect($connection->refresh_token)->toBeNull() + ->and($connection->access_token)->toBeNull() + ->and($connection->account_email)->toBeNull() + ->and($connection->client_id)->toBe('client-id-1') + ->and($connection->client_secret)->toBe('client-secret-1'); + + // The transport must not come back on the next boot: apply() layers + // onto config/mail.php's defaults, so simulate a fresh process by + // resetting the default before re-applying the (already flushed) + // resolved settings. + config()->set('mail.default', 'log'); + app(MailConfigApplier::class)->apply(); + + expect(config('mail.default'))->toBe('log'); +}); + +test('the settings page never ships the secret or tokens to the browser', function () { + connectMicrosoftMailbox(); + $this->actingAs($this->admin); + + $response = $this->get('/system/settings/email'); + + $response->assertInertia(fn ($page) => $page + ->where('mail_oauth_connections.microsoft365.connected', true) + ->where('mail_oauth_connections.microsoft365.account_email', 'portal@example.test') + ->where('mail_oauth_connections.microsoft365.has_client_secret', true) + ->missing('mail_oauth_connections.microsoft365.client_secret') + ->missing('mail_oauth_connections.microsoft365.access_token') + ->missing('mail_oauth_connections.microsoft365.refresh_token'), + ); +}); diff --git a/tests/Feature/Platform/SchedulerMonitoringTest.php b/tests/Feature/Platform/SchedulerMonitoringTest.php index 3de128a2..fb80228b 100644 --- a/tests/Feature/Platform/SchedulerMonitoringTest.php +++ b/tests/Feature/Platform/SchedulerMonitoringTest.php @@ -45,7 +45,7 @@ test('the scheduler page lists every known command, flagging ones that have neve ]); $response = $this->actingAs($this->admin)->get('/system/settings/scheduler'); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 10)); + $response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 11)); $tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command'); expect($tasks->get('projectsend:purge-expired-files')['status'])->toBe('success')