mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-11 22:38:54 +00:00
Merge pull request #1750 from denkfabrik-li/fix/mail-oauth-alarm-fires-once
RefreshMailOAuthTokensCommand is the daily refresh and, by its own docblock, the health check that goes with it: a delegated grant can die silently, and for a portal whose password-reset mails ride on this connection that must surface as a warning rather than as a support ticket weeks later. It decided whether to warn from last_error -- but last_error has a second writer. OAuthCodeFlowBroker::refresh() records a dead grant and notifies nobody, and freshAccessToken() reaches it from every send. So on an installation that is actually sending mail the send got there first, the command read the column as "already told them", and the warning never went out. last_error is cleared only by a successful refresh, which a dead grant never has, so it never went out later either. The alarm worked on installations that were not using the mailbox and failed on the ones that were. The anti-nag rule is not the problem and does not change: one notification per broken state is still all anybody gets. The problem is that one column was answering two questions, which the table's own comment describes -- "what the settings page's warning and the admin notification read". The warning wants "is this connection broken", and any writer may answer it, which is why the settings page turning red on a failed send is correct and stays. The notification wants "have the admins been told", and only the notifier can answer that. broken_notified_at is stamped when the command notifies, and the command asks that instead. It is cleared wherever last_error is cleared -- a successful refresh, a disconnect, a changed client id -- and those three sites now call clearFailure() rather than nulling two columns each, because a connection left healthy but still marked "already told them" would go quiet the next time it died, and a fourth caller is exactly how the first one happened. The send path still records the failure and still notifies nobody: a transport is not a place to decide who gets alarmed. Verified before merging: 27 passed on the merged tree, 2 failed / 25 passed with app/ reset and the migration and tests kept. The recovery test is green either way by design. This touches the same command and broker as #1739 and the follow-up to it, so the merged result was read rather than trusted: the refresh reporting sits in the try and the notify guard in the catch, they do not interact, and refreshSerially() re-reads the row before refreshing so the broken_notified_at the catch reads is the stored one -- while a stand-aside throws nothing and never reaches the catch at all. Note for the next release's upgrade notes: this adds a migration, so "nothing to do beyond dropping in the files" no longer holds. Reported and fixed by @denkfabrik-li.
This commit is contained in:
@@ -162,8 +162,9 @@ class EmailOAuthController extends Controller
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'account_email' => null,
|
||||
'last_error' => null,
|
||||
])->save();
|
||||
]);
|
||||
$connection->clearFailure();
|
||||
$connection->save();
|
||||
|
||||
$this->activateConnection();
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ class EmailSettingsController extends Controller
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'account_email' => null,
|
||||
'last_error' => null,
|
||||
]);
|
||||
$connection->clearFailure();
|
||||
}
|
||||
|
||||
$connection->save();
|
||||
|
||||
@@ -80,7 +80,17 @@ class RefreshMailOAuthTokensCommand extends Command
|
||||
// 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) {
|
||||
//
|
||||
// Asked of broken_notified_at, not of last_error. The
|
||||
// question is "have the admins been told", and last_error
|
||||
// cannot answer it: the send path writes that column too
|
||||
// (OAuthCodeFlowBroker::refresh, reached from
|
||||
// freshAccessToken) and notifies nobody. On an
|
||||
// installation that actually sends mail, that write lands
|
||||
// first — so reading it as "already told them" left this
|
||||
// silent for good, on exactly the installations whose
|
||||
// password-reset mail rides on the connection.
|
||||
if ($connection->broken_notified_at === null) {
|
||||
$recipients = array_values(User::query()->where('type', UserType::Staff)->get()
|
||||
->filter(fn (User $staff): bool => $permissions->allows($staff, Permission::EditSettings))
|
||||
->all());
|
||||
@@ -89,6 +99,9 @@ class RefreshMailOAuthTokensCommand extends Command
|
||||
'provider' => $connection->provider->label(),
|
||||
'account' => (string) $connection->account_email,
|
||||
]);
|
||||
|
||||
$connection->broken_notified_at = now();
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
$mailConfig->flush();
|
||||
|
||||
@@ -35,6 +35,7 @@ use Illuminate\Support\Carbon;
|
||||
* @property Carbon|null $token_expires_at
|
||||
* @property Carbon|null $last_refreshed_at
|
||||
* @property string|null $last_error
|
||||
* @property Carbon|null $broken_notified_at
|
||||
*/
|
||||
class MailOAuthConnection extends Model
|
||||
{
|
||||
@@ -51,9 +52,27 @@ class MailOAuthConnection extends Model
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'last_refreshed_at' => 'datetime',
|
||||
'broken_notified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure is over: the error and the record of having alarmed
|
||||
* about it go together, because they describe one state.
|
||||
*
|
||||
* One method rather than two nulls at each call site. The three
|
||||
* places that end a failure — a successful refresh, a disconnect, a
|
||||
* changed client id — must never clear one and keep the other: a
|
||||
* connection that is healthy but still marked "already told them"
|
||||
* would go quiet the next time it dies, which is the shape of the
|
||||
* bug this column was added to close.
|
||||
*/
|
||||
public function clearFailure(): void
|
||||
{
|
||||
$this->last_error = null;
|
||||
$this->broken_notified_at = null;
|
||||
}
|
||||
|
||||
public static function for(MailProvider $provider): self
|
||||
{
|
||||
return static::query()->firstOrNew(['provider' => $provider->value]);
|
||||
|
||||
@@ -230,7 +230,7 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
||||
}
|
||||
|
||||
$connection->last_refreshed_at = now();
|
||||
$connection->last_error = null;
|
||||
$connection->clearFailure();
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?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
|
||||
{
|
||||
// `last_error` was answering two questions at once — the table's
|
||||
// own comment says so: "what the settings page's warning and the
|
||||
// admin notification read". The warning wants "is this connection
|
||||
// broken", and any writer may answer it; the notification wants
|
||||
// "have the admins been told", which only the notifier can.
|
||||
//
|
||||
// They came apart because the send path writes last_error too
|
||||
// (OAuthCodeFlowBroker::refresh, reached from freshAccessToken).
|
||||
// On an installation that actually sends mail, that write lands
|
||||
// first, and the daily command then read it as "already notified"
|
||||
// and stayed silent forever.
|
||||
//
|
||||
// Cleared wherever last_error is cleared, and only there:
|
||||
// a successful refresh, a disconnect, and a changed client id.
|
||||
Schema::table('mail_oauth_connections', function (Blueprint $table) {
|
||||
$table->timestamp('broken_notified_at')->nullable()->after('last_error');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mail_oauth_connections', function (Blueprint $table) {
|
||||
$table->dropColumn('broken_notified_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -494,6 +494,82 @@ test('a dead grant records the error and notifies settings admins exactly once',
|
||||
expect(InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->count())->toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* The same grant, dying in the order it actually dies on an installation
|
||||
* that sends mail: a message goes out, the transport refreshes, and the
|
||||
* failure is recorded by the send path — which notifies nobody. Reading
|
||||
* that record as "already told them" is what kept the daily command
|
||||
* silent for good.
|
||||
*/
|
||||
test('a send that reaches the dead grant first does not swallow the alarm', function () {
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response([
|
||||
'error' => 'invalid_grant',
|
||||
'error_description' => 'AADSTS50173: The provided grant has expired.',
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$connection = connectMicrosoftMailbox();
|
||||
$connection->fill(['token_expires_at' => now()->subMinute()])->save();
|
||||
|
||||
// A password-reset mail — the case the command's docblock is about.
|
||||
try {
|
||||
Mail::mailer('microsoft-graph')->raw('Reset your password', function ($message) {
|
||||
$message->to('client@example.com')->subject('Password reset');
|
||||
});
|
||||
} catch (TransportException) {
|
||||
// The send fails; that half already worked.
|
||||
}
|
||||
|
||||
// The send path records the failure and tells nobody, as before.
|
||||
expect(MailOAuthConnection::for(MailProvider::Microsoft365)->last_error)->toContain('AADSTS50173')
|
||||
->and(InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->count())->toBe(0);
|
||||
|
||||
Artisan::call('projectsend:refresh-mail-oauth-tokens');
|
||||
|
||||
expect(InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->count())->toBe(1);
|
||||
|
||||
// And still exactly once: the anti-nag rule is unchanged.
|
||||
Artisan::call('projectsend:refresh-mail-oauth-tokens');
|
||||
|
||||
expect(InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a connection that recovers can raise the alarm a second time', function () {
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::sequence()
|
||||
->push(['error' => 'invalid_grant'], 400)
|
||||
->push(fakeTokenResponse())
|
||||
->push(['error' => 'invalid_grant'], 400),
|
||||
]);
|
||||
|
||||
connectMicrosoftMailbox();
|
||||
|
||||
Artisan::call('projectsend:refresh-mail-oauth-tokens'); // dies, alarms
|
||||
Artisan::call('projectsend:refresh-mail-oauth-tokens'); // recovers
|
||||
Artisan::call('projectsend:refresh-mail-oauth-tokens'); // dies again
|
||||
|
||||
expect(InAppNotification::query()->where('type', 'mail_oauth_connection_broken')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('ending the failure any other way also clears the record of having alarmed', function () {
|
||||
Http::fake([
|
||||
'login.microsoftonline.com/*' => Http::response(['error' => 'invalid_grant'], 400),
|
||||
]);
|
||||
|
||||
connectMicrosoftMailbox();
|
||||
Artisan::call('projectsend:refresh-mail-oauth-tokens');
|
||||
|
||||
expect(MailOAuthConnection::for(MailProvider::Microsoft365)->broken_notified_at)->not->toBeNull();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->from('/system/settings/email')
|
||||
->delete('/system/settings/email/oauth')
|
||||
->assertRedirect();
|
||||
|
||||
expect(MailOAuthConnection::for(MailProvider::Microsoft365)->broken_notified_at)->toBeNull();
|
||||
});
|
||||
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user