Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
use OCA\Mail\Listener\SpamReportListener;
use OCA\Mail\Listener\TaskProcessingListener;
use OCA\Mail\Listener\UserDeletedListener;
use OCA\Mail\Listener\UserEnabledDisabledListener;
use OCA\Mail\Notification\Notifier;
use OCA\Mail\Provider\MailProvider;
use OCA\Mail\Search\FilteringProvider;
Expand Down Expand Up @@ -85,6 +86,7 @@
use OCP\User\Events\OutOfOfficeEndedEvent;
use OCP\User\Events\OutOfOfficeScheduledEvent;
use OCP\User\Events\OutOfOfficeStartedEvent;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserDeletedEvent;
use OCP\Util;
use Psr\Container\ContainerInterface;
Expand Down Expand Up @@ -146,6 +148,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(NewMessagesSynchronized::class, NewMessagesNotifier::class);
$context->registerEventListener(NewMessagesSynchronized::class, NewMessagesSummarizeListener::class);
$context->registerEventListener(SynchronizationEvent::class, AccountSynchronizedThreadUpdaterListener::class);
$context->registerEventListener(UserChangedEvent::class, UserEnabledDisabledListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(NewMessagesSynchronized::class, FollowUpClassifierListener::class);
$context->registerEventListener(OutOfOfficeStartedEvent::class, OutOfOfficeListener::class);
Expand Down
52 changes: 52 additions & 0 deletions lib/Listener/UserEnabledDisabledListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Listener;

use OCA\Mail\Service\AccountService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\User\Events\UserChangedEvent;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* @template-implements IEventListener<Event|UserChangedEvent>
*/
class UserEnabledDisabledListener implements IEventListener {
public function __construct(
private AccountService $accountService,
private LoggerInterface $logger,
) {
}

#[\Override]
public function handle(Event $event): void {
if (!($event instanceof UserChangedEvent) || $event->getFeature() !== 'enabled') {
return;
}

$enabled = (bool)$event->getValue();
$uid = $event->getUser()->getUID();
try {
foreach ($this->accountService->findByUserId($uid) as $account) {
if ($enabled) {
$this->accountService->scheduleBackgroundJobs($account->getId());
} else {
$this->accountService->removeBackgroundJobs($account->getId());
}
}
} catch (Throwable $e) {
$this->logger->error('Could not update Mail background jobs after user {uid} was ' . ($enabled ? 'enabled' : 'disabled'), [
'uid' => $uid,
'exception' => $e,
]);
}
}
}
16 changes: 12 additions & 4 deletions lib/Migration/FixBackgroundJobs.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use OCA\Mail\Db\MailAccountMapper;
use OCA\Mail\Service\AccountService;
use OCP\IUserManager;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use function method_exists;
Expand All @@ -21,29 +22,36 @@ class FixBackgroundJobs implements IRepairStep {
public function __construct(
private MailAccountMapper $mapper,
private AccountService $accountService,
private IUserManager $userManager,
) {
}

#[\Override]
public function getName(): string {
return 'Insert background jobs for all accounts';
return 'Reconcile background jobs for all accounts';
}

/**
* @return void
*/
#[\Override]
public function run(IOutput $output) {
// Skip if method does not exist yet during upgrade
if (!method_exists($this->accountService, 'scheduleBackgroundJobs')) {
// Skip if methods do not exist yet during upgrade
if (!method_exists($this->accountService, 'scheduleBackgroundJobs')
|| !method_exists($this->accountService, 'removeBackgroundJobs')) {
return;
}

$accounts = $this->mapper->getAllAccounts();

$output->startProgress(count($accounts));
foreach ($accounts as $account) {
$this->accountService->scheduleBackgroundJobs($account->getId());
$user = $this->userManager->get($account->getUserId());
if ($user === null || !$user->isEnabled()) {
$this->accountService->removeBackgroundJobs($account->getId());
} else {
$this->accountService->scheduleBackgroundJobs($account->getId());
}
$output->advance();
}

Expand Down
31 changes: 24 additions & 7 deletions lib/Service/AccountService.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@
use function array_map;

class AccountService {
/**
* @var list<class-string<IJob>>
*/
private const PER_ACCOUNT_JOBS = [
SyncJob::class,
TrainImportanceClassifierJob::class,
PreviewEnhancementProcessingJob::class,
QuotaJob::class,
ScheduleJob::class,
RepairSyncJob::class,
];

/**
* Cache accounts for multiple calls to 'findByUserId'
*
Expand Down Expand Up @@ -246,14 +258,12 @@ public function scheduleBackgroundJobs(int $accountId): void {
$arguments = ['accountId' => $accountId];

$now = $this->timeFactory->getTime();
$this->scheduleBackgroundJob(SyncJob::class, $now, $arguments);
$this->scheduleBackgroundJob(TrainImportanceClassifierJob::class, $now, $arguments);
$this->scheduleBackgroundJob(PreviewEnhancementProcessingJob::class, $now, $arguments);
$this->scheduleBackgroundJob(QuotaJob::class, $now, $arguments);
$this->scheduleBackgroundJob(ScheduleJob::class, $now, $arguments);

$inThreeDays = $now + (3 * 86400);
$this->scheduleBackgroundJob(RepairSyncJob::class, $inThreeDays, $arguments);
foreach (self::PER_ACCOUNT_JOBS as $job) {
// Defer the expensive full repair sync so it doesn't compete with the initial sync
$runAfter = $job === RepairSyncJob::class ? $inThreeDays : $now;
$this->scheduleBackgroundJob($job, $runAfter, $arguments);
}
}

/**
Expand All @@ -268,4 +278,11 @@ private function scheduleBackgroundJob(string $job, int $runAfter, mixed $argume
$this->jobList->scheduleAfter($job, $runAfter, $argument);
}
}

public function removeBackgroundJobs(int $accountId): void {
$arguments = ['accountId' => $accountId];
foreach (self::PER_ACCOUNT_JOBS as $job) {
$this->jobList->remove($job, $arguments);
}
}
}
124 changes: 124 additions & 0 deletions tests/Unit/Listener/UserEnabledDisabledListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Tests\Unit\Listener;

use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Account;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Listener\UserEnabledDisabledListener;
use OCA\Mail\Service\AccountService;
use OCP\EventDispatcher\Event;
use OCP\IUser;
use OCP\User\Events\UserChangedEvent;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;

class UserEnabledDisabledListenerTest extends TestCase {
private AccountService&MockObject $accountService;
private LoggerInterface&MockObject $logger;
private UserEnabledDisabledListener $listener;

protected function setUp(): void {
parent::setUp();

$this->accountService = $this->createMock(AccountService::class);
$this->logger = $this->createMock(LoggerInterface::class);

$this->listener = new UserEnabledDisabledListener(
$this->accountService,
$this->logger,
);
}

private function createUserMock(string $userId): IUser&MockObject {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
return $user;
}

private function createAccountMock(int $id): Account {
$mailAccount = new MailAccount();
$mailAccount->setId($id);
return new Account($mailAccount);
}

public function testHandleUnrelatedEvent(): void {
$this->accountService->expects($this->never())
->method('findByUserId');

$this->listener->handle(new Event());
}

public function testHandleUnrelatedFeature(): void {
$event = new UserChangedEvent($this->createUserMock('user'), 'displayName', 'New name');

$this->accountService->expects($this->never())
->method('findByUserId');

$this->listener->handle($event);
}

public function testHandleDisableRemovesJobs(): void {
$user = $this->createUserMock('test-user');
$event = new UserChangedEvent($user, 'enabled', false, true);

$this->accountService->expects($this->once())
->method('findByUserId')
->with('test-user')
->willReturn([$this->createAccountMock(1), $this->createAccountMock(2)]);

$this->accountService->expects($this->never())
->method('scheduleBackgroundJobs');
$this->accountService->expects($this->exactly(2))
->method('removeBackgroundJobs')
->willReturnCallback(function (int $accountId): void {
$this->assertContains($accountId, [1, 2]);
});

$this->listener->handle($event);
}

public function testHandleEnableSchedulesJobs(): void {
$user = $this->createUserMock('test-user');
$event = new UserChangedEvent($user, 'enabled', true, false);

$this->accountService->expects($this->once())
->method('findByUserId')
->with('test-user')
->willReturn([$this->createAccountMock(42)]);

$this->accountService->expects($this->once())
->method('scheduleBackgroundJobs')
->with(42);
$this->accountService->expects($this->never())
->method('removeBackgroundJobs');

$this->listener->handle($event);
}

public function testHandleSwallowsExceptions(): void {
$user = $this->createUserMock('test-user');
$event = new UserChangedEvent($user, 'enabled', false, true);
$exception = new \RuntimeException('boom');

$this->accountService->expects($this->once())
->method('findByUserId')
->with('test-user')
->willReturn([$this->createAccountMock(1)]);
$this->accountService->method('removeBackgroundJobs')
->willThrowException($exception);

$this->logger->expects($this->once())
->method('error')
->with($this->anything(), ['uid' => 'test-user', 'exception' => $exception]);

$this->listener->handle($event);
}
}
85 changes: 85 additions & 0 deletions tests/Unit/Migration/FixBackgroundJobsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Tests\Unit\Migration;

use ChristophWurst\Nextcloud\Testing\TestCase;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\MailAccountMapper;
use OCA\Mail\Migration\FixBackgroundJobs;
use OCA\Mail\Service\AccountService;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Migration\IOutput;
use PHPUnit\Framework\MockObject\MockObject;

class FixBackgroundJobsTest extends TestCase {
private MailAccountMapper&MockObject $mapper;
private AccountService&MockObject $accountService;
private IUserManager&MockObject $userManager;
private IOutput&MockObject $output;
private FixBackgroundJobs $step;

protected function setUp(): void {
parent::setUp();

$this->mapper = $this->createMock(MailAccountMapper::class);
$this->accountService = $this->createMock(AccountService::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->output = $this->createMock(IOutput::class);

$this->step = new FixBackgroundJobs(
$this->mapper,
$this->accountService,
$this->userManager,
);
}

private function createAccount(int $id, string $uid): MailAccount {
$account = new MailAccount();
$account->setId($id);
$account->setUserId($uid);
return $account;
}

public function testReconcilesEnabledAndDisabledUsers(): void {
$enabledAccount = $this->createAccount(1, 'enabled-user');
$disabledAccount = $this->createAccount(2, 'disabled-user');
$orphanAccount = $this->createAccount(3, 'missing-user');

$this->mapper->method('getAllAccounts')
->willReturn([$enabledAccount, $disabledAccount, $orphanAccount]);

$enabledUser = $this->createMock(IUser::class);
$enabledUser->method('isEnabled')->willReturn(true);
$disabledUser = $this->createMock(IUser::class);
$disabledUser->method('isEnabled')->willReturn(false);

$this->userManager->method('get')
->willReturnMap([
['enabled-user', $enabledUser],
['disabled-user', $disabledUser],
['missing-user', null],
]);

$this->accountService->expects($this->once())
->method('scheduleBackgroundJobs')
->with(1);
$removed = [];
$this->accountService->expects($this->exactly(2))
->method('removeBackgroundJobs')
->willReturnCallback(function (int $id) use (&$removed): void {
$removed[] = $id;
});

$this->step->run($this->output);

$this->assertEqualsCanonicalizing([2, 3], $removed);
}
}
Loading
Loading