Skip to content
Open
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
2 changes: 2 additions & 0 deletions lib/BackgroundJob/SyncJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ protected function run($argument) {
'exception' => $e,
]);
}
} finally {
$this->protocolFactory->releaseClients($account);
}
}
}
69 changes: 69 additions & 0 deletions lib/Protocol/ConnectionPool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Protocol;

use JmapClient\Client as JmapClient;
use OCA\Mail\Account;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\JMAP\JmapClientFactory;
use function hash;
use function json_encode;

/**
* Long-running processes that handle many accounts must release each
* account once done, or connections accumulate for the whole process.
*/
class ConnectionPool {
/** @var array<int, array{fingerprint: string, client: JmapClient}> */
private array $jmapClients = [];

public function __construct(
private JmapClientFactory $jmapClientFactory,
) {
}

/**
* @throws ServiceException
*/
public function jmap(Account $account): JmapClient {
$id = $account->getId();
$fingerprint = $this->fingerprint($account);

$entry = $this->jmapClients[$id] ?? null;
if ($entry !== null && $entry['fingerprint'] === $fingerprint) {
return $entry['client'];
}

$client = $this->jmapClientFactory->getClient($account);
$this->jmapClients[$id] = [
'fingerprint' => $fingerprint,
'client' => $client,
];
return $client;
}

public function release(Account $account): void {
unset($this->jmapClients[$account->getId()]);
}

private function fingerprint(Account $account): string {
$mailAccount = $account->getMailAccount();
return hash('sha256', json_encode([
$mailAccount->getProtocol(),
$mailAccount->getInboundHost(),
$mailAccount->getInboundPort(),
$mailAccount->getInboundSslMode(),
$mailAccount->getPath(),
$mailAccount->getInboundUser(),
$mailAccount->getInboundPassword(),
$mailAccount->getAuthMethod(),
], JSON_THROW_ON_ERROR));
}
}
14 changes: 9 additions & 5 deletions lib/Protocol/ProtocolFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public function __construct(
private ContainerInterface $container,
private IMAPClientFactory $imapClientFactory,
private JmapClientFactory $jmapClientFactory,
private ConnectionPool $connectionPool,
) {
}

Expand All @@ -63,11 +64,17 @@ public function imapClient(Account $account, bool $useCache = true): Horde_Imap_
}

/**
* Get the account's shared JMAP client, which may already be connected
*
* @throws ServiceException
*/
public function jmapClient(Account $account): JmapClient {
$this->verifyProtocol($account, MailAccount::PROTOCOL_JMAP);
return $this->jmapClientFactory->getClient($account);
return $this->connectionPool->jmap($account);
}

public function releaseClients(Account $account): void {
$this->connectionPool->release($account);
}

/**
Expand All @@ -84,10 +91,7 @@ public function testConnection(Account $account): void {
}

if ($protocol === MailAccount::PROTOCOL_JMAP) {
$client = $this->jmapClient($account);
if (!$client->sessionStatus()) {
$client->connect();
}
$this->jmapClientFactory->getClient($account)->connect();
return;
}

Expand Down
6 changes: 6 additions & 0 deletions lib/Service/DraftsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ public function flush() {
]);
}
}

foreach ($accounts as $account) {
if ($account !== null) {
$this->protocolFactory->releaseClients($account);
}
}
}

/**
Expand Down
15 changes: 4 additions & 11 deletions lib/Service/JMAP/JmapOperationsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@
class JmapOperationsService {
protected Client $dataStore;
protected ?string $dataAccount = null;
private ?int $sessionAccountId = null;
private bool $supportsBlob = false;

protected array $entityPropertiesBasic = [
'id', 'blobId', 'threadId', 'mailboxIds', 'messageId', 'size',
Expand Down Expand Up @@ -98,22 +96,17 @@ public function __construct(
public function connect(Account $account): bool {
$this->dataStore = $this->protocolFactory->jmapClient($account);
try {
// evaluate if client was already connected
if (!$this->dataStore->sessionStatus()) {
$this->dataStore->connect();
}
} catch (Throwable $e) {
throw new ServiceException('Could not connect to JMAP server: ' . $e->getMessage(), 0, $e);
}
if ($this->sessionAccountId !== $account->getId()) {
$sessionAccount = $this->dataStore->sessionAccountDefault('mail');
if ($sessionAccount === null) {
throw new ServiceException('JMAP session does not provide a default mail account', 1);
}
$this->sessionAccountId = $account->getId();
$this->dataAccount = $sessionAccount->id();
$this->supportsBlob = $this->dataStore->sessionCapable('blob');
$sessionAccount = $this->dataStore->sessionAccountDefault('mail');
if ($sessionAccount === null) {
throw new ServiceException('JMAP session does not provide a default mail account', 1);
}
$this->dataAccount = $sessionAccount->id();

return true;
}
Expand Down
8 changes: 8 additions & 0 deletions lib/Service/OutboxService.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use OCA\Mail\Events\OutboxMessageCreatedEvent;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Protocol\ProtocolFactory;
use OCA\Mail\Send\Chain;
use OCA\Mail\Service\Attachment\AttachmentService;
use OCP\AppFramework\Db\DoesNotExistException;
Expand All @@ -42,6 +43,7 @@ public function __construct(
ITimeFactory $timeFactory,
private LoggerInterface $logger,
private Chain $sendChain,
private ProtocolFactory $protocolFactory,
) {
$this->eventDispatcher = $eventDispatcher;
$this->timeFactory = $timeFactory;
Expand Down Expand Up @@ -210,6 +212,12 @@ public function flush(): void {
]);
}
}

foreach ($accounts as $account) {
if ($account !== null) {
$this->protocolFactory->releaseClients($account);
}
}
}

public function convertDraft(LocalMessage $draftMessage, int $sendAt): LocalMessage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\MailboxMapper;
use OCA\Mail\Db\MessageMapper;
use OCA\Mail\Protocol\ProtocolFactory;
use OCA\Mail\Send\Chain;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\Attachment\AttachmentService;
Expand Down Expand Up @@ -119,7 +120,8 @@ protected function setUp(): void {
$this->accountService,
$this->timeFactory,
$this->createMock(LoggerInterface::class),
$this->chain
$this->chain,
Server::get(ProtocolFactory::class),
);
}

Expand Down
53 changes: 53 additions & 0 deletions tests/Unit/BackgroundJob/SyncJobTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
use OC\BackgroundJob\JobList;
use OCA\Mail\Account;
use OCA\Mail\BackgroundJob\SyncJob;
use OCA\Mail\Contracts\IMailboxConnector;
use OCA\Mail\Contracts\IMessageConnector;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Exception\ServiceException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use OCP\IUser;
Expand Down Expand Up @@ -130,6 +133,56 @@ public function testUserDoesntExist(): void {
$this->job->start($this->createMock(JobList::class));
}

private function mockSyncableAccount(): Account {
$mailAccount = new MailAccount();
$mailAccount->setProtocol(MailAccount::PROTOCOL_JMAP);
$account = $this->createMock(Account::class);
$account->method('getId')->willReturn(123);
$account->method('getUserId')->willReturn('user123');
$account->method('getMailAccount')->willReturn($mailAccount);
$this->serviceMock->getParameter('accountService')
->method('findById')
->with(123)
->willReturn($account);
$user = $this->createConfiguredMock(IUser::class, [
'isEnabled' => true,
]);
$this->serviceMock->getParameter('userManager')
->method('get')
->with('user123')
->willReturn($user);
return $account;
}

public function testReleasesClientsAfterSync(): void {
$account = $this->mockSyncableAccount();
$protocolFactory = $this->serviceMock->getParameter('protocolFactory');
$protocolFactory->method('mailboxConnector')
->willReturn($this->createMock(IMailboxConnector::class));
$protocolFactory->method('messageConnector')
->willReturn($this->createMock(IMessageConnector::class));
$protocolFactory->expects(self::once())
->method('releaseClients')
->with($account);

$this->job->start($this->createMock(JobList::class));
}

public function testReleasesClientsWhenSyncFails(): void {
$account = $this->mockSyncableAccount();
$mailboxConnector = $this->createMock(IMailboxConnector::class);
$mailboxConnector->method('syncAll')
->willThrowException(new ServiceException('Could not connect to JMAP server'));
$protocolFactory = $this->serviceMock->getParameter('protocolFactory');
$protocolFactory->method('mailboxConnector')
->willReturn($mailboxConnector);
$protocolFactory->expects(self::once())
->method('releaseClients')
->with($account);

$this->job->start($this->createMock(JobList::class));
}

public function testDisabledJob(): void {
$config = $this->createMock(IConfig::class);

Expand Down
Loading
Loading