Send mail through Microsoft Graph as an admin-connected mailbox

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.
This commit is contained in:
denkfabrik-li
2026-08-23 21:34:55 +02:00
parent 94e4aa36e4
commit 933eaa2ba4
20 changed files with 1749 additions and 104 deletions
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Http\Controllers;
use App\Http\Controllers\Controller;
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\MailOAuthBrokers;
use App\Modules\Platform\Mail\MailOAuthConnection;
use App\Modules\Platform\Mail\MailOAuthException;
use App\Modules\Platform\Settings\MailConfigApplier;
use App\Modules\Platform\Settings\MailProviderSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
/**
* Connecting the mailbox an OAuth mail provider sends as, and cutting it
* loose again.
*
* Mirrors SocialLoginController's shape a session marker written
* before the redirect is what ties the callback to an exchange somebody
* here actually started, and refuses a stray or replayed one but with
* its own state parameter instead of Socialite, because this flow wants
* raw tokens with a send scope, not a user identity (see
* MailOAuthBroker). Community-only like the rest of the transport
* configuration: on cloud, outgoing mail is the platform's relay and
* there is nothing to connect.
*/
class EmailOAuthController extends Controller
{
private const STATE = 'mail_oauth.state';
private const PROVIDER = 'mail_oauth.provider';
public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly MailOAuthBrokers $brokers,
private readonly MailConfigApplier $mailConfig,
private readonly ActivityLogger $activity,
) {}
/** Begin connecting: off to the provider's consent screen. */
public function connect(Request $request): RedirectResponse|Response
{
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);
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');
}
}
@@ -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,
@@ -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'),
];
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail\Console;
use App\Models\User;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\Notifier;
use App\Modules\Platform\Mail\MailOAuthBrokers;
use App\Modules\Platform\Mail\MailOAuthConnection;
use App\Modules\Platform\Mail\MailOAuthException;
use App\Modules\Platform\Settings\MailConfigApplier;
use Illuminate\Console\Command;
/**
* Keeps every connected OAuth mailbox able to send, and says so early
* when one no longer can.
*
* Transports already refresh on demand at send time; what they cannot do
* is refresh on an installation that sends rarely and a delegated
* refresh token dies of pure disuse (Microsoft's sliding inactivity
* window). A daily refresh keeps the window sliding, and doubles as the
* health check: the delegated flow's one real weakness is that a grant
* can die silently (password reset, Conditional Access change), which
* for a portal whose password-reset mails ride on this connection must
* surface as a warning, not as a support ticket weeks later.
*/
class RefreshMailOAuthTokensCommand extends Command
{
protected $signature = 'projectsend:refresh-mail-oauth-tokens';
protected $description = 'Refresh connected OAuth mailbox tokens and flag connections that need to be reconnected (runs daily)';
public function handle(MailOAuthBrokers $brokers, Notifier $notifier, PermissionChecker $permissions, MailConfigApplier $mailConfig): int
{
$connections = MailOAuthConnection::query()->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;
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
/**
* One OAuth mail provider's token machinery: building the consent URL,
* turning the returned code into tokens, and keeping those tokens fresh.
*
* Deliberately not Socialite: a mail connection needs raw tokens with a
* send scope, not a user identity, and Socialite's user() call would
* drag in a userinfo permission (User.Read on Graph) that sending mail
* does not need. Implementations write their results straight onto the
* MailOAuthConnection row and save it.
*/
interface MailOAuthBroker
{
/** The provider consent URL the admin's browser is sent to. */
public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string;
/**
* Exchange the callback's authorization code for tokens and record
* them, along with the connected mailbox's address, on the connection.
*
* @throws MailOAuthException
*/
public function exchange(MailOAuthConnection $connection, string $code, string $redirectUri): void;
/**
* Refresh the access token (rotating the refresh token when the
* provider hands back a new one) and record the outcome including
* `last_error` on failure, so the settings page and the scheduled
* health check read one source of truth.
*
* @throws MailOAuthException
*/
public function refresh(MailOAuthConnection $connection): void;
/**
* An access token currently valid for at least a small safety margin,
* refreshing first when needed what transports call at send time.
*
* @throws MailOAuthException
*/
public function freshAccessToken(MailOAuthConnection $connection): string;
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
use App\Modules\Platform\Settings\MailProvider;
use InvalidArgumentException;
/**
* Resolves the broker for an OAuth mail provider.
*
* A closed map rather than an open registry, for the same reason
* SocialProvider is a closed enum: each broker encodes decisions about a
* vendor's token semantics (rotation, what kills a grant) that somebody
* has reasoned about. Google's Gmail broker lands here as the second arm.
*/
class MailOAuthBrokers
{
public function for(MailProvider $provider): MailOAuthBroker
{
return match ($provider) {
MailProvider::Microsoft365 => app(MicrosoftMailBroker::class),
default => throw new InvalidArgumentException("{$provider->value} is not an OAuth mail provider."),
};
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
use App\Modules\Platform\Settings\MailProvider;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* One OAuth mail provider's app registration and its connected mailbox.
*
* Shaped after SocialSettings, including the part that matters most:
* `client_secret` and both tokens carry an `'encrypted'` cast, so a
* database dump does not hand over a credential that can send mail as
* the organization.
*
* The row splits into two halves with different lifetimes: the app
* registration (client_id/client_secret/tenant_id) survives a
* disconnect, while the connection itself (tokens, account, error state)
* is what connecting and disconnecting write. Transports read this row
* fresh at send time tokens must never travel through the boot-config
* cache (see MailConfigApplier, which caches only readiness and the
* account address).
*
* @property int $id
* @property MailProvider $provider
* @property string|null $client_id
* @property string|null $client_secret
* @property string|null $tenant_id
* @property string|null $account_email
* @property string|null $access_token
* @property string|null $refresh_token
* @property Carbon|null $token_expires_at
* @property Carbon|null $last_refreshed_at
* @property string|null $last_error
*/
class MailOAuthConnection extends Model
{
protected $table = 'mail_oauth_connections';
protected $guarded = [];
protected function casts(): array
{
return [
'provider' => 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) !== '';
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
use RuntimeException;
/**
* A failed token exchange or refresh.
*
* `needsReconnect` separates the two situations an admin can be in: the
* grant itself is dead (revoked consent, password/Conditional-Access
* change, expired refresh token only re-running the connect flow
* helps) versus a transient failure (endpoint unreachable, 5xx) where
* the existing connection is fine and retrying is the answer.
*/
class MailOAuthException extends RuntimeException
{
public function __construct(
string $message,
public readonly bool $needsReconnect = false,
) {
parent::__construct($message);
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
use App\Modules\Platform\Settings\MailProvider;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
/**
* Sends through Microsoft Graph's sendMail as the connected mailbox.
*
* Graph rather than smtp.office365.com because SMTP submission (both
* password and XOAUTH2) is the endpoint Microsoft is winding down, while
* Graph is where they invest this transport is the future-proof half
* of the Microsoft 365 provider, the connect flow in MicrosoftMailBroker
* is the other.
*
* The message goes up as base64 MIME, not as Graph's JSON message shape:
* Symfony already rendered the exact bytes (themed HTML, alternatives,
* attachments), and re-describing them in JSON is a second
* serializer to get subtly wrong. Exchange reads recipients from the
* MIME headers and strips Bcc on delivery, so all three recipient kinds
* behave. The connection row is read fresh on every send a queue
* worker holds this transport for its whole life, and tokens rotate
* underneath it.
*
* Sending as the connected mailbox is a property of delegated Graph, not
* a limitation of this class: the From header must be that mailbox (or
* one it holds SendAs rights over), which is why MailConfigApplier pins
* mail.from.address to the connected account while this provider is
* active.
*/
class MicrosoftGraphTransport extends AbstractTransport
{
public function __construct(private readonly MicrosoftMailBroker $broker)
{
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$connection = MailOAuthConnection::for(MailProvider::Microsoft365);
if (! $connection->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';
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Mail;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
/**
* Microsoft identity platform (v2.0) tokens for sending through Graph.
*
* Delegated flow on purpose: an administrator signs into the mailbox the
* installation should send as, and the resulting token can send as that
* mailbox and nothing else no admin consent, no PowerShell
* ApplicationAccessPolicy, and it works for work/school and personal
* accounts alike. The price of delegated is that the grant can die
* behind our back (password reset, Conditional Access change), which is
* why refresh() records `last_error` for the health check to surface
* instead of letting mail stop silently.
*
* Plain HTTP against the token endpoint rather than an SDK the
* project ships no Graph/Google SDKs and two POST requests do not
* justify one.
*/
class MicrosoftMailBroker implements MailOAuthBroker
{
/**
* Mail.Send is the one Graph permission sending needs; offline_access
* buys the refresh token; openid/profile/email buy the id_token this
* class reads the connected mailbox's address from which is what
* lets the whole flow avoid User.Read and a Graph /me call entirely.
*/
private const SCOPE = 'offline_access openid profile email https://graph.microsoft.com/Mail.Send';
/** Refresh when the access token has less life left than this. */
private const EXPIRY_MARGIN_SECONDS = 120;
public function authorizeUrl(MailOAuthConnection $connection, string $state, string $redirectUri): string
{
// select_account, always: the admin doing this is often signed
// into their own mailbox, and the one the installation should
// send as (noreply@, portal@) is usually a different one.
return 'https://login.microsoftonline.com/'.$this->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);
}
}
@@ -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
@@ -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);
}
+51 -6
View File
@@ -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;
}
}
+8
View File
@@ -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'),
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// One row per OAuth mail provider (unique on `provider`), separate
// from mail_provider_settings for the same reason that table is
// separate from the generic settings blob: client_secret and both
// tokens need real Eloquent-encrypted columns. Keeping the SMTP row
// untouched also means switching to an OAuth provider and back
// loses nothing.
Schema::create('mail_oauth_connections', function (Blueprint $table) {
$table->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');
}
};
+207 -66
View File
@@ -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<string, MailOAuthConnectionProps>;
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<SharedData>().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<Tab>('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')}
</button>
))}
</nav>
@@ -240,7 +289,7 @@ export default function EmailSettings({
</div>
)}
{tab === 'smtp' && canConfigureTransport && (
{tab === 'sending' && canConfigureTransport && (
<div className="space-y-6">
<p className="text-muted-foreground text-sm">{t('Where outgoing email is actually sent from.')}</p>
@@ -260,70 +309,162 @@ export default function EmailSettings({
</Select>
</div>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_host">{t('Host')}</Label>
<Input id="mail_host" value={data.host} onChange={(e) => setData('host', e.target.value)} />
<InputError message={errors.host} />
</div>
<div className="grid w-32 gap-2">
<Label htmlFor="mail_port">{t('Port')}</Label>
<Input id="mail_port" type="number" value={data.port} onChange={(e) => setData('port', Number(e.target.value))} />
<InputError message={errors.port} />
</div>
</div>
{isOAuth && (
<>
<p className="text-muted-foreground text-sm">
{t(
'Sends through the providers 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.',
)}
</p>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_username">{t('Username')}</Label>
<Input id="mail_username" value={data.username} onChange={(e) => setData('username', e.target.value)} />
<InputError message={errors.username} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_password">{t('Password')}</Label>
<Input
id="mail_password"
type="password"
placeholder={mail_provider.has_password ? t('Unchanged') : ''}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={errors.password} />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="mail_oauth_client_id">{t('Application (client) ID')}</Label>
<Input
id="mail_oauth_client_id"
value={data.client_id}
onChange={(e) => setData('client_id', e.target.value)}
/>
<InputError message={errors.client_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="mail_encryption">{t('Encryption')}</Label>
<Select value={data.encryption} onValueChange={(v) => setData('encryption', v)}>
<SelectTrigger id="mail_encryption" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tls">{t('TLS')}</SelectItem>
<SelectItem value="ssl">{t('SSL')}</SelectItem>
<SelectItem value="none">{t('None')}</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.encryption} />
</div>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_oauth_client_secret">{t('Client secret')}</Label>
<Input
id="mail_oauth_client_secret"
type="password"
placeholder={connection?.has_client_secret ? t('Unchanged') : ''}
value={data.client_secret}
onChange={(e) => setData('client_secret', e.target.value)}
/>
<InputError message={errors.client_secret} />
</div>
{selectedPreset?.needs_tenant && (
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_oauth_tenant_id">{t('Directory (tenant) ID')}</Label>
<Input
id="mail_oauth_tenant_id"
placeholder={t('Optional — empty allows any account')}
value={data.tenant_id}
onChange={(e) => setData('tenant_id', e.target.value)}
/>
<InputError message={errors.tenant_id} />
</div>
)}
</div>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_from_address">{t('From address')}</Label>
<Input
id="mail_from_address"
type="email"
value={data.from_address}
onChange={(e) => setData('from_address', e.target.value)}
/>
<InputError message={errors.from_address} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_from_name">{t('From name')}</Label>
<Input id="mail_from_name" value={data.from_name} onChange={(e) => setData('from_name', e.target.value)} />
<InputError message={errors.from_name} />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="mail_from_name">{t('From name')}</Label>
<Input id="mail_from_name" className="max-w-72" value={data.from_name} onChange={(e) => setData('from_name', e.target.value)} />
<InputError message={errors.from_name} />
</div>
{connection?.last_error != null && (
<Alert variant="destructive">
<AlertTitle>{t('The connection stopped working and needs to be reconnected')}</AlertTitle>
<AlertDescription>{connection.last_error}</AlertDescription>
</Alert>
)}
{connection?.connected && connection.last_error == null && (
<Alert variant="success">
<AlertTitle>
{t('Connected as :email', { email: connection.account_email ?? '' })}
</AlertTitle>
<AlertDescription>
{t('Outgoing email sends as this mailbox. Its address is the sender address.')}
</AlertDescription>
</Alert>
)}
<div className="flex items-center gap-2">
<Button type="button" onClick={connect} disabled={!canConnect || connecting}>
{connection?.connected ? t('Reconnect mailbox') : t('Connect mailbox')}
</Button>
{connection?.connected && (
<Button type="button" variant="outline" onClick={disconnect}>
{t('Disconnect')}
</Button>
)}
</div>
{!canConnect && (
<p className="text-muted-foreground text-sm">
{oauthDirty
? t('Save your changes first, then connect the mailbox.')
: t('Enter and save the application (client) ID and secret first.')}
</p>
)}
</>
)}
{!isOAuth && (
<>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_host">{t('Host')}</Label>
<Input id="mail_host" value={data.host} onChange={(e) => setData('host', e.target.value)} />
<InputError message={errors.host} />
</div>
<div className="grid w-32 gap-2">
<Label htmlFor="mail_port">{t('Port')}</Label>
<Input id="mail_port" type="number" value={data.port} onChange={(e) => setData('port', Number(e.target.value))} />
<InputError message={errors.port} />
</div>
</div>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_username">{t('Username')}</Label>
<Input id="mail_username" value={data.username} onChange={(e) => setData('username', e.target.value)} />
<InputError message={errors.username} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_password">{t('Password')}</Label>
<Input
id="mail_password"
type="password"
placeholder={mail_provider.has_password ? t('Unchanged') : ''}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={errors.password} />
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="mail_encryption">{t('Encryption')}</Label>
<Select value={data.encryption} onValueChange={(v) => setData('encryption', v)}>
<SelectTrigger id="mail_encryption" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tls">{t('TLS')}</SelectItem>
<SelectItem value="ssl">{t('SSL')}</SelectItem>
<SelectItem value="none">{t('None')}</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.encryption} />
</div>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_from_address">{t('From address')}</Label>
<Input
id="mail_from_address"
type="email"
value={data.from_address}
onChange={(e) => setData('from_address', e.target.value)}
/>
<InputError message={errors.from_address} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="mail_from_name">{t('From name')}</Label>
<Input id="mail_from_name" value={data.from_name} onChange={(e) => setData('from_name', e.target.value)} />
<InputError message={errors.from_name} />
</div>
</div>
</>
)}
</div>
)}
</form>
+1
View File
@@ -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();
+13
View File
@@ -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.
+473
View File
@@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Notifications\InAppNotification;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Mail\MailOAuthConnection;
use App\Modules\Platform\Settings\MailConfigApplier;
use App\Modules\Platform\Settings\MailProvider;
use App\Modules\Platform\Settings\MailProviderSettings;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mailer\Exception\TransportException;
beforeEach(function () {
$this->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'),
);
});
@@ -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')