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')