mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 01:25:09 +00:00
02eafb473b
freshAccessToken() serialises refreshes per connection, and its comment says why: both providers rotate the refresh token as they hand out an access token, so the token is good for exactly one use, and "a worker racing the nightly refresh command means the slower one spends a token the faster one has already replaced. The provider answers that with invalid_grant, which is the same thing it says about a genuinely revoked grant: last_error gets written, the settings page turns red, and every admin is told to go and re-consent a connection that was never broken." The nightly refresh command called refresh() directly, outside that lock. It was the racer the comment names, not a party to the arrangement it describes. It now goes through refreshSerially(), which takes the same lock -- named once, in one place, for both callers -- re-reads the row inside it, and refreshes. Unlike freshAccessToken() it refreshes a token that is still usable, which is the point of the daily run: a delegated refresh token dies of disuse and this keeps the window sliding. The lock is taken rather than waited for, unlike the send path. Nobody is standing at a screen for a scheduled job, and a held lock means somebody is refreshing this very connection right now -- which slides the window and establishes its health just as well as doing it again would. One test: with the lock held, the command sends no token request and leaves the connection untouched. Without the fix it spends the refresh token the holder is already spending.
95 lines
3.8 KiB
PHP
95 lines
3.8 KiB
PHP
<?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 {
|
|
// Serialised against sends: refresh() on its own is the
|
|
// other half of the race freshAccessToken()'s lock is
|
|
// there to stop.
|
|
$brokers->for($connection->provider)->refreshSerially($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;
|
|
}
|
|
}
|